From d26bb93d3dda2547c3733f2ef5932ce59e42bbef Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Fri, 8 Dec 2023 18:00:59 -0500 Subject: [PATCH 01/45] refactor: [M3-7537] - PaginationControls Storybook v7 Story (#9959) * pagination story * update comments and tests * changeset * fix page arg not taking effect * Update packages/manager/src/components/PaginationControls/PaginationControls.stories.tsx Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> * update import --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> --- .../pr-9959-tech-stories-1701811015350.md | 5 +++ .../PaginationControls.stories.mdx | 28 --------------- .../PaginationControls.stories.tsx | 34 +++++++++++++++++++ .../PaginationControls.test.tsx | 26 +++++++++++--- .../PaginationControls/PaginationControls.tsx | 18 +++++++++- 5 files changed, 77 insertions(+), 34 deletions(-) create mode 100644 packages/manager/.changeset/pr-9959-tech-stories-1701811015350.md delete mode 100644 packages/manager/src/components/PaginationControls/PaginationControls.stories.mdx create mode 100644 packages/manager/src/components/PaginationControls/PaginationControls.stories.tsx diff --git a/packages/manager/.changeset/pr-9959-tech-stories-1701811015350.md b/packages/manager/.changeset/pr-9959-tech-stories-1701811015350.md new file mode 100644 index 00000000000..c94eeea94e5 --- /dev/null +++ b/packages/manager/.changeset/pr-9959-tech-stories-1701811015350.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +PaginationControls V7 story migration ([#9959](https://github.com/linode/manager/pull/9959)) diff --git a/packages/manager/src/components/PaginationControls/PaginationControls.stories.mdx b/packages/manager/src/components/PaginationControls/PaginationControls.stories.mdx deleted file mode 100644 index 53da06fe697..00000000000 --- a/packages/manager/src/components/PaginationControls/PaginationControls.stories.mdx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { PaginationControls } from './PaginationControls'; -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; - - - -export const decorators = [ - (Story) => { - const [page, setPage] = React.useState(1); - return Story({page, setPage}); - }, -]; - -# Pagination Control - -export const Template = (args, context) => - - - - - { Template.bind({}) } - - diff --git a/packages/manager/src/components/PaginationControls/PaginationControls.stories.tsx b/packages/manager/src/components/PaginationControls/PaginationControls.stories.tsx new file mode 100644 index 00000000000..f512dc2645b --- /dev/null +++ b/packages/manager/src/components/PaginationControls/PaginationControls.stories.tsx @@ -0,0 +1,34 @@ +import { useArgs } from '@storybook/preview-api'; +import * as React from 'react'; + +import { PaginationControls } from './PaginationControls'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const PaginationControl: Story = { + args: { + count: 250, + page: 1, + pageSize: 25, + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [, setArgs] = useArgs(); + + return ( + setArgs({ page })} + /> + ); + }, +}; + +const meta: Meta = { + component: PaginationControls, + title: 'Components/Pagination Control', +}; + +export default meta; diff --git a/packages/manager/src/components/PaginationControls/PaginationControls.test.tsx b/packages/manager/src/components/PaginationControls/PaginationControls.test.tsx index 78f46994e3a..aaedbfb35ff 100644 --- a/packages/manager/src/components/PaginationControls/PaginationControls.test.tsx +++ b/packages/manager/src/components/PaginationControls/PaginationControls.test.tsx @@ -1,3 +1,4 @@ +import { fireEvent } from '@testing-library/react'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -12,12 +13,27 @@ const props = { }; describe('PaginationControls', () => { - it('should render a button for each page', () => { + it('should render a button for each page and call the button handler when clicked', () => { const { getByText } = renderWithTheme(); - expect(getByText('1')).toBeInTheDocument(); - expect(getByText('2')).toBeInTheDocument(); - expect(getByText('3')).toBeInTheDocument(); - expect(getByText('4')).toBeInTheDocument(); + const p1 = getByText('1'); + expect(p1).toBeInTheDocument(); + fireEvent.click(p1); + expect(props.onClickHandler).toHaveBeenCalledTimes(1); + + const p2 = getByText('2'); + expect(p2).toBeInTheDocument(); + fireEvent.click(p2); + expect(props.onClickHandler).toHaveBeenCalledTimes(2); + + const p3 = getByText('3'); + expect(p3).toBeInTheDocument(); + fireEvent.click(p3); + expect(props.onClickHandler).toHaveBeenCalledTimes(3); + + const p4 = getByText('4'); + expect(p4).toBeInTheDocument(); + fireEvent.click(p4); + expect(props.onClickHandler).toHaveBeenCalledTimes(4); }); }); diff --git a/packages/manager/src/components/PaginationControls/PaginationControls.tsx b/packages/manager/src/components/PaginationControls/PaginationControls.tsx index e83174687b6..19d351a8391 100644 --- a/packages/manager/src/components/PaginationControls/PaginationControls.tsx +++ b/packages/manager/src/components/PaginationControls/PaginationControls.tsx @@ -2,19 +2,35 @@ import Pagination from '@mui/material/Pagination'; import * as React from 'react'; export interface Props { + /** + * The number of items there are to display + */ count: number; + /** + * The action to perform for changing pages + */ onClickHandler: (page?: number) => void; + /** + * The page we are currently on + */ page: number; + /** + * The size of the page - specifically, how many items to display on each page + */ pageSize: number; } +/** + * `PaginationControls` allows for pagination, enabling users to select a specific page from a range of pages. Note that this component + * only handles pagination and not displaying the relevant data for each page. + */ export const PaginationControls = (props: Props) => { const { count, onClickHandler, page, pageSize } = props; return ( onClickHandler(page)} page={page} shape="rounded" From 6cb6c48198491abbf25b48b8bfbd205ad21733c3 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 8 Dec 2023 18:05:02 -0500 Subject: [PATCH 02/45] upcoming: [M3-7553] - AGLB Refinements and Improvements - Selects and Forms (#9975) * add context to select options * fix aglb e2e tests * Added changeset: Improve AGLB selects and other UX --------- Co-authored-by: Banks Nussman --- .../pr-9975-upcoming-features-1702056998142.md | 5 +++++ .../load-balancer-configurations.spec.ts | 16 ++++++++++++---- .../loadBalancers/load-balancer-routes.spec.ts | 12 ++++++------ .../manager/cypress/support/ui/autocomplete.ts | 9 +++++++-- .../src/components/Autocomplete/Autocomplete.tsx | 4 +++- .../LoadBalancerDetail/Routes/RouteSelect.tsx | 8 ++++++++ .../ServiceTargets/ServiceTargetSelect.tsx | 9 +++++++++ .../LoadBalancerDetail/Settings/Label.tsx | 9 +++++++-- 8 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 packages/manager/.changeset/pr-9975-upcoming-features-1702056998142.md diff --git a/packages/manager/.changeset/pr-9975-upcoming-features-1702056998142.md b/packages/manager/.changeset/pr-9975-upcoming-features-1702056998142.md new file mode 100644 index 00000000000..e5b7e90f014 --- /dev/null +++ b/packages/manager/.changeset/pr-9975-upcoming-features-1702056998142.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Improve AGLB selects and other UX ([#9975](https://github.com/linode/manager/pull/9975)) diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts index 588c9c27e17..961e0599991 100644 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts @@ -112,7 +112,9 @@ describe('Akamai Global Load Balancer configurations page', () => { ui.drawer.findByTitle('Add Route').within(() => { cy.findByLabelText('Route').click(); - ui.autocompletePopper.findByTitle(routes[0].label).click(); + ui.autocompletePopper + .findByTitle(routes[0].label, { exact: false }) + .click(); ui.buttonGroup .findButtonByTitle('Add Route') @@ -180,7 +182,9 @@ describe('Akamai Global Load Balancer configurations page', () => { ui.drawer.findByTitle('Add Route').within(() => { cy.findByLabelText('Route').click(); - ui.autocompletePopper.findByTitle(routes[0].label).click(); + ui.autocompletePopper + .findByTitle(routes[0].label, { exact: false }) + .click(); ui.buttonGroup .findButtonByTitle('Add Route') @@ -248,7 +252,9 @@ describe('Akamai Global Load Balancer configurations page', () => { ui.drawer.findByTitle('Add Route').within(() => { cy.findByLabelText('Route').click(); - ui.autocompletePopper.findByTitle(routes[0].label).click(); + ui.autocompletePopper + .findByTitle(routes[0].label, { exact: false }) + .click(); ui.buttonGroup .findButtonByTitle('Add Route') @@ -333,7 +339,9 @@ describe('Akamai Global Load Balancer configurations page', () => { ui.drawer.findByTitle('Add Route').within(() => { cy.findByLabelText('Route').click(); - ui.autocompletePopper.findByTitle(routes[0].label).click(); + ui.autocompletePopper + .findByTitle(routes[0].label, { exact: false }) + .click(); ui.buttonGroup .findButtonByTitle('Add Route') diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-routes.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-routes.spec.ts index 890c89f7066..37465adf226 100644 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-routes.spec.ts +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-routes.spec.ts @@ -229,7 +229,7 @@ describe('Akamai Global Load Balancer routes page', () => { cy.wait('@getServiceTargets'); ui.autocompletePopper - .findByTitle(serviceTargets[0].label) + .findByTitle(serviceTargets[0].label, { exact: false }) .should('be.visible') .click(); @@ -250,7 +250,7 @@ describe('Akamai Global Load Balancer routes page', () => { cy.wait('@getServiceTargets'); ui.autocompletePopper - .findByTitle(serviceTargets[1].label) + .findByTitle(serviceTargets[1].label, { exact: false }) .should('be.visible') .click(); @@ -327,7 +327,7 @@ describe('Akamai Global Load Balancer routes page', () => { cy.wait('@getServiceTargets'); ui.autocompletePopper - .findByTitle(serviceTargets[0].label) + .findByTitle(serviceTargets[0].label, { exact: false }) .should('be.visible') .click(); @@ -348,7 +348,7 @@ describe('Akamai Global Load Balancer routes page', () => { cy.wait('@getServiceTargets'); ui.autocompletePopper - .findByTitle(serviceTargets[1].label) + .findByTitle(serviceTargets[1].label, { exact: false }) .should('be.visible') .click(); @@ -408,7 +408,7 @@ describe('Akamai Global Load Balancer routes page', () => { cy.wait('@getServiceTargets'); ui.autocompletePopper - .findByTitle(serviceTargets[0].label) + .findByTitle(serviceTargets[0].label, { exact: false }) .should('be.visible') .click(); @@ -479,7 +479,7 @@ describe('Akamai Global Load Balancer routes page', () => { cy.wait('@getServiceTargets'); ui.autocompletePopper - .findByTitle(serviceTargets[0].label) + .findByTitle(serviceTargets[0].label, { exact: false }) .should('be.visible') .click(); diff --git a/packages/manager/cypress/support/ui/autocomplete.ts b/packages/manager/cypress/support/ui/autocomplete.ts index f96bb9cc7e3..94e391232b1 100644 --- a/packages/manager/cypress/support/ui/autocomplete.ts +++ b/packages/manager/cypress/support/ui/autocomplete.ts @@ -1,5 +1,7 @@ import { getRegionById, getRegionByLabel } from 'support/util/regions'; +import type { SelectorMatcherOptions } from '@testing-library/cypress'; + export const autocomplete = { /** * Finds a autocomplete popper that has the given title. @@ -19,12 +21,15 @@ export const autocompletePopper = { /** * Finds a autocomplete popper that has the given title. */ - findByTitle: (title: string): Cypress.Chainable => { + findByTitle: ( + title: string, + options?: SelectorMatcherOptions + ): Cypress.Chainable => { return cy .document() .its('body') .find('[data-qa-autocomplete-popper]') - .findByText(title); + .findByText(title, options); }, }; diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index 923efc6d703..4557e9a9c48 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -128,7 +128,9 @@ export const Autocomplete = < flexGrow: 1, }} > - {option.label} + {rest.getOptionLabel + ? rest.getOptionLabel(option) + : option.label} diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RouteSelect.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RouteSelect.tsx index a599fcdfedf..c12db1e1d08 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RouteSelect.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RouteSelect.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { useLoadBalancerRoutesInfiniteQuery } from 'src/queries/aglb/routes'; +import { pluralize } from 'src/utilities/pluralize'; import type { Filter, Route } from '@linode/api-v4'; @@ -68,6 +69,13 @@ export const RouteSelect = (props: Props) => { ListboxProps={{ onScroll, }} + getOptionLabel={({ label, protocol, rules }) => + `${label} (${protocol.toUpperCase()} - ${pluralize( + 'rule', + 'rules', + rules.length + )})` + } onInputChange={(_, value, reason) => { if (reason === 'input') { setInputValue(value); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetSelect.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetSelect.tsx index 139159599f9..b9055069296 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetSelect.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetSelect.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { TextFieldProps } from 'src/components/TextField'; import { useLoadBalancerServiceTargetsInfiniteQuery } from 'src/queries/aglb/serviceTargets'; +import { pluralize } from 'src/utilities/pluralize'; import type { Filter, ServiceTarget } from '@linode/api-v4'; import type { SxProps } from '@mui/material'; @@ -87,6 +88,13 @@ export const ServiceTargetSelect = (props: Props) => { ListboxProps={{ onScroll, }} + getOptionLabel={({ endpoints, label, protocol }) => + `${label} (${protocol.toUpperCase()} - ${pluralize( + 'endpoint', + 'endpoints', + endpoints.length + )})` + } inputValue={ selectedServiceTarget ? selectedServiceTarget.label : inputValue } @@ -96,6 +104,7 @@ export const ServiceTargetSelect = (props: Props) => { } }} errorText={error?.[0].reason ?? errorText} + filterOptions={(x) => x} label={label ?? 'Service Target'} loading={isLoading} noMarginTop diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Label.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Label.tsx index c46f29ae780..77276e4062b 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Label.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Label.tsx @@ -1,10 +1,10 @@ -import { Stack } from 'src/components/Stack'; import { useFormik } from 'formik'; import React from 'react'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; import { useLoadBalancerMutation, @@ -44,7 +44,12 @@ export const Label = ({ loadbalancerId }: Props) => { value={formik.values.label} /> - From 914d22605756343e6a70f72707c7d2f9d6d81670 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Mon, 11 Dec 2023 12:42:51 -0800 Subject: [PATCH 03/45] upcoming: [M3-7414] - Add mocks and update queries for new Parent/Child endpoints (#9977) * WIP * Allow unrestricted parent users to access the new endpoints * Clean up comments * Mock company names, not person names, for child accounts * Revert unintended change * Add changeset * Add mock expiry --- ...pr-9977-upcoming-features-1701987666651.md | 5 ++ packages/manager/src/env.d.ts | 1 + .../features/TopMenu/UserMenu/UserMenu.tsx | 2 +- packages/manager/src/mocks/serverHandlers.ts | 74 ++++++++++++++----- packages/manager/src/queries/account.ts | 13 +++- 5 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 packages/manager/.changeset/pr-9977-upcoming-features-1701987666651.md diff --git a/packages/manager/.changeset/pr-9977-upcoming-features-1701987666651.md b/packages/manager/.changeset/pr-9977-upcoming-features-1701987666651.md new file mode 100644 index 00000000000..112e0e9d8a6 --- /dev/null +++ b/packages/manager/.changeset/pr-9977-upcoming-features-1701987666651.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add mocks and update queries for new Parent/Child endpoints ([#9977](https://github.com/linode/manager/pull/9977)) diff --git a/packages/manager/src/env.d.ts b/packages/manager/src/env.d.ts index d8dc72f0c40..18d3b4353b3 100644 --- a/packages/manager/src/env.d.ts +++ b/packages/manager/src/env.d.ts @@ -27,6 +27,7 @@ interface ImportMetaEnv { REACT_APP_MOCK_SERVICE_WORKER?: string; REACT_APP_PAYPAL_CLIENT_ID?: string; REACT_APP_PAYPAL_ENV?: string; + REACT_APP_PROXY_PAT?: string; REACT_APP_SENTRY_URL?: string; REACT_APP_STATUS_PAGE_URL?: string; SSR: boolean; diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 555bab4de59..d7a7303415a 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -2,7 +2,6 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp'; import { Theme, styled, useMediaQuery } from '@mui/material'; import Popover from '@mui/material/Popover'; -import { Stack } from 'src/components/Stack'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; @@ -12,6 +11,7 @@ import { Divider } from 'src/components/Divider'; import { GravatarByEmail } from 'src/components/GravatarByEmail'; import { Hidden } from 'src/components/Hidden'; import { Link } from 'src/components/Link'; +import { Stack } from 'src/components/Stack'; import { Tooltip } from 'src/components/Tooltip'; import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 69636d13d99..e40f1bff3f4 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -488,20 +488,20 @@ const standardTypes = linodeTypeFactory.buildList(7); const dedicatedTypes = dedicatedTypeFactory.buildList(7); const proDedicatedType = proDedicatedTypeFactory.build(); -const proxyAccount = accountUserFactory.build({ +const proxyAccountUser = accountUserFactory.build({ email: 'partner@proxy.com', last_login: null, user_type: 'proxy', username: 'ParentCompany_a1b2c3d4e5', }); -const parentAccount = accountUserFactory.build({ +const parentAccountUser = accountUserFactory.build({ email: 'parent@acme.com', last_login: null, restricted: false, user_type: 'parent', username: 'ParentUser', }); -const childAccount = accountUserFactory.build({ +const childAccountUser = accountUserFactory.build({ email: 'child@linode.com', last_login: null, restricted: false, @@ -520,7 +520,11 @@ export const handlers = [ return res(ctx.json({ ...profileFactory.build(), ...(req.body as any) })); }), rest.get('*/profile/grants', (req, res, ctx) => { - return res(ctx.json(grantsFactory.build())); + return res( + // Parent/Child: switch out the return statement if you want to mock a restricted parent user with access to child accounts. + // ctx.json(grantsFactory.build({ global: { child_account_access: true } })) + ctx.json(grantsFactory.build()) + ); }), rest.get('*/profile/apps', (req, res, ctx) => { const tokens = appTokenFactory.buildList(5); @@ -1149,6 +1153,41 @@ export const handlers = [ return res(ctx.json(makeResourcePage(accountMaintenance))); }), + rest.get('*/account/child-accounts', (req, res, ctx) => { + const childAccounts = [ + accountFactory.build({ + company: 'Child Company 0', + euuid: '0', + }), + accountFactory.build({ + company: 'Child Company 1', + euuid: '1', + }), + accountFactory.build({ + company: 'Child Company 2', + euuid: '2', + }), + ]; + return res(ctx.json(makeResourcePage(childAccounts))); + }), + rest.get('*/account/child-accounts/:euuid', (req, res, ctx) => { + const childAccount = accountFactory.build({ + company: 'Child Company 1', + euuid: '1', + }); + return res(ctx.json(childAccount)); + }), + rest.post('*/account/child-accounts/:euuid/token', (req, res, ctx) => { + // Proxy tokens expire in 15 minutes. + const now = new Date(); + const expiry = new Date(now.setMinutes(now.getMinutes() + 15)); + + const proxyToken = appTokenFactory.build({ + expiry: expiry.toISOString(), + token: `Bearer ${import.meta.env.REACT_APP_PROXY_PAT}`, + }); + return res(ctx.json(proxyToken)); + }), rest.get('*/account/users', (req, res, ctx) => { const accountUsers = [ accountUserFactory.build({ @@ -1162,26 +1201,27 @@ export const handlers = [ }, }), accountUserFactory.build({ last_login: null }), - childAccount, - parentAccount, - proxyAccount, + childAccountUser, + parentAccountUser, + proxyAccountUser, ]; return res(ctx.json(makeResourcePage(accountUsers))); }), - rest.get(`*/account/users/${childAccount.username}`, (req, res, ctx) => { - return res(ctx.json(childAccount)); + rest.get(`*/account/users/${childAccountUser.username}`, (req, res, ctx) => { + return res(ctx.json(childAccountUser)); }), - rest.get(`*/account/users/${proxyAccount.username}`, (req, res, ctx) => { - return res(ctx.json(proxyAccount)); + rest.get(`*/account/users/${proxyAccountUser.username}`, (req, res, ctx) => { + return res(ctx.json(proxyAccountUser)); }), - rest.get(`*/account/users/${parentAccount.username}`, (req, res, ctx) => { - return res(ctx.json(parentAccount)); + rest.get(`*/account/users/${parentAccountUser.username}`, (req, res, ctx) => { + return res(ctx.json(parentAccountUser)); }), rest.get('*/account/users/:user', (req, res, ctx) => { - return res(ctx.json(accountUserFactory.build())); + // Parent/Child: switch the `user_type` depending on what account view you need to mock. + return res(ctx.json(accountUserFactory.build({ user_type: 'parent' }))); }), rest.get( - `*/account/users/${childAccount.username}/grants`, + `*/account/users/${childAccountUser.username}/grants`, (req, res, ctx) => { return res( ctx.json( @@ -1195,7 +1235,7 @@ export const handlers = [ } ), rest.get( - `*/account/users/${proxyAccount.username}/grants`, + `*/account/users/${proxyAccountUser.username}/grants`, (req, res, ctx) => { return res( ctx.json( @@ -1219,7 +1259,7 @@ export const handlers = [ } ), rest.get( - `*/account/users/${parentAccount.username}/grants`, + `*/account/users/${parentAccountUser.username}/grants`, (req, res, ctx) => { return res( ctx.json( diff --git a/packages/manager/src/queries/account.ts b/packages/manager/src/queries/account.ts index c69d16a9d88..9adb30e6f26 100644 --- a/packages/manager/src/queries/account.ts +++ b/packages/manager/src/queries/account.ts @@ -9,6 +9,7 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; import { useGrants, useProfile } from 'src/queries/profile'; +import { useAccountUser } from './accountUsers'; import { queryPresets } from './base'; import type { @@ -52,6 +53,8 @@ export const useChildAccounts = ({ headers, params, }: RequestOptions) => { + const { data: profile } = useProfile(); + const { data: user } = useAccountUser(profile?.username ?? ''); const { data: grants } = useGrants(); const hasExplicitAuthToken = Boolean(headers?.Authorization); @@ -60,13 +63,17 @@ export const useChildAccounts = ({ () => getChildAccounts({ filter, headers, params }), { enabled: - Boolean(grants?.global?.child_account_access) || hasExplicitAuthToken, + (Boolean(user?.user_type === 'parent') && !profile?.restricted) || + Boolean(grants?.global?.child_account_access) || + hasExplicitAuthToken, keepPreviousData: true, } ); }; export const useChildAccount = ({ euuid, headers }: ChildAccountPayload) => { + const { data: profile } = useProfile(); + const { data: user } = useAccountUser(profile?.username ?? ''); const { data: grants } = useGrants(); const hasExplicitAuthToken = Boolean(headers?.Authorization); @@ -75,7 +82,9 @@ export const useChildAccount = ({ euuid, headers }: ChildAccountPayload) => { () => getChildAccount({ euuid }), { enabled: - Boolean(grants?.global?.child_account_access) || hasExplicitAuthToken, + (Boolean(user?.user_type === 'parent') && !profile?.restricted) || + Boolean(grants?.global?.child_account_access) || + hasExplicitAuthToken, } ); }; From 8d20846e81ee8731cfc13634690bcea4b72de701 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Tue, 5 Dec 2023 16:55:23 -0500 Subject: [PATCH 04/45] TagsInput v7 story --- .../TagsInput/TagsInput.stories.mdx | 81 ------------------- .../TagsInput/TagsInput.stories.tsx | 47 +++++++++++ .../src/components/TagsInput/TagsInput.tsx | 30 ++++++- 3 files changed, 74 insertions(+), 84 deletions(-) delete mode 100644 packages/manager/src/components/TagsInput/TagsInput.stories.mdx create mode 100644 packages/manager/src/components/TagsInput/TagsInput.stories.tsx diff --git a/packages/manager/src/components/TagsInput/TagsInput.stories.mdx b/packages/manager/src/components/TagsInput/TagsInput.stories.mdx deleted file mode 100644 index 19957d07502..00000000000 --- a/packages/manager/src/components/TagsInput/TagsInput.stories.mdx +++ /dev/null @@ -1,81 +0,0 @@ -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; -import { useArgs } from '@storybook/client-api'; -import { TagsInput } from './TagsInput'; - - - -# Tags Input - -export const Template = (args) => { - const [args2, updateArgs] = useArgs(); - const onChange = (updatedTags) => { - updateArgs({ value: updatedTags }); - }; - return ; -}; - - - - {Template.bind({})} - - - - diff --git a/packages/manager/src/components/TagsInput/TagsInput.stories.tsx b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx new file mode 100644 index 00000000000..c27dfd1e3a8 --- /dev/null +++ b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx @@ -0,0 +1,47 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { Box } from 'src/components/Box'; + +import { TagsInput } from './TagsInput'; + +import type { TagsInputProps } from './TagsInput'; +import type { Item } from 'src/components/EnhancedSelect/Select'; + +export const Default: StoryObj = { + args: { + disabled: false, + hideLabel: false, + label: '', + menuPlacement: 'bottom', + name: '', + tagError: '', + value: [ + { label: 'tag-1', value: 'tag-1' }, + { label: 'tag-2', value: 'tag-2' }, + ], + }, + render: (args) => { + const TagsInputWrapper = () => { + const [value, setValue] = React.useState(args.value); + + return ( + + setValue(selected)} + value={value} + /> + + ); + }; + + return TagsInputWrapper(); + }, +}; + +const meta: Meta = { + component: TagsInput, + title: 'Components/Tags/Tags Input 2', +}; +export default meta; diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx index 7745e332061..2d3cb5495b4 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.tsx @@ -17,17 +17,43 @@ export interface Tag { } export interface TagsInputProps { + /** + * If true, the component is disabled. + * + * @default false + */ disabled?: boolean; + /** + * If true, the label is hidden, yet still accessible to screen readers. + */ hideLabel?: boolean; + /** + * The label for the input. + */ label?: string; + /** + * The placement of the menu, relative to the select input. + */ menuPlacement?: 'auto' | 'bottom' | 'top'; + /** + * The name of the input. + */ name?: string; + /** + * Callback fired when the value changes. + */ onChange: (selected: Item[]) => void; + /** + * An error to display beneath the input. + */ tagError?: string; + /** + * The value of the input. + */ value: Item[]; } -const TagsInput = (props: TagsInputProps) => { +export const TagsInput = (props: TagsInputProps) => { const { disabled, hideLabel, @@ -115,5 +141,3 @@ const TagsInput = (props: TagsInputProps) => { /> ); }; - -export { TagsInput }; From 900d302536d234eccf79a0d1eb5cdf96a47e888b Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Wed, 6 Dec 2023 11:24:19 -0500 Subject: [PATCH 05/45] TagsPanel v7 story + test --- packages/manager/.storybook/preview.tsx | 1 - .../TagsInput/TagsInput.stories.tsx | 11 +- .../TagsPanel/TagsPanel.stories.mdx | 66 ---------- .../TagsPanel/TagsPanel.stories.tsx | 40 ++++++ .../components/TagsPanel/TagsPanel.styles.ts | 99 +++++++++++++++ .../components/TagsPanel/TagsPanel.test.tsx | 114 +++++++++++++++++ .../src/components/TagsPanel/TagsPanel.tsx | 115 +++--------------- 7 files changed, 274 insertions(+), 172 deletions(-) delete mode 100644 packages/manager/src/components/TagsPanel/TagsPanel.stories.mdx create mode 100644 packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx create mode 100644 packages/manager/src/components/TagsPanel/TagsPanel.styles.ts create mode 100644 packages/manager/src/components/TagsPanel/TagsPanel.test.tsx diff --git a/packages/manager/.storybook/preview.tsx b/packages/manager/.storybook/preview.tsx index 5e83954a674..43df4076fb8 100644 --- a/packages/manager/.storybook/preview.tsx +++ b/packages/manager/.storybook/preview.tsx @@ -89,7 +89,6 @@ const preview: Preview = { - ), }, diff --git a/packages/manager/src/components/TagsInput/TagsInput.stories.tsx b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx index c27dfd1e3a8..6270bed362a 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.stories.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx @@ -1,3 +1,4 @@ +import { useArgs } from '@storybook/client-api'; import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; @@ -23,14 +24,16 @@ export const Default: StoryObj = { }, render: (args) => { const TagsInputWrapper = () => { - const [value, setValue] = React.useState(args.value); + const [, setTags] = useArgs(); + const handleUpdateTags = (selected: Item[]) => { + return setTags({ value: selected }); + }; return ( setValue(selected)} - value={value} + onChange={(selected) => handleUpdateTags(selected)} /> ); @@ -42,6 +45,6 @@ export const Default: StoryObj = { const meta: Meta = { component: TagsInput, - title: 'Components/Tags/Tags Input 2', + title: 'Components/Tags/Tags Input', }; export default meta; diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.stories.mdx b/packages/manager/src/components/TagsPanel/TagsPanel.stories.mdx deleted file mode 100644 index cc386cbf331..00000000000 --- a/packages/manager/src/components/TagsPanel/TagsPanel.stories.mdx +++ /dev/null @@ -1,66 +0,0 @@ -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; -import { Typography } from 'src/components/Typography'; -import { TagsPanel } from './TagsPanel'; -import { useArgs } from '@storybook/client-api'; - - ( -
- -
- ), - ]} -/> - -# Tags Panel - -export const _tags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5']; - -export const Template = (args) => { - const [localArgs, setLocalArgs] = useArgs(); - const updateTags = (selected) => { - return Promise.resolve(setLocalArgs({ tags: selected })); - }; - return ( - <> - - - ); -}; - - - - {Template.bind({})} - - - - diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx new file mode 100644 index 00000000000..a7c8ce8b5cb --- /dev/null +++ b/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx @@ -0,0 +1,40 @@ +import { useArgs } from '@storybook/client-api'; +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { Box } from 'src/components/Box'; + +import { TagsPanel } from './TagsPanel'; + +import type { TagsPanelProps } from './TagsPanel'; + +const _tags: string[] = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5']; + +export const Default: StoryObj = { + render: (args) => { + const TagsInputWrapper = () => { + const [{ tags }, updateArgs] = useArgs(); + const handleUpdateTags = (selected: string[]) => { + return Promise.resolve(updateArgs({ tags: selected })); + }; + + return ( + + + + ); + }; + + return TagsInputWrapper(); + }, +}; + +const meta: Meta = { + args: { + disabled: false, + tags: _tags, + }, + component: TagsPanel, + title: 'Components/Tags/Tags Panel', +}; +export default meta; diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts b/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts new file mode 100644 index 00000000000..af1bd19d7eb --- /dev/null +++ b/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts @@ -0,0 +1,99 @@ +import { Theme } from '@mui/material/styles'; +import { makeStyles } from 'tss-react/mui'; + +export const useStyles = makeStyles()((theme: Theme) => ({ + '@keyframes fadeIn': { + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, + }, + addButtonWrapper: { + display: 'flex', + justifyContent: 'flex-start', + width: '100%', + }, + addTagButton: { + '& svg': { + color: theme.color.tagIcon, + height: 10, + marginLeft: 10, + width: 10, + }, + alignItems: 'center', + backgroundColor: theme.color.tagButton, + border: 'none', + borderRadius: 3, + color: theme.textColors.linkActiveLight, + cursor: 'pointer', + display: 'flex', + fontFamily: theme.font.bold, + fontSize: '0.875rem', + justifyContent: 'center', + padding: '7px 10px', + whiteSpace: 'nowrap', + }, + errorNotice: { + '& .noticeText': { + fontFamily: '"LatoWeb", sans-serif', + }, + animation: '$fadeIn 225ms linear forwards', + borderLeft: `5px solid ${theme.palette.error.dark}`, + marginTop: 20, + paddingLeft: 10, + textAlign: 'left', + }, + hasError: { + marginTop: 0, + }, + loading: { + opacity: 0.4, + }, + progress: { + alignItems: 'center', + display: 'flex', + height: '100%', + justifyContent: 'center', + position: 'absolute', + width: '100%', + zIndex: 2, + }, + selectTag: { + '& .error-for-scroll > div': { + flexDirection: 'row', + flexWrap: 'wrap-reverse', + }, + '& .input': { + '& p': { + borderLeft: 'none', + color: theme.color.grey1, + fontSize: '.9rem', + }, + }, + '& .react-select__input': { + backgroundColor: 'transparent', + color: theme.palette.text.primary, + fontSize: '.9rem', + }, + '& .react-select__value-container': { + padding: '6px', + }, + animation: '$fadeIn .3s ease-in-out forwards', + marginTop: -3.5, + minWidth: 275, + position: 'relative', + textAlign: 'left', + width: '100%', + zIndex: 3, + }, + tag: { + marginRight: 4, + marginTop: theme.spacing(0.5), + }, + tagsPanelItemWrapper: { + marginBottom: theme.spacing(), + position: 'relative', + }, +})); diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx new file mode 100644 index 00000000000..287caf9ad74 --- /dev/null +++ b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, queryByLabelText, waitFor } from '@testing-library/react'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { TagsPanel } from './TagsPanel'; + +vi.mock('src/queries/profile', () => ({ + useProfile: vi.fn(() => ({ data: { restricted: false } })), +})); + +vi.mock('src/queries/tags', () => ({ + updateTagsSuggestionsData: vi.fn(), + useTagSuggestions: vi.fn(() => ({ data: [], isLoading: false })), +})); + +const queryClient = new QueryClient(); + +const renderWithQueryClient = (ui: any) => { + return renderWithTheme( + {ui} + ); +}; + +describe('TagsPanel', () => { + it('renders TagsPanel component with existing tags', async () => { + const updateTagsMock = vi.fn(() => Promise.resolve()); + + const { getByLabelText, getByText } = renderWithQueryClient( + + ); + + expect(getByText('Tag1')).toBeInTheDocument(); + expect(getByText('Tag2')).toBeInTheDocument(); + + const addTagButton = getByText('Add a tag'); + expect(addTagButton).toBeInTheDocument(); + + fireEvent.click(addTagButton); + + const tagInput = getByLabelText('Create or Select a Tag'); + expect(tagInput).toBeInTheDocument(); + }); + + it('creates a new tag successfully', async () => { + const updateTagsMock = vi.fn(() => Promise.resolve()); + + const { getByLabelText, getByText } = renderWithQueryClient( + + ); + + fireEvent.click(getByText('Add a tag')); + + fireEvent.change(getByLabelText('Create or Select a Tag'), { + target: { value: 'NewTag' }, + }); + + const newTagItem = getByText('Create "NewTag"'); + fireEvent.click(newTagItem); + + await waitFor(() => { + expect(updateTagsMock).toHaveBeenCalledWith(['NewTag', 'Tag1', 'Tag2']); + }); + }); + + it('displays an error message for invalid tag creation', async () => { + const updateTagsMock = vi.fn(() => Promise.resolve()); + + const { getByLabelText, getByText } = renderWithQueryClient( + + ); + + fireEvent.click(getByText('Add a tag')); + + fireEvent.change(getByLabelText('Create or Select a Tag'), { + target: { value: 'yz' }, + }); + + const newTagItem = getByText('Create "yz"'); + fireEvent.click(newTagItem); + + await waitFor(() => + expect( + getByText('Tag "yz" length must be 3-50 characters') + ).toBeInTheDocument() + ); + }); + + it('deletes a tag successfully', async () => { + const updateTagsMock = vi.fn(() => Promise.resolve()); + + const { + getByLabelText, + getByText, + queryByLabelText, + } = renderWithQueryClient( + + ); + + expect(getByText('Tag1')).toBeInTheDocument(); + expect(getByText('Tag2')).toBeInTheDocument(); + + // Click on the delete button for Tag1 + const deleteTagButton = getByLabelText("Delete Tag 'Tag1'"); + fireEvent.click(deleteTagButton); + + // Wait for the asynchronous updateTags to complete + await waitFor(() => expect(updateTagsMock).toHaveBeenCalledWith(['Tag2'])); + + // Check if Tag1 is removed + expect(queryByLabelText("Search for Tag 'tag2'")).toBeNull(); + }); +}); diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.tsx index 023b878a449..cb58ba20b02 100644 --- a/packages/manager/src/components/TagsPanel/TagsPanel.tsx +++ b/packages/manager/src/components/TagsPanel/TagsPanel.tsx @@ -1,7 +1,5 @@ -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { useQueryClient } from 'react-query'; -import { makeStyles } from 'tss-react/mui'; import Plus from 'src/assets/icons/plusSign.svg'; import { CircleProgress } from 'src/components/CircleProgress'; @@ -12,102 +10,7 @@ import { useProfile } from 'src/queries/profile'; import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; -const useStyles = makeStyles()((theme: Theme) => ({ - '@keyframes fadeIn': { - from: { - opacity: 0, - }, - to: { - opacity: 1, - }, - }, - addButtonWrapper: { - display: 'flex', - justifyContent: 'flex-start', - width: '100%', - }, - addTagButton: { - '& svg': { - color: theme.color.tagIcon, - height: 10, - marginLeft: 10, - width: 10, - }, - alignItems: 'center', - backgroundColor: theme.color.tagButton, - border: 'none', - borderRadius: 3, - color: theme.textColors.linkActiveLight, - cursor: 'pointer', - display: 'flex', - fontFamily: theme.font.bold, - fontSize: '0.875rem', - justifyContent: 'center', - padding: '7px 10px', - whiteSpace: 'nowrap', - }, - errorNotice: { - '& .noticeText': { - fontFamily: '"LatoWeb", sans-serif', - }, - animation: '$fadeIn 225ms linear forwards', - borderLeft: `5px solid ${theme.palette.error.dark}`, - marginTop: 20, - paddingLeft: 10, - textAlign: 'left', - }, - hasError: { - marginTop: 0, - }, - loading: { - opacity: 0.4, - }, - progress: { - alignItems: 'center', - display: 'flex', - height: '100%', - justifyContent: 'center', - position: 'absolute', - width: '100%', - zIndex: 2, - }, - selectTag: { - '& .error-for-scroll > div': { - flexDirection: 'row', - flexWrap: 'wrap-reverse', - }, - '& .input': { - '& p': { - borderLeft: 'none', - color: theme.color.grey1, - fontSize: '.9rem', - }, - }, - '& .react-select__input': { - backgroundColor: 'transparent', - color: theme.palette.text.primary, - fontSize: '.9rem', - }, - '& .react-select__value-container': { - padding: '6px', - }, - animation: '$fadeIn .3s ease-in-out forwards', - marginTop: -3.5, - minWidth: 275, - position: 'relative', - textAlign: 'left', - width: '100%', - zIndex: 3, - }, - tag: { - marginRight: 4, - marginTop: theme.spacing(0.5), - }, - tagsPanelItemWrapper: { - marginBottom: theme.spacing(), - position: 'relative', - }, -})); +import { useStyles } from './TagsPanel.styles'; interface Item { label: string; @@ -123,13 +26,25 @@ interface ActionMeta { } export interface TagsPanelProps { + /** + * Set the alignment of the tags panel. + */ align?: 'left' | 'right'; + /** + * If true, the component is disabled. + */ disabled?: boolean; + /** + * The tags to display. + */ tags: string[]; + /** + * Callback fired when the tags are updated. + */ updateTags: (tags: string[]) => Promise; } -const TagsPanel = (props: TagsPanelProps) => { +export const TagsPanel = (props: TagsPanelProps) => { const { classes, cx } = useStyles(); const { disabled, tags, updateTags } = props; @@ -307,5 +222,3 @@ const TagsPanel = (props: TagsPanelProps) => { ); }; - -export { TagsPanel }; From 653a0609ca0d87d2ee4afdcf65a33dcd46cdee02 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Wed, 6 Dec 2023 11:25:27 -0500 Subject: [PATCH 06/45] TagsPanel v7 story + test --- packages/manager/src/components/TagsPanel/TagsPanel.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx index 287caf9ad74..5ba322a0932 100644 --- a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx +++ b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, queryByLabelText, waitFor } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; From e957664dd2090329f906e8abd05a41eeddbfec04 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Wed, 6 Dec 2023 11:49:57 -0500 Subject: [PATCH 07/45] Added changeset: TagsInput & TagsPanel Storybook v7 Stories --- .../manager/.changeset/pr-9963-tech-stories-1701881397641.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-9963-tech-stories-1701881397641.md diff --git a/packages/manager/.changeset/pr-9963-tech-stories-1701881397641.md b/packages/manager/.changeset/pr-9963-tech-stories-1701881397641.md new file mode 100644 index 00000000000..0001d750737 --- /dev/null +++ b/packages/manager/.changeset/pr-9963-tech-stories-1701881397641.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +TagsInput & TagsPanel Storybook v7 Stories ([#9963](https://github.com/linode/manager/pull/9963)) From baa799f9f11b4c5fef77c219f2c1f58a20e16f25 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Wed, 6 Dec 2023 11:51:44 -0500 Subject: [PATCH 08/45] Cleanup --- .../src/components/TagsPanel/TagsPanel.test.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx index 5ba322a0932..de0ab9a2399 100644 --- a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx +++ b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx @@ -6,15 +6,6 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { TagsPanel } from './TagsPanel'; -vi.mock('src/queries/profile', () => ({ - useProfile: vi.fn(() => ({ data: { restricted: false } })), -})); - -vi.mock('src/queries/tags', () => ({ - updateTagsSuggestionsData: vi.fn(), - useTagSuggestions: vi.fn(() => ({ data: [], isLoading: false })), -})); - const queryClient = new QueryClient(); const renderWithQueryClient = (ui: any) => { @@ -101,14 +92,11 @@ describe('TagsPanel', () => { expect(getByText('Tag1')).toBeInTheDocument(); expect(getByText('Tag2')).toBeInTheDocument(); - // Click on the delete button for Tag1 const deleteTagButton = getByLabelText("Delete Tag 'Tag1'"); fireEvent.click(deleteTagButton); - // Wait for the asynchronous updateTags to complete await waitFor(() => expect(updateTagsMock).toHaveBeenCalledWith(['Tag2'])); - // Check if Tag1 is removed expect(queryByLabelText("Search for Tag 'tag2'")).toBeNull(); }); }); From 1b53f6d612ea9bc6493e145ff2b09aa6d213f818 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Mon, 11 Dec 2023 16:48:27 -0500 Subject: [PATCH 09/45] Remove alignment prop and add test for dissabled prop --- .../components/TagsPanel/TagsPanel.test.tsx | 24 +++++++++++++++++++ .../src/components/TagsPanel/TagsPanel.tsx | 6 +---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx index de0ab9a2399..74e3bb90ed2 100644 --- a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx +++ b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx @@ -99,4 +99,28 @@ describe('TagsPanel', () => { expect(queryByLabelText("Search for Tag 'tag2'")).toBeNull(); }); + + it('prevents creation or deletion of tags when disabled', async () => { + const updateTagsMock = vi.fn(() => Promise.resolve()); + + const { getByText, queryByLabelText, queryByText } = renderWithQueryClient( + + ); + + expect(getByText('Tag1')).toBeInTheDocument(); + expect(getByText('Tag2')).toBeInTheDocument(); + + const addTagButton = getByText('Add a tag'); + expect(addTagButton).toBeInTheDocument(); + + fireEvent.click(addTagButton); + + const tagInput = queryByText('Create or Select a Tag'); + expect(tagInput).toBeNull(); + + const deleteTagButton = queryByLabelText("Delete Tag 'Tag1'"); + expect(deleteTagButton).toBeNull(); + + await waitFor(() => expect(updateTagsMock).not.toHaveBeenCalled()); + }); }); diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.tsx index cb58ba20b02..ba1fb36718d 100644 --- a/packages/manager/src/components/TagsPanel/TagsPanel.tsx +++ b/packages/manager/src/components/TagsPanel/TagsPanel.tsx @@ -27,11 +27,7 @@ interface ActionMeta { export interface TagsPanelProps { /** - * Set the alignment of the tags panel. - */ - align?: 'left' | 'right'; - /** - * If true, the component is disabled. + * If true, the input will be disabled and no tags can be added or removed. */ disabled?: boolean; /** From 6b2ced26c0040f7a9d7301b4d1ba872fa49ef505 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 11 Dec 2023 17:16:40 -0500 Subject: [PATCH 10/45] chore: [M3-7551] - Update Vite and Vitest (#9961) * update vite and vitest major versions * try to get e2es to run * get eslint to run * correct path * bump versions for vitest bug --------- Co-authored-by: Banks Nussman --- packages/api-v4/package.json | 2 +- .../manager/{.eslintrc.js => .eslintrc.cjs} | 0 .../cypress/support/plugins/configure-api.ts | 5 +- .../plugins/discard-passed-test-recordings.ts | 6 +- .../support/plugins/load-env-config.ts | 10 +- .../support/plugins/vite-preprocessor.ts | 18 +- packages/manager/cypress/tsconfig.json | 1 + packages/manager/package.json | 13 +- yarn.lock | 771 +++++++++++++----- 9 files changed, 601 insertions(+), 225 deletions(-) rename packages/manager/{.eslintrc.js => .eslintrc.cjs} (100%) diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 567c02a8136..4e57f3052cd 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -67,7 +67,7 @@ "lint-staged": "^13.2.2", "prettier": "~2.2.1", "tsup": "^7.2.0", - "vitest": "^0.34.6" + "vitest": "^1.0.1" }, "lint-staged": { "*.{ts,tsx,js}": [ diff --git a/packages/manager/.eslintrc.js b/packages/manager/.eslintrc.cjs similarity index 100% rename from packages/manager/.eslintrc.js rename to packages/manager/.eslintrc.cjs diff --git a/packages/manager/cypress/support/plugins/configure-api.ts b/packages/manager/cypress/support/plugins/configure-api.ts index e8548892857..16d9c13c915 100644 --- a/packages/manager/cypress/support/plugins/configure-api.ts +++ b/packages/manager/cypress/support/plugins/configure-api.ts @@ -1,6 +1,7 @@ -import { CypressPlugin } from './plugin'; +import { Profile, getProfile } from '@linode/api-v4'; + import { configureLinodeApi, defaultApiRoot } from '../util/api'; -import { getProfile, Profile } from '@linode/api-v4'; +import { CypressPlugin } from './plugin'; /** * Configures API requests to use configure access token and API root. diff --git a/packages/manager/cypress/support/plugins/discard-passed-test-recordings.ts b/packages/manager/cypress/support/plugins/discard-passed-test-recordings.ts index 4eb2d3aeb14..eca3bca5c2b 100644 --- a/packages/manager/cypress/support/plugins/discard-passed-test-recordings.ts +++ b/packages/manager/cypress/support/plugins/discard-passed-test-recordings.ts @@ -1,7 +1,7 @@ -import { CypressPlugin } from './plugin'; +// @ts-expect-error for some reason, @node/types is v12 and it probably doesn't have this. +import fs from 'fs/promises'; -// Dependencies used in hooks have to use `require()` syntax. -const fs = require('fs/promises'); // eslint-disable-line +import { CypressPlugin } from './plugin'; /** * Delete recordings for any specs that passed without requiring any diff --git a/packages/manager/cypress/support/plugins/load-env-config.ts b/packages/manager/cypress/support/plugins/load-env-config.ts index c7ad9cbee88..3d29a1f2ebb 100644 --- a/packages/manager/cypress/support/plugins/load-env-config.ts +++ b/packages/manager/cypress/support/plugins/load-env-config.ts @@ -1,5 +1,6 @@ import * as dotenv from 'dotenv'; import { resolve } from 'path'; +import { fileURLToPath } from 'url'; import { CypressPlugin } from './plugin'; @@ -14,7 +15,14 @@ export const loadEnvironmentConfig: CypressPlugin = ( _on, config ): Cypress.PluginConfigOptions => { - const dotenvPath = resolve(__dirname, '..', '..', '..', '.env'); + const dotenvPath = resolve( + fileURLToPath(import.meta.url), + '..', + '..', + '..', + '..', + '.env' + ); const conf = dotenv.config({ path: dotenvPath, }); diff --git a/packages/manager/cypress/support/plugins/vite-preprocessor.ts b/packages/manager/cypress/support/plugins/vite-preprocessor.ts index af95f959637..1a0115f81ec 100644 --- a/packages/manager/cypress/support/plugins/vite-preprocessor.ts +++ b/packages/manager/cypress/support/plugins/vite-preprocessor.ts @@ -1,12 +1,20 @@ -import { CypressPlugin } from './plugin'; +import vitePreprocessor from 'cypress-vite'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; -// Dependencies used in hooks have to use `require()` syntax. -const path = require('path'); // eslint-disable-line -const vitePreprocessor = require('cypress-vite'); // eslint-disable-line +import { CypressPlugin } from './plugin'; export const vitePreprocess: CypressPlugin = (on, _config): void => { on( 'file:preprocessor', - vitePreprocessor(path.resolve(__dirname, '..', '..', 'vite.config.ts')) + vitePreprocessor( + resolve( + fileURLToPath(import.meta.url), + '..', + '..', + '..', + 'vite.config.ts' + ) + ) ); }; diff --git a/packages/manager/cypress/tsconfig.json b/packages/manager/cypress/tsconfig.json index cd349ce3c3b..e84f29358ae 100644 --- a/packages/manager/cypress/tsconfig.json +++ b/packages/manager/cypress/tsconfig.json @@ -7,6 +7,7 @@ "noImplicitAny": false, "target": "es6", "moduleResolution": "node", + "module": "esnext", "lib": ["es6", "dom"], "baseUrl": "..", "jsx": "react", diff --git a/packages/manager/package.json b/packages/manager/package.json index 9de36d46544..268d9921274 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -4,6 +4,7 @@ "description": "The Linode Manager website", "version": "1.107.0", "private": true, + "type": "module", "bugs": { "url": "https://github.com/Linode/manager/issues" }, @@ -163,9 +164,9 @@ "@types/zxcvbn": "^4.4.0", "@typescript-eslint/eslint-plugin": "^4.1.1", "@typescript-eslint/parser": "^4.1.1", - "@vitejs/plugin-react-swc": "^3.4.0", - "@vitest/coverage-v8": "^0.34.6", - "@vitest/ui": "^0.34.6", + "@vitejs/plugin-react-swc": "^3.5.0", + "@vitest/coverage-v8": "^1.0.4", + "@vitest/ui": "^1.0.4", "chai-string": "^1.5.0", "chalk": "^5.2.0", "css-mediaquery": "^0.1.2", @@ -173,7 +174,7 @@ "cypress-axe": "^1.0.0", "cypress-file-upload": "^5.0.7", "cypress-real-events": "^1.11.0", - "cypress-vite": "^1.4.2", + "cypress-vite": "^1.5.0", "dotenv": "^16.0.3", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.14.0", @@ -204,9 +205,9 @@ "serve": "^14.0.1", "storybook": "~7.5.2", "storybook-dark-mode": "^3.0.1", - "vite": "^4.5.0", + "vite": "^5.0.7", "vite-plugin-svgr": "^3.2.0", - "vitest": "^0.34.6" + "vitest": "^1.0.4" }, "browserslist": [ ">1%", diff --git a/yarn.lock b/yarn.lock index bddd48ae342..eb2f33c230a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -607,6 +607,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f" integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw== +"@babel/helper-string-parser@^7.23.4": + version "7.23.4" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83" + integrity sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ== + "@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": version "7.19.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" @@ -696,6 +701,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719" integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw== +"@babel/parser@^7.23.3": + version "7.23.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.5.tgz#37dee97c4752af148e1d38c34b856b2507660563" + integrity sha512-hOOqoiNXrmGdFbhgCzu6GiURxUgM27Xwd/aPuu8RfHEZPBzL1Z54okAHAQjXfcQNwvrlkAmAp4SlRTZ45vlthQ== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.22.15": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.15.tgz#02dc8a03f613ed5fdc29fb2f728397c78146c962" @@ -1580,6 +1590,15 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" +"@babel/types@^7.23.3": + version "7.23.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.5.tgz#48d730a00c95109fa4393352705954d74fb5b602" + integrity sha512-ON5kSOJwVO6xXVRTvOI0eOnWe7VdUcIpsovGo9U/Br4Ie4UVFQTboO2cYnDhAGU6Fp+UxSiT+pMft0SMHfuq6w== + dependencies: + "@babel/helper-string-parser" "^7.23.4" + "@babel/helper-validator-identifier" "^7.22.20" + to-fast-properties "^2.0.0" + "@base2/pretty-print-object@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz#371ba8be66d556812dc7fb169ebc3c08378f69d4" @@ -1920,111 +1939,221 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== +"@esbuild/android-arm64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.8.tgz#fb7130103835b6d43ea499c3f30cfb2b2ed58456" + integrity sha512-B8JbS61bEunhfx8kasogFENgQfr/dIp+ggYXwTqdbMAgGDhRa3AaPpQMuQU0rNxDLECj6FhDzk1cF9WHMVwrtA== + "@esbuild/android-arm@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== +"@esbuild/android-arm@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.8.tgz#b46e4d9e984e6d6db6c4224d72c86b7757e35bcb" + integrity sha512-31E2lxlGM1KEfivQl8Yf5aYU/mflz9g06H6S15ITUFQueMFtFjESRMoDSkvMo8thYvLBax+VKTPlpnx+sPicOA== + "@esbuild/android-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg== +"@esbuild/android-x64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.8.tgz#a13db9441b5a4f4e4fec4a6f8ffacfea07888db7" + integrity sha512-rdqqYfRIn4jWOp+lzQttYMa2Xar3OK9Yt2fhOhzFXqg0rVWEfSclJvZq5fZslnz6ypHvVf3CT7qyf0A5pM682A== + "@esbuild/darwin-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA== +"@esbuild/darwin-arm64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.8.tgz#49f5718d36541f40dd62bfdf84da9c65168a0fc2" + integrity sha512-RQw9DemMbIq35Bprbboyf8SmOr4UXsRVxJ97LgB55VKKeJOOdvsIPy0nFyF2l8U+h4PtBx/1kRf0BelOYCiQcw== + "@esbuild/darwin-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ== +"@esbuild/darwin-x64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.8.tgz#75c5c88371eea4bfc1f9ecfd0e75104c74a481ac" + integrity sha512-3sur80OT9YdeZwIVgERAysAbwncom7b4bCI2XKLjMfPymTud7e/oY4y+ci1XVp5TfQp/bppn7xLw1n/oSQY3/Q== + "@esbuild/freebsd-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw== +"@esbuild/freebsd-arm64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.8.tgz#9d7259fea4fd2b5f7437b52b542816e89d7c8575" + integrity sha512-WAnPJSDattvS/XtPCTj1tPoTxERjcTpH6HsMr6ujTT+X6rylVe8ggxk8pVxzf5U1wh5sPODpawNicF5ta/9Tmw== + "@esbuild/freebsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ== +"@esbuild/freebsd-x64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.8.tgz#abac03e1c4c7c75ee8add6d76ec592f46dbb39e3" + integrity sha512-ICvZyOplIjmmhjd6mxi+zxSdpPTKFfyPPQMQTK/w+8eNK6WV01AjIztJALDtwNNfFhfZLux0tZLC+U9nSyA5Zg== + "@esbuild/linux-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA== +"@esbuild/linux-arm64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.8.tgz#c577932cf4feeaa43cb9cec27b89cbe0df7d9098" + integrity sha512-z1zMZivxDLHWnyGOctT9JP70h0beY54xDDDJt4VpTX+iwA77IFsE1vCXWmprajJGa+ZYSqkSbRQ4eyLCpCmiCQ== + "@esbuild/linux-arm@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg== +"@esbuild/linux-arm@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.8.tgz#d6014d8b98b5cbc96b95dad3d14d75bb364fdc0f" + integrity sha512-H4vmI5PYqSvosPaTJuEppU9oz1dq2A7Mr2vyg5TF9Ga+3+MGgBdGzcyBP7qK9MrwFQZlvNyJrvz6GuCaj3OukQ== + "@esbuild/linux-ia32@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA== +"@esbuild/linux-ia32@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.8.tgz#2379a0554307d19ac4a6cdc15b08f0ea28e7a40d" + integrity sha512-1a8suQiFJmZz1khm/rDglOc8lavtzEMRo0v6WhPgxkrjcU0LkHj+TwBrALwoz/OtMExvsqbbMI0ChyelKabSvQ== + "@esbuild/linux-loong64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d" integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg== +"@esbuild/linux-loong64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.8.tgz#e2a5bbffe15748b49356a6cd7b2d5bf60c5a7123" + integrity sha512-fHZWS2JJxnXt1uYJsDv9+b60WCc2RlvVAy1F76qOLtXRO+H4mjt3Tr6MJ5l7Q78X8KgCFudnTuiQRBhULUyBKQ== + "@esbuild/linux-mips64el@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ== +"@esbuild/linux-mips64el@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.8.tgz#1359331e6f6214f26f4b08db9b9df661c57cfa24" + integrity sha512-Wy/z0EL5qZYLX66dVnEg9riiwls5IYnziwuju2oUiuxVc+/edvqXa04qNtbrs0Ukatg5HEzqT94Zs7J207dN5Q== + "@esbuild/linux-ppc64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA== +"@esbuild/linux-ppc64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.8.tgz#9ba436addc1646dc89dae48c62d3e951ffe70951" + integrity sha512-ETaW6245wK23YIEufhMQ3HSeHO7NgsLx8gygBVldRHKhOlD1oNeNy/P67mIh1zPn2Hr2HLieQrt6tWrVwuqrxg== + "@esbuild/linux-riscv64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A== +"@esbuild/linux-riscv64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.8.tgz#fbcf0c3a0b20f40b5fc31c3b7695f0769f9de66b" + integrity sha512-T2DRQk55SgoleTP+DtPlMrxi/5r9AeFgkhkZ/B0ap99zmxtxdOixOMI570VjdRCs9pE4Wdkz7JYrsPvsl7eESg== + "@esbuild/linux-s390x@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ== +"@esbuild/linux-s390x@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.8.tgz#989e8a05f7792d139d5564ffa7ff898ac6f20a4a" + integrity sha512-NPxbdmmo3Bk7mbNeHmcCd7R7fptJaczPYBaELk6NcXxy7HLNyWwCyDJ/Xx+/YcNH7Im5dHdx9gZ5xIwyliQCbg== + "@esbuild/linux-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w== +"@esbuild/linux-x64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.8.tgz#b187295393a59323397fe5ff51e769ec4e72212b" + integrity sha512-lytMAVOM3b1gPypL2TRmZ5rnXl7+6IIk8uB3eLsV1JwcizuolblXRrc5ShPrO9ls/b+RTp+E6gbsuLWHWi2zGg== + "@esbuild/netbsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A== +"@esbuild/netbsd-x64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.8.tgz#c1ec0e24ea82313cb1c7bae176bd5acd5bde7137" + integrity sha512-hvWVo2VsXz/8NVt1UhLzxwAfo5sioj92uo0bCfLibB0xlOmimU/DeAEsQILlBQvkhrGjamP0/el5HU76HAitGw== + "@esbuild/openbsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg== +"@esbuild/openbsd-x64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.8.tgz#0c5b696ac66c6d70cf9ee17073a581a28af9e18d" + integrity sha512-/7Y7u77rdvmGTxR83PgaSvSBJCC2L3Kb1M/+dmSIvRvQPXXCuC97QAwMugBNG0yGcbEGfFBH7ojPzAOxfGNkwQ== + "@esbuild/sunos-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ== +"@esbuild/sunos-x64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.8.tgz#2a697e1f77926ff09fcc457d8f29916d6cd48fb1" + integrity sha512-9Lc4s7Oi98GqFA4HzA/W2JHIYfnXbUYgekUP/Sm4BG9sfLjyv6GKKHKKVs83SMicBF2JwAX6A1PuOLMqpD001w== + "@esbuild/win32-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg== +"@esbuild/win32-arm64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.8.tgz#ec029e62a2fca8c071842ecb1bc5c2dd20b066f1" + integrity sha512-rq6WzBGjSzihI9deW3fC2Gqiak68+b7qo5/3kmB6Gvbh/NYPA0sJhrnp7wgV4bNwjqM+R2AApXGxMO7ZoGhIJg== + "@esbuild/win32-ia32@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g== +"@esbuild/win32-ia32@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.8.tgz#cbb9a3146bde64dc15543e48afe418c7a3214851" + integrity sha512-AIAbverbg5jMvJznYiGhrd3sumfwWs8572mIJL5NQjJa06P8KfCPWZQ0NwZbPQnbQi9OWSZhFVSUWjjIrn4hSw== + "@esbuild/win32-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ== +"@esbuild/win32-x64@0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.8.tgz#c8285183dbdb17008578dbacb6e22748709b4822" + integrity sha512-bfZ0cQ1uZs2PqpulNL5j/3w+GDhP36k1K5c38QdQg+Swy51jFZWWeIkteNsufkQxp986wnqRRsb/bHbY1WQ7TA== + "@eslint-community/eslint-utils@^4.2.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -2844,6 +2973,66 @@ estree-walker "^2.0.2" picomatch "^2.3.1" +"@rollup/rollup-android-arm-eabi@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.6.1.tgz#0ea289f68ff248b50fea5716ca9f65f7d4dba3ae" + integrity sha512-0WQ0ouLejaUCRsL93GD4uft3rOmB8qoQMU05Kb8CmMtMBe7XUDLAltxVZI1q6byNqEtU7N1ZX1Vw5lIpgulLQA== + +"@rollup/rollup-android-arm64@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.6.1.tgz#27c8c67fc5de574874085a1b480ac65b3e18378e" + integrity sha512-1TKm25Rn20vr5aTGGZqo6E4mzPicCUD79k17EgTLAsXc1zysyi4xXKACfUbwyANEPAEIxkzwue6JZ+stYzWUTA== + +"@rollup/rollup-darwin-arm64@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.6.1.tgz#c5735c042980c85495411af7183dd20294763bd8" + integrity sha512-cEXJQY/ZqMACb+nxzDeX9IPLAg7S94xouJJCNVE5BJM8JUEP4HeTF+ti3cmxWeSJo+5D+o8Tc0UAWUkfENdeyw== + +"@rollup/rollup-darwin-x64@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.6.1.tgz#af844bd54abb73ca3c9cf89a31eec17861d1375d" + integrity sha512-LoSU9Xu56isrkV2jLldcKspJ7sSXmZWkAxg7sW/RfF7GS4F5/v4EiqKSMCFbZtDu2Nc1gxxFdQdKwkKS4rwxNg== + +"@rollup/rollup-linux-arm-gnueabihf@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.6.1.tgz#5e972f63c441eaf859551039b3f18db9b035977d" + integrity sha512-EfI3hzYAy5vFNDqpXsNxXcgRDcFHUWSx5nnRSCKwXuQlI5J9dD84g2Usw81n3FLBNsGCegKGwwTVsSKK9cooSQ== + +"@rollup/rollup-linux-arm64-gnu@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.6.1.tgz#f4cfbc71e3b6fdb395b28b1472414e181515c72d" + integrity sha512-9lhc4UZstsegbNLhH0Zu6TqvDfmhGzuCWtcTFXY10VjLLUe4Mr0Ye2L3rrtHaDd/J5+tFMEuo5LTCSCMXWfUKw== + +"@rollup/rollup-linux-arm64-musl@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.6.1.tgz#6a94c691830dc29bf708de7c640f494996130893" + integrity sha512-FfoOK1yP5ksX3wwZ4Zk1NgyGHZyuRhf99j64I5oEmirV8EFT7+OhUZEnP+x17lcP/QHJNWGsoJwrz4PJ9fBEXw== + +"@rollup/rollup-linux-x64-gnu@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.6.1.tgz#f07bae3f7dc532d9ea5ab36c9071db329f9a1efb" + integrity sha512-DNGZvZDO5YF7jN5fX8ZqmGLjZEXIJRdJEdTFMhiyXqyXubBa0WVLDWSNlQ5JR2PNgDbEV1VQowhVRUh+74D+RA== + +"@rollup/rollup-linux-x64-musl@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.6.1.tgz#357a34fdbf410af88ce48bd802bea6462bb9a8bc" + integrity sha512-RkJVNVRM+piYy87HrKmhbexCHg3A6Z6MU0W9GHnJwBQNBeyhCJG9KDce4SAMdicQnpURggSvtbGo9xAWOfSvIQ== + +"@rollup/rollup-win32-arm64-msvc@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.6.1.tgz#b6e97fd38281667e35297033393cd1101f4a31be" + integrity sha512-v2FVT6xfnnmTe3W9bJXl6r5KwJglMK/iRlkKiIFfO6ysKs0rDgz7Cwwf3tjldxQUrHL9INT/1r4VA0n9L/F1vQ== + +"@rollup/rollup-win32-ia32-msvc@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.6.1.tgz#a95db026c640c8128bfd38546d85342f2329beaf" + integrity sha512-YEeOjxRyEjqcWphH9dyLbzgkF8wZSKAKUkldRY6dgNR5oKs2LZazqGB41cWJ4Iqqcy9/zqYgmzBkRoVz3Q9MLw== + +"@rollup/rollup-win32-x64-msvc@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.6.1.tgz#45785b5caf83200a34a9867ba50d69560880c120" + integrity sha512-0zfTlFAIhgz8V2G8STq8toAjsYYA6eci1hnXuyOTUFnymrtJwnS6uGKiv3v5UrPZkBlamLvrLV2iiaeqCKzb0A== + "@sentry-internal/tracing@7.57.0": version "7.57.0" resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.57.0.tgz#cb761931b635f8f24c84be0eecfacb8516b20551" @@ -3721,106 +3910,101 @@ "@svgr/hast-util-to-babel-ast" "^7.0.0" svg-parser "^2.0.4" +"@swc/core-darwin-arm64@1.3.100": + version "1.3.100" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.100.tgz#f582c5bbc9c49506f728fc1d14dff33c2cc226d5" + integrity sha512-XVWFsKe6ei+SsDbwmsuRkYck1SXRpO60Hioa4hoLwR8fxbA9eVp6enZtMxzVVMBi8ej5seZ4HZQeAWepbukiBw== + "@swc/core-darwin-arm64@1.3.36": version "1.3.36" resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.36.tgz#37f15d0edda0e78837bdab337d69777d2fecfa40" integrity sha512-lsP+C8p9cC/Vd9uAbtxpEnM8GoJI/MMnVuXak7OlxOtDH9/oTwmAcAQTfNGNaH19d2FAIRwf+5RbXCPnxa2Zjw== -"@swc/core-darwin-arm64@1.3.92": - version "1.3.92" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.92.tgz#0498d3584cf877e39107c94705c38fa4a8c04789" - integrity sha512-v7PqZUBtIF6Q5Cp48gqUiG8zQQnEICpnfNdoiY3xjQAglCGIQCjJIDjreZBoeZQZspB27lQN4eZ43CX18+2SnA== +"@swc/core-darwin-x64@1.3.100": + version "1.3.100" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.100.tgz#d84f5c0bb4603c252884d011a698ed7c634b1505" + integrity sha512-KF/MXrnH1nakm1wbt4XV8FS7kvqD9TGmVxeJ0U4bbvxXMvzeYUurzg3AJUTXYmXDhH/VXOYJE5N5RkwZZPs5iA== "@swc/core-darwin-x64@1.3.36": version "1.3.36" resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.36.tgz#da6327511b62a78c2992749dd9ed813a9345608b" integrity sha512-jaLXsozWN5xachl9fPxDMi5nbWq1rRxPAt6ISeiYB6RJk0MQKH1634pOweBBem2pUDDzwDFXFw6f22LTm/cFvA== -"@swc/core-darwin-x64@1.3.92": - version "1.3.92" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.92.tgz#1728e7ebbfe37b56c07d99e29dde78bfa90cf8d1" - integrity sha512-Q3XIgQfXyxxxms3bPN+xGgvwk0TtG9l89IomApu+yTKzaIIlf051mS+lGngjnh9L0aUiCp6ICyjDLtutWP54fw== - "@swc/core-linux-arm-gnueabihf@1.3.36": version "1.3.36" resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.36.tgz#4272d94f376e5b90e6453d56f52f2618e2f7b825" integrity sha512-vcBdTHjoEpvJDbFlgto+S6VwAHzLA9GyCiuNcTU2v4KNQlFzhbO4A4PMfMCb/Z0RLJEr16tirfHdWIxjU3h8nw== -"@swc/core-linux-arm-gnueabihf@1.3.92": - version "1.3.92" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.92.tgz#6f7c20833b739f8911c936c9783976ded2c449dc" - integrity sha512-tnOCoCpNVXC+0FCfG84PBZJyLlz0Vfj9MQhyhCvlJz9hQmvpf8nTdKH7RHrOn8VfxtUBLdVi80dXgIFgbvl7qA== +"@swc/core-linux-arm64-gnu@1.3.100": + version "1.3.100" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.100.tgz#1ed4b92b373882d8f338c4e0a0aa64cdaa6106f1" + integrity sha512-p8hikNnAEJrw5vHCtKiFT4hdlQxk1V7vqPmvUDgL/qe2menQDK/i12tbz7/3BEQ4UqUPnvwpmVn2d19RdEMNxw== "@swc/core-linux-arm64-gnu@1.3.36": version "1.3.36" resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.36.tgz#d5c39fa52803ec0891c861588e5c4deb89652f63" integrity sha512-o7f5OsvwWppJo+qIZmrGO5+XC6DPt6noecSbRHjF6o1YAcR13ETPC14k1eC9H1YbQwpyCFNVAFXyNcUbCeQyrQ== -"@swc/core-linux-arm64-gnu@1.3.92": - version "1.3.92" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.92.tgz#bb01dd9b922b0c076c38924013bd10036ce39c7c" - integrity sha512-lFfGhX32w8h1j74Iyz0Wv7JByXIwX11OE9UxG+oT7lG0RyXkF4zKyxP8EoxfLrDXse4Oop434p95e3UNC3IfCw== +"@swc/core-linux-arm64-musl@1.3.100": + version "1.3.100" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.100.tgz#9db560f7459e42e65ec02670d6a8316e7c850cfc" + integrity sha512-BWx/0EeY89WC4q3AaIaBSGfQxkYxIlS3mX19dwy2FWJs/O+fMvF9oLk/CyJPOZzbp+1DjGeeoGFuDYpiNO91JA== "@swc/core-linux-arm64-musl@1.3.36": version "1.3.36" resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.36.tgz#2a47ba9b438790f2e32584ca0698ef053cc3ddba" integrity sha512-FSHPngMi3c0fuGt9yY2Ubn5UcELi3EiPLJxBSC3X8TF9atI/WHZzK9PE9Gtn0C/LyRh4CoyOugDtSOPzGYmLQg== -"@swc/core-linux-arm64-musl@1.3.92": - version "1.3.92" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.92.tgz#0070165eed2805475c98eb732bab8bdca955932e" - integrity sha512-rOZtRcLj57MSAbiecMsqjzBcZDuaCZ8F6l6JDwGkQ7u1NYR57cqF0QDyU7RKS1Jq27Z/Vg21z5cwqoH5fLN+Sg== +"@swc/core-linux-x64-gnu@1.3.100": + version "1.3.100" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.100.tgz#228826ea48879bf1e73683fbef4373e3e762e424" + integrity sha512-XUdGu3dxAkjsahLYnm8WijPfKebo+jHgHphDxaW0ovI6sTdmEGFDew7QzKZRlbYL2jRkUuuKuDGvD6lO5frmhA== "@swc/core-linux-x64-gnu@1.3.36": version "1.3.36" resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.36.tgz#5e239123452231092eac7d6bd007949b5a7e38fb" integrity sha512-PHSsH2rek5pr3e0K09VgWAbrWK2vJhaI7MW9TPoTjyACYjcs3WwjcjQ30MghXUs2Dc/bXjWAOi9KFTjq/uCyFg== -"@swc/core-linux-x64-gnu@1.3.92": - version "1.3.92" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.92.tgz#d9785f93b9121eeef0f54e8d845dd216698e0115" - integrity sha512-qptoMGnBL6v89x/Qpn+l1TH1Y0ed+v0qhNfAEVzZvCvzEMTFXphhlhYbDdpxbzRmCjH6GOGq7Y+xrWt9T1/ARg== +"@swc/core-linux-x64-musl@1.3.100": + version "1.3.100" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.100.tgz#09a234dbbf625d071ecb663680e997a62d230d49" + integrity sha512-PhoXKf+f0OaNW/GCuXjJ0/KfK9EJX7z2gko+7nVnEA0p3aaPtbP6cq1Ubbl6CMoPL+Ci3gZ7nYumDqXNc3CtLQ== "@swc/core-linux-x64-musl@1.3.36": version "1.3.36" resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.36.tgz#ed2a26e32d4d4e6f7cbf9f34d50cd38feb78d8dd" integrity sha512-4LfMYQHzozHCKkIcmQy83b+4SpI+mOp6sYNbXqSRz5dYvTVjegKZXe596P1U/87cK2cgR4uYvkgkgBXquaWvwQ== -"@swc/core-linux-x64-musl@1.3.92": - version "1.3.92" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.92.tgz#8fe5cf244695bf4f0bc7dc7df450a9bd1bfccc2b" - integrity sha512-g2KrJ43bZkCZHH4zsIV5ErojuV1OIpUHaEyW1gf7JWKaFBpWYVyubzFPvPkjcxHGLbMsEzO7w/NVfxtGMlFH/Q== +"@swc/core-win32-arm64-msvc@1.3.100": + version "1.3.100" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.100.tgz#add1c82884c10a9054ed6a48f884097aa85c6d2b" + integrity sha512-PwLADZN6F9cXn4Jw52FeP/MCLVHm8vwouZZSOoOScDtihjY495SSjdPnlosMaRSR4wJQssGwiD/4MbpgQPqbAw== "@swc/core-win32-arm64-msvc@1.3.36": version "1.3.36" resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.36.tgz#d8fbba50bfbf5e39aa4826c8c46978c4b1fdfbe7" integrity sha512-7y3dDcun79TAjCyk3Iv0eOMw1X/KNQbkVyKOGqnEgq9g22F8F1FoUGKHNTzUqVdzpHeJSsHgW5PlkEkl3c/d9w== -"@swc/core-win32-arm64-msvc@1.3.92": - version "1.3.92" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.92.tgz#d6150785455c813a8e62f4e4b0a22773baf398eb" - integrity sha512-3MCRGPAYDoQ8Yyd3WsCMc8eFSyKXY5kQLyg/R5zEqA0uthomo0m0F5/fxAJMZGaSdYkU1DgF73ctOWOf+Z/EzQ== +"@swc/core-win32-ia32-msvc@1.3.100": + version "1.3.100" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.100.tgz#e0b6c5ae7f3250adeeb88dae83558d3f45148c56" + integrity sha512-0f6nicKSLlDKlyPRl2JEmkpBV4aeDfRQg6n8mPqgL7bliZIcDahG0ej+HxgNjZfS3e0yjDxsNRa6sAqWU2Z60A== "@swc/core-win32-ia32-msvc@1.3.36": version "1.3.36" resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.36.tgz#108830d0282a80d0f2d77bee7773d2985d389346" integrity sha512-zK0VR3B4LX5hzQ+7eD+K+FkxJlJg5Lo36BeahMzQ+/i0IURpnuyFlW88sdkFkMsc2swdU6bpvxLZeIRQ3W4OUg== -"@swc/core-win32-ia32-msvc@1.3.92": - version "1.3.92" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.92.tgz#8142166bceafbaa209d440b36fdc8cd4b4f82768" - integrity sha512-zqTBKQhgfWm73SVGS8FKhFYDovyRl1f5dTX1IwSKynO0qHkRCqJwauFJv/yevkpJWsI2pFh03xsRs9HncTQKSA== +"@swc/core-win32-x64-msvc@1.3.100": + version "1.3.100" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.100.tgz#34721dff151d7dcf165675f18aeed0a12264d88c" + integrity sha512-b7J0rPoMkRTa3XyUGt8PwCaIBuYWsL2DqbirrQKRESzgCvif5iNpqaM6kjIjI/5y5q1Ycv564CB51YDpiS8EtQ== "@swc/core-win32-x64-msvc@1.3.36": version "1.3.36" resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.36.tgz#96d9b1077a6877f6583f5d7405f8b9380b9364fc" integrity sha512-2bIjr9DhAckGiXZEvj6z2z7ECPcTimG+wD0VuQTvr+wkx46uAJKl5Kq+Zk+dd15ErL7JGUtCet1T7bf1k4FwvQ== -"@swc/core-win32-x64-msvc@1.3.92": - version "1.3.92" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.92.tgz#4ba542875fc690b579232721ccec7873e139646a" - integrity sha512-41bE66ddr9o/Fi1FBh0sHdaKdENPTuDpv1IFHxSg0dJyM/jX8LbkjnpdInYXHBxhcLVAPraVRrNsC4SaoPw2Pg== - "@swc/core@^1.3.1": version "1.3.36" resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.3.36.tgz#c82fd4e7789082aeff47a622ef3701fffaf835e7" @@ -3837,24 +4021,23 @@ "@swc/core-win32-ia32-msvc" "1.3.36" "@swc/core-win32-x64-msvc" "1.3.36" -"@swc/core@^1.3.85": - version "1.3.92" - resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.3.92.tgz#f51808cdb6cbb90b0877b9a51806eea9a70eafca" - integrity sha512-vx0vUrf4YTEw59njOJ46Ha5i0cZTMYdRHQ7KXU29efN1MxcmJH2RajWLPlvQarOP1ab9iv9cApD7SMchDyx2vA== +"@swc/core@^1.3.96": + version "1.3.100" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.3.100.tgz#8fa36f26a35137620234b084224c9fa9b8a0fee2" + integrity sha512-7dKgTyxJjlrMwFZYb1auj3Xq0D8ZBe+5oeIgfMlRU05doXZypYJe0LAk0yjj3WdbwYzpF+T1PLxwTWizI0pckw== dependencies: "@swc/counter" "^0.1.1" "@swc/types" "^0.1.5" optionalDependencies: - "@swc/core-darwin-arm64" "1.3.92" - "@swc/core-darwin-x64" "1.3.92" - "@swc/core-linux-arm-gnueabihf" "1.3.92" - "@swc/core-linux-arm64-gnu" "1.3.92" - "@swc/core-linux-arm64-musl" "1.3.92" - "@swc/core-linux-x64-gnu" "1.3.92" - "@swc/core-linux-x64-musl" "1.3.92" - "@swc/core-win32-arm64-msvc" "1.3.92" - "@swc/core-win32-ia32-msvc" "1.3.92" - "@swc/core-win32-x64-msvc" "1.3.92" + "@swc/core-darwin-arm64" "1.3.100" + "@swc/core-darwin-x64" "1.3.100" + "@swc/core-linux-arm64-gnu" "1.3.100" + "@swc/core-linux-arm64-musl" "1.3.100" + "@swc/core-linux-x64-gnu" "1.3.100" + "@swc/core-linux-x64-musl" "1.3.100" + "@swc/core-win32-arm64-msvc" "1.3.100" + "@swc/core-win32-ia32-msvc" "1.3.100" + "@swc/core-win32-x64-msvc" "1.3.100" "@swc/counter@^0.1.1": version "0.1.2" @@ -4021,18 +4204,6 @@ "@types/googlepay" "*" "@types/paypal-checkout-components" "*" -"@types/chai-subset@^1.3.3": - version "1.3.4" - resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.4.tgz#7938fa929dd12db451457e4d6faa27bcd599a729" - integrity sha512-CCWNXrJYSUIojZ1149ksLl3AN9cmZ5djf+yUoVVV+NuYrtydItQVlL2ZDqyC6M6O9LWRnVf8yYDxbXHO2TfQZg== - dependencies: - "@types/chai" "*" - -"@types/chai@*", "@types/chai@^4.3.5": - version "4.3.9" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.9.tgz#144d762491967db8c6dea38e03d2206c2623feec" - integrity sha512-69TtiDzu0bcmKQv3yg1Zx409/Kd7r0b5F1PfpYJfSHzLGtB53547V4u+9iqKYsTu/O2ai6KTb0TInNpvuQ3qmg== - "@types/chart.js@^2.9.21": version "2.9.37" resolved "https://registry.yarnpkg.com/@types/chart.js/-/chart.js-2.9.37.tgz#8af70862b154fedf938b5b87debdb3a70f6e3208" @@ -4909,12 +5080,12 @@ "@typescript-eslint/types" "5.60.1" eslint-visitor-keys "^3.3.0" -"@vitejs/plugin-react-swc@^3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.4.0.tgz#53ca6a07423abadec92f967e188d5ba49b350830" - integrity sha512-m7UaA4Uvz82N/0EOVpZL4XsFIakRqrFKeSNxa1FBLSXGvWrWRBwmZb4qxk+ZIVAZcW3c3dn5YosomDgx62XWcQ== +"@vitejs/plugin-react-swc@^3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-swc/-/plugin-react-swc-3.5.0.tgz#1fadff5148003e8091168c431e44c850f9a39e74" + integrity sha512-1PrOvAaDpqlCV+Up8RkAh9qaiUjoDUcjtttyhXDKw53XA6Ve16SOp6cCOpRs8Dj8DqUQs6eTW5YkLcLJjrXAig== dependencies: - "@swc/core" "^1.3.85" + "@swc/core" "^1.3.96" "@vitejs/plugin-react@^3.0.1": version "3.1.0" @@ -4927,78 +5098,123 @@ magic-string "^0.27.0" react-refresh "^0.14.0" -"@vitest/coverage-v8@^0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-0.34.6.tgz#931d9223fa738474e00c08f52b84e0f39cedb6d1" - integrity sha512-fivy/OK2d/EsJFoEoxHFEnNGTg+MmdZBAVK9Ka4qhXR2K3J0DS08vcGVwzDtXSuUMabLv4KtPcpSKkcMXFDViw== +"@vitest/coverage-v8@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-1.0.4.tgz#49193139399c37ddf16c23d96892ef32cd67a69a" + integrity sha512-xD6Yuql6RW0Ir/JJIs6rVrmnG2/KOWJF+IRX1oJQk5wGKGxbtdrYPbl+WTUn/4ICCQ2G20zbE1e8/nPNyAG5Vg== dependencies: "@ampproject/remapping" "^2.2.1" "@bcoe/v8-coverage" "^0.2.3" - istanbul-lib-coverage "^3.2.0" + debug "^4.3.4" + istanbul-lib-coverage "^3.2.2" istanbul-lib-report "^3.0.1" istanbul-lib-source-maps "^4.0.1" - istanbul-reports "^3.1.5" - magic-string "^0.30.1" + istanbul-reports "^3.1.6" + magic-string "^0.30.5" + magicast "^0.3.2" picocolors "^1.0.0" - std-env "^3.3.3" + std-env "^3.5.0" test-exclude "^6.0.0" - v8-to-istanbul "^9.1.0" + v8-to-istanbul "^9.2.0" + +"@vitest/expect@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.0.1.tgz#5e63902316a3c65948c6e36f284046962601fb88" + integrity sha512-3cdrb/eKD/0tygDX75YscuHEHMUJ70u3UoLSq2eqhWks57AyzvsDQbyn53IhZ0tBN7gA8Jj2VhXiOV2lef7thw== + dependencies: + "@vitest/spy" "1.0.1" + "@vitest/utils" "1.0.1" + chai "^4.3.10" -"@vitest/expect@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.34.6.tgz#608a7b7a9aa3de0919db99b4cc087340a03ea77e" - integrity sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw== +"@vitest/expect@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.0.4.tgz#2751018b6e527841043e046ff424304453a0a024" + integrity sha512-/NRN9N88qjg3dkhmFcCBwhn/Ie4h064pY3iv7WLRsDJW7dXnEgeoa8W9zy7gIPluhz6CkgqiB3HmpIXgmEY5dQ== dependencies: - "@vitest/spy" "0.34.6" - "@vitest/utils" "0.34.6" + "@vitest/spy" "1.0.4" + "@vitest/utils" "1.0.4" chai "^4.3.10" -"@vitest/runner@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-0.34.6.tgz#6f43ca241fc96b2edf230db58bcde5b974b8dcaf" - integrity sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ== +"@vitest/runner@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.0.1.tgz#d94cab9e3008dba52f89e811540184334766ab61" + integrity sha512-/+z0vhJ0MfRPT3AyTvAK6m57rzlew/ct8B2a4LMv7NhpPaiI2QLGyOBMB3lcioWdJHjRuLi9aYppfOv0B5aRQA== dependencies: - "@vitest/utils" "0.34.6" - p-limit "^4.0.0" + "@vitest/utils" "1.0.1" + p-limit "^5.0.0" pathe "^1.1.1" -"@vitest/snapshot@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-0.34.6.tgz#b4528cf683b60a3e8071cacbcb97d18b9d5e1d8b" - integrity sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w== +"@vitest/runner@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.0.4.tgz#c4dcb88c07f40b91293ff1331747ee58fad6d5e4" + integrity sha512-rhOQ9FZTEkV41JWXozFM8YgOqaG9zA7QXbhg5gy6mFOVqh4PcupirIJ+wN7QjeJt8S8nJRYuZH1OjJjsbxAXTQ== dependencies: - magic-string "^0.30.1" + "@vitest/utils" "1.0.4" + p-limit "^5.0.0" pathe "^1.1.1" - pretty-format "^29.5.0" -"@vitest/spy@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-0.34.6.tgz#b5e8642a84aad12896c915bce9b3cc8cdaf821df" - integrity sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ== +"@vitest/snapshot@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.0.1.tgz#9d2a01c64726afa62264175554690e5ce148d4a5" + integrity sha512-wIPtPDGSxEZ+DpNMc94AsybX6LV6uN6sosf5TojyP1m2QbKwiRuLV/5RSsjt1oWViHsTj8mlcwrQQ1zHGO0fMw== dependencies: - tinyspy "^2.1.1" + magic-string "^0.30.5" + pathe "^1.1.1" + pretty-format "^29.7.0" -"@vitest/ui@^0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/ui/-/ui-0.34.6.tgz#86a9d58d1514aaea6a4b27ddd3c430646afca488" - integrity sha512-/fxnCwGC0Txmr3tF3BwAbo3v6U2SkBTGR9UB8zo0Ztlx0BTOXHucE0gDHY7SjwEktCOHatiGmli9kZD6gYSoWQ== +"@vitest/snapshot@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.0.4.tgz#7020983b3963b473237fea08d347ea83b266b9bb" + integrity sha512-vkfXUrNyNRA/Gzsp2lpyJxh94vU2OHT1amoD6WuvUAA12n32xeVZQ0KjjQIf8F6u7bcq2A2k969fMVxEsxeKYA== dependencies: - "@vitest/utils" "0.34.6" - fast-glob "^3.3.0" - fflate "^0.8.0" - flatted "^3.2.7" + magic-string "^0.30.5" + pathe "^1.1.1" + pretty-format "^29.7.0" + +"@vitest/spy@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.0.1.tgz#d82af1c4d935e08443bf20432ba55afd001ac71f" + integrity sha512-yXwm1uKhBVr/5MhVeSmtNqK+0q2RXIchJt8kokEKdrWLtkPeDgdbZ6SjR1VQGZuNdWL6sSBnLayIyVvcS0qLfA== + dependencies: + tinyspy "^2.2.0" + +"@vitest/spy@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.0.4.tgz#e182c78fb9b1178ff789ad7eb4560ba6750e6e9b" + integrity sha512-9ojTFRL1AJVh0hvfzAQpm0QS6xIS+1HFIw94kl/1ucTfGCaj1LV/iuJU4Y6cdR03EzPDygxTHwE1JOm+5RCcvA== + dependencies: + tinyspy "^2.2.0" + +"@vitest/ui@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@vitest/ui/-/ui-1.0.4.tgz#810aa36bdd0d984483ae0c50d5163012d8191c7a" + integrity sha512-gd4p6e7pjukSe4joWS5wpnm/JcEfzCZUYkYWQOORqJK1mDJ0MOaXa/9BbPOEVO5TcvdnKvFJUdJpFHnqoyYwZA== + dependencies: + "@vitest/utils" "1.0.4" + fast-glob "^3.3.2" + fflate "^0.8.1" + flatted "^3.2.9" pathe "^1.1.1" picocolors "^1.0.0" sirv "^2.0.3" -"@vitest/utils@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-0.34.6.tgz#38a0a7eedddb8e7291af09a2409cb8a189516968" - integrity sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A== +"@vitest/utils@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.0.1.tgz#ab2bf6de50845649b252a9d263765ab7f16bd6a2" + integrity sha512-MGPCHkzXbbAyscrhwGzh8uP1HPrTYLWaj1WTDtWSGrpe2yJWLRN9mF9ooKawr6NMOg9vTBtg2JqWLfuLC7Dknw== dependencies: - diff-sequences "^29.4.3" - loupe "^2.3.6" - pretty-format "^29.5.0" + diff-sequences "^29.6.3" + loupe "^2.3.7" + pretty-format "^29.7.0" + +"@vitest/utils@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.0.4.tgz#6e673eaf87a2ff28a12688d17bdbb62cc22bf773" + integrity sha512-gsswWDXxtt0QvtK/y/LWukN7sGMYmnCcv1qv05CsY6cU/Y1zpGX1QuvLs+GO1inczpE6Owixeel3ShkjhYtGfA== + dependencies: + diff-sequences "^29.6.3" + loupe "^2.3.7" + pretty-format "^29.7.0" "@xmldom/xmldom@^0.8.3": version "0.8.10" @@ -5066,10 +5282,10 @@ acorn-walk@^7.2.0: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== -acorn-walk@^8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" - integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== +acorn-walk@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.0.tgz#2097665af50fd0cf7a2dfccd2b9368964e66540f" + integrity sha512-FS7hV565M5l1R08MXqo8odwMTB02C2UqzB17RVgu9EyuYFBqJZ3/ZY97sQD5FewVu1UyDFc1yztUDrAwT0EypA== acorn@^7.1.1, acorn@^7.4.1: version "7.4.1" @@ -5086,11 +5302,6 @@ acorn@^8.8.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== -acorn@^8.9.0: - version "8.10.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" - integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== - address@^1.0.1: version "1.2.2" resolved "https://registry.yarnpkg.com/address/-/address-1.2.2.tgz#2b5248dac5485a6390532c6a517fda2e3faac89e" @@ -6613,10 +6824,10 @@ cypress-real-events@^1.11.0: resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.11.0.tgz#292fe5281c5b6e955524e766ab7fec46930c7763" integrity sha512-4LXVRsyq+xBh5TmlEyO1ojtBXtN7xw720Pwb9rEE9rkJuXmeH3VyoR1GGayMGr+Itqf11eEjfDewtDmcx6PWPQ== -cypress-vite@^1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/cypress-vite/-/cypress-vite-1.4.2.tgz#56a93d1d6329306e27ce2f2ba30787fde0e51d1c" - integrity sha512-uKsCo6KC1KJgubDCs7PqqI0AVXaYDPLocNvZplw2kJ2Z8M1793oCcr9D2/dKxYllRkhfFuYPPNjme/Kr2YWojQ== +cypress-vite@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/cypress-vite/-/cypress-vite-1.5.0.tgz#471ecc1175c7ab51b3b132c595dc3c7e222fe944" + integrity sha512-vvTMqJZgI3sN2ylQTi4OQh8LRRjSrfrIdkQD5fOj+EC/e9oHkxS96lif1SyDF1PwailG1tnpJE+VpN6+AwO/rg== dependencies: chokidar "^3.5.3" debug "^4.3.4" @@ -7339,7 +7550,7 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -esbuild@^0.18.0, esbuild@^0.18.10, esbuild@^0.18.2: +esbuild@^0.18.0, esbuild@^0.18.2: version "0.18.20" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6" integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA== @@ -7367,6 +7578,34 @@ esbuild@^0.18.0, esbuild@^0.18.10, esbuild@^0.18.2: "@esbuild/win32-ia32" "0.18.20" "@esbuild/win32-x64" "0.18.20" +esbuild@^0.19.3: + version "0.19.8" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.8.tgz#ad05b72281d84483fa6b5345bd246c27a207b8f1" + integrity sha512-l7iffQpT2OrZfH2rXIp7/FkmaeZM0vxbxN9KfiCwGYuZqzMg/JdvX26R31Zxn/Pxvsrg3Y9N6XTcnknqDyyv4w== + optionalDependencies: + "@esbuild/android-arm" "0.19.8" + "@esbuild/android-arm64" "0.19.8" + "@esbuild/android-x64" "0.19.8" + "@esbuild/darwin-arm64" "0.19.8" + "@esbuild/darwin-x64" "0.19.8" + "@esbuild/freebsd-arm64" "0.19.8" + "@esbuild/freebsd-x64" "0.19.8" + "@esbuild/linux-arm" "0.19.8" + "@esbuild/linux-arm64" "0.19.8" + "@esbuild/linux-ia32" "0.19.8" + "@esbuild/linux-loong64" "0.19.8" + "@esbuild/linux-mips64el" "0.19.8" + "@esbuild/linux-ppc64" "0.19.8" + "@esbuild/linux-riscv64" "0.19.8" + "@esbuild/linux-s390x" "0.19.8" + "@esbuild/linux-x64" "0.19.8" + "@esbuild/netbsd-x64" "0.19.8" + "@esbuild/openbsd-x64" "0.19.8" + "@esbuild/sunos-x64" "0.19.8" + "@esbuild/win32-arm64" "0.19.8" + "@esbuild/win32-ia32" "0.19.8" + "@esbuild/win32-x64" "0.19.8" + escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -7823,6 +8062,21 @@ execa@^7.0.0: signal-exit "^3.0.7" strip-final-newline "^3.0.0" +execa@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-8.0.1.tgz#51f6a5943b580f963c3ca9c6321796db8cc39b8c" + integrity sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^8.0.1" + human-signals "^5.0.0" + is-stream "^3.0.0" + merge-stream "^2.0.0" + npm-run-path "^5.1.0" + onetime "^6.0.0" + signal-exit "^4.1.0" + strip-final-newline "^3.0.0" + executable@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/executable/-/executable-4.1.1.tgz#41532bff361d3e57af4d763b70582db18f5d133c" @@ -7962,10 +8216,10 @@ fast-glob@^3.2.9: merge2 "^1.3.0" micromatch "^4.0.4" -fast-glob@^3.3.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4" - integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg== +fast-glob@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== dependencies: "@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.walk" "^1.2.3" @@ -8034,7 +8288,7 @@ fflate@^0.4.8: resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== -fflate@^0.8.0: +fflate@^0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.1.tgz#1ed92270674d2ad3c73f077cd0acf26486dae6c9" integrity sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ== @@ -8195,7 +8449,7 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== -flatted@^3.2.7: +flatted@^3.2.9: version "3.2.9" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf" integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== @@ -8343,6 +8597,11 @@ fsevents@^2.3.2, fsevents@~2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== +fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -8446,6 +8705,11 @@ get-stream@^6.0.0, get-stream@^6.0.1: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== +get-stream@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-8.0.1.tgz#def9dfd71742cd7754a7761ed43749a27d02eca2" + integrity sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA== + get-symbol-description@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" @@ -8892,6 +9156,11 @@ human-signals@^4.3.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.0.tgz#2095c3cd5afae40049403d4b811235b03879db50" integrity sha512-zyzVyMjpGBX2+6cDVZeFPCdtOtdsxOeseRhB9tkQ6xXmGUNrcnBzdEKPy3VPNYz+4gy1oukVOXcrJCunSyc6QQ== +human-signals@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" + integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== + husky@^3.0.1: version "3.1.0" resolved "https://registry.yarnpkg.com/husky/-/husky-3.1.0.tgz#5faad520ab860582ed94f0c1a77f0f04c90b57c0" @@ -9468,7 +9737,7 @@ isstream@~0.1.2: resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== -istanbul-lib-coverage@^3.0.0: +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== @@ -9507,7 +9776,7 @@ istanbul-lib-source-maps@^4.0.1: istanbul-lib-coverage "^3.0.0" source-map "^0.6.1" -istanbul-reports@^3.1.5: +istanbul-reports@^3.1.6: version "3.1.6" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.6.tgz#2544bcab4768154281a2f0870471902704ccaa1a" integrity sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg== @@ -10121,10 +10390,13 @@ load-tsconfig@^0.2.3: resolved "https://registry.yarnpkg.com/load-tsconfig/-/load-tsconfig-0.2.3.tgz#08af3e7744943caab0c75f8af7f1703639c3ef1f" integrity sha512-iyT2MXws+dc2Wi6o3grCFtGXpeMvHmJqS27sMPGtV2eUu4PeFnG+33I8BlFK1t1NWMjOpcx9bridn5yxLDX2gQ== -local-pkg@^0.4.3: - version "0.4.3" - resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963" - integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g== +local-pkg@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.5.0.tgz#093d25a346bae59a99f80e75f6e9d36d7e8c925c" + integrity sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg== + dependencies: + mlly "^1.4.2" + pkg-types "^1.0.3" locate-path@^3.0.0: version "3.0.0" @@ -10238,7 +10510,7 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3 dependencies: js-tokens "^3.0.0 || ^4.0.0" -loupe@^2.3.6: +loupe@^2.3.6, loupe@^2.3.7: version "2.3.7" resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== @@ -10291,13 +10563,22 @@ magic-string@^0.27.0: dependencies: "@jridgewell/sourcemap-codec" "^1.4.13" -magic-string@^0.30.0, magic-string@^0.30.1: +magic-string@^0.30.0, magic-string@^0.30.5: version "0.30.5" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9" integrity sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA== dependencies: "@jridgewell/sourcemap-codec" "^1.4.15" +magicast@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.3.2.tgz#42dcade5573ed8f10f5540f9d04964e21dba9130" + integrity sha512-Fjwkl6a0syt9TFN0JSYpOybxiMCkYNEeOTnOTNRbjphirLakznZXAqrXgj/7GG3D1dvETONNwrBfinvAbpunDg== + dependencies: + "@babel/parser" "^7.23.3" + "@babel/types" "^7.23.3" + source-map-js "^1.0.2" + make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" @@ -10583,7 +10864,7 @@ mkdirp@^1.0.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mlly@^1.2.0, mlly@^1.4.0: +mlly@^1.2.0, mlly@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.4.2.tgz#7cf406aa319ff6563d25da6b36610a93f2a8007e" integrity sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg== @@ -10684,6 +10965,11 @@ nanoid@^3.3.6: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + natural-compare-lite@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" @@ -11049,10 +11335,10 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" -p-limit@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-4.0.0.tgz#914af6544ed32bfa54670b061cafcbd04984b644" - integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ== +p-limit@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-5.0.0.tgz#6946d5b7140b649b7a33a027d89b4c625b3a5985" + integrity sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ== dependencies: yocto-queue "^1.0.0" @@ -11380,7 +11666,7 @@ postcss-load-config@^4.0.1: lilconfig "^2.0.5" yaml "^2.1.1" -postcss@^8.3.11, postcss@^8.4.27: +postcss@^8.3.11: version "8.4.31" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== @@ -11389,6 +11675,15 @@ postcss@^8.3.11, postcss@^8.4.27: picocolors "^1.0.0" source-map-js "^1.0.2" +postcss@^8.4.32: + version "8.4.32" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.32.tgz#1dac6ac51ab19adb21b8b34fd2d93a86440ef6c9" + integrity sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.0.2" + postinstall@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/postinstall/-/postinstall-0.6.0.tgz#0b1b3b9bc2f4b2d492601cea77da06154f9aae17" @@ -11460,7 +11755,7 @@ pretty-format@^29.0.0, pretty-format@^29.4.3: ansi-styles "^5.0.0" react-is "^18.0.0" -pretty-format@^29.2.1, pretty-format@^29.5.0, pretty-format@^29.7.0: +pretty-format@^29.2.1, pretty-format@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== @@ -12456,11 +12751,23 @@ rimraf@^2.6.1, rimraf@^2.6.3: optionalDependencies: fsevents "~2.3.2" -rollup@^3.27.1: - version "3.29.4" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.4.tgz#4d70c0f9834146df8705bfb69a9a19c9e1109981" - integrity sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw== +rollup@^4.2.0: + version "4.6.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.6.1.tgz#351501c86b5b4f976dde8c5837516452b59921f8" + integrity sha512-jZHaZotEHQaHLgKr8JnQiDT1rmatjgKlMekyksz+yk9jt/8z9quNjnKNRoaM0wd9DC2QKXjmWWuDYtM3jfF8pQ== optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.6.1" + "@rollup/rollup-android-arm64" "4.6.1" + "@rollup/rollup-darwin-arm64" "4.6.1" + "@rollup/rollup-darwin-x64" "4.6.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.6.1" + "@rollup/rollup-linux-arm64-gnu" "4.6.1" + "@rollup/rollup-linux-arm64-musl" "4.6.1" + "@rollup/rollup-linux-x64-gnu" "4.6.1" + "@rollup/rollup-linux-x64-musl" "4.6.1" + "@rollup/rollup-win32-arm64-msvc" "4.6.1" + "@rollup/rollup-win32-ia32-msvc" "4.6.1" + "@rollup/rollup-win32-x64-msvc" "4.6.1" fsevents "~2.3.2" rrweb-cssom@^0.6.0: @@ -12745,6 +13052,11 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.0.2.tgz#ff55bb1d9ff2114c13b400688fa544ac63c36967" integrity sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q== +signal-exit@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + simple-git@^3.19.0: version "3.19.0" resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.19.0.tgz#fe8d0cd86a0e68372b75c0c44a0cb887201c3f7d" @@ -12936,10 +13248,10 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -std-env@^3.3.3: - version "3.4.3" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.4.3.tgz#326f11db518db751c83fd58574f449b7c3060910" - integrity sha512-f9aPhy8fYBuMN+sNfakZV18U39PbalgjXG3lLB9WkaYTxijru61wb57V9wxxNthXM5Sd88ETBWi29qLAsHO52Q== +std-env@^3.5.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.6.0.tgz#94807562bddc68fa90f2e02c5fd5b6865bb4e98e" + integrity sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg== stop-iteration-iterator@^1.0.0: version "1.0.0" @@ -13183,7 +13495,7 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== -strip-literal@^1.0.1: +strip-literal@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-1.3.0.tgz#db3942c2ec1699e6836ad230090b84bb458e3a07" integrity sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg== @@ -13423,17 +13735,17 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== -tinybench@^2.5.0: +tinybench@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.5.1.tgz#3408f6552125e53a5a48adee31261686fd71587e" integrity sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg== -tinypool@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.7.0.tgz#88053cc99b4a594382af23190c609d93fddf8021" - integrity sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww== +tinypool@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.8.1.tgz#b6c4e4972ede3e3e5cda74a3da1679303d386b03" + integrity sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg== -tinyspy@^2.1.1: +tinyspy@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.2.0.tgz#9dc04b072746520b432f77ea2c2d17933de5d6ce" integrity sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg== @@ -13921,7 +14233,7 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== -v8-to-istanbul@^9.1.0: +v8-to-istanbul@^9.2.0: version "9.2.0" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad" integrity sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA== @@ -13977,17 +14289,27 @@ victory-vendor@^36.6.8: d3-time "^3.0.0" d3-timer "^3.0.1" -vite-node@0.34.6: - version "0.34.6" - resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.34.6.tgz#34d19795de1498562bf21541a58edcd106328a17" - integrity sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA== +vite-node@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.0.1.tgz#c16c9df9b5d47b74156a6501c9db5b380d992768" + integrity sha512-Y2Jnz4cr2azsOMMYuVPrQkp3KMnS/0WV8ezZjCy4hU7O5mUHCAVOnFmoEvs1nvix/4mYm74Len8bYRWZJMNP6g== + dependencies: + cac "^6.7.14" + debug "^4.3.4" + pathe "^1.1.1" + picocolors "^1.0.0" + vite "^5.0.0-beta.15 || ^5.0.0" + +vite-node@1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.0.4.tgz#36d6c49e3b5015967d883845561ed67abe6553cc" + integrity sha512-9xQQtHdsz5Qn8hqbV7UKqkm8YkJhzT/zr41Dmt5N7AlD8hJXw/Z7y0QiD5I8lnTthV9Rvcvi0QW7PI0Fq83ZPg== dependencies: cac "^6.7.14" debug "^4.3.4" - mlly "^1.4.0" pathe "^1.1.1" picocolors "^1.0.0" - vite "^3.0.0 || ^4.0.0 || ^5.0.0-0" + vite "^5.0.0" vite-plugin-svgr@^3.2.0: version "3.2.0" @@ -13998,45 +14320,80 @@ vite-plugin-svgr@^3.2.0: "@svgr/core" "^7.0.0" "@svgr/plugin-jsx" "^7.0.0" -"vite@^3.0.0 || ^4.0.0 || ^5.0.0-0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0", vite@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.0.tgz#ec406295b4167ac3bc23e26f9c8ff559287cff26" - integrity sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw== +vite@^5.0.0, vite@^5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.7.tgz#ad081d735f6769f76b556818500bdafb72c3fe93" + integrity sha512-B4T4rJCDPihrQo2B+h1MbeGL/k/GMAHzhQ8S0LjQ142s6/+l3hHTT095ORvsshj4QCkoWu3Xtmob5mazvakaOw== dependencies: - esbuild "^0.18.10" - postcss "^8.4.27" - rollup "^3.27.1" + esbuild "^0.19.3" + postcss "^8.4.32" + rollup "^4.2.0" optionalDependencies: - fsevents "~2.3.2" + fsevents "~2.3.3" -vitest@^0.34.6: - version "0.34.6" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.34.6.tgz#44880feeeef493c04b7f795ed268f24a543250d7" - integrity sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q== +"vite@^5.0.0-beta.15 || ^5.0.0", "vite@^5.0.0-beta.19 || ^5.0.0": + version "5.0.5" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.5.tgz#3eebe3698e3b32cea36350f58879258fec858a3c" + integrity sha512-OekeWqR9Ls56f3zd4CaxzbbS11gqYkEiBtnWFFgYR2WV8oPJRRKq0mpskYy/XaoCL3L7VINDhqqOMNDiYdGvGg== dependencies: - "@types/chai" "^4.3.5" - "@types/chai-subset" "^1.3.3" - "@types/node" "*" - "@vitest/expect" "0.34.6" - "@vitest/runner" "0.34.6" - "@vitest/snapshot" "0.34.6" - "@vitest/spy" "0.34.6" - "@vitest/utils" "0.34.6" - acorn "^8.9.0" - acorn-walk "^8.2.0" + esbuild "^0.19.3" + postcss "^8.4.32" + rollup "^4.2.0" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.0.1.tgz#3ba1307066842bc801084fa384ce0b23941b91f7" + integrity sha512-MHsOj079S28hDsvdDvyD1pRj4dcS51EC5Vbe0xvOYX+WryP8soiK2dm8oULi+oA/8Xa/h6GoJEMTmcmBy5YM+Q== + dependencies: + "@vitest/expect" "1.0.1" + "@vitest/runner" "1.0.1" + "@vitest/snapshot" "1.0.1" + "@vitest/spy" "1.0.1" + "@vitest/utils" "1.0.1" + acorn-walk "^8.3.0" + cac "^6.7.14" + chai "^4.3.10" + debug "^4.3.4" + execa "^8.0.1" + local-pkg "^0.5.0" + magic-string "^0.30.5" + pathe "^1.1.1" + picocolors "^1.0.0" + std-env "^3.5.0" + strip-literal "^1.3.0" + tinybench "^2.5.1" + tinypool "^0.8.1" + vite "^5.0.0-beta.19 || ^5.0.0" + vite-node "1.0.1" + why-is-node-running "^2.2.2" + +vitest@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.0.4.tgz#c4b39ba4fcba674499c90e28f4d8dd16fa1d4eb3" + integrity sha512-s1GQHp/UOeWEo4+aXDOeFBJwFzL6mjycbQwwKWX2QcYfh/7tIerS59hWQ20mxzupTJluA2SdwiBuWwQHH67ckg== + dependencies: + "@vitest/expect" "1.0.4" + "@vitest/runner" "1.0.4" + "@vitest/snapshot" "1.0.4" + "@vitest/spy" "1.0.4" + "@vitest/utils" "1.0.4" + acorn-walk "^8.3.0" cac "^6.7.14" chai "^4.3.10" debug "^4.3.4" - local-pkg "^0.4.3" - magic-string "^0.30.1" + execa "^8.0.1" + local-pkg "^0.5.0" + magic-string "^0.30.5" pathe "^1.1.1" picocolors "^1.0.0" - std-env "^3.3.3" - strip-literal "^1.0.1" - tinybench "^2.5.0" - tinypool "^0.7.0" - vite "^3.1.0 || ^4.0.0 || ^5.0.0-0" - vite-node "0.34.6" + std-env "^3.5.0" + strip-literal "^1.3.0" + tinybench "^2.5.1" + tinypool "^0.8.1" + vite "^5.0.0" + vite-node "1.0.4" why-is-node-running "^2.2.2" w3c-xmlserializer@^4.0.0: From f62af1e8b66895ab6240e5746aedd03b9f95ac4e Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Mon, 11 Dec 2023 17:22:59 -0500 Subject: [PATCH 11/45] Improve story styling --- packages/manager/src/components/TagsInput/TagsInput.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/components/TagsInput/TagsInput.stories.tsx b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx index 6270bed362a..77c1266f579 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.stories.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx @@ -30,7 +30,7 @@ export const Default: StoryObj = { }; return ( - + handleUpdateTags(selected)} From ec4784c884e88be46e7e18f9dfb3ffa989b17f85 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 11 Dec 2023 17:58:15 -0500 Subject: [PATCH 12/45] fix: [M3-7543] - AGLB Rule Drawer - Session Stickiness TTL - Use Seconds instead of Milliseconds (#9969) * use seconds, disabled save if no changes were made in edit mode * Added changeset: Change AGLB Rule Session Stickiness unit from milliseconds to seconds * add unit tests --------- Co-authored-by: Banks Nussman --- ...pr-9969-upcoming-features-1701885967965.md | 5 ++ .../Routes/RuleDrawer.test.tsx | 78 +++++++++++++++++++ .../LoadBalancerDetail/Routes/RuleDrawer.tsx | 10 +-- .../LoadBalancerDetail/Routes/utils.ts | 13 ++-- .../ServiceTargetDrawer.test.tsx | 39 ++++++++++ 5 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 packages/manager/.changeset/pr-9969-upcoming-features-1701885967965.md create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RuleDrawer.test.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetDrawer.test.tsx diff --git a/packages/manager/.changeset/pr-9969-upcoming-features-1701885967965.md b/packages/manager/.changeset/pr-9969-upcoming-features-1701885967965.md new file mode 100644 index 00000000000..d9e83d083e2 --- /dev/null +++ b/packages/manager/.changeset/pr-9969-upcoming-features-1701885967965.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Change AGLB Rule Session Stickiness unit from milliseconds to seconds ([#9969](https://github.com/linode/manager/pull/9969)) diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RuleDrawer.test.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RuleDrawer.test.tsx new file mode 100644 index 00000000000..95491443e02 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RuleDrawer.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +import { routeFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RuleDrawer } from './RuleDrawer'; + +const props = { + loadbalancerId: 1, + onClose: vi.fn(), + open: true, + route: routeFactory.build({ + protocol: 'http', + rules: [ + { + match_condition: { + hostname: 'www.acme.com', + match_field: 'path_prefix', + match_value: '/A/*', + session_stickiness_cookie: 'my-cookie', + session_stickiness_ttl: 8, + }, + service_targets: [ + { + id: 1, + label: 'my-service-target', + percentage: 100, + }, + ], + }, + ], + }), +}; + +describe('RuleDrawer', () => { + it('should be in create mode when no rule index is passed', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Add Rule', { selector: 'h2' })).toBeVisible(); + }); + it('should be in edit mode when a serviceTarget is passed', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Edit Rule')).toBeVisible(); + }); + it('should populate fields if we are editing', () => { + const { getByLabelText } = renderWithTheme( + + ); + + const hostnameField = getByLabelText('Hostname (optional)'); + expect(hostnameField).toHaveDisplayValue( + props.route.rules[0].match_condition.hostname! + ); + + const matchTypeField = getByLabelText('Match Type'); + expect(matchTypeField).toHaveDisplayValue('Path'); + + const matchValueField = getByLabelText('Match Value'); + expect(matchValueField).toHaveDisplayValue( + props.route.rules[0].match_condition.match_value + ); + + const cookieField = getByLabelText('Cookie Key'); + expect(cookieField).toHaveDisplayValue( + props.route.rules[0].match_condition.session_stickiness_cookie! + ); + + const ttlField = getByLabelText('Stickiness TTL'); + expect(ttlField).toHaveDisplayValue( + String(props.route.rules[0].match_condition.session_stickiness_ttl) + ); + }); +}); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RuleDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RuleDrawer.tsx index fee6152e6c2..d204c638a17 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RuleDrawer.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/RuleDrawer.tsx @@ -27,6 +27,7 @@ import { TimeUnit, defaultServiceTarget, defaultTTL, + defaultTTLUnit, getIsSessionStickinessEnabled, getNormalizedRulePayload, initialValues, @@ -74,7 +75,7 @@ export const RuleDrawer = (props: Props) => { reset, } = useLoadBalancerRouteUpdateMutation(loadbalancerId, route?.id ?? -1); - const [ttlUnit, setTTLUnit] = useState('hour'); + const [ttlUnit, setTTLUnit] = useState(defaultTTLUnit); const formik = useFormik({ enableReinitialize: true, @@ -121,7 +122,7 @@ export const RuleDrawer = (props: Props) => { _onClose(); formik.resetForm(); reset(); - setTTLUnit('hour'); + setTTLUnit(defaultTTLUnit); }; const onAddServiceTarget = () => { @@ -463,11 +464,9 @@ export const RuleDrawer = (props: Props) => { /> { - const currentTTLUnit = ttlUnit; - const factor = timeUnitFactorMap[option.key] / - timeUnitFactorMap[currentTTLUnit]; + timeUnitFactorMap[ttlUnit]; setTTLUnit(option.key); @@ -504,6 +503,7 @@ export const RuleDrawer = (props: Props) => { primaryButtonProps={{ label: isEditMode ? 'Save' : 'Add Rule', loading: formik.isSubmitting || isLoading, + disabled: isEditMode && !formik.dirty, type: 'submit', }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/utils.ts b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/utils.ts index 1367c26260d..21d71f52f08 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/utils.ts +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/utils.ts @@ -43,6 +43,8 @@ export const defaultServiceTarget = { percentage: 100, }; +export const defaultTTLUnit = 'second'; + export const initialValues = { match_condition: { hostname: '', @@ -76,11 +78,10 @@ export const getNormalizedRulePayload = (rule: RulePayload) => ({ }); export const timeUnitFactorMap = { - millisecond: 1, - second: 1000, - minute: 60000, - hour: 3_600_000, - day: 86_400_000, + second: 1, + minute: 60, + hour: 3_600, + day: 86_400, }; export type TimeUnit = keyof typeof timeUnitFactorMap; @@ -93,7 +94,7 @@ export const timeUnitOptions = Object.keys(timeUnitFactorMap).map( }) ); -export const defaultTTL = timeUnitFactorMap['hour'] * 8; +export const defaultTTL = timeUnitFactorMap['hour'] * 1; /** * Routes can be `http` or `tcp`. diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetDrawer.test.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetDrawer.test.tsx new file mode 100644 index 00000000000..6378f95333b --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetDrawer.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { serviceTargetFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ServiceTargetDrawer } from './ServiceTargetDrawer'; + +const props = { + loadbalancerId: 1, + onClose: vi.fn(), + open: true, +}; + +describe('ServiceTargetDrawer', () => { + it('should be in create mode when no serviceTarget is passed', () => { + const { getByText } = renderWithTheme(); + + expect(getByText('Add a Service Target')).toBeVisible(); + }); + it('should be in edit mode when a serviceTarget is passed', () => { + const serviceTarget = serviceTargetFactory.build(); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText(`Edit ${serviceTarget.label}`)).toBeVisible(); + }); + it('should populate TextFields with data if we are editing', () => { + const serviceTarget = serviceTargetFactory.build(); + + const { getByLabelText } = renderWithTheme( + + ); + + const labelTextField = getByLabelText('Service Target Label'); + expect(labelTextField).toHaveDisplayValue(serviceTarget.label); + }); +}); From 0ad0937a0899a2db6fef61f33c9c6865b95dab6c Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 11 Dec 2023 21:31:25 -0500 Subject: [PATCH 13/45] ci: [M3-7550] - Add Lint To Github Actions (#9973) * add lint step for Cloud Manager * don't lint storybook build js and fix eslint error * try linting all packages * clean up strings * add changesets --------- Co-authored-by: Banks Nussman --- .github/workflows/ci.yml | 18 ++++++++++++++++++ .../pr-9973-tech-stories-1701959506143.md | 5 +++++ .../pr-9973-tech-stories-1701959520658.md | 5 +++++ packages/manager/.eslintrc.cjs | 1 + .../src/features/OneClickApps/oneClickApps.ts | 6 ++++-- .../pr-9973-tech-stories-1701959541076.md | 5 +++++ 6 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-9973-tech-stories-1701959506143.md create mode 100644 packages/manager/.changeset/pr-9973-tech-stories-1701959520658.md create mode 100644 packages/validation/.changeset/pr-9973-tech-stories-1701959541076.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f66114fce88..3ab595b6619 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,24 @@ on: pull_request: jobs: + lint: + strategy: + matrix: + package: ['linode-manager', '@linode/api-v4', '@linode/validation'] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "18.14" + - uses: actions/cache@v3 + with: + path: | + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - run: yarn --frozen-lockfile + - run: yarn workspace ${{ matrix.package }} run lint + build-validation: runs-on: ubuntu-latest steps: diff --git a/packages/api-v4/.changeset/pr-9973-tech-stories-1701959506143.md b/packages/api-v4/.changeset/pr-9973-tech-stories-1701959506143.md new file mode 100644 index 00000000000..50d57b80fd2 --- /dev/null +++ b/packages/api-v4/.changeset/pr-9973-tech-stories-1701959506143.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Tech Stories +--- + +Add Lint Github Action ([#9973](https://github.com/linode/manager/pull/9973)) diff --git a/packages/manager/.changeset/pr-9973-tech-stories-1701959520658.md b/packages/manager/.changeset/pr-9973-tech-stories-1701959520658.md new file mode 100644 index 00000000000..84c7d915162 --- /dev/null +++ b/packages/manager/.changeset/pr-9973-tech-stories-1701959520658.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Add Lint Github Action ([#9973](https://github.com/linode/manager/pull/9973)) diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 1aefa4734d9..3c64f9b2c0e 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -20,6 +20,7 @@ module.exports = { ignorePatterns: [ 'node_modules', 'build', + 'storybook-static', '.storybook', 'e2e', 'public', diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index 07f7e37cfee..4fd73932de4 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -1141,7 +1141,8 @@ export const oneClickApps: OCA[] = [ 'Deploy MainConcept FFmpeg Plugins Demo through the Linode Marketplace', }, ], - summary: 'MainConcept FFmpeg Plugins Demo contains advanced video encoding tools.', + summary: + 'MainConcept FFmpeg Plugins Demo contains advanced video encoding tools.', website: 'https://www.mainconcept.com/ffmpeg', }, { @@ -1159,7 +1160,8 @@ export const oneClickApps: OCA[] = [ { href: 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-live-encoder-demo/', - title: 'Deploy MainConcept Live Encoder Demo through the Linode Marketplace', + title: + 'Deploy MainConcept Live Encoder Demo through the Linode Marketplace', }, ], summary: 'MainConcept Live Encoder is a real time video encoding engine.', diff --git a/packages/validation/.changeset/pr-9973-tech-stories-1701959541076.md b/packages/validation/.changeset/pr-9973-tech-stories-1701959541076.md new file mode 100644 index 00000000000..55b8344c6cf --- /dev/null +++ b/packages/validation/.changeset/pr-9973-tech-stories-1701959541076.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Tech Stories +--- + +Add Lint Github Action ([#9973](https://github.com/linode/manager/pull/9973)) From ffde327049b44d4afd5d27a5927c968f54a2c230 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Tue, 12 Dec 2023 11:34:38 -0500 Subject: [PATCH 14/45] Feedback --- packages/manager/src/components/Tag/Tag.stories.tsx | 2 +- packages/manager/src/components/TagsPanel/TagsPanel.test.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/components/Tag/Tag.stories.tsx b/packages/manager/src/components/Tag/Tag.stories.tsx index 1a30037ecae..3cf20683e26 100644 --- a/packages/manager/src/components/Tag/Tag.stories.tsx +++ b/packages/manager/src/components/Tag/Tag.stories.tsx @@ -21,6 +21,6 @@ const meta: Meta = { label: 'Tag', }, component: Tag, - title: 'Components/Chip/Tag', + title: 'Components/Tags/Tag', }; export default meta; diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx index 74e3bb90ed2..1c3f2b5b92c 100644 --- a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx +++ b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx @@ -6,9 +6,11 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { TagsPanel } from './TagsPanel'; +import type { TagsPanelProps } from './TagsPanel'; + const queryClient = new QueryClient(); -const renderWithQueryClient = (ui: any) => { +const renderWithQueryClient = (ui: React.ReactElement) => { return renderWithTheme( {ui} ); From 645284c0324f722f480d1d1a9f158b28eef4f8aa Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 12 Dec 2023 12:22:36 -0500 Subject: [PATCH 15/45] ci: Speed Up Code Coverage Github Actions Jobs (#9988) * only build code needed to run the unit tests * Added changeset: Speed up code coverage Github Actions jobs by skipping Cloud Manager build --------- Co-authored-by: Banks Nussman --- .github/workflows/coverage.yml | 14 ++++++++++---- .github/workflows/coverage_badge.yml | 7 +++++-- .../pr-9988-tech-stories-1702336644030.md | 5 +++++ 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-9988-tech-stories-1702336644030.md diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e55968c0cdc..fda1fed0802 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -25,8 +25,11 @@ jobs: - name: Install Dependencies run: yarn --frozen-lockfile - - name: Run build - run: yarn build + - name: Build @linode/validation + run: yarn build:validation + + - name: Build @linode/api-v4 + run: yarn build:sdk - name: Run Base Branch Coverage run: yarn coverage:summary @@ -65,8 +68,11 @@ jobs: - name: Install Dependencies run: yarn --frozen-lockfile - - name: Run Build - run: yarn build + - name: Build @linode/validation + run: yarn build:validation + + - name: Build @linode/api-v4 + run: yarn build:sdk - name: Run Current Branch Coverage run: yarn coverage:summary diff --git a/.github/workflows/coverage_badge.yml b/.github/workflows/coverage_badge.yml index b4cf419261c..dee947804c6 100644 --- a/.github/workflows/coverage_badge.yml +++ b/.github/workflows/coverage_badge.yml @@ -27,8 +27,11 @@ jobs: - name: Install Dependencies run: yarn --frozen-lockfile - - name: Run Build - run: yarn build + - name: Build @linode/validation + run: yarn build:validation + + - name: Build @linode/api-v4 + run: yarn build:sdk - name: Run Base Branch Coverage run: yarn coverage:summary diff --git a/packages/manager/.changeset/pr-9988-tech-stories-1702336644030.md b/packages/manager/.changeset/pr-9988-tech-stories-1702336644030.md new file mode 100644 index 00000000000..6aa010c3484 --- /dev/null +++ b/packages/manager/.changeset/pr-9988-tech-stories-1702336644030.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Speed up code coverage Github Actions jobs by skipping Cloud Manager build ([#9988](https://github.com/linode/manager/pull/9988)) From 1c21e09d04efa671b9b8b352bcc59196fd8aa992 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 12 Dec 2023 13:11:10 -0500 Subject: [PATCH 16/45] upcoming: [M3-7293] - AGLB Full Create Flow - Service Targets (#9965) * add paper and table * working flow * improve UX * add testing * Added changeset: Add AGLB Service Target Section to Full Create Flow * feedback @mjac0bs * more feedback (hide CA for not https and add tooltip) @mjac0bs * Update packages/validation/src/loadbalancers.schema.ts Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * make stepper clickable * clean up and hook up `Add Another Configuration` * add filtering --------- Co-authored-by: Banks Nussman Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- ...pr-9965-upcoming-features-1701894892064.md | 5 + .../VerticalLinearStepper.tsx | 67 ++-- .../ConfigurationDetails.tsx | 34 +- .../LoadBalancerActionPanel.tsx | 15 +- .../LoadBalancerConfiguration.test.tsx | 29 +- .../LoadBalancerConfiguration.tsx | 14 +- .../LoadBalancerConfigurations.tsx | 20 +- .../LoadBalancerCreate/LoadBalancerCreate.tsx | 18 +- .../LoadBalancerLabel.test.tsx | 7 +- .../ServiceTargetDrawer.test.tsx | 61 +++ .../ServiceTargetDrawer.tsx | 359 ++++++++++++++++++ .../ServiceTargets.test.tsx | 48 +++ .../LoadBalancerCreate/ServiceTargets.tsx | 164 ++++++++ .../ServiceTargets/ServiceTargetDrawer.tsx | 40 +- .../ServiceTargets/constants.tsx | 5 +- .../src/features/LoadBalancers/constants.ts | 1 + .../validation/src/loadbalancers.schema.ts | 12 +- 17 files changed, 783 insertions(+), 116 deletions(-) create mode 100644 packages/manager/.changeset/pr-9965-upcoming-features-1701894892064.md create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/ServiceTargetDrawer.test.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/ServiceTargetDrawer.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/ServiceTargets.test.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/ServiceTargets.tsx diff --git a/packages/manager/.changeset/pr-9965-upcoming-features-1701894892064.md b/packages/manager/.changeset/pr-9965-upcoming-features-1701894892064.md new file mode 100644 index 00000000000..85d67567997 --- /dev/null +++ b/packages/manager/.changeset/pr-9965-upcoming-features-1701894892064.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add AGLB Service Target Section to Full Create Flow ([#9965](https://github.com/linode/manager/pull/9965)) diff --git a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx index e76f6732885..1cc903dd39f 100644 --- a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx +++ b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx @@ -11,7 +11,6 @@ import useMediaQuery from '@mui/material/useMediaQuery'; import { useTheme } from '@mui/styles'; import React, { useState } from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { convertToKebabCase } from 'src/utilities/convertToKebobCase'; import { @@ -19,6 +18,7 @@ import { StyledCircleIcon, StyledColorlibConnector, } from './VerticalLinearStepper.styles'; +import { Button } from '../Button/Button'; type VerticalLinearStep = { content: JSX.Element; @@ -63,7 +63,7 @@ export const VerticalLinearStepper = ({ orientation="vertical" > {steps.map((step: VerticalLinearStep, index: number) => ( - + setActiveStep(index)}> } sx={{ + cursor: 'pointer !important', '& .MuiStepIcon-text': { display: 'none', }, @@ -124,40 +125,34 @@ export const VerticalLinearStepper = ({ > {content} - - - { - handleNext(); - handler?.(); - }, - sx: { mr: 1, mt: 1 }, - } - : undefined - } - secondaryButtonProps={ - index !== 0 - ? { - buttonType: 'outlined', - label: `Previous: ${steps[index - 1]?.label}`, - onClick: handleBack, - sx: { mr: 1, mt: 1 }, - } - : undefined - } - style={{ justifyContent: 'flex-start' }} - /> + + {index !== 0 && ( + + )} + {index !== 2 && ( + + )} ) : null} diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/ConfigurationDetails.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/ConfigurationDetails.tsx index daad8310f82..38d6fb97610 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/ConfigurationDetails.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/ConfigurationDetails.tsx @@ -1,5 +1,5 @@ import Stack from '@mui/material/Stack'; -import { useFormikContext } from 'formik'; +import { useFormikContext, getIn } from 'formik'; import * as React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; @@ -16,14 +16,13 @@ import { protocolOptions, } from '../LoadBalancerDetail/Configurations/constants'; -import type { CreateLoadbalancerPayload } from '@linode/api-v4'; +import type { LoadBalancerCreateFormData } from './LoadBalancerCreate'; interface Props { index: number; - name: string; } -export const ConfigurationDetails = ({ index, name }: Props) => { +export const ConfigurationDetails = ({ index }: Props) => { const { errors, handleBlur, @@ -31,7 +30,7 @@ export const ConfigurationDetails = ({ index, name }: Props) => { setFieldValue, touched, values, - } = useFormikContext(); + } = useFormikContext(); return ( @@ -44,18 +43,19 @@ export const ConfigurationDetails = ({ index, name }: Props) => { - setFieldValue(`${name}.${index}.protocol`, value) + setFieldValue(`configurations[${index}].protocol`, value) } textFieldProps={{ labelTooltipText: CONFIGURATION_COPY.Protocol, }} value={protocolOptions.find( - (option) => option.value === values[name]?.[index]?.protocol + (option) => + option.value === values.configurations?.[index]?.protocol )} disableClearable label="Protocol" @@ -63,16 +63,18 @@ export const ConfigurationDetails = ({ index, name }: Props) => { /> @@ -98,15 +100,17 @@ export const ConfigurationDetails = ({ index, name }: Props) => { ); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerActionPanel.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerActionPanel.tsx index 8639e6f8f83..b4c27cfd098 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerActionPanel.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerActionPanel.tsx @@ -1,8 +1,9 @@ -import { useFormikContext } from 'formik'; +import { useFormikContext, FieldArray } from 'formik'; import * as React from 'react'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; +import { initialValues } from './LoadBalancerCreate'; export const LoadBalancerActionPanel = () => { const { submitForm } = useFormikContext(); @@ -14,7 +15,17 @@ export const LoadBalancerActionPanel = () => { justifyContent="space-between" rowGap={3} > - + ( + + )} + /> + + setQuery('')} + size="small" + sx={{ padding: 'unset' }} + > + + + + ), + }} + hideLabel + label="Filter" + onChange={(e) => setQuery(e.target.value)} + placeholder="Filter" + value={query} + /> + + + + + Service Target Label + Endpoints + Algorithm + Health Checks + + + + + {values.service_targets.length === 0 && ( + + )} + {values.service_targets + .filter((serviceTarget) => { + if (query) { + return serviceTarget.label.includes(query); + } + return true; + }) + .map((serviceTarget, index) => ( + + {serviceTarget.label} + + {serviceTarget.endpoints.length === 0 ? ( + 0 + ) : ( + + {serviceTarget.endpoints.map( + ({ ip, port }, index) => ( + + {ip}:{port} + + ) + )} + + } + displayText={String(serviceTarget.endpoints.length)} + minWidth={100} + /> + )} + + + {serviceTarget.load_balancing_policy.replace('_', ' ')} + + + {serviceTarget.healthcheck ? 'Yes' : 'No'} + + + handleEditServiceTarget(index), + title: 'Edit', + }, + { + onClick: () => handleRemoveServiceTarget(index), + title: 'Remove', + }, + ]} + ariaLabel={`Action Menu for Service Target ${serviceTarget.label}`} + /> + + + ))} + +
+ + { + setIsDrawerOpen(false); + setSelectedServiceTargetIndex(undefined); + }} + open={isDrawerOpen} + serviceTargetIndex={selectedServiceTargetIndex} + /> + + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetDrawer.tsx index bdcf878bdf5..cd02f396ccd 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetDrawer.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/ServiceTargetDrawer.tsx @@ -224,23 +224,29 @@ export const ServiceTargetDrawer = (props: Props) => { onRemove={onRemoveEndpoint} /> - - - Service Target CA Certificate - - - - formik.setFieldValue('certificate_id', cert?.id ?? null) - } - errorText={formik.errors.certificate_id} - filter={{ type: 'ca' }} - loadbalancerId={loadbalancerId} - value={formik.values.certificate_id} - /> + {formik.values.protocol === 'https' && ( + <> + + + + Service Target CA Certificate + + + + + formik.setFieldValue('certificate_id', cert?.id ?? null) + } + errorText={formik.errors.certificate_id} + filter={{ type: 'ca' }} + loadbalancerId={loadbalancerId} + value={formik.values.certificate_id} + /> + + )} Health Checks diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/constants.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/constants.tsx index e8e0b5d6590..a53a2314102 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/constants.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/ServiceTargets/constants.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { Box } from 'src/components/Box'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; @@ -133,13 +134,13 @@ export const SERVICE_TARGET_COPY = { 'The number of consecutive health checks that must fail to consider a service target as unhealthy. Minimum value is 1.', }, Protocol: ( - + The protocol this target is configured to serve.
  • The HTTP and TCP protocols do not support TLS certificates.
  • HTTPS requires TLS certificates.
-
+
), }, }; diff --git a/packages/manager/src/features/LoadBalancers/constants.ts b/packages/manager/src/features/LoadBalancers/constants.ts index f5fa62d1ad4..063070fcd9e 100644 --- a/packages/manager/src/features/LoadBalancers/constants.ts +++ b/packages/manager/src/features/LoadBalancers/constants.ts @@ -12,6 +12,7 @@ export const AGLB_DOCS_TLS_CERTIFICATE = 'https://deploy-preview-14--roaring-gelato-12dc9e.netlify.app/docs/products/networking/global-loadbalancer/guides/certificates/'; export const AGLB_DOCS = { + Certificates: `${AGLB_DOCS_URL}/docs/products/networking/global-loadbalancer/guides/certificates/#service-target-certificates`, Developers: `${AGLB_DOCS_URL}/docs/products/networking/global-loadbalancer/developers`, GettingStarted: `${AGLB_DOCS_URL}/docs/products/networking/global-loadbalancer/get-started`, Guides: `${AGLB_DOCS_URL}/docs/products/networking/global-loadbalancer/guides`, diff --git a/packages/validation/src/loadbalancers.schema.ts b/packages/validation/src/loadbalancers.schema.ts index c99a46218c7..1153342ccd0 100644 --- a/packages/validation/src/loadbalancers.schema.ts +++ b/packages/validation/src/loadbalancers.schema.ts @@ -50,10 +50,14 @@ export const EndpointSchema = object({ const HealthCheckSchema = object({ protocol: string().oneOf(['http', 'tcp']), - interval: number().min(0), - timeout: number().min(0), - unhealthy_threshold: number().min(0), - healthy_threshold: number().min(0), + interval: number().typeError('Interval must be a number.').min(1, 'Interval must be greater than zero.'), + timeout: number().typeError('Timeout must be a number.').min(1, 'Timeout must be greater than zero.'), + unhealthy_threshold: number() + .typeError('Unhealthy Threshold must be a number.') + .min(1, 'Unhealthy Threshold must be greater than zero.'), + healthy_threshold: number() + .typeError('Healthy Threshold must be a number.') + .min(1, 'Healthy Threshold must be greater than zero.'), path: string().nullable(), host: string().nullable(), }); From 62a9230ac93e65774ac6608cf71ad05d9907b34c Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Tue, 12 Dec 2023 13:52:30 -0500 Subject: [PATCH 17/45] =?UTF-8?q?change:=20[M3-7505]=20=E2=80=93=20Update?= =?UTF-8?q?=20copy=20for=20Private=20IP=20add-on=20in=20Linode=20Create=20?= =?UTF-8?q?flow=20(#9990)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.changeset/pr-9990-upcoming-features-1702400746535.md | 5 +++++ .../src/features/Linodes/LinodesCreate/AddonsPanel.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-9990-upcoming-features-1702400746535.md diff --git a/packages/manager/.changeset/pr-9990-upcoming-features-1702400746535.md b/packages/manager/.changeset/pr-9990-upcoming-features-1702400746535.md new file mode 100644 index 00000000000..827fe4f351c --- /dev/null +++ b/packages/manager/.changeset/pr-9990-upcoming-features-1702400746535.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Revised copy for Private IP add-on in Linode Create flow ([#9990](https://github.com/linode/manager/pull/9990)) diff --git a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx index 82c7944c4e5..3c1e1c35f1f 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx @@ -250,8 +250,8 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { data-testid="private-ip-contextual-copy" variant="body1" > - Use this for a backend node to a NodeBalancer. Use VPC instead for - private communication between your Linodes. + Use Private IP for a backend node to a NodeBalancer. Use VPC instead + for private communication between your Linodes. )} From 850d4cc79306a960ace0318b69ae958cb8e48434 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Tue, 12 Dec 2023 13:16:33 -0600 Subject: [PATCH 18/45] test: [M3-7135] - Add integration test for AGLB Load Balancer delete flows (#9955) * Create mockDeleteLoadBalancer and mockDeleteLoadBalancerError * Deletes Loadbalancer from landing page * Deletes Loadbalancer from Settings tab * code cleanup * Added changeset: Add integration test for AGLB Load Balancer delete flows. * Validate error handling while deleting loadbalancer * PR - feedback --- .../.changeset/pr-9955-tests-1701693591911.md | 5 + .../load-balancer-landing-page.spec.ts | 152 ++++++++++++++++++ .../load-balancer-summary.spec.ts | 101 +++++++++++- .../support/intercepts/load-balancers.ts | 31 ++++ 4 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-9955-tests-1701693591911.md diff --git a/packages/manager/.changeset/pr-9955-tests-1701693591911.md b/packages/manager/.changeset/pr-9955-tests-1701693591911.md new file mode 100644 index 00000000000..9dc23a01573 --- /dev/null +++ b/packages/manager/.changeset/pr-9955-tests-1701693591911.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add integration test for AGLB Load Balancer delete flows. ([#9955](https://github.com/linode/manager/pull/9955)) diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-landing-page.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-landing-page.spec.ts index b7be9ae8955..f5d15fe1045 100644 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-landing-page.spec.ts @@ -13,6 +13,8 @@ import { randomLabel } from 'support/util/random'; import { mockGetLoadBalancer, mockGetLoadBalancers, + mockDeleteLoadBalancerError, + mockDeleteLoadBalancer, } from 'support/intercepts/load-balancers'; import type { Loadbalancer } from '@linode/api-v4'; import { chooseRegion } from 'support/util/regions'; @@ -127,3 +129,153 @@ describe('Akamai Global Load Balancer landing page', () => { }); }); }); + +describe('Delete', () => { + /* + * - Confirms that Deleting a load balancer from the AGLB landing page. + * - Confirms AGLB landing page reverts to its empty state when all of the load balancers have been deleted. + */ + it('Delete a Load Balancer from landing page.', () => { + const chosenRegion = chooseRegion(); + const loadBalancerConfiguration = configurationFactory.build(); + const loadbalancerMocks = [ + loadbalancerFactory.build({ + id: 1, + label: randomLabel(), + configurations: [ + { + id: loadBalancerConfiguration.id, + label: loadBalancerConfiguration.label, + }, + ], + regions: ['us-east', chosenRegion.id], + }), + ]; + + // TODO Delete feature flag mocks when AGLB feature flag goes away. + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetLoadBalancers(loadbalancerMocks).as('getLoadBalancers'); + mockGetLoadBalancer(loadbalancerMocks[0]); + + const loadbalancer = loadbalancerMocks[0]; + + cy.visitWithLogin('/loadbalancers'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getLoadBalancers']); + + ui.actionMenu + .findByTitle(`Action menu for Load Balancer ${loadbalancer.label}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + // Mock the API call for deleting the load balancer. + mockDeleteLoadBalancer(loadbalancer.id).as('deleteLoadBalancer'); + + mockGetLoadBalancers([]).as('getLoadBalancers'); + + // Handle the delete confirmation dialog. + ui.dialog + .findByTitle(`Delete ${loadbalancer.label}?`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Load Balancer Label') + .should('be.visible') + .click() + .type(loadbalancer.label); + + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deleteLoadBalancer', '@getLoadBalancers']); + + // Confirm that user is navigated to the empty loadbalancer empty state landing page. + + cy.get('[data-qa-header]') + .should('be.visible') + .should('have.text', 'Global Load Balancers'); + + cy.findByText( + 'Scalable Layer 4 and Layer 7 load balancer to route and manage enterprise traffic between clients and your distributed applications and networks globally.' + ).should('be.visible'); + cy.findByText('Getting Started Guides').should('be.visible'); + + // Create button exists and navigates user to create page. + ui.button + .findByTitle('Create Global Load Balancer') + .should('be.visible') + .should('be.enabled'); + + cy.findByText(loadbalancer.label).should('not.exist'); + }); + + it('Shows API errors when deleting a load balancer', () => { + const chosenRegion = chooseRegion(); + const loadBalancerConfiguration = configurationFactory.build(); + const loadbalancerMocks = [ + loadbalancerFactory.build({ + id: 1, + label: randomLabel(), + configurations: [ + { + id: loadBalancerConfiguration.id, + label: loadBalancerConfiguration.label, + }, + ], + regions: ['us-east', chosenRegion.id], + }), + ]; + + // TODO Delete feature flag mocks when AGLB feature flag goes away. + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetLoadBalancers(loadbalancerMocks).as('getLoadBalancers'); + mockGetLoadBalancer(loadbalancerMocks[0]); + + const loadbalancer = loadbalancerMocks[0]; + + cy.visitWithLogin('/loadbalancers'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getLoadBalancers']); + + ui.actionMenu + .findByTitle(`Action menu for Load Balancer ${loadbalancer.label}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + // Mock the API call for deleting the load balancer. + mockDeleteLoadBalancerError(loadbalancer.id, 'Control Plane Error').as( + 'deleteLoadBalancer' + ); + + mockGetLoadBalancers([]).as('getLoadBalancers'); + + // Handle the delete confirmation dialog. + ui.dialog + .findByTitle(`Delete ${loadbalancer.label}?`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Load Balancer Label') + .should('be.visible') + .click() + .type(loadbalancer.label); + + ui.buttonGroup.findButtonByTitle('Delete').click(); + + cy.wait(['@deleteLoadBalancer']); + + cy.findByText('Control Plane Error').should('be.visible'); + + ui.buttonGroup.findButtonByTitle('Cancel').click(); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-summary.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-summary.spec.ts index 8a59b03a063..f7709968e77 100644 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-summary.spec.ts +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-summary.spec.ts @@ -2,12 +2,19 @@ * @file Integration tests for Akamai Global Load Balancer summary page. */ -import { loadbalancerFactory } from '@src/factories/aglb'; +import { loadbalancerFactory, configurationFactory } from '@src/factories/aglb'; import { mockAppendFeatureFlags, mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; -import { mockGetLoadBalancer } from 'support/intercepts/load-balancers'; + +import { + mockGetLoadBalancer, + mockGetLoadBalancers, + mockDeleteLoadBalancer, +} from 'support/intercepts/load-balancers'; +import { randomLabel } from 'support/util/random'; +import { ui } from 'support/ui'; import { makeFeatureFlagData } from 'support/util/feature-flags'; describe('Akamai Global Load Balancer details page', () => { @@ -41,3 +48,93 @@ describe('Akamai Global Load Balancer details page', () => { cy.findByText(mockLoadBalancer.hostname).should('be.visible'); }); }); + +describe('Delete', () => { + /* + * Deleting a load balancer from the AGLB load balancer details page "Settings" tab (route: /loadbalancers/:id/settings) + * Confirms User is redirected to AGLB landing page upon deleting from Load Balancer details page "Settings" tab, and load balancer is not listed on the landing page. + */ + + // Test case for deleting a load balancer from the Settings tab. + it('Deletes a loadbalancer from Settings tab', () => { + const mockLoadBalancer = loadbalancerFactory.build(); + // Setup additional mock load balancer data. + const loadBalancerConfiguration = configurationFactory.build(); + const loadbalancerMocks = [ + loadbalancerFactory.build({ + id: 1, + label: randomLabel(), + configurations: [ + { + id: loadBalancerConfiguration.id, + label: loadBalancerConfiguration.label, + }, + ], + regions: ['us-east'], + }), + ]; + const loadbalancerMock = loadbalancerMocks[0]; + + mockGetLoadBalancers(loadbalancerMocks).as('getLoadBalancers'); + + mockAppendFeatureFlags({ + aglb: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetLoadBalancer(mockLoadBalancer).as('getLoadBalancer'); + + // Visit the specific load balancer's page with login. + cy.visitWithLogin(`/loadbalancers/${mockLoadBalancer.id}`); + + // Wait for all the mock API calls to complete. + cy.wait(['@getFeatureFlags', '@getClientStream', '@getLoadBalancer']); + + // Navigate to the 'Settings' tab. + cy.findByText('Settings').should('be.visible').click(); + + cy.findByText(mockLoadBalancer.label).should('be.visible'); + + cy.findByText('Delete Load Balancer').should('be.visible'); + + // Mock the API call for deleting the load balancer. + mockDeleteLoadBalancer(mockLoadBalancer.id).as('deleteLoadBalancer'); + + ui.button.findByTitle('Delete').should('be.visible').click(); + + // Handle the delete confirmation dialog. + ui.dialog + .findByTitle(`Delete ${mockLoadBalancer.label}?`) + .should('be.visible') + .within(() => { + cy.findByTestId('textfield-input') + .should('be.visible') + .click() + .type(mockLoadBalancer.label); + + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for the delete operation and subsequent data retrieval to complete. + cy.wait(['@deleteLoadBalancer', '@getLoadBalancers']); + + // Confirm user is navigated to the load balancers landing page list. + cy.findByText(loadbalancerMock.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle( + `Action menu for Load Balancer ${loadbalancerMock.label}` + ) + .should('be.visible'); + }); + + // Verify that the deleted load balancer no longer exists in the list. + cy.findByText(mockLoadBalancer.label).should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/load-balancers.ts b/packages/manager/cypress/support/intercepts/load-balancers.ts index ff7ed60fbe2..3d6bac9a2b1 100644 --- a/packages/manager/cypress/support/intercepts/load-balancers.ts +++ b/packages/manager/cypress/support/intercepts/load-balancers.ts @@ -42,6 +42,37 @@ export const mockGetLoadBalancers = (loadBalancers: Loadbalancer[]) => { ); }; +/** + * Intercepts DELETE requests to delete an AGLB load balancer. + * + * @param loadBalancerId - ID of load balancer for which to delete. + * + * @returns Cypress chainable. + */ +export const mockDeleteLoadBalancer = (loadBalancerId: number) => { + return cy.intercept('DELETE', apiMatcher(`/aglb/${loadBalancerId}`), {}); +}; + +/** + * Intercepts DELETE requests to delete an AGLB load balancer and mocks HTTP 500 error response. + * + * @param loadBalancerId - ID of load balancer for which to delete. + * @param message - Optional error message with which to respond. + * + * @returns Cypress chainable. + */ +export const mockDeleteLoadBalancerError = ( + loadBalancerId: number, + message?: string +) => { + const defaultMessage = 'An error occurred while deleting Load Balancer.'; + return cy.intercept( + 'DELETE', + apiMatcher(`/aglb/${loadBalancerId}`), + makeErrorResponse(message ?? defaultMessage, 500) + ); +}; + /** * Intercepts GET requests to retrieve AGLB load balancer configurations and mocks response. * From 5dfe0b0f2ec64f3e814cefb968151eb2efd2e59e Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 13 Dec 2023 09:36:19 -0500 Subject: [PATCH 19/45] chore: Rewrite some Enzyme Unit Tests (#9984) Co-authored-by: Banks Nussman --- packages/manager/src/App.test.tsx | 24 ---- .../components/ModeSelect/ModeSelect.test.tsx | 26 +++-- .../manager/src/factories/accountUsers.ts | 2 +- .../LinodeSettings/ImageAndPassword.test.tsx | 105 +++++++++++++++--- .../LinodeSettings/ImageAndPassword.tsx | 7 +- 5 files changed, 112 insertions(+), 52 deletions(-) delete mode 100644 packages/manager/src/App.test.tsx diff --git a/packages/manager/src/App.test.tsx b/packages/manager/src/App.test.tsx deleted file mode 100644 index d9410cf2872..00000000000 --- a/packages/manager/src/App.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { shallow } from 'enzyme'; -import * as React from 'react'; -import { Provider } from 'react-redux'; -import { StaticRouter } from 'react-router-dom'; - -import { App } from './App'; -import { LinodeThemeWrapper } from './LinodeThemeWrapper'; -import { queryClientFactory } from './queries/base'; -import { storeFactory } from './store'; - -const store = storeFactory(queryClientFactory()); - -it('renders without crashing.', () => { - const component = shallow( - - - - - - - - ); - expect(component.find('App')).toHaveLength(1); -}); diff --git a/packages/manager/src/components/ModeSelect/ModeSelect.test.tsx b/packages/manager/src/components/ModeSelect/ModeSelect.test.tsx index bb8d049b471..f260b1cf120 100644 --- a/packages/manager/src/components/ModeSelect/ModeSelect.test.tsx +++ b/packages/manager/src/components/ModeSelect/ModeSelect.test.tsx @@ -1,9 +1,9 @@ -import { shallow } from 'enzyme'; import * as React from 'react'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + import { ModeSelect } from './ModeSelect'; -const classes = { root: '' }; const modes = [ { label: 'Edit', @@ -16,19 +16,27 @@ const modes = [ ]; const props = { - classes, modes, onChange: vi.fn(), selected: 'edit', }; -const component = shallow(); +describe('ModeSelect', () => { + it('should render Mode labels', () => { + const { getByText } = renderWithTheme(); + + for (const mode of modes) { + expect(getByText(mode.label)).toBeVisible(); + } + }); + it('should render one radio for each mode option', () => { + const { getAllByRole } = renderWithTheme(); -describe('Component', () => { - it('should render', () => { - expect(component).toBeDefined(); + expect(getAllByRole('radio')).toHaveLength(modes.length); }); - it('should render a radio button for each mode', () => { - expect(component.find('[data-qa-radio]')).toHaveLength(2); + it('should render one radio group', () => { + const { getAllByRole } = renderWithTheme(); + + expect(getAllByRole('radiogroup')).toHaveLength(1); }); }); diff --git a/packages/manager/src/factories/accountUsers.ts b/packages/manager/src/factories/accountUsers.ts index 46bc3f33abd..838bb124609 100644 --- a/packages/manager/src/factories/accountUsers.ts +++ b/packages/manager/src/factories/accountUsers.ts @@ -9,6 +9,6 @@ export const accountUserFactory = Factory.Sync.makeFactory({ ssh_keys: [], tfa_enabled: false, user_type: null, - username: 'user', + username: Factory.each((i) => `user-${i}`), verified_phone_number: null, }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx index 3f598de483f..184c704d46e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.test.tsx @@ -1,21 +1,16 @@ -import { shallow } from 'enzyme'; import * as React from 'react'; -import { imageFactory, normalizeEntities } from 'src/factories'; -import { wrapWithTheme } from 'src/utilities/testHelpers'; - -const normalizedImages = normalizeEntities(imageFactory.buildList(10)); +import { profileFactory } from 'src/factories'; +import { accountUserFactory } from 'src/factories/accountUsers'; +import { grantsFactory } from 'src/factories/grants'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; import { ImageAndPassword } from './ImageAndPassword'; const props = { authorizedUsers: [], - classes: { root: '' }, - images: normalizedImages, - imagesData: {}, - imagesError: {}, - imagesLastUpdated: 0, - imagesLoading: false, linodeId: 0, onImageChange: vi.fn(), onPasswordChange: vi.fn(), @@ -23,10 +18,90 @@ const props = { setAuthorizedUsers: vi.fn(), }; -const component = shallow(wrapWithTheme()); +describe('ImageAndPassword', () => { + it('should render an Image Select', () => { + const { getByLabelText } = renderWithTheme(); + + expect(getByLabelText('Image')).toBeVisible(); + expect(getByLabelText('Image')).toBeEnabled(); + }); + it('should render a password error if defined', async () => { + const passwordError = 'Unable to set password.'; + const { findByText } = renderWithTheme( + + ); + + expect(await findByText(passwordError)).toBeVisible(); + }); + it('should render an SSH Keys section', async () => { + const { getByText } = renderWithTheme(); + + expect(getByText('SSH Keys', { selector: 'h2' })).toBeVisible(); + }); + it('should render ssh keys for each user on the account', async () => { + const users = accountUserFactory.buildList(3, { ssh_keys: ['my-ssh-key'] }); + + server.use( + rest.get('*/account/users', (req, res, ctx) => { + return res(ctx.json(makeResourcePage(users))); + }) + ); + + const { findByText } = renderWithTheme(); + + for (const user of users) { + // eslint-disable-next-line no-await-in-loop + const username = await findByText(user.username); + const tableRow = username.closest('tr'); + + expect(username).toBeVisible(); + expect(tableRow).toHaveTextContent(user.ssh_keys[0]); + } + }); + it('should be disabled for a restricted user with read only access', async () => { + server.use( + rest.get('*/profile', (req, res, ctx) => { + return res(ctx.json(profileFactory.build({ restricted: true }))); + }), + rest.get('*/profile/grants', (req, res, ctx) => { + return res( + ctx.json( + grantsFactory.build({ + linode: [{ id: 0, permissions: 'read_only' }], + }) + ) + ); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme( + + ); + + await findByText(`You don't have permission to modify this Linode.`, { + exact: false, + }); + + expect(getByLabelText('Image')).toBeDisabled(); + }); + it('should be disabled for a restricted user with no access', async () => { + server.use( + rest.get('*/profile', (req, res, ctx) => { + return res(ctx.json(profileFactory.build({ restricted: true }))); + }), + rest.get('*/profile/grants', (req, res, ctx) => { + return res(ctx.json(grantsFactory.build({ linode: [] }))); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme( + + ); + + await findByText(`You don't have permission to modify this Linode.`, { + exact: false, + }); -describe('Component', () => { - it('should render', () => { - expect(component).toBeDefined(); + expect(getByLabelText('Image')).toBeDisabled(); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx index f9d901a927d..cbf0a15c7b5 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx @@ -5,7 +5,7 @@ import { AccessPanel } from 'src/components/AccessPanel/AccessPanel'; import { Item } from 'src/components/EnhancedSelect/Select'; import { ImageSelect } from 'src/features/Images/ImageSelect'; import { useAllImagesQuery } from 'src/queries/images'; -import { useGrants } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { LinodePermissionsError } from '../LinodePermissionsError'; @@ -34,6 +34,7 @@ export const ImageAndPassword = (props: Props) => { } = props; const { data: grants } = useGrants(); + const { data: profile } = useProfile(); const { data: imagesData, error: imagesError } = useAllImagesQuery(); const _imagesError = imagesError @@ -41,8 +42,8 @@ export const ImageAndPassword = (props: Props) => { : undefined; const disabled = - grants !== undefined && - grants.linode.find((g) => g.id === linodeId)?.permissions === 'read_only'; + profile?.restricted && + grants?.linode.find((g) => g.id === linodeId)?.permissions !== 'read_write'; return ( From ea02c3f05d2e1424ada0bbf1cad967ffee411d0c Mon Sep 17 00:00:00 2001 From: carrillo-erik <119514965+carrillo-erik@users.noreply.github.com> Date: Wed, 13 Dec 2023 09:40:47 -0800 Subject: [PATCH 20/45] test: [M3-7470] - Add Cypress test for Volumes landing page empty state (#9995) * test: [M3-7470] - Add Cypress test for Volumes landing page empty state * Add changeset --- .../.changeset/pr-9995-tests-1702420300908.md | 5 ++++ .../volumes/landing-page-empty-state.spec.ts | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 packages/manager/.changeset/pr-9995-tests-1702420300908.md create mode 100644 packages/manager/cypress/e2e/core/volumes/landing-page-empty-state.spec.ts diff --git a/packages/manager/.changeset/pr-9995-tests-1702420300908.md b/packages/manager/.changeset/pr-9995-tests-1702420300908.md new file mode 100644 index 00000000000..c7acfbdf028 --- /dev/null +++ b/packages/manager/.changeset/pr-9995-tests-1702420300908.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Introduce Cypress test for the Volumes landing page empty state ([#9995](https://github.com/linode/manager/pull/9995)) diff --git a/packages/manager/cypress/e2e/core/volumes/landing-page-empty-state.spec.ts b/packages/manager/cypress/e2e/core/volumes/landing-page-empty-state.spec.ts new file mode 100644 index 00000000000..1d254586082 --- /dev/null +++ b/packages/manager/cypress/e2e/core/volumes/landing-page-empty-state.spec.ts @@ -0,0 +1,29 @@ +import { ui } from 'support/ui'; +import { mockGetVolumes } from 'support/intercepts/volumes'; + +describe('confirms Volumes landing page empty state is shown when no Volumes exist', () => { + /* + * - Confirms that Getting Started Guides is listed on landing page. + * - Confirms that Video Playlist is listed on landing page. + * - Confirms that clicking on Create Volume button navigates user to volume create page. + */ + it('shows the empty state when no Volumes exist', () => { + mockGetVolumes([]).as('getVolumes'); + + cy.visitWithLogin('/volumes'); + cy.wait(['@getVolumes']); + + cy.findByText('NVMe block storage service').should('be.visible'); + cy.findByText('Getting Started Guides').should('be.visible'); + cy.findByText('Video Playlist').should('be.visible'); + + // Create Volume button exists and clicking it navigates user to create volume page. + ui.button + .findByTitle('Create Volume') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('endWith', '/volumes/create'); + }); +}); From cc9020e96525ffa9bbe564ee8fc03d0bf0150705 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Thu, 14 Dec 2023 12:03:27 -0500 Subject: [PATCH 21/45] upcoming: [M3-7302] - Replace NodeBalancer detail charts with Recharts (#9983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 This PR is part of a bigger effort to replace chart.js with the more modern Recharts. ## Changes 🔄 List any change relevant to the reviewer. - Replace the NodeBalancer details page charts with Recharts - Refactor code to use one AreaChart component ## How to test 🧪 ### Prerequisites (How to setup test environment) - Have a NodeBalancer that's been running for a while ### Verification steps (How to verify changes) - Go to the Network tab of a NodeBalancer that's been running for a while - Confirm the updated UI and that there are no regressions in the graph, units, etc - Confirm there are no regressions in the Linode Network Transfer History chart from the refactoring --- ...pr-9983-upcoming-features-1702070124570.md | 5 + packages/manager/src/components/AreaChart.tsx | 138 ++++++++++++++ .../NetworkTransferHistoryChart.tsx | 114 ------------ .../TransferHistory.tsx | 28 ++- .../NodeBalancerSummary/TablesPanel.tsx | 170 ++++++++++++------ 5 files changed, 283 insertions(+), 172 deletions(-) create mode 100644 packages/manager/.changeset/pr-9983-upcoming-features-1702070124570.md create mode 100644 packages/manager/src/components/AreaChart.tsx delete mode 100644 packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransferHistoryChart.tsx diff --git a/packages/manager/.changeset/pr-9983-upcoming-features-1702070124570.md b/packages/manager/.changeset/pr-9983-upcoming-features-1702070124570.md new file mode 100644 index 00000000000..6d8acb79720 --- /dev/null +++ b/packages/manager/.changeset/pr-9983-upcoming-features-1702070124570.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Replace NodeBalancer detail charts with Recharts ([#9983](https://github.com/linode/manager/pull/9983)) diff --git a/packages/manager/src/components/AreaChart.tsx b/packages/manager/src/components/AreaChart.tsx new file mode 100644 index 00000000000..d88aff1a691 --- /dev/null +++ b/packages/manager/src/components/AreaChart.tsx @@ -0,0 +1,138 @@ +import { Typography, useTheme } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { DateTime } from 'luxon'; +import React from 'react'; +import { + AreaChart as _AreaChart, + Area, + CartesianGrid, + ResponsiveContainer, + Tooltip, + TooltipProps, + XAxis, + YAxis, +} from 'recharts'; + +import { Paper } from 'src/components/Paper'; +import { roundTo } from 'src/utilities/roundTo'; + +interface AreaProps { + color: string; + dataKey: string; +} + +interface XAxisProps { + tickFormat: string; + tickGap: number; +} + +interface AreaChartProps { + areas: AreaProps[]; + data: any; + height: number; + timezone: string; + unit: string; + xAxis: XAxisProps; +} + +const humanizeLargeData = (value: number) => { + if (value >= 1000000) { + return value / 1000000 + 'M'; + } + if (value >= 1000) { + return value / 1000 + 'K'; + } + return `${value}`; +}; + +export const AreaChart = (props: AreaChartProps) => { + const { areas, data, height, timezone, unit, xAxis } = props; + + const theme = useTheme(); + + const xAxisTickFormatter = (t: number) => { + return DateTime.fromMillis(t, { zone: timezone }).toFormat( + xAxis.tickFormat + ); + }; + + const tooltipLabelFormatter = (t: number) => { + return DateTime.fromMillis(t, { zone: timezone }).toFormat( + 'LLL dd, yyyy, h:mm a' + ); + }; + + const tooltipValueFormatter = (value: number) => + `${roundTo(value)} ${unit}/s`; + + const CustomTooltip = ({ + active, + label, + payload, + }: TooltipProps) => { + if (active && payload && payload.length) { + return ( + + {tooltipLabelFormatter(label)} + {payload.map((item) => ( + + {item.dataKey}: {tooltipValueFormatter(item.value)} + + ))} + + ); + } + + return null; + }; + + return ( + + <_AreaChart data={data}> + + + + } + /> + {areas.map(({ color, dataKey }) => ( + + ))} + + + ); +}; + +const StyledPaper = styled(Paper, { + label: 'StyledPaper', +})(({ theme }) => ({ + border: `1px solid ${theme.color.border2}`, + padding: theme.spacing(1), +})); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransferHistoryChart.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransferHistoryChart.tsx deleted file mode 100644 index ae6862a260f..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkTransferHistoryChart.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Typography, useTheme } from '@mui/material'; -import { styled } from '@mui/material/styles'; -import { DateTime } from 'luxon'; -import React from 'react'; -import { - Area, - AreaChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - TooltipProps, - XAxis, - YAxis, -} from 'recharts'; - -import { Box } from 'src/components/Box'; -import { Paper } from 'src/components/Paper'; -import { NetworkUnit } from 'src/features/Longview/shared/utilities'; -import { roundTo } from 'src/utilities/roundTo'; - -interface NetworkTransferHistoryChartProps { - data: [number, null | number][]; - timezone: string; - unit: NetworkUnit; -} - -export const NetworkTransferHistoryChart = ( - props: NetworkTransferHistoryChartProps -) => { - const { data, timezone, unit } = props; - - const theme = useTheme(); - - const xAxisTickFormatter = (t: number) => { - return DateTime.fromMillis(t, { zone: timezone }).toFormat('LLL dd'); - }; - - const tooltipLabelFormatter = (t: number) => { - return DateTime.fromMillis(t, { zone: timezone }).toFormat( - 'LLL dd, yyyy, h:mm a' - ); - }; - - const tooltipValueFormatter = (value: number) => - `${roundTo(value)} ${unit}/s`; - - const CustomTooltip = ({ - active, - label, - payload, - }: TooltipProps) => { - if (active && payload && payload.length) { - return ( - - {tooltipLabelFormatter(label)} - - Public outbound traffic: {tooltipValueFormatter(payload[0].value)} - - - ); - } - - return null; - }; - - return ( - - - - - - - } - /> - - - - - ); -}; - -const StyledPaper = styled(Paper, { - label: 'StyledPaper', -})(({ theme }) => ({ - border: `1px solid ${theme.color.border2}`, - padding: theme.spacing(1), -})); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx index 137ba13a639..67dbe306337 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx @@ -7,6 +7,7 @@ import { DateTime, Interval } from 'luxon'; import * as React from 'react'; import PendingIcon from 'src/assets/icons/pending.svg'; +import { AreaChart } from 'src/components/AreaChart'; import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; @@ -28,8 +29,6 @@ import { useProfile } from 'src/queries/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { readableBytes } from 'src/utilities/unitConversions'; -import { NetworkTransferHistoryChart } from './NetworkTransferHistoryChart'; - interface Props { linodeCreated: string; linodeID: number; @@ -168,12 +167,25 @@ export const TransferHistory = React.memo((props: Props) => { }, []); return ( - + + + ); } diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx index dc0a7959987..2dc23c62495 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx @@ -4,13 +4,16 @@ import * as React from 'react'; import { useParams } from 'react-router-dom'; import PendingIcon from 'src/assets/icons/pending.svg'; +import { AreaChart } from 'src/components/AreaChart'; +import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LineGraph } from 'src/components/LineGraph/LineGraph'; import MetricsDisplay from 'src/components/LineGraph/MetricsDisplay'; -import { Typography } from 'src/components/Typography'; import { Paper } from 'src/components/Paper'; +import { Typography } from 'src/components/Typography'; import { formatBitsPerSecond } from 'src/features/Longview/shared/utilities'; +import { useFlags } from 'src/hooks/useFlags'; import { NODEBALANCER_STATS_NOT_READY_API_MESSAGE, useNodeBalancerQuery, @@ -37,6 +40,8 @@ export const TablesPanel = () => { nodebalancer?.created ); + const flags = useFlags(); + const statsErrorString = error ? getAPIErrorOrDefault(error, 'Unable to load stats')[0].reason : undefined; @@ -80,24 +85,58 @@ export const TablesPanel = () => { const metrics = getMetrics(data); + let timeData = []; + // @TODO recharts: remove conditional code and delete old chart when we decide recharts is stable + if (flags.recharts) { + timeData = data.reduce((acc: any, point: any) => { + acc.push({ + Connections: point[1], + t: point[0], + }); + return acc; + }, []); + } + return ( - - - + {flags.recharts ? ( + + + + ) : ( + + + + )} { const renderTrafficChart = () => { const trafficIn = stats?.data.traffic.in ?? []; const trafficOut = stats?.data.traffic.out ?? []; + const timeData = []; + + // @TODO recharts: remove conditional code and delete old chart when we decide recharts is stable + if (flags.recharts && trafficIn) { + for (let i = 0; i < trafficIn.length; i++) { + timeData.push({ + 'Traffic In': trafficIn[i][1], + 'Traffic Out': trafficOut[i][1], + t: trafficIn[i][0], + }); + } + } if (statsNotReadyError) { return ( @@ -152,26 +203,52 @@ export const TablesPanel = () => { return ( - + {flags.recharts ? ( + + + + ) : ( + + )} { return ( - - Graphs - + Graphs Connections (CXN/s, 5 min avg.) @@ -223,9 +298,14 @@ const StyledHeader = styled(Typography, { const StyledTitle = styled(Typography, { label: 'StyledTitle', })(({ theme }) => ({ + alignItems: 'center', + display: 'flex', [theme.breakpoints.down('lg')]: { marginLeft: theme.spacing(), }, + [theme.breakpoints.up('md')]: { + margin: `${theme.spacing(2)} 0`, + }, })); const StyledChart = styled('div', { @@ -247,16 +327,6 @@ const StyledBottomLegend = styled('div', { padding: 10, })); -const StyledgGraphControls = styled(Typography, { - label: 'StyledgGraphControls', -})(({ theme }) => ({ - alignItems: 'center', - display: 'flex', - [theme.breakpoints.up('md')]: { - margin: `${theme.spacing(2)} 0`, - }, -})); - const StyledPanel = styled(Paper, { label: 'StyledPanel', })(({ theme }) => ({ From 249ad9352cfd9f11962771b36b2c2f3d5bf77295 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 15 Dec 2023 09:05:26 -0500 Subject: [PATCH 22/45] change: [M3-7446] - VLAN availability text on Linode Create (#9989) * Initial commit - save work * Replace long list with tooltip content * Test part 1 * Test part 1 * Test part 2 * Delete packages/manager/src/features/Linodes/LinodesCreate/VlanAvailabilityNotice.tsx * Added changeset: VLAN availability text on Linode Create --- .../pr-9989-changed-1702494798508.md | 5 + .../Linodes/LinodesCreate/AttachVLAN.tsx | 18 +--- .../LinodesCreate/VLANAccordion.test.tsx | 95 +++++++++++++++++++ .../Linodes/LinodesCreate/VLANAccordion.tsx | 22 +---- .../LinodesCreate/VLANAvailabilityNotice.tsx | 67 +++++++++++++ 5 files changed, 175 insertions(+), 32 deletions(-) create mode 100644 packages/manager/.changeset/pr-9989-changed-1702494798508.md create mode 100644 packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodesCreate/VLANAvailabilityNotice.tsx diff --git a/packages/manager/.changeset/pr-9989-changed-1702494798508.md b/packages/manager/.changeset/pr-9989-changed-1702494798508.md new file mode 100644 index 00000000000..fce9fa99624 --- /dev/null +++ b/packages/manager/.changeset/pr-9989-changed-1702494798508.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +VLAN availability text on Linode Create ([#9989](https://github.com/linode/manager/pull/9989)) diff --git a/packages/manager/src/features/Linodes/LinodesCreate/AttachVLAN.tsx b/packages/manager/src/features/Linodes/LinodesCreate/AttachVLAN.tsx index c3f6a0f5028..4332e95edaa 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/AttachVLAN.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/AttachVLAN.tsx @@ -10,13 +10,10 @@ import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions'; import { queryKey as vlansQueryKey } from 'src/queries/vlans'; -import { arrayToList } from 'src/utilities/arrayToList'; -import { - doesRegionSupportFeature, - regionsWithFeature, -} from 'src/utilities/doesRegionSupportFeature'; +import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; import { InterfaceSelect } from '../LinodesDetail/LinodeSettings/InterfaceSelect'; +import { VLANAvailabilityNotice } from './VLANAvailabilityNotice'; // @TODO VPC: Delete this file when VPC is released @@ -61,15 +58,6 @@ export const AttachVLAN = React.memo((props: Props) => { 'Vlans' ); - const regionsThatSupportVLANs = regionsWithFeature(regions, 'Vlans').map( - (region) => region.label - ); - - const regionalAvailabilityMessage = `VLANs are currently available in ${arrayToList( - regionsThatSupportVLANs, - ';' - )}.`; - return ( { - {regionalAvailabilityMessage} + VLANs are used to create a private L2 Virtual Local Area Network between Linodes. A VLAN created or attached in this section will be diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.test.tsx new file mode 100644 index 00000000000..ff2516dd172 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { queryClientFactory } from 'src/queries/base'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { VLANAccordion } from './VLANAccordion'; + +import type { VLANAccordionProps } from './VLANAccordion'; + +const queryClient = queryClientFactory(); + +const defaultProps: VLANAccordionProps = { + handleVLANChange: vi.fn(), + helperText: 'helper text', + ipamAddress: '', + vlanLabel: '', +}; + +describe('VLANAccordion Component', () => { + it('renders collapsed VLANAccordion component', () => { + const { getByRole } = renderWithTheme(, { + queryClient, + }); + + expect(getByRole('button', { name: 'VLAN' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + }); + + it('contains expected elements when expanded', () => { + const { + container, + getByPlaceholderText, + getByRole, + getByTestId, + getByText, + } = renderWithTheme(, { + queryClient, + }); + + fireEvent.click(getByRole('button', { name: 'VLAN' })); + + expect(getByTestId('notice-warning')).toBeVisible(); + expect( + getByRole('link', { + name: 'Configuration Profile - link opens in a new tab', + }) + ).toBeVisible(); + expect(getByText('Create or select a VLAN')).toBeVisible(); + expect(container.querySelector('#vlan-label-1')).toHaveAttribute( + 'disabled' + ); + expect(getByPlaceholderText('192.0.2.0/24')).toBeVisible(); + expect(container.querySelector('#ipam-input-1')).toHaveAttribute( + 'disabled' + ); + }); + + it('enables the input fields when a region is selected', () => { + const { container, getByRole } = renderWithTheme( + , + { + queryClient, + } + ); + + fireEvent.click(getByRole('button', { name: 'VLAN' })); + + expect(container.querySelector('#vlan-label-1')).not.toHaveAttribute( + 'disabled' + ); + expect(container.querySelector('#ipam-input-1')).not.toHaveAttribute( + 'disabled' + ); + }); + + it('contains a tooltip containing avalable VLAN regions', async () => { + const { getAllByRole, getByRole, getByText } = renderWithTheme( + , + { + queryClient, + } + ); + + fireEvent.click(getByRole('button', { name: 'VLAN' })); + fireEvent.mouseOver(getByText('select regions')); + + await waitFor(() => { + expect(getByRole('tooltip')).toBeVisible(); + expect(getAllByRole('listitem').length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx index e77e2c5ba78..3a9cc92b3e3 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VLANAccordion.tsx @@ -9,15 +9,12 @@ import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions'; import { queryKey as vlansQueryKey } from 'src/queries/vlans'; -import { arrayToList } from 'src/utilities/arrayToList'; -import { - doesRegionSupportFeature, - regionsWithFeature, -} from 'src/utilities/doesRegionSupportFeature'; +import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; import { InterfaceSelect } from '../LinodesDetail/LinodeSettings/InterfaceSelect'; +import { VLANAvailabilityNotice } from './VLANAvailabilityNotice'; -interface Props { +export interface VLANAccordionProps { handleVLANChange: (updatedInterface: Interface) => void; helperText?: string; ipamAddress: string; @@ -28,7 +25,7 @@ interface Props { vlanLabel: string; } -export const VLANAccordion = React.memo((props: Props) => { +export const VLANAccordion = React.memo((props: VLANAccordionProps) => { const { handleVLANChange, helperText, @@ -56,15 +53,6 @@ export const VLANAccordion = React.memo((props: Props) => { 'Vlans' ); - const regionsThatSupportVLANs = regionsWithFeature(regions, 'Vlans').map( - (region) => region.label - ); - - const regionalAvailabilityMessage = `VLANs are currently available in ${arrayToList( - regionsThatSupportVLANs, - ';' - )}.`; - return ( { data-qa-add-ons data-testid="vlan-accordion" > - {regionalAvailabilityMessage} + VLANs are used to create a private L2 Virtual Local Area Network between Linodes. A VLAN created or attached in this section will be assigned to diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VLANAvailabilityNotice.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VLANAvailabilityNotice.tsx new file mode 100644 index 00000000000..fb8bca1aac4 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesCreate/VLANAvailabilityNotice.tsx @@ -0,0 +1,67 @@ +import { Theme, styled } from '@mui/material/styles'; +import * as React from 'react'; + +import { List } from 'src/components/List'; +import { ListItem } from 'src/components/ListItem'; +import { Notice } from 'src/components/Notice/Notice'; +import { TextTooltip } from 'src/components/TextTooltip'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions'; +import { regionsWithFeature } from 'src/utilities/doesRegionSupportFeature'; + +import type { Region } from '@linode/api-v4'; + +export const VLANAvailabilityNotice = () => { + const regions = useRegionsQuery().data ?? []; + + const regionsThatSupportVLANs: Region[] = regionsWithFeature(regions, 'Vlans') + .map((region) => region) + .sort((a, b) => a.label.localeCompare(b.label)); + + const VLANsTooltipList = React.useCallback(() => { + return ( + + {regionsThatSupportVLANs.map((region, idx) => ( + + {region.label} ({region.id}) + + ))} + + ); + }, [regionsThatSupportVLANs]); + + return ( + + + VLANs are currently available in  + theme.font.bold, + }} + displayText="select regions" + minWidth={400} + placement="bottom-start" + tooltipText={} + variant="body2" + /> + . + + + ); +}; + +const StyledFormattedRegionList = styled(List, { + label: 'VlanFormattedRegionList', +})(({ theme }) => ({ + '& li': { + padding: `4px 0`, + }, + columns: '2 auto', + padding: `${theme.spacing(0.5)} ${theme.spacing()}`, +})); + +const StyledNoticeTypography = styled(Typography, { + label: 'StyledNoticeTypography', +})(({ theme }) => ({ + fontFamily: theme.font.bold, +})); From be18999cdd97f1a997508c561f82321cec6648b6 Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Fri, 15 Dec 2023 12:41:37 -0500 Subject: [PATCH 23/45] refactor: [M3-7562] - Remove Radio.mdx and Textfield.mdx files (#9994) * stories * add tests * Added changeset: Radio and TextField v7 storybook migrations --- .../pr-9994-tech-stories-1702486520396.md | 5 ++ .../manager/src/components/Radio/Radio.mdx | 37 ----------- .../src/components/Radio/Radio.test.tsx | 27 ++++++++ .../manager/src/components/Radio/Radio.tsx | 18 ++++++ packages/manager/src/components/TextField.mdx | 62 ------------------- .../manager/src/components/TextField.test.tsx | 32 ++++++++++ packages/manager/src/components/TextField.tsx | 53 ++++++++++++++-- 7 files changed, 131 insertions(+), 103 deletions(-) create mode 100644 packages/manager/.changeset/pr-9994-tech-stories-1702486520396.md delete mode 100644 packages/manager/src/components/Radio/Radio.mdx create mode 100644 packages/manager/src/components/Radio/Radio.test.tsx delete mode 100644 packages/manager/src/components/TextField.mdx diff --git a/packages/manager/.changeset/pr-9994-tech-stories-1702486520396.md b/packages/manager/.changeset/pr-9994-tech-stories-1702486520396.md new file mode 100644 index 00000000000..c6567c790a0 --- /dev/null +++ b/packages/manager/.changeset/pr-9994-tech-stories-1702486520396.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Radio and TextField v7 storybook migrations ([#9994](https://github.com/linode/manager/pull/9994)) diff --git a/packages/manager/src/components/Radio/Radio.mdx b/packages/manager/src/components/Radio/Radio.mdx deleted file mode 100644 index 939043ca6cb..00000000000 --- a/packages/manager/src/components/Radio/Radio.mdx +++ /dev/null @@ -1,37 +0,0 @@ -import { Canvas, Meta, ArgsTable, Controls } from '@storybook/blocks'; -import * as RadioStories from './Radio.stories'; - - - -# Radio - ---- - -### Use radio buttons to - -- Expose all available options -- Select a single option from a list - -### Guidelines - -- If there are 3 or fewer items to select, use radio buttons rather than drop-down menus. -- If possible, offer a default selection. -- Because radio buttons allow only one choice, make sure that the options are both comprehensive and distinct. -- Let users select an option by clicking on either the button itself or its label to provide as big a target area as possible. - -### Reasons for a Default Selection - -- Expedite tasks -- Give people control and align with their expectations - - - - - -## Radio Groups - - - -## Controlled Radio - - diff --git a/packages/manager/src/components/Radio/Radio.test.tsx b/packages/manager/src/components/Radio/Radio.test.tsx new file mode 100644 index 00000000000..c46243be778 --- /dev/null +++ b/packages/manager/src/components/Radio/Radio.test.tsx @@ -0,0 +1,27 @@ +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { Radio } from './Radio'; + +// This test is for a single radio button, not a radio group +describe('Radio', () => { + it('renders a single radio properly', () => { + const screen = renderWithTheme(); + + const radio = screen.getByRole('radio'); + expect(radio).toBeInTheDocument(); + const notFilled = screen.container.querySelector('[id="Oval-2"]'); + expect(notFilled).not.toBeInTheDocument(); + fireEvent.click(radio); + const filled = screen.container.querySelector('[id="Oval-2"]'); + expect(filled).toBeInTheDocument(); + }); + + it('can render a disabled radio', () => { + const screen = renderWithTheme(); + const disabled = screen.container.querySelector('[aria-disabled="true"]'); + expect(disabled).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/Radio/Radio.tsx b/packages/manager/src/components/Radio/Radio.tsx index f9042e5121f..d7d9d30d8cb 100644 --- a/packages/manager/src/components/Radio/Radio.tsx +++ b/packages/manager/src/components/Radio/Radio.tsx @@ -4,6 +4,24 @@ import * as React from 'react'; import RadioIcon from '../../assets/icons/radio.svg'; import RadioIconRadioed from '../../assets/icons/radioRadioed.svg'; +/** +### Use radio buttons to + +- Expose all available options +- Select a single option from a list + +### Guidelines + +- If there are 3 or fewer items to select, use radio buttons rather than drop-down menus. +- If possible, offer a default selection. +- Because radio buttons allow only one choice, make sure that the options are both comprehensive and distinct. +- Let users select an option by clicking on either the button itself or its label to provide as big a target area as possible. + +### Reasons for a Default Selection + +- Expedite tasks +- Give people control and align with their expectations + */ export const Radio = (props: RadioProps) => { return ( <_Radio diff --git a/packages/manager/src/components/TextField.mdx b/packages/manager/src/components/TextField.mdx deleted file mode 100644 index dbd4739c48b..00000000000 --- a/packages/manager/src/components/TextField.mdx +++ /dev/null @@ -1,62 +0,0 @@ -import { Canvas, Meta, Story, Controls } from '@storybook/addon-docs'; -import * as TextFieldStories from './TextField.stories'; - - - -# Text Field - - - -### Overview - -Text fields allow users to enter text into a UI. - -### Usage - -- Input fields should be sized to the data being entered (ex. the entry for a street address should be wider than a zip code). -- Ensure that the field can accommodate at least one more character than the maximum number to be entered. - -### Rules - -- Every input must have a descriptive label of what that field is. -- Required fields should include the text “(Required)” as part of the input label. -- If most fields are required, then indicate the optional fields with the text “(Optional)” instead. -- Avoid long labels; use succinct, short and descriptive labels (a word or two) so users can quickly scan your form.
Label text shouldn’t take up multiple lines. -- Placeholder text is the text that users see before they interact with a field. It should be a useful guide to the input type and format
Don’t make the user guess what format they should use for the field. Tell this information up front. - -### Best Practices - -- A single column form with input fields stacked sequentially is the easiest to understand and leads to the highest success rate. Input fields in multiple columns can be overlooked or add unnecessary visual clutter. -- Grouping related inputs (ex. mailing address) under a subhead or rule can add meaning and make the form feel more manageable. -- Avoid breaking a single form into multiple “papers” unless those sections are truly independent of each other. -- Consider sizing the input field to the data being entered (ex. the field for a street address should be wider than the field for a zip code). Balance this goal with the visual benefits of fields of the same length. A somewhat outsized input that aligns with the fields above and below it might be the best choice. - -## Error - -### Overview - -Error messages are an indicator of system status: they let users know that a hurdle was encountered and give solutions to fix it. Users should not have to memorize instructions in order to fix the error. - -### Main Principles - -- Should be easy to notice and understand. -- Should give solutions to how to fix the error. -- Users should not have to memorize instructions in order to fix the error. -- Long error messages for short text fields can extend beyond the text field. -- When the user has finished filling in a field and clicks the submit button, an indicator should appear if the field contains an error. Use red to differentiate error fields from normal ones. - - - -## Number - -### Overview - -Number Text Fields are used for strictly numerical input - - - -## Component API - - - - \ No newline at end of file diff --git a/packages/manager/src/components/TextField.test.tsx b/packages/manager/src/components/TextField.test.tsx index 797ea3c83f2..2c372ada0a2 100644 --- a/packages/manager/src/components/TextField.test.tsx +++ b/packages/manager/src/components/TextField.test.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { InputAdornment } from './InputAdornment'; import { TextField } from './TextField'; describe('TextField', () => { @@ -59,4 +60,35 @@ describe('TextField', () => { ); expect(getByText(/There was an error/i)).toBeInTheDocument(); }); + + it('can change the input type and renders an input adornment', () => { + const { getByDisplayValue, getByTestId, getByText } = renderWithTheme( + $, + }} + label={'Money'} + type={'number'} + value={'100'} + /> + ); + + expect(getByText('Money')).toBeInTheDocument(); + expect(getByDisplayValue('100')).toBeInTheDocument(); + expect(getByText('$')).toBeInTheDocument(); + const input = getByTestId('textfield-input'); + expect(input?.getAttribute('type')).toBe('number'); + }); + + it('accepts a min and max value for a type of number and clamps the value within the range', () => { + const { getByTestId } = renderWithTheme( + + ); + const input = getByTestId('textfield-input'); + expect(input?.getAttribute('value')).toBe('5'); + fireEvent.change(input, { target: { value: '50' } }); + expect(input?.getAttribute('value')).toBe('10'); + fireEvent.change(input, { target: { value: '1' } }); + expect(input?.getAttribute('value')).toBe('2'); + }); }); diff --git a/packages/manager/src/components/TextField.tsx b/packages/manager/src/components/TextField.tsx index 58d928d9f29..844d3440530 100644 --- a/packages/manager/src/components/TextField.tsx +++ b/packages/manager/src/components/TextField.tsx @@ -73,6 +73,10 @@ interface BaseProps { * className to apply to the underlying TextField component */ className?: string; + /** + * Props applied to the root element + */ + containerProps?: BoxProps; /** * Data attributes are applied to the underlying TextField component for testing purposes */ @@ -146,10 +150,6 @@ interface BaseProps { */ trimmed?: boolean; value?: Value; - /** - * Props applied to the root element - */ - containerProps?: BoxProps; } type Value = null | number | string | undefined; @@ -176,6 +176,51 @@ export type TextFieldProps = BaseProps & LabelToolTipProps & InputToolTipProps; +/** +### Overview + +Text fields allow users to enter text into a UI. + +### Usage + +- Input fields should be sized to the data being entered (ex. the entry for a street address should be wider than a zip code). +- Ensure that the field can accommodate at least one more character than the maximum number to be entered. + +### Rules + +- Every input must have a descriptive label of what that field is. +- Required fields should include the text “(Required)” as part of the input label. +- If most fields are required, then indicate the optional fields with the text “(Optional)” instead. +- Avoid long labels; use succinct, short and descriptive labels (a word or two) so users can quickly scan your form.
Label text shouldn’t take up multiple lines. +- Placeholder text is the text that users see before they interact with a field. It should be a useful guide to the input type and format
Don’t make the user guess what format they should use for the field. Tell this information up front. + +### Best Practices + +- A single column form with input fields stacked sequentially is the easiest to understand and leads to the highest success rate. Input fields in multiple columns can be overlooked or add unnecessary visual clutter. +- Grouping related inputs (ex. mailing address) under a subhead or rule can add meaning and make the form feel more manageable. +- Avoid breaking a single form into multiple “papers” unless those sections are truly independent of each other. +- Consider sizing the input field to the data being entered (ex. the field for a street address should be wider than the field for a zip code). Balance this goal with the visual benefits of fields of the same length. A somewhat outsized input that aligns with the fields above and below it might be the best choice. + +## Textfield errors + +### Overview + +Error messages are an indicator of system status: they let users know that a hurdle was encountered and give solutions to fix it. Users should not have to memorize instructions in order to fix the error. + +### Main Principles + +- Should be easy to notice and understand. +- Should give solutions to how to fix the error. +- Users should not have to memorize instructions in order to fix the error. +- Long error messages for short text fields can extend beyond the text field. +- When the user has finished filling in a field and clicks the submit button, an indicator should appear if the field contains an error. Use red to differentiate error fields from normal ones. + +## Number Text Fields + +### Overview + +Number Text Fields are used for strictly numerical input + */ export const TextField = (props: TextFieldProps) => { const { classes, cx } = useStyles(); From bde20de72d054abcece50b90dfc5393ebc9540e8 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 15 Dec 2023 15:26:35 -0500 Subject: [PATCH 24/45] refactor: Finish Remaining MUI Style Migration with codemod (#9978) * finish migration with codemod * remove commit * rewrite enzyme test * fix: kubernetes node pool drawer width @coliu-akamai * fix order styles are applied to fix secret download icon * clean up * change `Learn More` to `Learn more` * make MetricsDisplay look a lot less broken * Added changeset: Complete @mui/styles to tss-react migration and remove @mui/styles * fix Linode and NodeBalancer summaries * make Node Pool Drawer width match production * fix longview to the best of my ability * So long old `Grid`s --------- Co-authored-by: Banks Nussman Co-authored-by: Alban Bailly --- .../pr-9978-tech-stories-1702564889256.md | 5 + packages/manager/package.json | 1 - packages/manager/src/GoTo.tsx | 6 +- packages/manager/src/LinodeThemeWrapper.tsx | 8 +- .../CheckoutSummary/CheckoutSummary.tsx | 4 +- .../CheckoutSummary/SummaryItem.tsx | 6 +- .../CopyableAndDownloadableTextField.tsx | 6 +- .../src/components/DownloadTooltip.tsx | 18 +- .../EntityTable/EntityTableHeader.tsx | 192 ------------- .../src/components/EntityTable/types.ts | 51 ---- packages/manager/src/components/Grid.tsx | 263 ------------------ .../src/components/Grid/Grid.stories.tsx | 119 -------- packages/manager/src/components/Grid/Grid.tsx | 25 -- packages/manager/src/components/Grid/index.ts | 5 - .../src/components/GroupByTagToggle.tsx | 43 +++ .../components/ImageSelect/ImageOption.tsx | 9 +- .../LineGraph/MetricDisplay.styles.ts | 175 ++++-------- .../LineGraph/MetricsDisplay.test.tsx | 144 +++++----- .../components/LineGraph/MetricsDisplay.tsx | 35 ++- .../manager/src/components/LinkButton.tsx | 9 +- .../src/components/MainContentBanner.tsx | 6 +- .../DeletePaymentMethodDialog.tsx | 3 +- .../manager/src/components/RenderGuard.tsx | 17 +- .../VerticalLinearStepper.tsx | 2 +- .../src/containers/SummaryPanels.styles.ts | 78 ------ .../BillingSummary/BillingSummary.tsx | 5 +- .../src/features/Help/Panels/PopularPosts.tsx | 8 +- .../src/features/Images/ImageUpload.tsx | 6 +- .../Images/ImagesCreate/CreateImageTab.tsx | 6 +- .../src/features/Images/ImagesDrawer.tsx | 6 +- .../src/features/Images/ImagesLanding.tsx | 6 +- .../ClusterList/KubernetesClusterRow.tsx | 6 +- .../KubeCheckoutBar/NodePoolSummary.tsx | 6 +- .../KubeClusterSpecs.tsx | 6 +- .../KubeConfigDisplay.tsx | 11 +- .../KubeConfigDrawer.tsx | 6 +- .../KubeConfigPanel.tsx | 9 +- .../KubeSummaryPanel.tsx | 6 +- .../NodePoolsDisplay/AddNodePoolDrawer.tsx | 23 +- .../NodePoolsDisplay/AutoscalePoolDialog.tsx | 11 +- .../NodePoolsDisplay/NodeActionMenu.tsx | 19 +- .../NodePoolsDisplay/NodePool.tsx | 6 +- .../NodePoolsDisplay/NodePoolsDisplay.tsx | 16 +- .../NodePoolsDisplay/NodeTable.tsx | 78 +++--- .../NodePoolsDisplay/ResizeNodePoolDrawer.tsx | 6 +- .../UpgradeClusterDialog.tsx | 6 +- .../UpgradeVolumesDialog.tsx | 6 +- .../LinodesLanding/SortableTableHead.tsx | 2 +- packages/manager/src/features/Lish/Glish.tsx | 6 +- .../manager/src/features/Lish/Weblish.tsx | 34 +-- .../LongviewClientInstructions.tsx | 56 ++-- .../LongviewClientRow.styles.ts | 13 - .../LongviewLanding/LongviewClientRow.tsx | 134 ++++----- .../NodeBalancerSummary/TablesPanel.tsx | 2 - .../features/OneClickApps/AppDetailDrawer.tsx | 6 +- .../src/features/TheApplicationIsOnFire.tsx | 6 +- .../src/features/Volumes/VolumeCreate.tsx | 6 +- .../Volumes/VolumeDrawer/SizeField.tsx | 6 +- .../src/features/Volumes/VolumeTableRow.tsx | 6 +- yarn.lock | 113 +------- 60 files changed, 476 insertions(+), 1402 deletions(-) create mode 100644 packages/manager/.changeset/pr-9978-tech-stories-1702564889256.md delete mode 100644 packages/manager/src/components/EntityTable/EntityTableHeader.tsx delete mode 100644 packages/manager/src/components/EntityTable/types.ts delete mode 100644 packages/manager/src/components/Grid.tsx delete mode 100644 packages/manager/src/components/Grid/Grid.stories.tsx delete mode 100644 packages/manager/src/components/Grid/Grid.tsx delete mode 100644 packages/manager/src/components/Grid/index.ts create mode 100644 packages/manager/src/components/GroupByTagToggle.tsx delete mode 100644 packages/manager/src/containers/SummaryPanels.styles.ts delete mode 100644 packages/manager/src/features/Longview/LongviewLanding/LongviewClientRow.styles.ts diff --git a/packages/manager/.changeset/pr-9978-tech-stories-1702564889256.md b/packages/manager/.changeset/pr-9978-tech-stories-1702564889256.md new file mode 100644 index 00000000000..819deb12aa2 --- /dev/null +++ b/packages/manager/.changeset/pr-9978-tech-stories-1702564889256.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Complete @mui/styles to tss-react migration and remove @mui/styles ([#9978](https://github.com/linode/manager/pull/9978)) diff --git a/packages/manager/package.json b/packages/manager/package.json index ae868867563..31716f6af03 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -20,7 +20,6 @@ "@linode/validation": "*", "@mui/icons-material": "^5.14.7", "@mui/material": "^5.14.7", - "@mui/styles": "^5.14.7", "@paypal/react-paypal-js": "^7.8.3", "@reach/tabs": "^0.10.5", "@sentry/react": "^7.57.0", diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index 32af40cb471..c0189b2a0bb 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -1,6 +1,6 @@ import Dialog from '@mui/material/Dialog'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from '@mui/styles'; +import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -9,7 +9,7 @@ import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; import { useAccountManagement } from './hooks/useAccountManagement'; import { useFlags } from './hooks/useFlags'; -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles()((theme: Theme) => ({ input: { width: '100%', }, @@ -59,7 +59,7 @@ interface Props { } export const GoTo = React.memo((props: Props) => { - const classes = useStyles(); + const { classes } = useStyles(); const routerHistory = useHistory(); const { _hasAccountAccess, _isManagedAccount } = useAccountManagement(); const flags = useFlags(); diff --git a/packages/manager/src/LinodeThemeWrapper.tsx b/packages/manager/src/LinodeThemeWrapper.tsx index 0164cee437d..74e5d054aec 100644 --- a/packages/manager/src/LinodeThemeWrapper.tsx +++ b/packages/manager/src/LinodeThemeWrapper.tsx @@ -1,15 +1,9 @@ -import { Theme, ThemeProvider } from '@mui/material/styles'; -import { StyledEngineProvider } from '@mui/material/styles'; +import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; import * as React from 'react'; import { ThemeName } from './foundations/themes'; import { themes, useColorMode } from './utilities/theme'; -declare module '@mui/styles/defaultTheme' { - // eslint-disable-next-line @typescript-eslint/no-empty-interface - interface DefaultTheme extends Theme {} -} - interface Props { children: React.ReactNode; /** Allows theme to be overwritten. Used for Storybook theme switching */ diff --git a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx index 40ad820d84f..ada8a14e6bb 100644 --- a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx +++ b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx @@ -1,9 +1,9 @@ import { useTheme } from '@mui/material'; +import Grid2 from '@mui/material/Unstable_Grid2/Grid2'; import { Theme, styled } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; -import { Grid } from '../Grid'; import { Paper } from '../Paper'; import { Typography } from '../Typography'; import { SummaryItem } from './SummaryItem'; @@ -62,7 +62,7 @@ const StyledHeading = styled(Typography)(({ theme }) => ({ marginBottom: theme.spacing(3), })); -const StyledSummary = styled(Grid)(({ theme }) => ({ +const StyledSummary = styled(Grid2)(({ theme }) => ({ [theme.breakpoints.up('md')]: { '& > div': { '&:last-child': { diff --git a/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx b/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx index 096b2b3c47a..1797684864a 100644 --- a/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx +++ b/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx @@ -1,13 +1,13 @@ import { styled } from '@mui/material/styles'; import React from 'react'; -import { Grid } from '../Grid'; +import Grid2 from '@mui/material/Unstable_Grid2/Grid2'; import { Typography } from '../Typography'; import { SummaryItem as Props } from './CheckoutSummary'; export const SummaryItem = ({ details, title }: Props) => { return ( - + {title ? ( <> { ); }; -const StyledGrid = styled(Grid)(({ theme }) => ({ +const StyledGrid = styled(Grid2)(({ theme }) => ({ marginBottom: `${theme.spacing()} !important`, marginTop: `${theme.spacing()} !important`, paddingBottom: '0 !important', diff --git a/packages/manager/src/components/CopyableAndDownloadableTextField.tsx b/packages/manager/src/components/CopyableAndDownloadableTextField.tsx index 753928e69e0..a3efa809511 100644 --- a/packages/manager/src/components/CopyableAndDownloadableTextField.tsx +++ b/packages/manager/src/components/CopyableAndDownloadableTextField.tsx @@ -1,5 +1,5 @@ import { Theme } from '@mui/material/styles'; -import { makeStyles } from '@mui/styles'; +import { makeStyles } from 'tss-react/mui'; import snakeCase from 'lodash/snakeCase'; import * as React from 'react'; @@ -7,7 +7,7 @@ import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { DownloadTooltip } from 'src/components/DownloadTooltip'; import { TextField, TextFieldProps } from 'src/components/TextField'; -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles()((theme: Theme) => ({ copyIcon: { '& svg': { height: 14, @@ -32,7 +32,7 @@ type Props = TextFieldProps & { }; export const CopyableAndDownloadableTextField = (props: Props) => { - const classes = useStyles(); + const { classes } = useStyles(); const { className, hideIcon, value, ...restProps } = props; const fileName = props.fileName ?? snakeCase(props.label); diff --git a/packages/manager/src/components/DownloadTooltip.tsx b/packages/manager/src/components/DownloadTooltip.tsx index dbc5834a67c..1b8a543f3ed 100644 --- a/packages/manager/src/components/DownloadTooltip.tsx +++ b/packages/manager/src/components/DownloadTooltip.tsx @@ -1,6 +1,5 @@ import { Theme } from '@mui/material/styles'; -import { makeStyles } from '@mui/styles'; -import classNames from 'classnames'; +import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; import FileDownload from 'src/assets/icons/download.svg'; @@ -32,7 +31,7 @@ interface Props { text: string; } -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles()((theme: Theme) => ({ displayText: { color: theme.textColors.linkActiveLight, marginLeft: 6, @@ -65,7 +64,7 @@ const useStyles = makeStyles((theme: Theme) => ({ })); export const DownloadTooltip = (props: Props) => { - const classes = useStyles(); + const { classes, cx } = useStyles(); const { className, displayText, fileName, onClickCallback, text } = props; @@ -79,10 +78,13 @@ export const DownloadTooltip = (props: Props) => { return ( { ), }} hideLabel + inputId={`configuration-${configurationIndex}-service-target-filter`} label="Filter" onChange={(e) => setQuery(e.target.value)} placeholder="Filter" @@ -91,10 +94,10 @@ export const ServiceTargets = () => { - {values.service_targets.length === 0 && ( + {configuration.service_targets.length === 0 && ( )} - {values.service_targets + {configuration.service_targets .filter((serviceTarget) => { if (query) { return serviceTarget.label.includes(query); @@ -135,7 +138,11 @@ export const ServiceTargets = () => { handleEditServiceTarget(index), + onClick: () => + handlers.handleEditServiceTarget( + index, + configurationIndex + ), title: 'Edit', }, { @@ -151,14 +158,6 @@ export const ServiceTargets = () => { - { - setIsDrawerOpen(false); - setSelectedServiceTargetIndex(undefined); - }} - open={isDrawerOpen} - serviceTargetIndex={selectedServiceTargetIndex} - /> ); }; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/EditRouteDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/EditRouteDrawer.tsx index e1d49a0170f..e66b51d24e3 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/EditRouteDrawer.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/EditRouteDrawer.tsx @@ -1,4 +1,5 @@ import { UpdateRoutePayload } from '@linode/api-v4'; +import { UpdateRouteSchema } from '@linode/validation'; import { useFormik, yupToFormErrors } from 'formik'; import React from 'react'; @@ -16,7 +17,6 @@ import { capitalize } from 'src/utilities/capitalize'; import { getFormikErrorsFromAPIErrors } from 'src/utilities/formikErrorUtils'; import type { Route } from '@linode/api-v4'; -import { UpdateRouteSchema } from '@linode/validation'; interface Props { loadbalancerId: number; diff --git a/packages/manager/src/utilities/stringUtils.test.ts b/packages/manager/src/utilities/stringUtils.test.ts index 43e66f7814e..62bbea50283 100644 --- a/packages/manager/src/utilities/stringUtils.test.ts +++ b/packages/manager/src/utilities/stringUtils.test.ts @@ -1,4 +1,10 @@ -import { isNumeric, truncateAndJoinList } from './stringUtils'; +import { + getNextLabel, + getNumberAtEnd, + isNumeric, + removeNumberAtEnd, + truncateAndJoinList, +} from './stringUtils'; describe('truncateAndJoinList', () => { const strList = ['a', 'b', 'c']; @@ -49,3 +55,44 @@ describe('isNumeric', () => { expect(isNumeric('my-linode')).toBe(false); }); }); + +describe('getNumberAtEnd', () => { + it('should return 1 when given test-1', () => { + expect(getNumberAtEnd('test-1')).toBe(1); + }); + it('should return null if there is no number in the string', () => { + expect(getNumberAtEnd('test')).toBe(null); + }); + it('should get the last number in the string', () => { + expect(getNumberAtEnd('test-1-2-3')).toBe(3); + }); + it('should handle a string that only contains numbers', () => { + expect(getNumberAtEnd('123')).toBe(123); + }); +}); + +describe('removeNumberAtEnd', () => { + it('should return 1 in "test-1"', () => { + expect(removeNumberAtEnd('test-1')).toBe('test-'); + }); + it('should return the same string if there is no number at the end', () => { + expect(removeNumberAtEnd('test')).toBe('test'); + }); + it('should return an empty string if the input is just a number', () => { + expect(removeNumberAtEnd('123')).toBe(''); + }); + it('should not remove the first number', () => { + expect(removeNumberAtEnd('1-2-3')).toBe('1-2-'); + }); +}); + +describe('getNextLabel', () => { + it('should append a number to get the next label', () => { + expect(getNextLabel({ label: 'test' }, [{ label: 'test' }])).toBe('test-1'); + }); + it('should not duplicate labels so that the returned label is unique', () => { + expect(getNextLabel({ label: 'test' }, [{ label: 'test-1' }])).toBe( + 'test-2' + ); + }); +}); diff --git a/packages/manager/src/utilities/stringUtils.ts b/packages/manager/src/utilities/stringUtils.ts index ae87327330b..a497d7a4a70 100644 --- a/packages/manager/src/utilities/stringUtils.ts +++ b/packages/manager/src/utilities/stringUtils.ts @@ -35,3 +35,47 @@ export const convertForAria = (str: string) => { .toLowerCase() .replace(/([^A-Z0-9]+)(.)/gi, (match, p1, p2) => p2.toUpperCase()); }; + +export function getNumberAtEnd(str: string) { + // Use a regular expression to match one or more digits at the end of the string + const match = str.match(/\d+$/); + + // If there is a match, return the matched number; otherwise, return null + return match ? parseInt(match[0], 10) : null; +} + +export function removeNumberAtEnd(str: string) { + // Use a regular expression to match one or more digits at the end of the string + const regex = /\d+$/; + + // Use the replace() method to remove the matched portion + return str.replace(regex, ''); +} + +/** + * Gets the next available unique entity label + */ +export function getNextLabel( + selectedEntity: T, + allEntities: T[] +): string { + const numberAtEnd = getNumberAtEnd(selectedEntity.label); + + let labelToReturn = ''; + + if (numberAtEnd === null) { + labelToReturn = `${selectedEntity.label}-1`; + } else { + labelToReturn = `${removeNumberAtEnd(selectedEntity.label)}${ + numberAtEnd + 1 + }`; + } + + if (allEntities.some((r) => r.label === labelToReturn)) { + return getNextLabel( + { ...selectedEntity, label: labelToReturn }, + allEntities + ); + } + return labelToReturn; +} From 89230bfd0f74b3a8efddf64dc1e2b2145a26d055 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 19 Dec 2023 17:34:02 -0500 Subject: [PATCH 31/45] refactor: [M3-7547] - Graphs stories v7 migration (#9999) * GaugePercent + test * LineGraph + test * LineGraph addityional story + storybook upgrade * Added changeset: Graphs stories v7 migration * Straemline tests since they will go away as such * Feedback - Cleanup --- .../pr-9999-tech-stories-1702588492903.md | 5 + packages/manager/.storybook/main.ts | 1 + packages/manager/package.json | 25 +- .../GaugePercent/GaugePercent.stories.mdx | 82 - .../GaugePercent/GaugePercent.stories.tsx | 25 + .../GaugePercent/GaugePercent.test.tsx | 41 + .../components/GaugePercent/GaugePercent.tsx | 74 +- .../LineGraph/LineGraph.stories.mdx | 168 -- .../LineGraph/LineGraph.stories.tsx | 90 + .../components/LineGraph/LineGraph.test.tsx | 33 + .../src/components/LineGraph/LineGraph.tsx | 57 +- yarn.lock | 1656 +++++++++-------- 12 files changed, 1209 insertions(+), 1048 deletions(-) create mode 100644 packages/manager/.changeset/pr-9999-tech-stories-1702588492903.md delete mode 100644 packages/manager/src/components/GaugePercent/GaugePercent.stories.mdx create mode 100644 packages/manager/src/components/GaugePercent/GaugePercent.stories.tsx create mode 100644 packages/manager/src/components/GaugePercent/GaugePercent.test.tsx delete mode 100644 packages/manager/src/components/LineGraph/LineGraph.stories.mdx create mode 100644 packages/manager/src/components/LineGraph/LineGraph.stories.tsx create mode 100644 packages/manager/src/components/LineGraph/LineGraph.test.tsx diff --git a/packages/manager/.changeset/pr-9999-tech-stories-1702588492903.md b/packages/manager/.changeset/pr-9999-tech-stories-1702588492903.md new file mode 100644 index 00000000000..aa4e9696096 --- /dev/null +++ b/packages/manager/.changeset/pr-9999-tech-stories-1702588492903.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Graphs stories v7 migration ([#9999](https://github.com/linode/manager/pull/9999)) diff --git a/packages/manager/.storybook/main.ts b/packages/manager/.storybook/main.ts index 8372cb5378a..a4a9b625d6f 100644 --- a/packages/manager/.storybook/main.ts +++ b/packages/manager/.storybook/main.ts @@ -13,6 +13,7 @@ const config: StorybookConfig = { '@storybook/addon-measure', '@storybook/addon-actions', 'storybook-dark-mode', + '@storybook/addon-storysource', ], staticDirs: ['../public'], framework: { diff --git a/packages/manager/package.json b/packages/manager/package.json index 31716f6af03..04b9588406b 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -111,16 +111,17 @@ }, "devDependencies": { "@linode/eslint-plugin-cloud-manager": "^0.0.3", - "@storybook/addon-actions": "~7.5.2", - "@storybook/addon-controls": "~7.5.2", - "@storybook/addon-docs": "~7.5.2", - "@storybook/addon-measure": "~7.5.2", - "@storybook/addon-viewport": "~7.5.2", - "@storybook/addons": "~7.5.2", - "@storybook/client-api": "~7.5.2", - "@storybook/react": "~7.5.2", - "@storybook/react-vite": "^7.5.2", - "@storybook/theming": "~7.5.2", + "@storybook/addon-actions": "~7.6.4", + "@storybook/addon-controls": "~7.6.4", + "@storybook/addon-docs": "~7.6.4", + "@storybook/addon-measure": "~7.6.4", + "@storybook/addon-storysource": "^7.6.4", + "@storybook/addon-viewport": "~7.6.4", + "@storybook/addons": "~7.6.4", + "@storybook/client-api": "~7.6.4", + "@storybook/react": "~7.6.4", + "@storybook/react-vite": "^7.6.4", + "@storybook/theming": "~7.6.4", "@swc/core": "^1.3.1", "@testing-library/cypress": "^10.0.0", "@testing-library/jest-dom": "~5.11.3", @@ -202,8 +203,8 @@ "redux-mock-store": "^1.5.3", "reselect-tools": "^0.0.7", "serve": "^14.0.1", - "storybook": "~7.5.2", - "storybook-dark-mode": "^3.0.1", + "storybook": "~7.6.4", + "storybook-dark-mode": "^3.0.3", "vite": "^5.0.7", "vite-plugin-svgr": "^3.2.0", "vitest": "^1.0.4" diff --git a/packages/manager/src/components/GaugePercent/GaugePercent.stories.mdx b/packages/manager/src/components/GaugePercent/GaugePercent.stories.mdx deleted file mode 100644 index 07e2ce5f9c1..00000000000 --- a/packages/manager/src/components/GaugePercent/GaugePercent.stories.mdx +++ /dev/null @@ -1,82 +0,0 @@ -import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs'; -import { GaugePercent } from './GaugePercent'; -import { Typography } from 'src/components/Typography'; - - - -# 📈 Gauges - -- **Chart.js** is the charting tool we use for gauges -- Best used to show percentages - -export const Template = (args) => ; - - - - {Template.bind({})} - - - - diff --git a/packages/manager/src/components/GaugePercent/GaugePercent.stories.tsx b/packages/manager/src/components/GaugePercent/GaugePercent.stories.tsx new file mode 100644 index 00000000000..49602c78fdb --- /dev/null +++ b/packages/manager/src/components/GaugePercent/GaugePercent.stories.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; + +import { GaugePercent } from './GaugePercent'; + +import type { GaugePercentProps } from './GaugePercent'; +import type { Meta, StoryObj } from '@storybook/react'; + +export const Default: StoryObj = { + render: (args) => , +}; + +const meta: Meta = { + args: { + height: 150, + innerText: '25%', + innerTextFontSize: 12, + max: 200, + subTitle: 'CPU', + value: 50, + width: 150, + }, + component: GaugePercent, + title: 'Components/Graphs/GaugesPercent', +}; +export default meta; diff --git a/packages/manager/src/components/GaugePercent/GaugePercent.test.tsx b/packages/manager/src/components/GaugePercent/GaugePercent.test.tsx new file mode 100644 index 00000000000..4f0c63ac444 --- /dev/null +++ b/packages/manager/src/components/GaugePercent/GaugePercent.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { GaugePercent, GaugePercentProps } from './GaugePercent'; + +describe('GaugePercent Component', () => { + const defaultProps: GaugePercentProps = { + max: 100, + value: 50, + }; + + it('renders', () => { + const { getByTestId } = renderWithTheme(); + const gaugeWrapper = getByTestId('gauge-wrapper'); + + expect(gaugeWrapper).toBeInTheDocument(); + }); + + it('renders the inner text when provided', () => { + const innerText = '50%'; + const { getByTestId } = renderWithTheme( + + ); + const gaugeInnerText = getByTestId('gauge-innertext'); + + expect(gaugeInnerText).toBeInTheDocument(); + expect(gaugeInnerText).toHaveTextContent(innerText); + }); + + it('renders the subtitle when provided', () => { + const subTitle = 'Subtitle'; + const { getByTestId } = renderWithTheme( + + ); + const gaugeSubText = getByTestId('gauge-subtext'); + + expect(gaugeSubText).toBeInTheDocument(); + expect(gaugeSubText).toHaveTextContent(subTitle); + }); +}); diff --git a/packages/manager/src/components/GaugePercent/GaugePercent.tsx b/packages/manager/src/components/GaugePercent/GaugePercent.tsx index 816e5b2e76f..76e95cc0451 100644 --- a/packages/manager/src/components/GaugePercent/GaugePercent.tsx +++ b/packages/manager/src/components/GaugePercent/GaugePercent.tsx @@ -1,24 +1,56 @@ -import * as React from 'react'; import { useTheme } from '@mui/material/styles'; import { Chart } from 'chart.js'; +import * as React from 'react'; + import { - StyledSubTitleDiv, - StyledInnerTextDiv, StyledGaugeWrapperDiv, + StyledInnerTextDiv, + StyledSubTitleDiv, } from './GaugePercent.styles'; export interface GaugePercentProps { - width?: number | string; - height?: number; + /** + * The color for the filled in portion of the gauge. + */ filledInColor?: string; - nonFilledInColor?: string; - value: number; - max: number; + /** + * The height of the gauge. + */ + height?: number; + /** + * Text that shows up inside (in the middle) of the gauge. + */ innerText?: string; + /** + * The font size in `px` of the inner text. + */ innerTextFontSize?: number; - subTitle?: string | JSX.Element | null; + /** + * The max value of the gauge. + */ + max: number; + /** + * The color for the non-filled in portion of the gauge. + */ + nonFilledInColor?: string; + /** + * A subtitle that appears below the gauge. + */ + subTitle?: JSX.Element | null | string; + /** + * The value that is displayed by the gauge. This value should be <= `max`. + */ + value: number; + /** + * The width of the gauge. + */ + width?: number | string; } +/** + * - **Chart.js** is the charting tool we use for gauges + * - Best used to show percentages + */ export const GaugePercent = React.memo((props: GaugePercentProps) => { const theme = useTheme(); const width = props.width || 300; @@ -37,14 +69,14 @@ export const GaugePercent = React.memo((props: GaugePercentProps) => { const graphDatasets = [ { - borderWidth: 0, - hoverBackgroundColor: [ + backgroundColor: [ props.filledInColor || theme.palette.primary.main, props.nonFilledInColor || theme.color.grey2, ], + borderWidth: 0, /** so basically, index 0 is the filled in, index 1 is the full graph percentage */ data: [props.value, finalMax], - backgroundColor: [ + hoverBackgroundColor: [ props.filledInColor || theme.palette.primary.main, props.nonFilledInColor || theme.color.grey2, ], @@ -55,16 +87,16 @@ export const GaugePercent = React.memo((props: GaugePercentProps) => { animateRotate: false, animateScale: false, }, - maintainAspectRatio: false, - rotation: -1.25 * Math.PI, circumference: 1.5 * Math.PI, cutoutPercentage: 70, - responsive: true, /** get rid of all hover events with events: [] */ events: [], legend: { display: false, }, + maintainAspectRatio: false, + responsive: true, + rotation: -1.25 * Math.PI, }; const graphRef: React.RefObject = React.useRef(null); @@ -75,22 +107,26 @@ export const GaugePercent = React.memo((props: GaugePercentProps) => { // https://dev.to/vcanales/using-chart-js-in-a-function-component-with-react-hooks-246l if (graphRef.current) { new Chart(graphRef.current.getContext('2d'), { - type: 'doughnut', data: { datasets: graphDatasets, }, options: graphOptions, + type: 'doughnut', }); } }); return ( - + {props.innerText && ( {props.innerText} @@ -98,9 +134,9 @@ export const GaugePercent = React.memo((props: GaugePercentProps) => { {props.subTitle && ( {props.subTitle} diff --git a/packages/manager/src/components/LineGraph/LineGraph.stories.mdx b/packages/manager/src/components/LineGraph/LineGraph.stories.mdx deleted file mode 100644 index 0acee75d341..00000000000 --- a/packages/manager/src/components/LineGraph/LineGraph.stories.mdx +++ /dev/null @@ -1,168 +0,0 @@ -import { LineGraph } from 'src/components/LineGraph/LineGraph'; -import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs'; -import { TransferHistory } from 'src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory'; - -export const data = [ - [1644330600000, 0.45], - [1644330900000, 0.45], - [1644331200000, 0.46], - [1644331500000, 0.46], - [1644331800000, 0.45], - [1644332100000, 1.11], - [1644332400000, 1.11], - [1644332700000, 0.48], - [1644333000000, 0.57], - [1644333300000, 0.66], - [1644333600000, 0.46], - [1644333900000, 0.45], - [1644334200000, 0.45], - [1644334500000, 0.46], - [1644334800000, 0.46], - [1644335100000, 0.45], - [1644335400000, 0.44], - [1644335700000, 0.46], - [1644336000000, 0.46], - [1644336300000, 0.45], - [1644336600000, 0.45], - [1644336900000, 0.46], - [1644337200000, 0.45], - [1644337500000, 0.47], - [1644337800000, 0.63], - [1644338100000, 0.26], - [1644338400000, 0.45], - [1644338700000, 0.45], - [1644339000000, 0.46], - [1644339300000, 0.46], - [1644339600000, 0.45], - [1644339900000, 0.45], - [1644340200000, 0.46], - [1644340500000, 0.45], -]; - - - -# 📈 Graphs - -- **Chart.js** is the charting tool we use for analytics shown on the Linode detail page -- Keep charts compact -- When selecting a chart color palette make sure colors are distinct when viewed by a person with color blindness - - Test the palette with a checker such as the [Coblis — Color Blindness Simulator](https://www.color-blindness.com/coblis-color-blindness-simulator/) - -export const Template = (args) => ; - - - - {Template.bind({})} - - - - - -## Example - ---- - -### Linode Network Transfer History - - - - - - diff --git a/packages/manager/src/components/LineGraph/LineGraph.stories.tsx b/packages/manager/src/components/LineGraph/LineGraph.stories.tsx new file mode 100644 index 00000000000..0d596ef46de --- /dev/null +++ b/packages/manager/src/components/LineGraph/LineGraph.stories.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; + +import { LineGraph } from 'src/components/LineGraph/LineGraph'; +import { formatPercentage, getMetrics } from 'src/utilities/statMetrics'; + +import type { DataSet, LineGraphProps } from './LineGraph'; +import type { Meta, StoryObj } from '@storybook/react'; + +const data: DataSet['data'] = [ + [1644330600000, 0.45], + [1644330900000, 0.45], + [1644331200000, 0.46], + [1644331500000, 0.46], + [1644331800000, 0.45], + [1644332100000, 1.11], + [1644332400000, 1.11], + [1644332700000, 0.48], + [1644333000000, 0.57], + [1644333300000, 0.66], + [1644333600000, 0.46], + [1644333900000, 0.45], + [1644334200000, 0.45], + [1644334500000, 0.46], + [1644334800000, 0.46], + [1644335100000, 0.45], + [1644335400000, 0.44], + [1644335700000, 0.46], + [1644336000000, 0.46], + [1644336300000, 0.45], + [1644336600000, 0.45], + [1644336900000, 0.46], + [1644337200000, 0.45], + [1644337500000, 0.47], + [1644337800000, 0.63], + [1644338100000, 0.26], + [1644338400000, 0.45], + [1644338700000, 0.45], + [1644339000000, 0.46], + [1644339300000, 0.46], + [1644339600000, 0.45], + [1644339900000, 0.45], + [1644340200000, 0.46], + [1644340500000, 0.45], +]; + +const metrics = getMetrics(data as number[][]); + +export const Default: StoryObj = { + render: (args) => , +}; + +export const WithLegend: StoryObj = { + render: (args) => ( + + ), +}; + +const meta: Meta = { + args: { + accessibleDataTable: { + unit: '%', + }, + chartHeight: 300, + data: [ + { + backgroundColor: 'rgba(54, 131, 220, 0.7)', + borderColor: 'transparent', + data, + label: 'CPU (%)', + }, + ], + nativeLegend: false, + showToday: true, + timezone: 'America/New_York', + unit: '%', + }, + component: LineGraph, + title: 'Components/Graphs/LineGraph', +}; +export default meta; diff --git a/packages/manager/src/components/LineGraph/LineGraph.test.tsx b/packages/manager/src/components/LineGraph/LineGraph.test.tsx new file mode 100644 index 00000000000..c894a0c3058 --- /dev/null +++ b/packages/manager/src/components/LineGraph/LineGraph.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { LineGraph } from './LineGraph'; + +import type { LineGraphProps } from './LineGraph'; + +describe('GaugePercent Component', () => { + const defaultProps: LineGraphProps = { + accessibleDataTable: { unit: '%' }, + ariaLabel: 'Stats and metrics for Linode-1', + data: [], + showToday: true, + timezone: 'America/New_York', + unit: 'Unit', + }; + + it('renders', () => { + const { getByTestId } = renderWithTheme(); + const linegraphWrapper = getByTestId('linegraph-wrapper'); + + expect(linegraphWrapper).toBeInTheDocument(); + }); + + it('renders the canvas element with aria-label', () => { + const { getByLabelText } = renderWithTheme(); + const canvas = getByLabelText(defaultProps.ariaLabel || ''); + + expect(canvas).toBeInTheDocument(); + expect(canvas).toBeInstanceOf(HTMLCanvasElement); + }); +}); diff --git a/packages/manager/src/components/LineGraph/LineGraph.tsx b/packages/manager/src/components/LineGraph/LineGraph.tsx index 5f9f838cf21..b47640b5f97 100644 --- a/packages/manager/src/components/LineGraph/LineGraph.tsx +++ b/packages/manager/src/components/LineGraph/LineGraph.tsx @@ -37,14 +37,16 @@ setUpCharts(); export interface DataSet { backgroundColor?: string; borderColor: string; - // this data property type might not be the perfect fit, but it works for - // the data returned from /linodes/:linodeID/stats and - // /nodebalancers/:nodebalancer/stats + /** + * The data point to plot on the line graph that should be of type `DataSet`. + * This data property type might not be the perfect fit, but it works for the data returned from /linodes/:linodeID/stats and /nodebalancers/:nodebalancer/stats. + */ data: [number, null | number][]; // the first number will be a UTC data and the second will be the amount per second fill?: boolean | string; label: string; } + export interface LineGraphProps { /** * `accessibleDataTable` is responsible to both rendering the accessible graph data table and an associated unit. @@ -52,18 +54,57 @@ export interface LineGraphProps { accessibleDataTable?: { unit: string; }; + /** + * The accessible aria-label for the chart. + */ ariaLabel?: string; + /** + * The height in `px` of the chart. + */ chartHeight?: number; + /** + * The data to be plotted on the line graph. + */ data: DataSet[]; + /** + * The function that formats the data point values. + */ formatData?: (value: number) => null | number; + /** + * The function that formats the tooltip text. + */ formatTooltip?: (value: number) => string; + /** + * Legend row labels that are used in the legend. + */ legendRows?: Array; - nativeLegend?: boolean; // Display chart.js native legend + /** + * Show the native **Chart.js** legend + */ + nativeLegend?: boolean; + /** + * Row headers for the legend. + */ rowHeaders?: Array; + /** + * Determines whether dates or times are shown on the x-axis and also determines the x-axis step size. + */ showToday: boolean; + /** + * The suggested maximum y-axis value passed to **Chart,js**. + */ suggestedMax?: number; + /** + * The suggested maximum y-axis value passed to **Chart,js**. + */ tabIndex?: number; + /** + * The timezone the graph should use for interpreting the UNIX date-times in the data set. + */ timezone: string; + /** + * The unit to add to the mouse-over tooltip in the chart. + */ unit?: string; } @@ -85,6 +126,12 @@ const humanizeLargeData = (value: number) => { return value; }; +/** + * **Chart.js** is the charting tool we use for analytics shown on the Linode detail page + * - Keep charts compact + * - When selecting a chart color palette make sure colors are distinct when viewed by a person with color blindness + * - Test the palette with a checker such as the [Coblis — Color Blindness Simulator](https://www.color-blindness.com/coblis-color-blindness-simulator/) + */ export const LineGraph = (props: LineGraphProps) => { const theme = useTheme(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); @@ -308,7 +355,7 @@ export const LineGraph = (props: LineGraphProps) => { // Screen readers read from top to bottom, so the legend should be read before the data tables, esp considering their size // and the fact that the legend can filter them. // Meanwhile the CSS uses column-reverse to visually retain the original order - + {legendRendered && legendRows && ( Date: Wed, 20 Dec 2023 07:26:59 -0800 Subject: [PATCH 32/45] upcoming, change: [M3-7524] - Add child_account OAuth scope to Create and View PAT drawers (#9992) * Add child_account scope to Create and View PAT drawers * Make new scope name match mocks * Fix APITokens utils failing tests * Fix one failing unit test with await * Update comment * Fix the unit tests for real, hopefully * Mock the useAccountUser hook in test, thanks @jdamore-linode * Update test for View PAT drawer * Clean up * Added changeset: Add `child_account` oauth scope to Personal Access Token drawers * More test clean up * Address feedback: clean up filtering in return statement * Default access to None rather than ReadWrite when creating a PAT --- .../pr-9992-changed-1703028128822.md | 5 + ...pr-9992-upcoming-features-1702939785550.md | 5 + .../APITokens/CreateAPITokenDrawer.test.tsx | 54 +++++++- .../APITokens/CreateAPITokenDrawer.tsx | 125 ++++++++++-------- .../APITokens/ViewAPITokenDrawer.test.tsx | 84 +++++++++++- .../Profile/APITokens/ViewAPITokenDrawer.tsx | 114 +++++++++------- .../features/Profile/APITokens/utils.test.ts | 12 ++ .../src/features/Profile/APITokens/utils.ts | 2 + 8 files changed, 291 insertions(+), 110 deletions(-) create mode 100644 packages/manager/.changeset/pr-9992-changed-1703028128822.md create mode 100644 packages/manager/.changeset/pr-9992-upcoming-features-1702939785550.md diff --git a/packages/manager/.changeset/pr-9992-changed-1703028128822.md b/packages/manager/.changeset/pr-9992-changed-1703028128822.md new file mode 100644 index 00000000000..bc20dc6ade7 --- /dev/null +++ b/packages/manager/.changeset/pr-9992-changed-1703028128822.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Default access to `None` for all scopes when creating Personal Access Tokens ([#9992](https://github.com/linode/manager/pull/9992)) diff --git a/packages/manager/.changeset/pr-9992-upcoming-features-1702939785550.md b/packages/manager/.changeset/pr-9992-upcoming-features-1702939785550.md new file mode 100644 index 00000000000..711c7142e3e --- /dev/null +++ b/packages/manager/.changeset/pr-9992-upcoming-features-1702939785550.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add `child_account` oauth scope to Personal Access Token drawers ([#9992](https://github.com/linode/manager/pull/9992)) diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index d31df383ce0..90fdb8b8ced 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -3,11 +3,25 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { appTokenFactory } from 'src/factories'; +import { accountUserFactory } from 'src/factories/accountUsers'; import { rest, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateAPITokenDrawer } from './CreateAPITokenDrawer'; +// Mock the useAccountUser hooks to immediately return the expected data, circumventing the HTTP request and loading state. +const queryMocks = vi.hoisted(() => ({ + useAccountUser: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/accountUsers', async () => { + const actual = await vi.importActual('src/queries/accountUsers'); + return { + ...actual, + useAccountUser: queryMocks.useAccountUser, + }; +}); + const props = { onClose: vi.fn(), open: true, @@ -38,6 +52,7 @@ describe('Create API Token Drawer', () => { expect(cancelBtn).toBeEnabled(); expect(cancelBtn).toBeVisible(); }); + it('Should see secret modal with secret when you type a label and submit the form successfully', async () => { server.use( rest.post('*/profile/tokens', (req, res, ctx) => { @@ -58,19 +73,48 @@ describe('Create API Token Drawer', () => { expect(props.showSecret).toBeCalledWith('secret-value') ); }); - it('Should default to read/write for all scopes', () => { + + it('Should default to None for all scopes', () => { const { getByLabelText } = renderWithTheme( ); - const selectAllReadWriteRadioButton = getByLabelText( - 'Select read/write for all' - ); - expect(selectAllReadWriteRadioButton).toBeChecked(); + const selectAllNonePermRadioButton = getByLabelText('Select none for all'); + expect(selectAllNonePermRadioButton).toBeChecked(); }); + it('Should default to 6 months for expiration', () => { const { getByText } = renderWithTheme(); getByText('In 6 months'); }); + + it('Should show the Child Account Access scope for a parent user account with the parent/child feature flag on', () => { + queryMocks.useAccountUser.mockReturnValue({ + data: accountUserFactory.build({ user_type: 'parent' }), + }); + + const { getByText } = renderWithTheme(, { + flags: { parentChildAccountAccess: true }, + }); + const childScope = getByText('Child Account Access'); + expect(childScope).toBeInTheDocument(); + }); + + it('Should not show the Child Account Access scope for a non-parent user account with the parent/child feature flag on', () => { + queryMocks.useAccountUser.mockReturnValue({ + data: accountUserFactory.build({ user_type: null }), + }); + + const { queryByText } = renderWithTheme( + , + { + flags: { parentChildAccountAccess: true }, + } + ); + + const childScope = queryByText('Child Account Access'); + expect(childScope).not.toBeInTheDocument(); + }); + it('Should close when Cancel is pressed', () => { const { getByText } = renderWithTheme(); const cancelButton = getByText(/Cancel/); diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index 87e1d178331..f4f6d0781c3 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -5,6 +5,8 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import { FormControl } from 'src/components/FormControl'; +import { FormHelperText } from 'src/components/FormHelperText'; import { Notice } from 'src/components/Notice/Notice'; import { Radio } from 'src/components/Radio/Radio'; import { TableBody } from 'src/components/TableBody'; @@ -12,10 +14,11 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TextField } from 'src/components/TextField'; -import { FormControl } from 'src/components/FormControl'; -import { FormHelperText } from 'src/components/FormHelperText'; import { ISO_DATETIME_NO_TZ_FORMAT } from 'src/constants'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; +import { useFlags } from 'src/hooks/useFlags'; +import { useAccountUser } from 'src/queries/accountUsers'; +import { useProfile } from 'src/queries/profile'; import { useCreatePersonalAccessTokenMutation } from 'src/queries/tokens'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -82,12 +85,17 @@ export const CreateAPITokenDrawer = (props: Props) => { const expiryTups = genExpiryTups(); const { onClose, open, showSecret } = props; + const flags = useFlags(); + const initialValues = { expiry: expiryTups[0][1], label: '', - scopes: scopeStringToPermTuples('*'), + scopes: scopeStringToPermTuples(''), }; + const { data: profile } = useProfile(); + const { data: user } = useAccountUser(profile?.username ?? ''); + const { error, isLoading, @@ -152,6 +160,15 @@ export const CreateAPITokenDrawer = (props: Props) => { return { label: expiryTup[0], value: expiryTup[1] }; }); + // Filter permissions for all users except parent user accounts. + const allPermissions = form.values.scopes; + const showFilteredPermissions = + (flags.parentChildAccountAccess && user?.user_type !== 'parent') || + Boolean(!flags.parentChildAccountAccess); + const filteredPermissions = allPermissions.filter( + (scopeTup) => basePermNameMap[scopeTup[0]] !== 'Child Account Access' + ); + return ( {errorMap.none && } @@ -236,57 +253,59 @@ export const CreateAPITokenDrawer = (props: Props) => { /> - {form.values.scopes.map((scopeTup) => { - if (!basePermNameMap[scopeTup[0]]) { - return null; - } - return ( - - - {basePermNameMap[scopeTup[0]]} - - - - - { + if (!basePermNameMap[scopeTup[0]]) { + return null; + } + return ( + - - - - - - - ); - })} + + {basePermNameMap[scopeTup[0]]} + + + + + + + + + + + + ); + } + )} {errorMap.scopes && ( diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx index 5f2c0b8e7b3..577d7d717ee 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx @@ -1,12 +1,32 @@ import * as React from 'react'; import { appTokenFactory } from 'src/factories'; +import { accountUserFactory } from 'src/factories/accountUsers'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { ViewAPITokenDrawer } from './ViewAPITokenDrawer'; import { basePerms } from './utils'; +// Mock the useAccountUser hooks to immediately return the expected data, circumventing the HTTP request and loading state. +const queryMocks = vi.hoisted(() => ({ + useAccountUser: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/accountUsers', async () => { + const actual = await vi.importActual('src/queries/accountUsers'); + return { + ...actual, + useAccountUser: queryMocks.useAccountUser, + }; +}); + +const nonParentPerms = basePerms.filter((value) => value !== 'child_account'); + const token = appTokenFactory.build({ label: 'my-token', scopes: '*' }); +const limitedToken = appTokenFactory.build({ + label: 'my-limited-token', + scopes: '', +}); const props = { onClose: vi.fn(), @@ -21,9 +41,9 @@ describe('View API Token Drawer', () => { expect(getByText(token.label)).toBeVisible(); }); - it('should all permissions as read/write with wildcard scopes', () => { + it('should show all permissions as read/write with wildcard scopes', () => { const { getByTestId } = renderWithTheme(); - for (const permissionName of basePerms) { + for (const permissionName of nonParentPerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( 'aria-label', `This token has 2 access for ${permissionName}` @@ -31,6 +51,19 @@ describe('View API Token Drawer', () => { } }); + it('should show all permissions as none with no scopes', () => { + const { getByTestId } = renderWithTheme( + , + { flags: { parentChildAccountAccess: false } } + ); + for (const permissionName of nonParentPerms) { + expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( + 'aria-label', + `This token has 0 access for ${permissionName}` + ); + } + }); + it('only account has read/write, all others are none', () => { const { getByTestId } = renderWithTheme( { token={appTokenFactory.build({ scopes: 'account:read_write' })} /> ); - for (const permissionName of basePerms) { + for (const permissionName of nonParentPerms) { // We only expect account to have read/write for this test const expectedScopeLevel = permissionName === 'account' ? 2 : 0; expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( @@ -76,7 +109,7 @@ describe('View API Token Drawer', () => { volumes: 1, } as const; - for (const permissionName of basePerms) { + for (const permissionName of nonParentPerms) { const expectedScopeLevel = expectedScopeLevels[permissionName]; expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( 'aria-label', @@ -84,4 +117,47 @@ describe('View API Token Drawer', () => { ); } }); + + it('should show Child Account Access scope with read/write perms for a parent user account with the parent/child feature flag on', () => { + queryMocks.useAccountUser.mockReturnValue({ + data: accountUserFactory.build({ user_type: 'parent' }), + }); + + const { getByTestId, getByText } = renderWithTheme( + , + { + flags: { parentChildAccountAccess: true }, + } + ); + + const childScope = getByText('Child Account Access'); + const expectedScopeLevels = { + child_account: 2, + } as const; + const childPermissionName = 'child_account'; + + expect(childScope).toBeInTheDocument(); + expect(getByTestId(`perm-${childPermissionName}`)).toHaveAttribute( + 'aria-label', + `This token has ${expectedScopeLevels[childPermissionName]} access for ${childPermissionName}` + ); + }); + + it('should not show the Child Account Access scope for a non-parent user account with the parent/child feature flag on', () => { + queryMocks.useAccountUser.mockReturnValue({ + data: accountUserFactory.build({ user_type: null }), + }); + + const { queryByText } = renderWithTheme(, { + flags: { parentChildAccountAccess: true }, + }); + + const childScope = queryByText('Child Account Access'); + expect(childScope).not.toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx index 12493e8ffa0..e62b455b24d 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx @@ -7,6 +7,9 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; +import { useFlags } from 'src/hooks/useFlags'; +import { useAccountUser } from 'src/queries/accountUsers'; +import { useProfile } from 'src/queries/profile'; import { StyledAccessCell, @@ -24,7 +27,20 @@ interface Props { export const ViewAPITokenDrawer = (props: Props) => { const { onClose, open, token } = props; - const permissions = scopeStringToPermTuples(token?.scopes ?? ''); + const flags = useFlags(); + + const { data: profile } = useProfile(); + const { data: user } = useAccountUser(profile?.username ?? ''); + + const allPermissions = scopeStringToPermTuples(token?.scopes ?? ''); + + // Filter permissions for all users except parent user accounts. + const showFilteredPermissions = + (flags.parentChildAccountAccess && user?.user_type !== 'parent') || + Boolean(!flags.parentChildAccountAccess); + const filteredPermissions = allPermissions.filter( + (scopeTup) => basePermNameMap[scopeTup[0]] !== 'Child Account Access' + ); return ( @@ -48,54 +64,56 @@ export const ViewAPITokenDrawer = (props: Props) => { - {permissions.map((scopeTup) => { - if (!basePermNameMap[scopeTup[0]]) { - return null; - } - return ( - - - {basePermNameMap[scopeTup[0]]} - - - null} - scope="0" - scopeDisplay={scopeTup[0]} - viewOnly={true} - /> - - { + if (!basePermNameMap[scopeTup[0]]) { + return null; + } + return ( + - null} - scope="1" - scopeDisplay={scopeTup[0]} - viewOnly={true} - /> - - - null} - scope="2" - scopeDisplay={scopeTup[0]} - viewOnly={true} - /> - - - ); - })} + + {basePermNameMap[scopeTup[0]]} + + + null} + scope="0" + scopeDisplay={scopeTup[0]} + viewOnly={true} + /> + + + null} + scope="1" + scopeDisplay={scopeTup[0]} + viewOnly={true} + /> + + + null} + scope="2" + scopeDisplay={scopeTup[0]} + viewOnly={true} + /> + + + ); + } + )} diff --git a/packages/manager/src/features/Profile/APITokens/utils.test.ts b/packages/manager/src/features/Profile/APITokens/utils.test.ts index 83181c2fbbf..fdf56838f95 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.test.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.test.ts @@ -26,6 +26,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('*'); const expected = [ ['account', 2], + ['child_account', 2], ['databases', 2], ['domains', 2], ['events', 2], @@ -49,6 +50,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples(''); const expected = [ ['account', 0], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -73,6 +75,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:none'); const expected = [ ['account', 0], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -97,6 +100,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:read_only'); const expected = [ ['account', 1], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -121,6 +125,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:read_write'); const expected = [ ['account', 2], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -147,6 +152,7 @@ describe('APIToken utils', () => { ); const expected = [ ['account', 0], + ['child_account', 0], ['databases', 0], ['domains', 1], ['events', 0], @@ -175,6 +181,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:none,tokens:read_write'); const expected = [ ['account', 2], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -203,6 +210,7 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:read_only,tokens:none'); const expected = [ ['account', 1], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -227,6 +235,7 @@ describe('APIToken utils', () => { it('should return 0 if all scopes are 0', () => { const scopes: Permission[] = [ ['account', 0], + ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -246,6 +255,7 @@ describe('APIToken utils', () => { it('should return 1 if all scopes are 1', () => { const scopes: Permission[] = [ ['account', 1], + ['child_account', 1], ['databases', 1], ['domains', 1], ['events', 1], @@ -265,6 +275,7 @@ describe('APIToken utils', () => { it('should return 2 if all scopes are 2', () => { const scopes: Permission[] = [ ['account', 2], + ['child_account', 2], ['databases', 2], ['domains', 2], ['events', 2], @@ -284,6 +295,7 @@ describe('APIToken utils', () => { it('should return null if all scopes are different', () => { const scopes: Permission[] = [ ['account', 1], + ['child_account', 0], ['databases', 0], ['domains', 2], ['events', 0], diff --git a/packages/manager/src/features/Profile/APITokens/utils.ts b/packages/manager/src/features/Profile/APITokens/utils.ts index f6dbd4c2a7f..27ad619c364 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.ts @@ -6,6 +6,7 @@ export type Permission = [string, number]; export const basePerms = [ 'account', + 'child_account', 'databases', 'domains', 'events', @@ -23,6 +24,7 @@ export const basePerms = [ export const basePermNameMap: Record = { account: 'Account', + child_account: 'Child Account Access', databases: 'Databases', domains: 'Domains', events: 'Events', From c471eac90f97787d7c247997d32cb35f3169cbec Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Thu, 21 Dec 2023 13:08:22 -0500 Subject: [PATCH 33/45] refactor: [M3-7575] CircleProgress and ColorPalette storybook v7 migrations (#10015) * circle progress story and test * color palette * Added changeset: ColorPalette and CircleProgress v7 storybook migration * update color palette test --- .../pr-10015-tech-stories-1703101491856.md | 5 + .../CircleProgress/CircleProgress.stories.mdx | 35 ----- .../CircleProgress/CircleProgress.stories.tsx | 18 +++ .../CircleProgress/CircleProgress.test.tsx | 54 +++++++ .../CircleProgress/CircleProgress.tsx | 41 +++--- .../ColorPalette/ColorPalette.stories.mdx | 21 --- .../ColorPalette/ColorPalette.stories.tsx | 16 +++ .../ColorPalette/ColorPalette.test.tsx | 132 ++++++++++++++++++ .../components/ColorPalette/ColorPalette.tsx | 10 ++ 9 files changed, 259 insertions(+), 73 deletions(-) create mode 100644 packages/manager/.changeset/pr-10015-tech-stories-1703101491856.md delete mode 100644 packages/manager/src/components/CircleProgress/CircleProgress.stories.mdx create mode 100644 packages/manager/src/components/CircleProgress/CircleProgress.stories.tsx create mode 100644 packages/manager/src/components/CircleProgress/CircleProgress.test.tsx delete mode 100644 packages/manager/src/components/ColorPalette/ColorPalette.stories.mdx create mode 100644 packages/manager/src/components/ColorPalette/ColorPalette.stories.tsx create mode 100644 packages/manager/src/components/ColorPalette/ColorPalette.test.tsx diff --git a/packages/manager/.changeset/pr-10015-tech-stories-1703101491856.md b/packages/manager/.changeset/pr-10015-tech-stories-1703101491856.md new file mode 100644 index 00000000000..9c8fc08bc74 --- /dev/null +++ b/packages/manager/.changeset/pr-10015-tech-stories-1703101491856.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +ColorPalette and CircleProgress v7 storybook migration ([#10015](https://github.com/linode/manager/pull/10015)) diff --git a/packages/manager/src/components/CircleProgress/CircleProgress.stories.mdx b/packages/manager/src/components/CircleProgress/CircleProgress.stories.mdx deleted file mode 100644 index cb82e380a0b..00000000000 --- a/packages/manager/src/components/CircleProgress/CircleProgress.stories.mdx +++ /dev/null @@ -1,35 +0,0 @@ -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; -import { CircleProgress } from './CircleProgress'; - - - -# Circle Progress - -Use for short, indeterminate activities requiring user attention. - -export const Template = (args) => ; - - - - {Template.bind()} - - - - diff --git a/packages/manager/src/components/CircleProgress/CircleProgress.stories.tsx b/packages/manager/src/components/CircleProgress/CircleProgress.stories.tsx new file mode 100644 index 00000000000..28b1e392d38 --- /dev/null +++ b/packages/manager/src/components/CircleProgress/CircleProgress.stories.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { CircleProgress } from './CircleProgress'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , +}; + +const meta: Meta = { + component: CircleProgress, + title: 'Components/Loading States/Circle Progress', +}; + +export default meta; diff --git a/packages/manager/src/components/CircleProgress/CircleProgress.test.tsx b/packages/manager/src/components/CircleProgress/CircleProgress.test.tsx new file mode 100644 index 00000000000..b9416f0f1e6 --- /dev/null +++ b/packages/manager/src/components/CircleProgress/CircleProgress.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CircleProgress } from './CircleProgress'; + +const CONTENT_LOADING = 'Content is loading'; + +describe('CircleProgress', () => { + it('renders a CircleProgress properly', () => { + const screen = renderWithTheme(); + + const circleProgress = screen.getByLabelText(CONTENT_LOADING); + expect(circleProgress).toBeVisible(); + const circle = screen.getByTestId('circle-progress'); + expect(circle).toBeInTheDocument(); + expect(circle).toHaveStyle('width: 124px; height: 124px;'); + const innerCircle = screen.getByTestId('inner-circle-progress'); + expect(innerCircle).toBeInTheDocument(); + }); + + it('renders a mini CircleProgress', () => { + const screen = renderWithTheme(); + + const circleProgress = screen.getByLabelText(CONTENT_LOADING); + expect(circleProgress).toBeVisible(); + expect(circleProgress).toHaveStyle('width: 40px; height: 40px;'); + }); + + it('sets a mini CircleProgress with no padding', () => { + const screen = renderWithTheme(); + + const circleProgress = screen.getByLabelText(CONTENT_LOADING); + expect(circleProgress).toBeVisible(); + expect(circleProgress).toHaveStyle('width: 22px; height: 22px;'); + }); + + it('sets a mini CircleProgress with a custom size', () => { + const screen = renderWithTheme(); + + const circleProgress = screen.getByLabelText(CONTENT_LOADING); + expect(circleProgress).toBeVisible(); + expect(circleProgress).toHaveStyle('width: 25px; height: 25px;'); + }); + + it('renders a CircleProgress without the inner circle', () => { + const screen = renderWithTheme(); + + const circleProgress = screen.getByLabelText(CONTENT_LOADING); + expect(circleProgress).toBeVisible(); + const innerCircle = screen.queryByTestId('inner-circle-progress'); + expect(innerCircle).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/CircleProgress/CircleProgress.tsx b/packages/manager/src/components/CircleProgress/CircleProgress.tsx index 308a5d61219..24fe6e240fb 100644 --- a/packages/manager/src/components/CircleProgress/CircleProgress.tsx +++ b/packages/manager/src/components/CircleProgress/CircleProgress.tsx @@ -9,26 +9,37 @@ import { import { omittedProps } from 'src/utilities/omittedProps'; interface CircleProgressProps extends CircularProgressProps { + /** + * Additional child elements to pass in + */ children?: JSX.Element; - className?: string; + /** + * Displays a smaller version of the circle progress. + */ mini?: boolean; + /** + * If true, will not show an inner circle beneath the spinning circle + */ noInner?: boolean; + /** + * Removes the padding for `mini` circle progresses only. + */ noPadding?: boolean; + /** + * To be primarily used with mini and noPadding. Set spinner to a custom size. + */ size?: number; + /** + * Additional styles to apply to the root element. + */ sx?: SxProps; } +/** + * Use for short, indeterminate activities requiring user attention. + */ const CircleProgress = (props: CircleProgressProps) => { - const { - children, - className, - mini, - noInner, - noPadding, - size, - sx, - ...rest - } = props; + const { children, mini, noInner, noPadding, size, sx, ...rest } = props; const variant = typeof props.value === 'number' ? 'determinate' : 'indeterminate'; @@ -48,16 +59,12 @@ const CircleProgress = (props: CircleProgressProps) => { } return ( - + {children !== undefined && ( {children} )} {noInner !== true && ( - + )} diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.stories.mdx b/packages/manager/src/components/ColorPalette/ColorPalette.stories.mdx deleted file mode 100644 index 4e741748053..00000000000 --- a/packages/manager/src/components/ColorPalette/ColorPalette.stories.mdx +++ /dev/null @@ -1,21 +0,0 @@ -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; -import { ColorPalette } from './ColorPalette'; - - - -# Color Palette - -Add a new color to the palette, especially another tint of gray or blue, only after exhausting the option of using an existing color. - -- Colors used in light mode are located in `foundations/light.ts` -- Colors used in dark mode are located in `foundations/dark.ts` - -If a color does not exist in the current palette and is only used once, consider applying the color conditionally: - -`theme.name === 'light' ? '#fff' : '#000'` - - - - - - diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.stories.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.stories.tsx new file mode 100644 index 00000000000..ea406d68902 --- /dev/null +++ b/packages/manager/src/components/ColorPalette/ColorPalette.stories.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { ColorPalette } from './ColorPalette'; + +import type { Meta, StoryObj } from '@storybook/react'; + +export const Default: StoryObj = { + render: () => , +}; + +const meta: Meta = { + component: ColorPalette, + title: 'Design System/Color Palette', +}; + +export default meta; diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx new file mode 100644 index 00000000000..a9f6024520e --- /dev/null +++ b/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx @@ -0,0 +1,132 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ColorPalette } from './ColorPalette'; + +describe('Color Palette', () => { + it('renders the Color Palette', () => { + const { getAllByText, getByText } = renderWithTheme(); + + // primary colors + getByText('Primary Colors'); + getByText('theme.palette.primary.main'); + const mainHash = getAllByText('#3683dc'); + expect(mainHash).toHaveLength(2); + getByText('theme.palette.primary.light'); + getByText('#4d99f1'); + getByText('theme.palette.primary.dark'); + getByText('#2466b3'); + getByText('theme.palette.text.primary'); + const primaryHash = getAllByText('#606469'); + expect(primaryHash).toHaveLength(3); + getByText('theme.color.headline'); + const headlineHash = getAllByText('#32363c'); + expect(headlineHash).toHaveLength(2); + getByText('theme.palette.divider'); + const dividerHash = getAllByText('#f4f4f4'); + expect(dividerHash).toHaveLength(2); + const whiteColor = getAllByText('theme.color.white'); + expect(whiteColor).toHaveLength(2); + const whiteHash = getAllByText('#fff'); + expect(whiteHash).toHaveLength(3); + + // etc + getByText('Etc.'); + getByText('theme.color.red'); + getByText('#ca0813'); + getByText('theme.color.orange'); + getByText('#ffb31a'); + getByText('theme.color.yellow'); + getByText('#fecf2f'); + getByText('theme.color.green'); + getByText('#00b159'); + getByText('theme.color.teal'); + getByText('#17cf73'); + getByText('theme.color.border2'); + getByText('#c5c6c8'); + getByText('theme.color.border3'); + getByText('#eee'); + getByText('theme.color.grey1'); + getByText('#abadaf'); + getByText('theme.color.grey2'); + getByText('#e7e7e7'); + getByText('theme.color.grey3'); + getByText('#ccc'); + getByText('theme.color.grey4'); + getByText('#8C929D'); + getByText('theme.color.grey5'); + getByText('#f5f5f5'); + getByText('theme.color.grey6'); + const borderGreyHash = getAllByText('#e3e5e8'); + expect(borderGreyHash).toHaveLength(3); + getByText('theme.color.grey7'); + getByText('#e9eaef'); + getByText('theme.color.grey8'); + getByText('#dbdde1'); + getByText('theme.color.grey9'); + const borderGrey9Hash = getAllByText('#f4f5f6'); + expect(borderGrey9Hash).toHaveLength(3); + getByText('theme.color.black'); + getByText('#222'); + getByText('theme.color.offBlack'); + getByText('#444'); + getByText('theme.color.boxShadow'); + getByText('#ddd'); + getByText('theme.color.boxShadowDark'); + getByText('#aaa'); + getByText('theme.color.blueDTwhite'); + getByText('theme.color.tableHeaderText'); + getByText('rgba(0, 0, 0, 0.54)'); + getByText('theme.color.drawerBackdrop'); + getByText('rgba(255, 255, 255, 0.5)'); + getByText('theme.color.label'); + getByText('#555'); + getByText('theme.color.disabledText'); + getByText('#c9cacb'); + getByText('theme.color.tagButton'); + getByText('#f1f7fd'); + getByText('theme.color.tagIcon'); + getByText('#7daee8'); + + // background colors + getByText('Background Colors'); + getByText('theme.bg.app'); + getByText('theme.bg.main'); + getByText('theme.bg.offWhite'); + getByText('#fbfbfb'); + getByText('theme.bg.lightBlue1'); + getByText('#f0f7ff'); + getByText('theme.bg.lightBlue2'); + getByText('#e5f1ff'); + getByText('theme.bg.white'); + getByText('theme.bg.tableHeader'); + getByText('#f9fafa'); + getByText('theme.bg.primaryNavPaper'); + getByText('#3a3f46'); + getByText('theme.bg.mainContentBanner'); + getByText('#33373d'); + getByText('theme.bg.bgPaper'); + getByText('#ffffff'); + getByText('theme.bg.bgAccessRow'); + getByText('#fafafa'); + getByText('theme.bg.bgAccessRowTransparentGradient'); + getByText('rgb(255, 255, 255, .001)'); + + // typography colors + getByText('Typography Colors'); + getByText('theme.textColors.linkActiveLight'); + getByText('#2575d0'); + getByText('theme.textColors.headlineStatic'); + getByText('theme.textColors.tableHeader'); + getByText('#888f91'); + getByText('theme.textColors.tableStatic'); + getByText('theme.textColors.textAccessTable'); + + // border colors + getByText('Border Colors'); + getByText('theme.borderColors.borderTypography'); + getByText('theme.borderColors.borderTable'); + getByText('theme.borderColors.divider'); + }); +}); diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.tsx index a6f04adfa4d..a3bfaadb121 100644 --- a/packages/manager/src/components/ColorPalette/ColorPalette.tsx +++ b/packages/manager/src/components/ColorPalette/ColorPalette.tsx @@ -42,6 +42,16 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); +/** + * Add a new color to the palette, especially another tint of gray or blue, only after exhausting the option of using an existing color. + * + * - Colors used in light mode are located in `foundations/light.ts + * - Colors used in dark mode are located in `foundations/dark.ts` + * + * If a color does not exist in the current palette and is only used once, consider applying the color conditionally: + * + * `theme.name === 'light' ? '#fff' : '#000'` + */ export const ColorPalette = () => { const { classes } = useStyles(); const theme = useTheme(); From d3fdefab7bae691d89acbcb2e674dcbe1b7bd39a Mon Sep 17 00:00:00 2001 From: tyler-akamai <139489745+tyler-akamai@users.noreply.github.com> Date: Wed, 27 Dec 2023 10:40:10 -0500 Subject: [PATCH 34/45] change: [M3-7461] - Update toast notifications for UserPermissions (#10011) * updated toast notifications for UserPermissions * Added changeset: Update toast notifications for UserPermissions * added user permissions --- .../pr-10011-changed-1703028750129.md | 5 +++++ .../src/features/Users/UserPermissions.tsx | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 packages/manager/.changeset/pr-10011-changed-1703028750129.md diff --git a/packages/manager/.changeset/pr-10011-changed-1703028750129.md b/packages/manager/.changeset/pr-10011-changed-1703028750129.md new file mode 100644 index 00000000000..6aa3f4f70a7 --- /dev/null +++ b/packages/manager/.changeset/pr-10011-changed-1703028750129.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Update toast notifications for UserPermissions ([#10011](https://github.com/linode/manager/pull/10011)) diff --git a/packages/manager/src/features/Users/UserPermissions.tsx b/packages/manager/src/features/Users/UserPermissions.tsx index 1bfc2848375..3155b00c3ff 100644 --- a/packages/manager/src/features/Users/UserPermissions.tsx +++ b/packages/manager/src/features/Users/UserPermissions.tsx @@ -268,6 +268,9 @@ class UserPermissions extends React.Component { this.getUserGrants(); // refresh the data on /account/users so it is accurate this.props.queryClient.invalidateQueries('account-users'); + this.props.enqueueSnackbar('User permissions successfully saved.', { + variant: 'success', + }); }) .catch((errResponse) => { this.setState({ @@ -658,9 +661,12 @@ class UserPermissions extends React.Component { const { tabs } = this.getTabInformation(grantsResponse); this.setState({ isSavingGlobal: false, tabs }); - this.props.enqueueSnackbar('Successfully saved global permissions', { - variant: 'success', - }); + this.props.enqueueSnackbar( + 'General user permissions successfully saved.', + { + variant: 'success', + } + ); }) .catch((errResponse) => { this.setState({ @@ -711,8 +717,10 @@ class UserPermissions extends React.Component { this.setState((compose as any)(...updateFns)); } this.props.enqueueSnackbar( - 'Successfully saved entity-specific permissions', - { variant: 'success' } + 'Entity-specific user permissions successfully saved.', + { + variant: 'success', + } ); // In the chance a new type entity was added to the account, re-calculate what tabs need to be shown. const { tabs } = this.getTabInformation(grantsResponse); From ee80428e99d640800e8fcae1d1702c036cab96cf Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Wed, 27 Dec 2023 11:38:34 -0500 Subject: [PATCH 35/45] test: [M3-7484] - Add Cypress integration tests for User Permissions page (#10009) * Add mock utils related to user permission and grant management * Add array shuffle util * Add account access toggle ARIA label * Improve QA attributes for billing access selection * WIP add user permission management tests * Add ARIA labels for entity-specific user permissions radio buttons * Add assertions for entity-specific section * Add changesets * Update toast messages to reflect toasts in PR #10011 --------- Co-authored-by: mjac0bs --- .../pr-10009-fixed-1703009466518.md | 5 + .../pr-10009-tests-1703009412715.md | 5 + .../e2e/core/account/user-permissions.spec.ts | 473 ++++++++++++++++++ .../support/constants/user-permissions.ts | 58 +++ .../cypress/support/intercepts/account.ts | 87 ++++ .../manager/cypress/support/util/arrays.ts | 14 + .../SelectionCard/SelectionCard.tsx | 1 + .../src/features/Users/UserPermissions.tsx | 4 +- .../Users/UserPermissionsEntitySection.tsx | 274 +++++----- 9 files changed, 777 insertions(+), 144 deletions(-) create mode 100644 packages/manager/.changeset/pr-10009-fixed-1703009466518.md create mode 100644 packages/manager/.changeset/pr-10009-tests-1703009412715.md create mode 100644 packages/manager/cypress/e2e/core/account/user-permissions.spec.ts create mode 100644 packages/manager/cypress/support/constants/user-permissions.ts diff --git a/packages/manager/.changeset/pr-10009-fixed-1703009466518.md b/packages/manager/.changeset/pr-10009-fixed-1703009466518.md new file mode 100644 index 00000000000..d005618332a --- /dev/null +++ b/packages/manager/.changeset/pr-10009-fixed-1703009466518.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Improve accessibility of User Permissions account access toggle and entity-specific permission radio buttons ([#10009](https://github.com/linode/manager/pull/10009)) diff --git a/packages/manager/.changeset/pr-10009-tests-1703009412715.md b/packages/manager/.changeset/pr-10009-tests-1703009412715.md new file mode 100644 index 00000000000..6407d0e6f44 --- /dev/null +++ b/packages/manager/.changeset/pr-10009-tests-1703009412715.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress integration tests for User Permissions page ([#10009](https://github.com/linode/manager/pull/10009)) diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts new file mode 100644 index 00000000000..84e1f87800a --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -0,0 +1,473 @@ +import type { Grant, Grants } from '@linode/api-v4'; +import { accountUserFactory } from '@src/factories/accountUsers'; +import { grantsFactory } from '@src/factories/grants'; +import { userPermissionsGrants } from 'support/constants/user-permissions'; +import { + mockGetUser, + mockGetUserGrants, + mockGetUsers, + mockUpdateUser, + mockUpdateUserGrants, +} from 'support/intercepts/account'; +import { ui } from 'support/ui'; +import { shuffleArray } from 'support/util/arrays'; +import { randomLabel } from 'support/util/random'; + +// Message shown when user has unrestricted account acess. +const unrestrictedAccessMessage = + 'This user has unrestricted access to the account.'; + +// Toggle button labels for Global Permissions section. +const globalPermissionsLabels = [ + 'Can add Linodes to this account ($)', + 'Can add Longview clients to this account', + 'Can add Domains using the DNS Manager', + 'Can create frozen Images under this account ($)', + 'Can add Firewalls to this account', + 'Can add VPCs to this account', + 'Can add NodeBalancers to this account ($)', + 'Can modify this account’s Longview subscription ($)', + 'Can create StackScripts under this account', + 'Can add Block Storage Volumes to this account ($)', + 'Can add Databases to this account ($)', +]; + +// Specific permission entity types. +const specificPermissionsTypes = [ + 'Linodes', + 'Firewalls', + 'StackScripts', + 'Images', + 'Volumes', + 'NodeBalancers', + 'Domains', + 'Longview Clients', + 'Databases', + 'VPCs', +]; + +/** + * Returns a copy of a Grants object with its entity-specific permissions set to a new value. + * + * @param grants - Grants that should be copied with new permissions applied. + * @param newPermissions - New permissions to apply to Grants. + * + * @returns Clone of `grants` with new permissions applied. + */ +const updateGrantMockPermissions = ( + grants: Grants, + newPermissions: 'read_only' | 'read_write' | null +) => { + return { + ...grants, + database: grants.database.map((grant: Grant) => ({ + ...grant, + permissions: newPermissions, + })), + domain: grants.domain.map((grant: Grant) => ({ + ...grant, + permissions: newPermissions, + })), + firewall: grants.firewall.map((grant: Grant) => ({ + ...grant, + permissions: newPermissions, + })), + image: grants.image.map((grant: Grant) => ({ + ...grant, + permissions: newPermissions, + })), + linode: grants.linode.map((grant: Grant) => ({ + ...grant, + permissions: newPermissions, + })), + longview: grants.longview.map((grant: Grant) => ({ + ...grant, + permissions: newPermissions, + })), + nodebalancer: grants.nodebalancer.map((grant: Grant) => ({ + ...grant, + permissions: newPermissions, + })), + stackscript: grants.stackscript.map((grant: Grant) => ({ + ...grant, + permissions: newPermissions, + })), + volume: grants.volume.map((grant: Grant) => ({ + ...grant, + permissions: newPermissions, + })), + vpc: grants.vpc.map((grant: Grant) => ({ + ...grant, + permissions: newPermissions, + })), + }; +}; + +/** + * Returns an array of entity labels belonging to the given Grants object. + * + * @returns Array of entity labels. + */ +const entityLabelsFromGrants = (grants: Grants) => { + return [ + ...grants.database, + ...grants.domain, + ...grants.firewall, + ...grants.image, + ...grants.linode, + ...grants.longview, + ...grants.nodebalancer, + ...grants.stackscript, + ...grants.volume, + ...grants.vpc, + ].map((grant: Grant) => grant.label); +}; + +/** + * Assert whether all global permissions are enabled or disabled. + * + * @param enabled - When `true`, assert that all permissions are enabled. Otherwise, assert they are disabled. + */ +const assertAllGlobalPermissions = (enabled: boolean) => { + globalPermissionsLabels.forEach((permissionLabel: string) => { + const checkedQuery = enabled ? 'be.checked' : 'not.be.checked'; + cy.findByLabelText(permissionLabel).should(checkedQuery); + }); +}; + +/** + * Selects "None", "Read Only", or "Read-Write" billing access. + * + * @param billingAccess - Billing access to select. + */ +const selectBillingAccess = ( + billingAccess: 'None' | 'Read Only' | 'Read-Write' +) => { + cy.get(`[data-qa-select-card-heading="${billingAccess}"]`) + .closest('[data-qa-selection-card]') + .should('be.visible') + .click(); +}; + +/** + * Asserts whether "None", "Read Only", or "Read-Write" billing access is selected. + * + * @param billingAccess - Selected billing access to assert. + */ +const assertBillingAccessSelected = ( + billingAccess: 'None' | 'Read Only' | 'Read-Write' +) => { + cy.get(`[data-qa-select-card-heading="${billingAccess}"]`) + .closest('[data-qa-selection-card]') + .should('be.visible') + .should('have.attr', 'data-qa-selection-card-checked', 'true'); +}; + +describe('User permission management', () => { + /* + * - Confirms that full account access can be toggled for account users using mock API data. + * - Confirms that users can navigate to User Permissions pages via Users & Grants page. + * - Confirms that User Permissions page updates to reflect enabled full account access. + * - Confirms that User Permissions page updates to reflect disabled full account access. + */ + it('can toggle full account access', () => { + const mockUser = accountUserFactory.build({ + username: randomLabel(), + restricted: false, + }); + + const mockUserUpdated = { + ...mockUser, + restricted: true, + }; + + const mockUserGrantsUpdated = grantsFactory.build(); + const mockUserGrants = { + ...mockUserGrantsUpdated, + global: undefined, + }; + + mockGetUsers([mockUser]).as('getUsers'); + mockGetUser(mockUser).as('getUser'); + mockGetUserGrants(mockUser.username, mockUserGrants).as('getUserGrants'); + + // Navigate to Users & Grants page, find mock user, click its "User Permissions" button. + cy.visitWithLogin('/account/users'); + cy.wait('@getUsers'); + cy.findByText(mockUser.username) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('User Permissions') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that Cloud navigates to the user's permissions page and that user has + // unrestricted account access. + cy.url().should( + 'endWith', + `/account/users/${mockUser.username}/permissions` + ); + cy.findByText(unrestrictedAccessMessage).should('be.visible'); + + // Restrict account access, confirm page updates to reflect change. + mockUpdateUser(mockUser.username, mockUserUpdated); + mockGetUserGrants(mockUser.username, mockUserGrantsUpdated); + cy.findByLabelText('Toggle Full Account Access') + .should('be.visible') + .click(); + + ui.toast.assertMessage('User permissions successfully saved.'); + + // Smoke tests to confirm that "Global Permissions" and "Specific Permissions" + // sections are visible. + cy.findByText(unrestrictedAccessMessage).should('not.exist'); + cy.get('[data-qa-global-section]') + .should('be.visible') + .within(() => { + cy.findByText('Global Permissions').should('be.visible'); + cy.findByText('Billing Access').should('be.visible'); + globalPermissionsLabels.forEach((permissionLabel: string) => { + cy.findByText(permissionLabel).should('be.visible'); + }); + }); + + cy.get('[data-qa-entity-section]') + .should('be.visible') + .within(() => { + cy.findByText('Specific Permissions').should('be.visible'); + specificPermissionsTypes.forEach((permissionLabel: string) => { + cy.findByText(permissionLabel).should('be.visible'); + }); + }); + + // Re-enable unrestricted account access, confirm page updates to reflect change. + mockUpdateUser(mockUser.username, mockUser); + mockGetUserGrants(mockUser.username, mockUserGrants); + cy.findByLabelText('Toggle Full Account Access') + .should('be.visible') + .click(); + + cy.findByText(unrestrictedAccessMessage).should('be.visible'); + cy.findByText('Global Permissions').should('not.exist'); + cy.findByText('Billing Access').should('not.exist'); + cy.findByText('Specific Permissions').should('not.exist'); + }); + + /* + * - Confirms that global and specific user permissions can be updated using mock API data. + * - Confirms that toast notification is shown when updating global and specific permissions. + */ + it('can update global and specific permissions', () => { + const mockUser = accountUserFactory.build({ + username: randomLabel(), + restricted: true, + }); + + const mockUserGrants = { ...userPermissionsGrants }; + const grantEntities = entityLabelsFromGrants(mockUserGrants); + + // Mock grants after global permissions changes have been applied. + const mockUserGrantsUpdatedGlobal: Grants = { + ...mockUserGrants, + global: { + account_access: 'read_only', + cancel_account: true, + child_account_access: true, + add_domains: true, + add_firewalls: true, + add_images: true, + add_linodes: true, + add_longview: true, + add_nodebalancers: true, + add_stackscripts: true, + add_volumes: true, + add_vpcs: true, + longview_subscription: true, + }, + }; + + // Mock grants after entity-specific permissions changes have been applied. + const mockUserGrantsUpdatedSpecific = { + ...mockUserGrantsUpdatedGlobal, + ...updateGrantMockPermissions(mockUserGrantsUpdatedGlobal, 'read_write'), + }; + + mockGetUser(mockUser).as('getUser'); + mockGetUserGrants(mockUser.username, mockUserGrants).as('getUserGrants'); + cy.visitWithLogin(`/account/users/${mockUser.username}/permissions`); + cy.wait(['@getUser', '@getUserGrants']); + + mockUpdateUserGrants(mockUser.username, mockUserGrantsUpdatedGlobal).as( + 'updateUserGrants' + ); + cy.get('[data-qa-global-section]') + .should('be.visible') + .within(() => { + // Confirm that all global permissions are disabled, and then enable some. + assertAllGlobalPermissions(false); + assertBillingAccessSelected('None'); + + // Enable all global permissions and "Read-Only" billing access. + globalPermissionsLabels.forEach((permissionLabel: string) => { + cy.findByText(permissionLabel).should('be.visible').click(); + }); + selectBillingAccess('Read Only'); + + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@updateUserGrants'); + }); + + // Confirm that toast notification appears when updating global permissions. + ui.toast.assertMessage('General user permissions successfully saved.'); + + // Update entity-specific user permissions. + mockUpdateUserGrants(mockUser.username, mockUserGrantsUpdatedSpecific).as( + 'updateUserGrants' + ); + cy.get('[data-qa-entity-section]') + .should('be.visible') + .within(() => { + grantEntities.forEach((entityLabel: string) => { + cy.findByText(entityLabel) + .should('be.visible') + .closest('tr') + .within(() => { + // Confirm that "None" radio button is selected. + cy.get('[data-qa-permission="None"]') + .should('have.attr', 'data-qa-radio', 'true') + .should('be.visible'); + + // Click "Read-Write" radio button, confirm selection changes. + cy.get('[data-qa-permission="Read-Write"]') + .should('have.attr', 'data-qa-radio', 'false') + .should('be.visible') + .click(); + + cy.get('[data-qa-permission="Read-Write"]').should( + 'have.attr', + 'data-qa-radio', + 'true' + ); + }); + }); + + // Save changes and confirm that toast notification appears. + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@updateUserGrants'); + }); + + ui.toast.assertMessage( + 'Entity-specific user permissions successfully saved.' + ); + }); + + /* + * - Confirms that users can discard changes to their global permissions using "Reset" button. + * - Confirms that users can discard changes to their entity-specific permissions using "Reset" button. + */ + it('can reset user permissions changes', () => { + const mockUser = accountUserFactory.build({ + username: randomLabel(), + restricted: true, + }); + + const mockUserGrants = { ...userPermissionsGrants }; + const grantEntities = entityLabelsFromGrants(mockUserGrants); + + mockGetUser(mockUser); + mockGetUserGrants(mockUser.username, mockUserGrants); + cy.visitWithLogin(`/account/users/${mockUser.username}/permissions`); + + // Test reset in Global Permissions section. + cy.get('[data-qa-global-section]') + .should('be.visible') + .within(() => { + // Confirm that all global permissions are disabled and that the user + // does not have billing access. + assertAllGlobalPermissions(false); + assertBillingAccessSelected('None'); + + // Enable random permissions and billing read-write access. + shuffleArray(globalPermissionsLabels) + .slice(0, 5) + .forEach((permissionLabel: string) => { + cy.findByText(permissionLabel).should('be.visible').click(); + }); + + selectBillingAccess('Read-Write'); + + // Click "Reset" button and confirm that global permissions revert to + // their initial state. + ui.button + .findByTitle('Reset') + .should('be.visible') + .should('be.enabled') + .click(); + + assertAllGlobalPermissions(false); + assertBillingAccessSelected('None'); + }); + + // Test reset in Specific Permissions section. + cy.get('[data-qa-entity-section]') + .should('be.visible') + .within(() => { + grantEntities.forEach((entityLabel: string) => { + cy.findByText(entityLabel) + .should('be.visible') + .closest('tr') + .within(() => { + // Confirm that "None" radio button is selected. + cy.get('[data-qa-permission="None"]') + .should('have.attr', 'data-qa-radio', 'true') + .should('be.visible'); + + // Click "Read Only" radio button, confirm selection changes. + cy.get('[data-qa-permission="Read Only"]') + .should('have.attr', 'data-qa-radio', 'false') + .should('be.visible') + .click(); + + cy.get('[data-qa-permission="Read Only"]').should( + 'have.attr', + 'data-qa-radio', + 'true' + ); + }); + }); + + // Reset changes and confirm that permissions revert to initial state. + ui.button + .findByTitle('Reset') + .should('be.visible') + .should('be.enabled') + .click(); + + grantEntities.forEach((entityLabel: string) => { + cy.findByText(entityLabel) + .should('be.visible') + .closest('tr') + .within(() => { + // Confirm that "None" radio button is selected. + cy.get('[data-qa-permission="None"]') + .should('have.attr', 'data-qa-radio', 'true') + .should('be.visible'); + }); + }); + }); + }); +}); diff --git a/packages/manager/cypress/support/constants/user-permissions.ts b/packages/manager/cypress/support/constants/user-permissions.ts new file mode 100644 index 00000000000..d6ca785951d --- /dev/null +++ b/packages/manager/cypress/support/constants/user-permissions.ts @@ -0,0 +1,58 @@ +import { randomLabel } from 'support/util/random'; +import { grantFactory, grantsFactory } from 'src/factories/grants'; +import type { Grants } from '@linode/api-v4'; + +/** + * User permission grants with all permissions restricted. + */ +export const userPermissionsGrants: Grants = grantsFactory.build({ + global: { + account_access: null, + cancel_account: false, + child_account_access: false, + add_domains: false, + add_firewalls: false, + add_images: false, + add_linodes: false, + add_longview: false, + add_nodebalancers: false, + add_stackscripts: false, + add_volumes: false, + add_vpcs: false, + longview_subscription: false, + }, + database: grantFactory.buildList(1, { + label: randomLabel(), + permissions: null, + }), + domain: grantFactory.buildList(1, { + label: randomLabel(), + permissions: null, + }), + firewall: grantFactory.buildList(1, { + label: randomLabel(), + permissions: null, + }), + image: grantFactory.buildList(1, { label: randomLabel(), permissions: null }), + linode: grantFactory.buildList(1, { + label: randomLabel(), + permissions: null, + }), + longview: grantFactory.buildList(1, { + label: randomLabel(), + permissions: null, + }), + nodebalancer: grantFactory.buildList(1, { + label: randomLabel(), + permissions: null, + }), + stackscript: grantFactory.buildList(1, { + label: randomLabel(), + permissions: null, + }), + volume: grantFactory.buildList(1, { + label: randomLabel(), + permissions: null, + }), + vpc: grantFactory.buildList(1, { label: randomLabel(), permissions: null }), +}); diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index 552a7bcd9f4..d88ebb44732 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -18,6 +18,7 @@ import type { Payment, PaymentMethod, User, + Grants, } from '@linode/api-v4'; /** @@ -48,6 +49,21 @@ export const mockUpdateAccount = ( ); }; +/** + * Intercepts GET request to fetch account users and mocks response. + * + * @param users - User objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetUsers = (users: User[]): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('account/users*'), + paginateResponse(users) + ); +}; + /** * Intercepts GET request to fetch account user information. * @@ -59,6 +75,77 @@ export const interceptGetUser = (username: string): Cypress.Chainable => { return cy.intercept('GET', apiMatcher(`account/users/${username}`)); }; +/** + * Intercepts GET request to fetch account user information and mocks response. + * + * @param username - Username of user whose info is being fetched. + * + * @returns Cypress chainable. + */ +export const mockGetUser = (user: User): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`account/users/${user.username}`), + makeResponse(user) + ); +}; + +/** + * Intercepts PUT request to update account user information and mocks response. + * + * @param username - Username of user to update. + * @param updatedUser - Updated user account info with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateUser = ( + username: string, + updatedUser: User +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`account/users/${username}`), + makeResponse(updatedUser) + ); +}; + +/** + * Intercepts GET request to fetch account user grants and mocks response. + * + * @param username - Username of user for which to fetch grants. + * + * @returns Cypress chainable. + */ +export const mockGetUserGrants = ( + username: string, + grants: Grants +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`account/users/${username}/grants`), + makeResponse(grants) + ); +}; + +/** + * Intercepts PUT request to update account user grants and mocks response. + * + * @param username - Username of user for which to update grants. + * @param grants - Updated grants with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateUserGrants = ( + username: string, + grants: Grants +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`account/users/${username}/grants`), + makeResponse(grants) + ); +}; + /** * Intercepts POST request to generate entity transfer token. * diff --git a/packages/manager/cypress/support/util/arrays.ts b/packages/manager/cypress/support/util/arrays.ts index 86a1899ba70..74ce77cdf9f 100644 --- a/packages/manager/cypress/support/util/arrays.ts +++ b/packages/manager/cypress/support/util/arrays.ts @@ -18,3 +18,17 @@ export const buildArray = ( .fill(null) .map((_item: null, i: number) => builder(i)); }; + +/** + * Returns a copy of an array with its items sorted randomly. + * + * @param unsortedArray - Array to shuffle. + * + * @returns Copy of `unsortedArray` with its items sorted randomly. + */ +export const shuffleArray = (unsortedArray: T[]): T[] => { + return unsortedArray + .map((value: T) => ({ value, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ value }) => value); +}; diff --git a/packages/manager/src/components/SelectionCard/SelectionCard.tsx b/packages/manager/src/components/SelectionCard/SelectionCard.tsx index feafc1c93aa..703a6601338 100644 --- a/packages/manager/src/components/SelectionCard/SelectionCard.tsx +++ b/packages/manager/src/components/SelectionCard/SelectionCard.tsx @@ -147,6 +147,7 @@ export const SelectionCard = React.memo((props: SelectionCardProps) => { { } return ( - + ({ marginTop: theme.spacing(2), paddingBottom: 0, })} container - data-qa-billing-section spacing={2} > @@ -407,6 +406,7 @@ class UserPermissions extends React.Component { { - const theme: Theme = useTheme(); - const pagination = usePagination(1); +export const UserPermissionsEntitySection = React.memo( + ({ entity, grants, setGrantTo, entitySetAllTo, showHeading }: Props) => { + const theme: Theme = useTheme(); + const pagination = usePagination(1); - if (!grants || grants.length === 0) { - return null; - } + if (!grants || grants.length === 0) { + return null; + } - const page = createDisplayPage( - pagination.page, - pagination.pageSize - )(grants); + const page = createDisplayPage( + pagination.page, + pagination.pageSize + )(grants); - const entityIsAll = (value: GrantLevel): boolean => { - if (!grants) { - return false; - } + const entityIsAll = (value: GrantLevel): boolean => { + if (!grants) { + return false; + } - return !grants.some((grant) => grant.permissions !== value); - }; + return !grants.some((grant) => grant.permissions !== value); + }; - return ( - - {showHeading && ( - - {entityNameMap[entity]} - - )} - - - - Label - - {/* eslint-disable-next-line */} - - - - {/* eslint-disable-next-line */} - - - - {/* eslint-disable-next-line */} - - - - - - {page.map((grant, _idx) => { - // Index must be corrected to account for pagination - const idx = (pagination.page - 1) * pagination.pageSize + _idx; - return ( - - - {grant.label} - - + return ( + + {showHeading && ( + + {entityNameMap[entity]} + + )} + + + + Label + + {/* eslint-disable-next-line */} + - + + + + {/* eslint-disable-next-line */} + - + + + + {/* eslint-disable-next-line */} + - - ); - })} - - - - - ); -}); + + + + + + {page.map((grant, _idx) => { + // Index must be corrected to account for pagination + const idx = (pagination.page - 1) * pagination.pageSize + _idx; + return ( + + + {grant.label} + + + + + + + + + + + + ); + })} + + + +
+ ); + } +); From 0f556ca06772336874e9227be982e6562f1427d7 Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Thu, 28 Dec 2023 14:28:54 -0500 Subject: [PATCH 36/45] refactor: [M3-7576] - DebouncedSearchTextfield and EditableText v7 storybook migrations (#10017) * editable text * update comment * debounced search storybook * tests for debounced search text field * Added changeset: DebouncedSearchTextfield and EditableText v7 storybook migrations * removed file * Update packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> * Do a little unit test clean up for readability --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Co-authored-by: mjac0bs --- .../pr-10017-tech-stories-1703176302247.md | 5 + .../DebouncedSearchTextField.stories.mdx | 44 ------- .../DebouncedSearchTextField.stories.tsx | 94 +++++++++++++++ .../DebouncedSearchTextField.tsx | 12 +- .../DebouncedSearchTextfield.test.tsx | 87 ++++++++++++++ .../StoryComponents/TextFieldExample.tsx | 69 ----------- .../EditableText/EditableText.stories.mdx | 27 ----- .../EditableText/EditableText.stories.tsx | 32 +++++ .../EditableText/EditableText.test.tsx | 111 ++++++++++++++++++ .../components/EditableText/EditableText.tsx | 14 ++- 10 files changed, 353 insertions(+), 142 deletions(-) create mode 100644 packages/manager/.changeset/pr-10017-tech-stories-1703176302247.md delete mode 100644 packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.mdx create mode 100644 packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.tsx create mode 100644 packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx delete mode 100644 packages/manager/src/components/DebouncedSearchTextField/StoryComponents/TextFieldExample.tsx delete mode 100644 packages/manager/src/components/EditableText/EditableText.stories.mdx create mode 100644 packages/manager/src/components/EditableText/EditableText.stories.tsx create mode 100644 packages/manager/src/components/EditableText/EditableText.test.tsx diff --git a/packages/manager/.changeset/pr-10017-tech-stories-1703176302247.md b/packages/manager/.changeset/pr-10017-tech-stories-1703176302247.md new file mode 100644 index 00000000000..67822032053 --- /dev/null +++ b/packages/manager/.changeset/pr-10017-tech-stories-1703176302247.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +DebouncedSearchTextfield and EditableText v7 storybook migrations ([#10017](https://github.com/linode/manager/pull/10017)) diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.mdx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.mdx deleted file mode 100644 index da7bda23c47..00000000000 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.mdx +++ /dev/null @@ -1,44 +0,0 @@ -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; -import TextFieldExample from './StoryComponents/TextFieldExample'; - - - -export const list = [ - 'apples', - 'oranges', - 'grapes', - 'walruses', - 'keyboards', - 'chairs', - 'speakers', - 'ecumenical council number two', -]; - -export const Template = (args) => { - return ( - - ); - }; - -# Search -### Text Field - - - {Template.bind({})} - - - - diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.tsx new file mode 100644 index 00000000000..42e8d540205 --- /dev/null +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.stories.tsx @@ -0,0 +1,94 @@ +import { action } from '@storybook/addon-actions'; +import * as React from 'react'; + +import { DebouncedSearchTextField } from './DebouncedSearchTextField'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +const SEARCH_FOR_SOMETHING = 'Search for something'; + +const exampleList = [ + 'apples', + 'oranges', + 'grapes', + 'walruses', + 'keyboards', + 'chairs', + 'speakers', + 'ecumenical council number two', +]; + +export const Default: Story = { + args: { + debounceTime: 400, + hideLabel: true, + isSearching: true, + label: SEARCH_FOR_SOMETHING, + onSearch: action('searching'), + placeholder: SEARCH_FOR_SOMETHING, + value: 'searching', + }, + render: (args) => , +}; + +export const LiveSearchExample: Story = { + render: () => { + const SearchTextFieldWrapper = () => { + const [list, setList] = React.useState([...exampleList]); + const [isSearching, setIsSearching] = React.useState(false); + + const handleSearch = async (value: string) => { + setIsSearching(true); + action('searching')(value); + const res: string[] = await new Promise((resolve) => { + setTimeout(() => { + if (!value.trim()) { + return resolve(exampleList); + } + const filteredList = list.filter((eachVal: string) => + eachVal.includes(value.toLowerCase()) + ); + return resolve(filteredList); + }, 800); + }); + action('result')(res); + setIsSearching(false); + setList(res); + }; + + return ( + <> + +
    + {list.map((eachThing: string) => { + return ( +
  • + {eachThing} +
  • + ); + })} +
+ + ); + }; + + return ; + }, +}; + +const meta: Meta = { + component: DebouncedSearchTextField, + title: 'Components/Search', +}; + +export default meta; diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx index 4b3c4d47a00..fa8759bf210 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx @@ -7,12 +7,22 @@ import { InputAdornment } from 'src/components/InputAdornment'; import { TextField, TextFieldProps } from 'src/components/TextField'; import { usePrevious } from 'src/hooks/usePrevious'; -interface DebouncedSearchProps extends TextFieldProps { +export interface DebouncedSearchProps extends TextFieldProps { className?: string; + /** + * Interval in milliseconds of time that passes before search queries are accepted. + * @default 400 + */ debounceTime?: number; defaultValue?: string; hideLabel?: boolean; + /** + * Determines if the textbox is currently searching for inputted query + */ isSearching?: boolean; + /** + * Function to perform when searching for query + */ onSearch: (query: string) => void; placeholder?: string; } diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx new file mode 100644 index 00000000000..99d1b375924 --- /dev/null +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx @@ -0,0 +1,87 @@ +import userEvent from '@testing-library/user-event'; +import { debounce } from 'lodash'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { DebouncedSearchTextField } from './DebouncedSearchTextField'; + +vi.useFakeTimers(); + +const labelVal = 'Search textfield label'; +const textfieldId = 'textfield-input'; +const props = { + isSearching: false, + label: labelVal, + onSearch: vi.fn(), +}; + +describe('Debounced Search Text Field', () => { + it('renders the search field', () => { + const screen = renderWithTheme(); + + const label = screen.getByText(labelVal); + const textfield = screen.getByTestId(textfieldId); + const searchIcon = screen.getByTestId('SearchIcon'); + + expect(label).toBeVisible(); + expect(textfield).toBeVisible(); + expect(textfield).toEqual( + screen.container.querySelector('[placeholder="Filter by query"]') + ); + expect(searchIcon).toBeVisible(); + + // circle icon is not visible + const circleIcon = screen.queryByTestId('circle-progress'); + expect(circleIcon).not.toBeInTheDocument(); + }); + + it('renders a loading icon if isSearching is true', () => { + const screen = renderWithTheme( + + ); + + const circleIcon = screen.queryByTestId('circle-progress'); + expect(circleIcon).toBeInTheDocument(); + }); + + it('calls isSearching', () => { + const debouncedOnSearch = debounce(props.onSearch, 250); + const screen = renderWithTheme( + + ); + + const textfield = screen.getByTestId(textfieldId); + userEvent.type(textfield, 'test'); + vi.runAllTimers(); + expect(props.onSearch).toHaveBeenCalled(); + }); + + it('renders the expected placeholder', () => { + const screen = renderWithTheme( + + ); + + const placeholderTextfield = screen.container.querySelector( + '[placeholder="this is a placeholder"]' + ); + expect(placeholderTextfield).toEqual(screen.getByTestId(textfieldId)); + }); + + it('hides the label', () => { + const screen = renderWithTheme( + + ); + + const label = screen.queryByText(labelVal); + expect(label).toBeInTheDocument(); + expect(label?.className).toContain('visually-hidden'); + }); +}); diff --git a/packages/manager/src/components/DebouncedSearchTextField/StoryComponents/TextFieldExample.tsx b/packages/manager/src/components/DebouncedSearchTextField/StoryComponents/TextFieldExample.tsx deleted file mode 100644 index 535422e06ee..00000000000 --- a/packages/manager/src/components/DebouncedSearchTextField/StoryComponents/TextFieldExample.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { action } from '@storybook/addon-actions'; -import * as React from 'react'; - -import { DebouncedSearchTextField } from '../DebouncedSearchTextField'; - -interface Props { - list: string[]; -} - -interface State { - isSearching: boolean; - list: string[]; -} - -class Example extends React.Component { - render() { - return ( - - -
    - {this.state.list.map((eachThing: string) => { - return ( -
  • - {eachThing} -
  • - ); - })} -
-
- ); - } - - handleSearch = (value: string) => { - this.setState({ isSearching: true }); - const { list } = this.state; - action('searching')(value); - return new Promise((resolve) => { - setTimeout(() => { - if (!value.trim()) { - return resolve(this.props.list); - } - const filteredList = list.filter((eachVal) => - eachVal.includes(value.toLowerCase()) - ); - return resolve(filteredList); - }, 800); - }).then((res: string[]) => { - action('result')(res); - this.setState({ - isSearching: false, - list: res, - }); - }); - }; - - state: State = { - isSearching: false, - list: this.props.list, - }; -} - -export default Example; diff --git a/packages/manager/src/components/EditableText/EditableText.stories.mdx b/packages/manager/src/components/EditableText/EditableText.stories.mdx deleted file mode 100644 index fd40c8d607f..00000000000 --- a/packages/manager/src/components/EditableText/EditableText.stories.mdx +++ /dev/null @@ -1,27 +0,0 @@ -import { EditableText } from './EditableText'; -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; -import { useArgs } from '@storybook/client-api'; - - - -# Editable Text - -export const Template = (args, context) => { - const [localArgs, setLocalArgs] = useArgs(); - const onEdit = (updatedText) => { - return Promise.resolve(setLocalArgs({text: updatedText})); - } - return {}}/> -} - - - - {Template.bind({})} - - - - \ No newline at end of file diff --git a/packages/manager/src/components/EditableText/EditableText.stories.tsx b/packages/manager/src/components/EditableText/EditableText.stories.tsx new file mode 100644 index 00000000000..011e063b5c4 --- /dev/null +++ b/packages/manager/src/components/EditableText/EditableText.stories.tsx @@ -0,0 +1,32 @@ +import { action } from '@storybook/addon-actions'; +import { useArgs } from '@storybook/client-api'; +import * as React from 'react'; + +import { EditableText } from './EditableText'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onCancel: action('onCancel'), + text: 'Edit me!', + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [, setLocalArgs] = useArgs(); + const onEdit = (updatedText: string) => { + return Promise.resolve(setLocalArgs({ text: updatedText })); + }; + + return ; + }, +}; + +const meta: Meta = { + component: EditableText, + title: 'Components/Editable Text', +}; + +export default meta; diff --git a/packages/manager/src/components/EditableText/EditableText.test.tsx b/packages/manager/src/components/EditableText/EditableText.test.tsx new file mode 100644 index 00000000000..91ee4623548 --- /dev/null +++ b/packages/manager/src/components/EditableText/EditableText.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EditableText } from './EditableText'; + +const props = { + onCancel: vi.fn(), + onEdit: vi.fn(() => Promise.resolve()), + text: 'Edit this', +}; + +const BUTTON_LABEL = 'Edit Edit this'; +const CLOSE_BUTTON_ICON = 'CloseIcon'; +const SAVE_BUTTON_ICON = 'CheckIcon'; + +describe('Editable Text', () => { + it('renders an Editable Text input', () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + const text = getByText('Edit this'); + expect(text).toBeVisible(); + + const button = getByLabelText(BUTTON_LABEL); + expect(button).toBeInTheDocument(); + }); + + it('shows error text', () => { + const { getByText } = renderWithTheme( + + ); + + const errorText = getByText('this is an error'); + expect(errorText).toBeVisible(); + }); + + it('can switch between a label and a textfield', () => { + const { getByLabelText, getByTestId, queryByTestId } = renderWithTheme( + + ); + + const button = getByLabelText(BUTTON_LABEL); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + expect(button).not.toBeInTheDocument(); + + const textfield = getByTestId('textfield-input'); + const saveButton = getByTestId(SAVE_BUTTON_ICON); + const closeButton = getByTestId(CLOSE_BUTTON_ICON); + + expect(textfield).toHaveValue('Edit this'); + expect(saveButton).toBeVisible(); + expect(closeButton).toBeVisible(); + + fireEvent.click(closeButton); + expect(props.onCancel).toHaveBeenCalled(); + + // after clicking the cancel icon + expect(queryByTestId(CLOSE_BUTTON_ICON)).not.toBeInTheDocument(); + expect(queryByTestId(SAVE_BUTTON_ICON)).not.toBeInTheDocument(); + expect(getByLabelText(BUTTON_LABEL)).toBeInTheDocument(); + }); + + it('does not call onEdit if there are no changes to the text', () => { + const { getByLabelText, getByTestId, queryByTestId } = renderWithTheme( + + ); + const button = getByLabelText(BUTTON_LABEL); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + + const saveButton = getByTestId(SAVE_BUTTON_ICON); + expect(saveButton).toBeVisible(); + fireEvent.click(saveButton); + expect(props.onEdit).not.toHaveBeenCalled(); + + // after clicking the save button + expect(queryByTestId(CLOSE_BUTTON_ICON)).not.toBeInTheDocument(); + expect(queryByTestId(SAVE_BUTTON_ICON)).not.toBeInTheDocument(); + expect(getByLabelText(BUTTON_LABEL)).toBeInTheDocument(); + }); + + it('calls onEdit if the text has been changed', () => { + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + const button = getByLabelText(BUTTON_LABEL); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + + const saveButton = getByTestId(SAVE_BUTTON_ICON); + expect(saveButton).toBeVisible(); + + // editing text + const textfield = getByTestId('textfield-input'); + expect(textfield).toHaveValue('Edit this'); + userEvent.type(textfield, ' has now been edited'); + expect(textfield).toHaveValue('Edit this has now been edited'); + + // saving text + fireEvent.click(saveButton); + expect(props.onEdit).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/components/EditableText/EditableText.tsx b/packages/manager/src/components/EditableText/EditableText.tsx index 3aec41144d8..edc3d9e75d6 100644 --- a/packages/manager/src/components/EditableText/EditableText.tsx +++ b/packages/manager/src/components/EditableText/EditableText.tsx @@ -7,8 +7,8 @@ import { Link } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; import { Button } from 'src/components/Button/Button'; -import { H1Header } from 'src/components/H1Header/H1Header'; import { ClickAwayListener } from 'src/components/ClickAwayListener'; +import { H1Header } from 'src/components/H1Header/H1Header'; import { fadeIn } from 'src/styles/keyframes'; import { TextField, TextFieldProps } from '../TextField'; @@ -106,9 +106,21 @@ const useStyles = makeStyles()( interface Props { className?: string; errorText?: string; + /** + * Optional link for the text when it is not in editing mode + */ labelLink?: string; + /** + * Function to cancel editing and restore text to previous text + */ onCancel: () => void; + /** + * The function to handle saving edited text + */ onEdit: (text: string) => Promise; + /** + * The text inside the textbox + */ text: string; } From f893474500bd559590aa94b723af4c6278218b7c Mon Sep 17 00:00:00 2001 From: tyler-akamai <139489745+tyler-akamai@users.noreply.github.com> Date: Fri, 29 Dec 2023 10:35:25 -0500 Subject: [PATCH 37/45] change: [M3-7454] - Add child access user permissions for parent accounts (#10005) * initial commit * removed unrestricted render * layout improvements * Added changeset: Add child access user permissions for parent accounts * placed toggle behind feature flag and enabled it for specific scenerios listed in the PR description * invalidated account query so Account access column gets updated * fixed mobil breakpoint * fixed mobil breakpoint pt2 * updated styling and mocks * fixed styling for Loading Circle * fixed global perm name to match GlobalGrantTypes * fixed toggle description * specified mock account name, cleaned up code, fixed error * Update pr-10005-changed-1702679066439.md * Added changeset: Add child access user permissions for parent accounts * added upcomming changeset * add test id back to typography * e2e test still failing * fixed e2e tests --- .../pr-10005-changed-1702679066439.md | 5 + ...r-10005-upcoming-features-1703273545929.md | 5 + .../e2e/core/account/user-permissions.spec.ts | 4 +- .../manager/src/features/Users/UserDetail.tsx | 1 + .../features/Users/UserPermissions.styles.ts | 57 ++++++ .../src/features/Users/UserPermissions.tsx | 184 +++++++++++------- packages/manager/src/mocks/serverHandlers.ts | 38 ++++ 7 files changed, 224 insertions(+), 70 deletions(-) create mode 100644 packages/manager/.changeset/pr-10005-changed-1702679066439.md create mode 100644 packages/manager/.changeset/pr-10005-upcoming-features-1703273545929.md diff --git a/packages/manager/.changeset/pr-10005-changed-1702679066439.md b/packages/manager/.changeset/pr-10005-changed-1702679066439.md new file mode 100644 index 00000000000..3e8af847c90 --- /dev/null +++ b/packages/manager/.changeset/pr-10005-changed-1702679066439.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Improve layout of User Permissions page ([#10005](https://github.com/linode/manager/pull/10005)) diff --git a/packages/manager/.changeset/pr-10005-upcoming-features-1703273545929.md b/packages/manager/.changeset/pr-10005-upcoming-features-1703273545929.md new file mode 100644 index 00000000000..6f553b95749 --- /dev/null +++ b/packages/manager/.changeset/pr-10005-upcoming-features-1703273545929.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add child access user permissions for parent accounts ([#10005](https://github.com/linode/manager/pull/10005)) diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index 84e1f87800a..f0f45768b7b 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -228,7 +228,9 @@ describe('User permission management', () => { cy.get('[data-qa-global-section]') .should('be.visible') .within(() => { - cy.findByText('Global Permissions').should('be.visible'); + cy.contains( + 'Configure the specific rights and privileges this user has within the account.' + ).should('be.visible'); cy.findByText('Billing Access').should('be.visible'); globalPermissionsLabels.forEach((permissionLabel: string) => { cy.findByText(permissionLabel).should('be.visible'); diff --git a/packages/manager/src/features/Users/UserDetail.tsx b/packages/manager/src/features/Users/UserDetail.tsx index bfd614cbe5c..280ab5cf5b8 100644 --- a/packages/manager/src/features/Users/UserDetail.tsx +++ b/packages/manager/src/features/Users/UserDetail.tsx @@ -252,6 +252,7 @@ export const UserDetail = () => { diff --git a/packages/manager/src/features/Users/UserPermissions.styles.ts b/packages/manager/src/features/Users/UserPermissions.styles.ts index afdcef657d0..a5bc3241b98 100644 --- a/packages/manager/src/features/Users/UserPermissions.styles.ts +++ b/packages/manager/src/features/Users/UserPermissions.styles.ts @@ -1,6 +1,9 @@ +import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import { CircleProgress } from 'src/components/CircleProgress'; import Select from 'src/components/EnhancedSelect/Select'; +import { Paper } from 'src/components/Paper'; export const StyledSelect = styled(Select, { label: 'StyledSelect', @@ -30,3 +33,57 @@ export const StyledDivWrapper = styled('div', { marginTop: theme.spacing(2), paddingBottom: 0, })); + +export const StyledHeaderGrid = styled(Grid, { + label: 'StyledHeaderGrid', +})(({ theme }) => ({ + padding: 0, + [theme.breakpoints.down('sm')]: { + marginLeft: theme.spacing(2), + marginTop: theme.spacing(1), + width: '100%', + }, +})); + +export const StyledSubHeaderGrid = styled(Grid, { + label: 'StyledSubHeaderGrid', +})(({ theme }) => ({ + [theme.breakpoints.down('sm')]: { + margin: theme.spacing(0.5), + padding: 0, + }, +})); + +export const StyledUnrestrictedGrid = styled(Grid, { + label: 'StyledUnrestrictedGrid', +})(({ theme }) => ({ + paddingBottom: theme.spacing(2), + paddingLeft: theme.spacing(3), + [theme.breakpoints.down('sm')]: { + paddingLeft: theme.spacing(2), + }, +})); + +export const StyledPaper = styled(Paper, { + label: 'StyledPaper', +})(({ theme }) => ({ + paddingBottom: 0, + paddingTop: 0, + [theme.breakpoints.down('sm')]: { + padding: 0, + }, +})); + +export const StyledPermPaper = styled(Paper, { + label: 'StyledPermPaper', +})(({ theme }) => ({ + [theme.breakpoints.down('sm')]: { + padding: theme.spacing(2), + }, +})); + +export const StyledCircleProgress = styled(CircleProgress, { + label: 'StyledCircleProgress', +})(({ theme }) => ({ + marginTop: theme.spacing(2), +})); diff --git a/packages/manager/src/features/Users/UserPermissions.tsx b/packages/manager/src/features/Users/UserPermissions.tsx index 0e50f293655..34468dc98c8 100644 --- a/packages/manager/src/features/Users/UserPermissions.tsx +++ b/packages/manager/src/features/Users/UserPermissions.tsx @@ -4,24 +4,26 @@ import { GrantType, Grants, getGrants, + getUser, updateGrants, updateUser, } from '@linode/api-v4/lib/account'; import { APIError } from '@linode/api-v4/lib/types'; +import { Paper } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; import { WithSnackbarProps, withSnackbar } from 'notistack'; import { compose, flatten, lensPath, omit, set } from 'ramda'; import * as React from 'react'; +import { QueryClient } from 'react-query'; import { compose as recompose } from 'recompose'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { CircleProgress } from 'src/components/CircleProgress'; -import { Divider } from 'src/components/Divider'; +import { Box } from 'src/components/Box'; +// import { Button } from 'src/components/Button/Button'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Item } from 'src/components/EnhancedSelect/Select'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Notice } from 'src/components/Notice/Notice'; -import { Paper } from 'src/components/Paper'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { Tab } from 'src/components/Tabs/Tab'; @@ -41,7 +43,16 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; -import { StyledDivWrapper, StyledSelect } from './UserPermissions.styles'; +import { + StyledCircleProgress, + StyledDivWrapper, + StyledHeaderGrid, + StyledPaper, + StyledPermPaper, + StyledSelect, + StyledSubHeaderGrid, + StyledUnrestrictedGrid, +} from './UserPermissions.styles'; import { UserPermissionsEntitySection, entityNameMap, @@ -49,6 +60,7 @@ import { interface Props { clearNewUser: () => void; currentUser?: string; + queryClient: QueryClient; username?: string; } @@ -58,6 +70,7 @@ interface TabInfo { } interface State { + childAccountAccessEnabled: boolean; errors?: APIError[]; grants?: Grants; isSavingEntity: boolean; @@ -83,6 +96,7 @@ type CombinedProps = Props & class UserPermissions extends React.Component { componentDidMount() { this.getUserGrants(); + this.checkAndEnableChildAccountAccess(); if (this.props.flags.vpc) { this.setState({ vpcEnabled: true }); @@ -94,17 +108,17 @@ class UserPermissions extends React.Component { componentDidUpdate(prevProps: CombinedProps) { if (prevProps.username !== this.props.username) { this.getUserGrants(); + this.checkAndEnableChildAccountAccess(); } } render() { - const { loading } = this.state; const { username } = this.props; return ( - {loading ? : this.renderBody()} + {this.renderBody()} ); } @@ -138,6 +152,32 @@ class UserPermissions extends React.Component { } }; + checkAndEnableChildAccountAccess = async () => { + const { currentUser: currentUsername, flags } = this.props; + if (currentUsername) { + try { + const currentUser = await getUser(currentUsername); + + const isParentAccount = currentUser.user_type === 'parent'; + const isFeatureFlagOn = flags.parentChildAccountAccess; + + this.setState({ + childAccountAccessEnabled: Boolean( + isParentAccount && isFeatureFlagOn + ), + }); + } catch (error) { + this.setState({ + errors: getAPIErrorOrDefault( + error, + 'Unknown error occurred while fetching user permissions. Try again later.' + ), + }); + scrollErrorIntoView(); + } + } + }; + entityIsAll = (entity: string, value: GrantLevel): boolean => { const { grants } = this.state; if (!(grants && grants[entity])) { @@ -262,6 +302,7 @@ class UserPermissions extends React.Component { this.setState({ restricted: user.restricted, }); + this.props.queryClient.invalidateQueries(['account', 'users']); }) .then(() => { // unconditionally sets this.state.loadingGrants to false @@ -378,49 +419,47 @@ class UserPermissions extends React.Component { const generalError = hasErrorFor('none'); return ( - + theme.spacing(4) }}> {generalError && ( )} - - - ({ - [theme.breakpoints.down('md')]: { - paddingLeft: theme.spacing(), - }, - })} - data-qa-restrict-access={restricted} - variant="h2" - > - Full Account Access: - - - - {!restricted ? 'On' : 'Off'} - - - + + + + + General Permissions + + + + + + + theme.font.bold }} + variant="subtitle2" + > + Full Account Access + + - + {restricted ? this.renderPermissions() : this.renderUnrestricted()} - + ); }; @@ -444,6 +483,11 @@ class UserPermissions extends React.Component { permDescriptionMap['add_vpcs'] = 'Can add VPCs to this account'; } + if (this.state.childAccountAccessEnabled) { + permDescriptionMap['child_account_access'] = + 'Enable child account access'; + } + return ( { })} label={permDescriptionMap[perm]} /> - ); }; renderGlobalPerms = () => { const { grants, isSavingGlobal } = this.state; + if ( + this.state.childAccountAccessEnabled && + !this.globalBooleanPerms.includes('child_account_access') + ) { + this.globalBooleanPerms.push('child_account_access'); + } return ( - ({ - marginTop: theme.spacing(2), - })} - data-qa-global-section - > + - Global Permissions + Configure the specific rights and privileges this user has within the + account.{
}Remember that permissions related to actions with the + '$' symbol may incur additional charges.
({ @@ -506,14 +552,14 @@ class UserPermissions extends React.Component { this.cancelPermsType('global'), isSavingGlobal )} -
+ ); }; renderPermissions = () => { - const { loadingGrants } = this.state; - if (loadingGrants) { - return ; + const { loading, loadingGrants } = this.state; + if (loadingGrants || loading) { + return ; } else { return ( @@ -538,7 +584,7 @@ class UserPermissions extends React.Component { }); return ( - ({ marginTop: theme.spacing(2), })} @@ -610,22 +656,21 @@ class UserPermissions extends React.Component { this.cancelPermsType('entity'), isSavingEntity )} - + ); }; renderUnrestricted = () => { - /* TODO: render all permissions disabled with this message above */ return ( - ({ - marginTop: theme.spacing(2), - padding: theme.spacing(3), - })} - > - - This user has unrestricted access to the account. - + + + + This user has unrestricted access to the account. + + {/* */} + ); }; @@ -757,6 +802,7 @@ class UserPermissions extends React.Component { }; state: State = { + childAccountAccessEnabled: false, isSavingEntity: false, isSavingGlobal: false, loading: true, diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index e40f1bff3f4..38a7b2b9888 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -2,6 +2,7 @@ import { NotificationType, SecurityQuestionsPayload, TokenRequest, + User, VolumeStatus, } from '@linode/api-v4'; import { DateTime } from 'luxon'; @@ -508,6 +509,12 @@ const childAccountUser = accountUserFactory.build({ user_type: 'child', username: 'ChildUser', }); +const parentAccountNonAdminUser = accountUserFactory.build({ + email: 'account@linode.com', + last_login: null, + restricted: false, + username: 'NonAdminUser', +}); export const handlers = [ rest.get('*/profile', (req, res, ctx) => { @@ -1204,6 +1211,7 @@ export const handlers = [ childAccountUser, parentAccountUser, proxyAccountUser, + parentAccountNonAdminUser, ]; return res(ctx.json(makeResourcePage(accountUsers))); }), @@ -1216,10 +1224,26 @@ export const handlers = [ rest.get(`*/account/users/${parentAccountUser.username}`, (req, res, ctx) => { return res(ctx.json(parentAccountUser)); }), + rest.get( + `*/account/users/${parentAccountNonAdminUser.username}`, + (req, res, ctx) => { + return res(ctx.json(parentAccountNonAdminUser)); + } + ), rest.get('*/account/users/:user', (req, res, ctx) => { // Parent/Child: switch the `user_type` depending on what account view you need to mock. return res(ctx.json(accountUserFactory.build({ user_type: 'parent' }))); }), + rest.put( + `*/account/users/${parentAccountNonAdminUser.username}`, + (req, res, ctx) => { + const { restricted } = req.body as Partial; + if (restricted !== undefined) { + parentAccountNonAdminUser.restricted = restricted; + } + return res(ctx.json(parentAccountNonAdminUser)); + } + ), rest.get( `*/account/users/${childAccountUser.username}/grants`, (req, res, ctx) => { @@ -1273,6 +1297,20 @@ export const handlers = [ ); } ), + rest.get( + `*/account/users/${parentAccountNonAdminUser.username}/grants`, + (req, res, ctx) => { + const grantsResponse = grantsFactory.build({ + global: parentAccountNonAdminUser.restricted + ? { + cancel_account: false, + child_account_access: true, + } + : undefined, + }); + return res(ctx.json(grantsResponse)); + } + ), rest.get('*/account/users/:user/grants', (req, res, ctx) => { return res( ctx.json( From 25a9f7b0e7eba05410796ad4cc267ae5c26bdbd4 Mon Sep 17 00:00:00 2001 From: tyler-akamai <139489745+tyler-akamai@users.noreply.github.com> Date: Fri, 29 Dec 2023 14:56:41 -0500 Subject: [PATCH 38/45] change: [M3-7225] - Filter out already assigned services in firewall drawers (#9993) * initial commit for filtering out already assigned services * Added changeset: Filter already assigned services from firewall dropdowns * invalidated queries * fixed unit tests * fixed unit tests --- packages/api-v4/src/firewalls/types.ts | 6 ++ .../pr-9993-changed-1702418666735.md | 5 ++ packages/manager/src/__data__/firewalls.ts | 17 ++++ .../components/Autocomplete/Autocomplete.tsx | 2 +- packages/manager/src/factories/firewalls.ts | 8 ++ .../Devices/AddLinodeDrawer.tsx | 78 +++++++++-------- .../Devices/AddNodebalancerDrawer.tsx | 83 +++++++++---------- .../Devices/RemoveDeviceDialog.tsx | 3 + .../FirewallLanding/CreateFirewallDrawer.tsx | 45 ++++++++-- .../FirewallLanding/FirewallDialog.tsx | 6 +- .../LinodeSelect/LinodeSelect.test.tsx | 2 +- .../Linodes/LinodeSelect/LinodeSelect.tsx | 3 +- .../NodeBalancers/NodeBalancerSelect.test.tsx | 4 +- .../NodeBalancers/NodeBalancerSelect.tsx | 3 +- 14 files changed, 170 insertions(+), 95 deletions(-) create mode 100644 packages/manager/.changeset/pr-9993-changed-1702418666735.md diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index 5d5148b6f3b..19f86dc1480 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -14,6 +14,12 @@ export interface Firewall { rules: FirewallRules; created_dt: string; updated_dt: string; + entities: { + id: number; + type: FirewallDeviceEntityType; + label: string; + url: string; + }[]; } export interface FirewallRules { diff --git a/packages/manager/.changeset/pr-9993-changed-1702418666735.md b/packages/manager/.changeset/pr-9993-changed-1702418666735.md new file mode 100644 index 00000000000..9aea26ea95f --- /dev/null +++ b/packages/manager/.changeset/pr-9993-changed-1702418666735.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Filter already assigned services from firewall dropdowns ([#9993](https://github.com/linode/manager/pull/9993)) diff --git a/packages/manager/src/__data__/firewalls.ts b/packages/manager/src/__data__/firewalls.ts index a366da01816..aed2bbdb1eb 100644 --- a/packages/manager/src/__data__/firewalls.ts +++ b/packages/manager/src/__data__/firewalls.ts @@ -1,7 +1,16 @@ import { Firewall } from '@linode/api-v4/lib/firewalls'; +import { FirewallDeviceEntityType } from '@linode/api-v4/lib/firewalls'; export const firewall: Firewall = { created_dt: '2019-09-11T19:44:38.526Z', + entities: [ + { + id: 1, + label: 'my-linode', + type: 'linode' as FirewallDeviceEntityType, + url: '/test', + }, + ], id: 1, label: 'my-firewall', rules: { @@ -33,6 +42,14 @@ export const firewall: Firewall = { export const firewall2: Firewall = { created_dt: '2019-12-11T19:44:38.526Z', + entities: [ + { + id: 1, + label: 'my-linode', + type: 'linode' as FirewallDeviceEntityType, + url: '/test', + }, + ], id: 2, label: 'zzz', rules: { diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index 4557e9a9c48..583dcee9eff 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -150,7 +150,7 @@ export const Autocomplete = < multiple={multiple} noOptionsText={noOptionsText || You have no options to choose from} onBlur={onBlur} - options={multiple ? optionsWithSelectAll : options} + options={multiple && options.length > 0 ? optionsWithSelectAll : options} popupIcon={} value={value} {...rest} diff --git a/packages/manager/src/factories/firewalls.ts b/packages/manager/src/factories/firewalls.ts index a2d5818d27e..5d70b52d97c 100644 --- a/packages/manager/src/factories/firewalls.ts +++ b/packages/manager/src/factories/firewalls.ts @@ -28,6 +28,14 @@ export const firewallRulesFactory = Factory.Sync.makeFactory({ export const firewallFactory = Factory.Sync.makeFactory({ created_dt: '2020-01-01 00:00:00', + entities: [ + { + id: 1, + label: 'my-linode', + type: 'linode' as FirewallDeviceEntityType, + url: '/test', + }, + ], id: Factory.each((id) => id), label: Factory.each((id) => `mock-firewall-${id}`), rules: firewallRulesFactory.build(), diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx index 617e44b54ce..a9aff82b984 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -6,17 +6,16 @@ import { useParams } from 'react-router-dom'; import sanitize from 'sanitize-html'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Drawer } from 'src/components/Drawer'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { SupportLink } from 'src/components/SupportLink'; +import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; +import { useFlags } from 'src/hooks/useFlags'; import { useAddFirewallDeviceMutation, - useAllFirewallDevicesQuery, - useFirewallQuery, + useAllFirewallsQuery, } from 'src/queries/firewalls'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useGrants, useProfile } from 'src/queries/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; @@ -38,17 +37,17 @@ export const AddLinodeDrawer = (props: Props) => { const { data: profile } = useProfile(); const isRestrictedUser = Boolean(profile?.restricted); - const { data: firewall } = useFirewallQuery(Number(id)); - const { - data: currentDevices, - isLoading: currentDevicesLoading, - } = useAllFirewallDevicesQuery(Number(id)); + const { data, error, isLoading } = useAllFirewallsQuery(); + const flags = useFlags(); + + const firewall = data?.find((firewall) => firewall.id === Number(id)); const theme = useTheme(); - const { isLoading, mutateAsync: addDevice } = useAddFirewallDeviceMutation( - Number(id) - ); + const { + isLoading: addDeviceIsLoading, + mutateAsync: addDevice, + } = useAddFirewallDeviceMutation(Number(id)); const [selectedLinodes, setSelectedLinodes] = React.useState([]); @@ -144,33 +143,36 @@ export const AddLinodeDrawer = (props: Props) => { } }; - const currentLinodeIds = - currentDevices - ?.filter((device) => device.entity.type === 'linode') - .map((device) => device.entity.id) ?? []; - // If a user is restricted, they can not add a read-only Linode to a firewall. const readOnlyLinodeIds = isRestrictedUser ? getEntityIdsByPermission(grants, 'linode', 'read_only') : []; - const optionsFilter = (linode: Linode) => { - return ![...currentLinodeIds, ...readOnlyLinodeIds].includes(linode.id); - }; + const linodeOptionsFilter = (() => { + // When `firewallNodebalancer` feature flag is disabled, no filtering + // occurs. In this case, pass a filter callback that always returns `true`. + if (!flags.firewallNodebalancer) { + return () => true; + } - const { - data, - error: linodeError, - isLoading: linodeIsLoading, - } = useAllLinodesQuery(); + const assignedLinodes = data + ?.map((firewall) => firewall.entities) + .flat() + ?.filter((service) => service.type === 'linode'); + + return (linode: Linode) => { + return ( + !readOnlyLinodeIds.includes(linode.id) && + !assignedLinodes?.some((service) => service.id === linode.id) + ); + }; + })(); React.useEffect(() => { - if (linodeError) { - setLocalError('Could not load Linode Data'); + if (error) { + setLocalError('Could not load firewall data'); } - }, [linodeError]); - - const linodes = data?.filter(optionsFilter); + }, [error]); return ( { }} > {localError ? errorNotice() : null} - setSelectedLinodes(linodes)} - options={linodes || []} - value={selectedLinodes} + onSelectionChange={(linodes) => setSelectedLinodes(linodes)} + optionsFilter={linodeOptionsFilter} + value={selectedLinodes.map((linode) => linode.id)} /> { const { data: profile } = useProfile(); const isRestrictedUser = Boolean(profile?.restricted); const queryClient = useQueryClient(); - const { data: firewall } = useFirewallQuery(Number(id)); - const { - data: currentDevices, - isLoading: currentDevicesLoading, - } = useAllFirewallDevicesQuery(Number(id)); + const flags = useFlags(); + + const { data, error, isLoading } = useAllFirewallsQuery(); + + const firewall = data?.find((firewall) => firewall.id === Number(id)); const theme = useTheme(); - const { isLoading, mutateAsync: addDevice } = useAddFirewallDeviceMutation( - Number(id) - ); + const { + isLoading: addDeviceIsLoading, + mutateAsync: addDevice, + } = useAddFirewallDeviceMutation(Number(id)); const [selectedNodebalancers, setSelectedNodebalancers] = React.useState< NodeBalancer[] @@ -152,35 +152,38 @@ export const AddNodebalancerDrawer = (props: Props) => { } }; - const currentNodebalancerIds = - currentDevices - ?.filter((device) => device.entity.type === 'nodebalancer') - .map((device) => device.entity.id) ?? []; - // If a user is restricted, they can not add a read-only Nodebalancer to a firewall. const readOnlyNodebalancerIds = isRestrictedUser ? getEntityIdsByPermission(grants, 'nodebalancer', 'read_only') : []; - const optionsFilter = (nodebalancer: NodeBalancer) => { - return ![...currentNodebalancerIds, ...readOnlyNodebalancerIds].includes( - nodebalancer.id - ); - }; + const nodebalancerOptionsFilter = (() => { + // When `firewallNodebalancer` feature flag is disabled, no filtering + // occurs. In this case, pass a filter callback that always returns `true`. + if (!flags.firewallNodebalancer) { + return () => true; + } - const { - data, - error: nodebalancerError, - isLoading: nodebalancerIsLoading, - } = useAllNodeBalancersQuery(); + const assignedNodeBalancers = data + ?.map((firewall) => firewall.entities) + .flat() + ?.filter((service) => service.type === 'nodebalancer'); + + return (nodebalancer: NodeBalancer) => { + return ( + !readOnlyNodebalancerIds.includes(nodebalancer.id) && + !assignedNodeBalancers?.some( + (service) => service.id === nodebalancer.id + ) + ); + }; + })(); React.useEffect(() => { - if (nodebalancerError) { - setLocalError('Could not load NodeBalancer Data'); + if (error) { + setLocalError('Could not load firewall data'); } - }, [nodebalancerError]); - - const nodebalancers = data?.filter(optionsFilter); + }, [error]); return ( { }} > {localError ? errorNotice() : null} - + setSelectedNodebalancers(nodebalancers) } - data-testid="add-nodebalancer-autocomplete" - disabled={currentDevicesLoading || nodebalancerIsLoading} + disabled={isLoading} helperText={helperText} - label="NodeBalancers" - loading={currentDevicesLoading || nodebalancerIsLoading} multiple - noOptionsText="No NodeBalancers available to add" - options={nodebalancers || []} - value={selectedNodebalancers} + optionsFilter={nodebalancerOptionsFilter} + value={selectedNodebalancers.map((nodebalancer) => nodebalancer.id)} /> { 'firewalls', ]); + queryClient.invalidateQueries([firewallQueryKey]); + onClose(); }; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index 490ae26bcd1..89c1eced39e 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx @@ -5,6 +5,7 @@ import { Firewall, FirewallDeviceEntityType, } from '@linode/api-v4/lib/firewalls'; +import { useAllFirewallsQuery } from 'src/queries/firewalls'; import { NodeBalancer } from '@linode/api-v4/lib/nodebalancers'; import { CreateFirewallSchema } from '@linode/validation/lib/firewalls.schema'; import { useFormik } from 'formik'; @@ -34,6 +35,7 @@ import { handleGeneralErrors, } from 'src/utilities/formikErrorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; +import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { LINODE_CREATE_FLOW_TEXT, @@ -71,6 +73,7 @@ export const CreateFirewallDrawer = React.memo( const { _hasGrant, _isRestrictedUser } = useAccountManagement(); const { data: grants } = useGrants(); const { mutateAsync } = useCreateFirewall(); + const { data } = useAllFirewallsQuery(); const { enqueueSnackbar } = useSnackbar(); const queryClient = useQueryClient(); @@ -117,6 +120,7 @@ export const CreateFirewallDrawer = React.memo( mutateAsync(payload) .then((response) => { setSubmitting(false); + queryClient.invalidateQueries([firewallQueryKey]); enqueueSnackbar(`Firewall ${payload.label} successfully created`, { variant: 'success', }); @@ -197,13 +201,42 @@ export const CreateFirewallDrawer = React.memo( ? READ_ONLY_DEVICES_HIDDEN_MESSAGE : undefined; - const linodeOptionsFilter = (linode: Linode) => { - return !readOnlyLinodeIds.includes(linode.id); - }; + const [linodeOptionsFilter, nodebalancerOptionsFilter] = (() => { + // When `firewallNodebalancer` feature flag is disabled, no filtering + // occurs. In this case, pass filter callbacks that always returns `true`. + if (!flags.firewallNodebalancer) { + return [() => true, () => true]; + } + + const assignedServices = data + ?.map((firewall) => firewall.entities) + .flat(); + + const assignedLinodes = assignedServices?.filter( + (service) => service.type === 'linode' + ); + const assignedNodeBalancers = assignedServices?.filter( + (service) => service.type === 'nodebalancer' + ); + + const linodeOptionsFilter = (linode: Linode) => { + return ( + !readOnlyLinodeIds.includes(linode.id) && + !assignedLinodes?.some((service) => service.id === linode.id) + ); + }; + + const nodebalancerOptionsFilter = (nodebalancer: NodeBalancer) => { + return ( + !readOnlyNodebalancerIds.includes(nodebalancer.id) && + !assignedNodeBalancers?.some( + (service) => service.id === nodebalancer.id + ) + ); + }; - const nodebalancerOptionsFilter = (nodebalancer: NodeBalancer) => { - return !readOnlyNodebalancerIds.includes(nodebalancer.id); - }; + return [linodeOptionsFilter, nodebalancerOptionsFilter]; + })(); const learnMoreLink = ( Learn more diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx index 67566f6b603..47573efb469 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx @@ -4,11 +4,12 @@ import { useQueryClient } from 'react-query'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; +import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { useDeleteFirewall, useMutateFirewall } from 'src/queries/firewalls'; -import { capitalize } from 'src/utilities/capitalize'; +import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { queryKey as nodebalancerQueryKey } from 'src/queries/nodebalancers'; +import { capitalize } from 'src/utilities/capitalize'; export type Mode = 'delete' | 'disable' | 'enable'; @@ -76,6 +77,7 @@ export const FirewallDialog = React.memo((props: Props) => { device.entity.id, 'firewalls', ]); + queryClient.invalidateQueries([firewallQueryKey]); }); } enqueueSnackbar(`Firewall ${label} successfully ${mode}d`, { diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx index 2f561a30628..10a294b5443 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.test.tsx @@ -97,7 +97,7 @@ describe('LinodeSelect', () => { await waitFor(() => { // The default no options message should be displayed when noOptionsMessage prop is not provided - expect(screen.getByText('No options')).toBeInTheDocument(); + expect(screen.getByText('No available Linodes')).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx index c9416fcf5d7..f2a3690ebe4 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx @@ -152,6 +152,7 @@ export const LinodeSelect = ( ChipProps={{ deleteIcon: }} PopperComponent={CustomPopper} clearOnBlur={false} + data-testid="add-linode-autocomplete" disableClearable={!clearable} disableCloseOnSelect={multiple} disablePortal={true} @@ -182,6 +183,6 @@ const getDefaultNoOptionsMessage = ( } else if (loading) { return 'Loading your Linodes...'; } else { - return 'No options'; + return 'No available Linodes'; } }; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx index 6d59d3a05b6..4ab0ea4a8ab 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx @@ -99,7 +99,9 @@ describe('NodeBalancerSelect', () => { await waitFor(() => { // The default no options message should be displayed when noOptionsMessage prop is not provided - expect(screen.getByText('No options')).toBeInTheDocument(); + expect( + screen.getByText('No available NodeBalancers') + ).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx index 0fc3edd6a6d..100c84b6cf2 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx @@ -150,6 +150,7 @@ export const NodeBalancerSelect = ( ChipProps={{ deleteIcon: }} PopperComponent={CustomPopper} clearOnBlur={false} + data-testid="add-nodebalancer-autocomplete" disableClearable={!clearable} disableCloseOnSelect={multiple} disablePortal={true} @@ -180,6 +181,6 @@ const getDefaultNoOptionsMessage = ( } else if (loading) { return 'Loading your NodeBalancers...'; } else { - return 'No options'; + return 'No available NodeBalancers'; } }; From 9fb22e66afb9569f72a4974b2f651a43963af727 Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Tue, 2 Jan 2024 08:21:46 -0500 Subject: [PATCH 39/45] refactor: [M3-7578] - Placeholder and EntityDetails v7 storybook migrations (#10019) * placeholder * entity details * add comment * Added changeset: PLaceholder and EntityDetails v7 storybook migrations * Fix typo in changeset; tiny bit of clean up in test --------- Co-authored-by: mjac0bs --- .../pr-10019-tech-stories-1703256618481.md | 5 ++ .../EntityDetail/.EntityDetail.stories.mdx | 48 --------------- .../EntityDetail/EntityDetail.stories.tsx | 53 ++++++++++++++++ .../EntityDetail/EntityDetail.test.tsx | 28 +++++++++ .../components/EntityDetail/EntityDetail.tsx | 17 +++--- .../src/components/EntityDetail/index.tsx | 1 - .../components/ErrorState/ErrorState.test.tsx | 2 +- .../Placeholder/.Placeholder.stories.mdx | 9 --- .../Placeholder/Placeholder.stories.tsx | 24 ++++++++ .../Placeholder/Placeholder.test.tsx | 61 +++++++++++++++++++ .../components/Placeholder/Placeholder.tsx | 42 ++++++++++++- .../features/Linodes/LinodeEntityDetail.tsx | 2 +- 12 files changed, 221 insertions(+), 71 deletions(-) create mode 100644 packages/manager/.changeset/pr-10019-tech-stories-1703256618481.md delete mode 100644 packages/manager/src/components/EntityDetail/.EntityDetail.stories.mdx create mode 100644 packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx create mode 100644 packages/manager/src/components/EntityDetail/EntityDetail.test.tsx delete mode 100644 packages/manager/src/components/EntityDetail/index.tsx delete mode 100644 packages/manager/src/components/Placeholder/.Placeholder.stories.mdx create mode 100644 packages/manager/src/components/Placeholder/Placeholder.stories.tsx create mode 100644 packages/manager/src/components/Placeholder/Placeholder.test.tsx diff --git a/packages/manager/.changeset/pr-10019-tech-stories-1703256618481.md b/packages/manager/.changeset/pr-10019-tech-stories-1703256618481.md new file mode 100644 index 00000000000..e4fedaaedaf --- /dev/null +++ b/packages/manager/.changeset/pr-10019-tech-stories-1703256618481.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Placeholder and EntityDetails v7 storybook migrations ([#10019](https://github.com/linode/manager/pull/10019)) diff --git a/packages/manager/src/components/EntityDetail/.EntityDetail.stories.mdx b/packages/manager/src/components/EntityDetail/.EntityDetail.stories.mdx deleted file mode 100644 index cf50046ba1c..00000000000 --- a/packages/manager/src/components/EntityDetail/.EntityDetail.stories.mdx +++ /dev/null @@ -1,48 +0,0 @@ -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; -import { Provider } from 'react-redux'; -import { kubernetesClusterFactory } from '/src/factories/kubernetesCluster'; -import { linodeConfigFactory } from 'src/factories/linodeConfigs'; -import { linodeBackupsFactory, linodeFactory } from 'src/factories/linodes'; -import { LinodeEntityDetail } from 'src/features/Linodes/LinodeEntityDetail'; -import KubeSummaryPanel from 'src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel'; - - - -# Entity Detail - - - -
-

Linode Details:

- null} - openDialog={() => null} - openPowerActionDialog={() => null} - backups={linodeBackupsFactory.build()} - linodeConfigs={linodeConfigFactory.buildList(2)} - /> -
-
-
- - - -

Kubernetes Details:

- null} - isClusterHighlyAvailable={false} - isKubeDashboardFeatureEnabled={false} - /> -
-
diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx new file mode 100644 index 00000000000..09ca52ed104 --- /dev/null +++ b/packages/manager/src/components/EntityDetail/EntityDetail.stories.tsx @@ -0,0 +1,53 @@ +import { action } from '@storybook/addon-actions'; +import * as React from 'react'; + +import { linodeFactory } from 'src/factories/linodes'; +import { LinodeEntityDetail } from 'src/features/Linodes/LinodeEntityDetail'; + +import { EntityDetail } from './EntityDetail'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +/** + * Barebone, no frills example + */ +export const Default: Story = { + args: { + body:
this is a body
, + footer:
this is a footer
, + header:
this is a header
, + }, + render: (args) => , +}; + +export const LinodeExample: Story = { + render: () => { + return ( +
+

Linode Details:

+ +
+ ); + }, +}; + +const meta: Meta = { + component: EntityDetail, + title: 'Features/Entity Detail', +}; + +export default meta; diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.test.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.test.tsx new file mode 100644 index 00000000000..c3023c96645 --- /dev/null +++ b/packages/manager/src/components/EntityDetail/EntityDetail.test.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EntityDetail } from './EntityDetail'; + +const props = { + footer:
footer
, + header:
header
, +}; +describe('Entity detail', () => { + it('renders an Entity Detail', () => { + const { getByText } = renderWithTheme( + body} /> + ); + + getByText('body'); + getByText('footer'); + getByText('header'); + }); + + it('does not render the body', () => { + const { getByText } = renderWithTheme(); + + getByText('footer'); + getByText('header'); + }); +}); diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.tsx index fcf4ae8ea3b..df4f9bc5a35 100644 --- a/packages/manager/src/components/EntityDetail/EntityDetail.tsx +++ b/packages/manager/src/components/EntityDetail/EntityDetail.tsx @@ -1,11 +1,3 @@ -/** - * EntityDetail provides a framework for the "Detail Summary" components found on: - * 1. Detail Pages - * 2. List Pages - * 3. Dashboard - * Provide a Header, Body, and Footer and this component provides the proper positioning for each. - */ - import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -18,6 +10,13 @@ export interface EntityDetailProps { header: JSX.Element; } +/** + * EntityDetail provides a framework for the "Detail Summary" components found on: + * 1. Detail Pages + * 2. List Pages + * 3. Dashboard + * Provide a Header, Body, and Footer and this component provides the proper positioning for each. + */ export const EntityDetail = (props: EntityDetailProps) => { const { body, footer, header } = props; @@ -54,5 +53,3 @@ const GridFooter = styled(Grid, { flexDirection: 'row', padding: `${theme.spacing(1)} ${theme.spacing(2)}`, })); - -export default EntityDetail; diff --git a/packages/manager/src/components/EntityDetail/index.tsx b/packages/manager/src/components/EntityDetail/index.tsx deleted file mode 100644 index bc46568814e..00000000000 --- a/packages/manager/src/components/EntityDetail/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from './EntityDetail'; diff --git a/packages/manager/src/components/ErrorState/ErrorState.test.tsx b/packages/manager/src/components/ErrorState/ErrorState.test.tsx index 08b555e6572..4163770435f 100644 --- a/packages/manager/src/components/ErrorState/ErrorState.test.tsx +++ b/packages/manager/src/components/ErrorState/ErrorState.test.tsx @@ -11,7 +11,7 @@ const props = { errorText, }; -describe('Removable Selections List', () => { +describe('Error State', () => { it('renders the ErrorState with specified text properly', () => { const screen = renderWithTheme(); expect(screen.getByText(errorText)).toBeVisible(); diff --git a/packages/manager/src/components/Placeholder/.Placeholder.stories.mdx b/packages/manager/src/components/Placeholder/.Placeholder.stories.mdx deleted file mode 100644 index a7a5edb4a62..00000000000 --- a/packages/manager/src/components/Placeholder/.Placeholder.stories.mdx +++ /dev/null @@ -1,9 +0,0 @@ -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; -import { Placeholder } from 'src/components/Placeholder/Placeholder'; - - - -# Placeholders diff --git a/packages/manager/src/components/Placeholder/Placeholder.stories.tsx b/packages/manager/src/components/Placeholder/Placeholder.stories.tsx new file mode 100644 index 00000000000..2c841495df8 --- /dev/null +++ b/packages/manager/src/components/Placeholder/Placeholder.stories.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { Placeholder } from './Placeholder'; + +import type { Meta, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +export const Default: Story = { + args: { + additionalCopy: 'This is some additional text', + showTransferDisplay: true, + subtitle: 'Placeholder subtitle', + title: 'Placeholder title', + }, + render: (args) => , +}; + +const meta: Meta = { + component: Placeholder, + title: 'Features/Entity Landing Page/Placeholders', +}; + +export default meta; diff --git a/packages/manager/src/components/Placeholder/Placeholder.test.tsx b/packages/manager/src/components/Placeholder/Placeholder.test.tsx new file mode 100644 index 00000000000..b46c7270c9e --- /dev/null +++ b/packages/manager/src/components/Placeholder/Placeholder.test.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; + +import StackScriptIcon from 'src/assets/icons/entityIcons/stackscript.svg'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { Placeholder } from './Placeholder'; + +describe('Placeholder', () => { + it('renders a placeholder with a title', () => { + const { container, getByTestId, getByText } = renderWithTheme( + + ); + + const title = getByText('This is a title'); + const icon = getByTestId('placeholder-icon'); + expect(title).toHaveClass('MuiTypography-h1'); + expect(container.querySelector('[height="50"]')).toEqual(icon); + }); + + it('displays the given icon and changes the heading style', () => { + const { container, getByTestId } = renderWithTheme( + + ); + + const icon = getByTestId('placeholder-icon'); + const iconQueriedDifferently = container.querySelector('[height="21"]'); + expect(icon).toEqual(iconQueriedDifferently); + }); + + it('renders a placeholder with additional props', () => { + const { getByText } = renderWithTheme( + + ); + + getByText('title'); + getByText('additional copy'); + getByText('subtitle'); + getByText('Loading transfer data...'); + }); + + it('displays children, links and buttons', () => { + const { getByTestId, getByText } = renderWithTheme( + Pretend this is a link} + title="title" + > + This is a child element + + ); + + getByText('Pretend this is a link'); + getByText('This is a child element'); + getByTestId('placeholder-button'); + }); +}); diff --git a/packages/manager/src/components/Placeholder/Placeholder.tsx b/packages/manager/src/components/Placeholder/Placeholder.tsx index 17d9636218f..003c2984029 100644 --- a/packages/manager/src/components/Placeholder/Placeholder.tsx +++ b/packages/manager/src/components/Placeholder/Placeholder.tsx @@ -14,18 +14,58 @@ export interface ExtendedButtonProps extends ButtonProps { } export interface PlaceholderProps { + /** + * Additional copy text to display + */ additionalCopy?: React.ReactNode | string; + /** + * Determines the buttons to display + */ buttonProps?: ExtendedButtonProps[]; + /** + * Additional children to pass in + */ children?: React.ReactNode | string; + /** + * Additional styles to pass to the root element + */ className?: string; + /** + * Used for testing + */ dataQAPlaceholder?: boolean | string; + /** + * If provided, determines the max width of any children or additional copy text + */ descriptionMaxWidth?: number; + /** + * Icon to display as placeholder + * @default LinodeIcon + */ icon?: React.ComponentType; + /** + * If true, applies additional styles to the icon container + */ isEntity?: boolean; + /** + * Links to display + */ linksSection?: JSX.Element; + /** + *If true, uses 'h2' as the root node of the title instead of 'h1' + */ renderAsSecondary?: boolean; + /** + * If true, displays transfer display + */ showTransferDisplay?: boolean; + /** + * Subtitle text to display + */ subtitle?: string; + /** + * Title text to display as placeholder + */ title: string; } @@ -78,7 +118,7 @@ export const Placeholder = (props: PlaceholderProps) => { data-qa-placeholder-container={dataQAPlaceholder || true} > - {Icon && } + {Icon && }
); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 7f9c0bbcb32..ea55bd18bb0 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -914,7 +914,7 @@ export const handlers = [ const page = Number(req.url.searchParams.get('page') || 1); const pageSize = Number(req.url.searchParams.get('page_size') || 25); - const buckets = objectStorageBucketFactory.buildList(0); + const buckets = objectStorageBucketFactory.buildList(1); return res( ctx.json({ @@ -1448,7 +1448,7 @@ export const handlers = [ longview_subscription: 'longview-100', managed: true, network_helper: true, - object_storage: 'disabled', + object_storage: 'active', }) ); }), diff --git a/packages/validation/src/buckets.schema.ts b/packages/validation/src/buckets.schema.ts index 97cd17611b7..90f248ceb4b 100644 --- a/packages/validation/src/buckets.schema.ts +++ b/packages/validation/src/buckets.schema.ts @@ -1,14 +1,24 @@ import { boolean, object, string } from 'yup'; -export const CreateBucketSchema = object({ - label: string() - .required('Label is required.') - .matches(/^\S*$/, 'Label must not contain spaces.') - .ensure() - .min(3, 'Label must be between 3 and 63 characters.') - .max(63, 'Label must be between 3 and 63 characters.'), - cluster: string().required('Cluster is required.'), -}); +export const CreateBucketSchema = object().shape( + { + label: string() + .required('Label is required.') + .matches(/^\S*$/, 'Label must not contain spaces.') + .ensure() + .min(3, 'Label must be between 3 and 63 characters.') + .max(63, 'Label must be between 3 and 63 characters.'), + cluster: string().when('region', { + is: (region: string) => !region || region.length === 0, + then: string().required('Cluster is required.'), + }), + region: string().when('cluster', { + is: (cluster: string) => !cluster || cluster.length === 0, + then: string().required('Region is required.'), + }), + }, + [['cluster', 'region']] +); export const UploadCertificateSchema = object({ certificate: string().required('Certificate is required.'), From c1ce3ec03bf43ff5d1dda4a24fd421624ffe420e Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 4 Jan 2024 11:02:38 -0500 Subject: [PATCH 44/45] Cloud version v1.109.0, API v4 version v0.107.0, and Validation version v0.37.0 --- .../pr-9973-tech-stories-1701959506143.md | 5 -- .../.changeset/pr-9987-added-1702566330400.md | 5 -- packages/api-v4/CHANGELOG.md | 11 ++++ packages/api-v4/package.json | 2 +- .../pr-10000-tests-1702591069388.md | 5 -- .../pr-10004-tests-1702676791962.md | 5 -- .../pr-10005-changed-1702679066439.md | 5 -- ...r-10005-upcoming-features-1703273545929.md | 5 -- .../pr-10007-tech-stories-1702922152291.md | 5 -- .../pr-10009-fixed-1703009466518.md | 5 -- .../pr-10009-tests-1703009412715.md | 5 -- .../pr-10011-changed-1703028750129.md | 5 -- ...r-10014-upcoming-features-1703710696662.md | 5 -- .../pr-10015-tech-stories-1703101491856.md | 5 -- .../pr-10017-tech-stories-1703176302247.md | 5 -- .../pr-10019-tech-stories-1703256618481.md | 5 -- .../pr-10023-tests-1704213295558.md | 5 -- .../.changeset/pr-9955-tests-1701693591911.md | 5 -- .../pr-9959-tech-stories-1701811015350.md | 5 -- .../pr-9963-tech-stories-1701881397641.md | 5 -- ...pr-9965-upcoming-features-1701894892064.md | 5 -- ...pr-9969-upcoming-features-1701885967965.md | 5 -- .../pr-9973-tech-stories-1701959520658.md | 5 -- ...pr-9975-upcoming-features-1702056998142.md | 5 -- ...pr-9976-upcoming-features-1702579922373.md | 5 -- ...pr-9977-upcoming-features-1701987666651.md | 5 -- .../pr-9978-tech-stories-1702564889256.md | 5 -- .../.changeset/pr-9980-added-1701986820847.md | 5 -- .../pr-9981-tech-stories-1702058586434.md | 5 -- ...pr-9983-upcoming-features-1702070124570.md | 5 -- ...pr-9987-upcoming-features-1702333742297.md | 5 -- .../pr-9988-tech-stories-1702336644030.md | 5 -- .../pr-9989-changed-1702494798508.md | 5 -- ...pr-9990-upcoming-features-1702400746535.md | 5 -- .../pr-9992-changed-1703028128822.md | 5 -- ...pr-9992-upcoming-features-1702939785550.md | 5 -- .../pr-9993-changed-1702418666735.md | 5 -- .../pr-9994-tech-stories-1702486520396.md | 5 -- .../.changeset/pr-9995-tests-1702420300908.md | 5 -- ...pr-9997-upcoming-features-1702492550625.md | 5 -- .../pr-9999-tech-stories-1702588492903.md | 5 -- packages/manager/CHANGELOG.md | 56 +++++++++++++++++++ packages/manager/package.json | 2 +- .../pr-9973-tech-stories-1701959541076.md | 5 -- packages/validation/CHANGELOG.md | 7 +++ packages/validation/package.json | 2 +- 46 files changed, 77 insertions(+), 203 deletions(-) delete mode 100644 packages/api-v4/.changeset/pr-9973-tech-stories-1701959506143.md delete mode 100644 packages/api-v4/.changeset/pr-9987-added-1702566330400.md delete mode 100644 packages/manager/.changeset/pr-10000-tests-1702591069388.md delete mode 100644 packages/manager/.changeset/pr-10004-tests-1702676791962.md delete mode 100644 packages/manager/.changeset/pr-10005-changed-1702679066439.md delete mode 100644 packages/manager/.changeset/pr-10005-upcoming-features-1703273545929.md delete mode 100644 packages/manager/.changeset/pr-10007-tech-stories-1702922152291.md delete mode 100644 packages/manager/.changeset/pr-10009-fixed-1703009466518.md delete mode 100644 packages/manager/.changeset/pr-10009-tests-1703009412715.md delete mode 100644 packages/manager/.changeset/pr-10011-changed-1703028750129.md delete mode 100644 packages/manager/.changeset/pr-10014-upcoming-features-1703710696662.md delete mode 100644 packages/manager/.changeset/pr-10015-tech-stories-1703101491856.md delete mode 100644 packages/manager/.changeset/pr-10017-tech-stories-1703176302247.md delete mode 100644 packages/manager/.changeset/pr-10019-tech-stories-1703256618481.md delete mode 100644 packages/manager/.changeset/pr-10023-tests-1704213295558.md delete mode 100644 packages/manager/.changeset/pr-9955-tests-1701693591911.md delete mode 100644 packages/manager/.changeset/pr-9959-tech-stories-1701811015350.md delete mode 100644 packages/manager/.changeset/pr-9963-tech-stories-1701881397641.md delete mode 100644 packages/manager/.changeset/pr-9965-upcoming-features-1701894892064.md delete mode 100644 packages/manager/.changeset/pr-9969-upcoming-features-1701885967965.md delete mode 100644 packages/manager/.changeset/pr-9973-tech-stories-1701959520658.md delete mode 100644 packages/manager/.changeset/pr-9975-upcoming-features-1702056998142.md delete mode 100644 packages/manager/.changeset/pr-9976-upcoming-features-1702579922373.md delete mode 100644 packages/manager/.changeset/pr-9977-upcoming-features-1701987666651.md delete mode 100644 packages/manager/.changeset/pr-9978-tech-stories-1702564889256.md delete mode 100644 packages/manager/.changeset/pr-9980-added-1701986820847.md delete mode 100644 packages/manager/.changeset/pr-9981-tech-stories-1702058586434.md delete mode 100644 packages/manager/.changeset/pr-9983-upcoming-features-1702070124570.md delete mode 100644 packages/manager/.changeset/pr-9987-upcoming-features-1702333742297.md delete mode 100644 packages/manager/.changeset/pr-9988-tech-stories-1702336644030.md delete mode 100644 packages/manager/.changeset/pr-9989-changed-1702494798508.md delete mode 100644 packages/manager/.changeset/pr-9990-upcoming-features-1702400746535.md delete mode 100644 packages/manager/.changeset/pr-9992-changed-1703028128822.md delete mode 100644 packages/manager/.changeset/pr-9992-upcoming-features-1702939785550.md delete mode 100644 packages/manager/.changeset/pr-9993-changed-1702418666735.md delete mode 100644 packages/manager/.changeset/pr-9994-tech-stories-1702486520396.md delete mode 100644 packages/manager/.changeset/pr-9995-tests-1702420300908.md delete mode 100644 packages/manager/.changeset/pr-9997-upcoming-features-1702492550625.md delete mode 100644 packages/manager/.changeset/pr-9999-tech-stories-1702588492903.md delete mode 100644 packages/validation/.changeset/pr-9973-tech-stories-1701959541076.md diff --git a/packages/api-v4/.changeset/pr-9973-tech-stories-1701959506143.md b/packages/api-v4/.changeset/pr-9973-tech-stories-1701959506143.md deleted file mode 100644 index 50d57b80fd2..00000000000 --- a/packages/api-v4/.changeset/pr-9973-tech-stories-1701959506143.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Tech Stories ---- - -Add Lint Github Action ([#9973](https://github.com/linode/manager/pull/9973)) diff --git a/packages/api-v4/.changeset/pr-9987-added-1702566330400.md b/packages/api-v4/.changeset/pr-9987-added-1702566330400.md deleted file mode 100644 index 10a77e15e16..00000000000 --- a/packages/api-v4/.changeset/pr-9987-added-1702566330400.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Added ---- - -Add setHeaders to GET profile/ endpoint ([#9987](https://github.com/linode/manager/pull/9987)) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 89cf1b68b8c..65c16932d3b 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,14 @@ +## [2024-01-08] - v0.107.0 + + +### Added: + +- Optional `headers` to `getProfile` function ([#9987](https://github.com/linode/manager/pull/9987)) + +### Tech Stories: + +- Add Lint GitHub Action ([#9973](https://github.com/linode/manager/pull/9973)) + ## [2023-12-11] - v0.106.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 73cecdf9327..b47b260030f 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.106.0", + "version": "0.107.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/manager/.changeset/pr-10000-tests-1702591069388.md b/packages/manager/.changeset/pr-10000-tests-1702591069388.md deleted file mode 100644 index 7721221aff4..00000000000 --- a/packages/manager/.changeset/pr-10000-tests-1702591069388.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Introduce Cypress test for the Firewalls landing page empty state ([#10000](https://github.com/linode/manager/pull/10000)) diff --git a/packages/manager/.changeset/pr-10004-tests-1702676791962.md b/packages/manager/.changeset/pr-10004-tests-1702676791962.md deleted file mode 100644 index 1ae4b90a545..00000000000 --- a/packages/manager/.changeset/pr-10004-tests-1702676791962.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add integration test for Domains empty landing page ([#10004](https://github.com/linode/manager/pull/10004)) diff --git a/packages/manager/.changeset/pr-10005-changed-1702679066439.md b/packages/manager/.changeset/pr-10005-changed-1702679066439.md deleted file mode 100644 index 3e8af847c90..00000000000 --- a/packages/manager/.changeset/pr-10005-changed-1702679066439.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Improve layout of User Permissions page ([#10005](https://github.com/linode/manager/pull/10005)) diff --git a/packages/manager/.changeset/pr-10005-upcoming-features-1703273545929.md b/packages/manager/.changeset/pr-10005-upcoming-features-1703273545929.md deleted file mode 100644 index 6f553b95749..00000000000 --- a/packages/manager/.changeset/pr-10005-upcoming-features-1703273545929.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add child access user permissions for parent accounts ([#10005](https://github.com/linode/manager/pull/10005)) diff --git a/packages/manager/.changeset/pr-10007-tech-stories-1702922152291.md b/packages/manager/.changeset/pr-10007-tech-stories-1702922152291.md deleted file mode 100644 index 9500a2686f4..00000000000 --- a/packages/manager/.changeset/pr-10007-tech-stories-1702922152291.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Currency and DateTimeDisplay v7 storybook migrations ([#10007](https://github.com/linode/manager/pull/10007)) diff --git a/packages/manager/.changeset/pr-10009-fixed-1703009466518.md b/packages/manager/.changeset/pr-10009-fixed-1703009466518.md deleted file mode 100644 index d005618332a..00000000000 --- a/packages/manager/.changeset/pr-10009-fixed-1703009466518.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Improve accessibility of User Permissions account access toggle and entity-specific permission radio buttons ([#10009](https://github.com/linode/manager/pull/10009)) diff --git a/packages/manager/.changeset/pr-10009-tests-1703009412715.md b/packages/manager/.changeset/pr-10009-tests-1703009412715.md deleted file mode 100644 index 6407d0e6f44..00000000000 --- a/packages/manager/.changeset/pr-10009-tests-1703009412715.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Cypress integration tests for User Permissions page ([#10009](https://github.com/linode/manager/pull/10009)) diff --git a/packages/manager/.changeset/pr-10011-changed-1703028750129.md b/packages/manager/.changeset/pr-10011-changed-1703028750129.md deleted file mode 100644 index 6aa3f4f70a7..00000000000 --- a/packages/manager/.changeset/pr-10011-changed-1703028750129.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Update toast notifications for UserPermissions ([#10011](https://github.com/linode/manager/pull/10011)) diff --git a/packages/manager/.changeset/pr-10014-upcoming-features-1703710696662.md b/packages/manager/.changeset/pr-10014-upcoming-features-1703710696662.md deleted file mode 100644 index 74c1951baf0..00000000000 --- a/packages/manager/.changeset/pr-10014-upcoming-features-1703710696662.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Update top menu for parent, child, and proxy accounts ([#10014](https://github.com/linode/manager/pull/10014)) diff --git a/packages/manager/.changeset/pr-10015-tech-stories-1703101491856.md b/packages/manager/.changeset/pr-10015-tech-stories-1703101491856.md deleted file mode 100644 index 9c8fc08bc74..00000000000 --- a/packages/manager/.changeset/pr-10015-tech-stories-1703101491856.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -ColorPalette and CircleProgress v7 storybook migration ([#10015](https://github.com/linode/manager/pull/10015)) diff --git a/packages/manager/.changeset/pr-10017-tech-stories-1703176302247.md b/packages/manager/.changeset/pr-10017-tech-stories-1703176302247.md deleted file mode 100644 index 67822032053..00000000000 --- a/packages/manager/.changeset/pr-10017-tech-stories-1703176302247.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -DebouncedSearchTextfield and EditableText v7 storybook migrations ([#10017](https://github.com/linode/manager/pull/10017)) diff --git a/packages/manager/.changeset/pr-10019-tech-stories-1703256618481.md b/packages/manager/.changeset/pr-10019-tech-stories-1703256618481.md deleted file mode 100644 index e4fedaaedaf..00000000000 --- a/packages/manager/.changeset/pr-10019-tech-stories-1703256618481.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Placeholder and EntityDetails v7 storybook migrations ([#10019](https://github.com/linode/manager/pull/10019)) diff --git a/packages/manager/.changeset/pr-10023-tests-1704213295558.md b/packages/manager/.changeset/pr-10023-tests-1704213295558.md deleted file mode 100644 index 0aad444224f..00000000000 --- a/packages/manager/.changeset/pr-10023-tests-1704213295558.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fix `CreditCard.test.tsx` failing unit test ([#10023](https://github.com/linode/manager/pull/10023)) diff --git a/packages/manager/.changeset/pr-9955-tests-1701693591911.md b/packages/manager/.changeset/pr-9955-tests-1701693591911.md deleted file mode 100644 index 9dc23a01573..00000000000 --- a/packages/manager/.changeset/pr-9955-tests-1701693591911.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add integration test for AGLB Load Balancer delete flows. ([#9955](https://github.com/linode/manager/pull/9955)) diff --git a/packages/manager/.changeset/pr-9959-tech-stories-1701811015350.md b/packages/manager/.changeset/pr-9959-tech-stories-1701811015350.md deleted file mode 100644 index c94eeea94e5..00000000000 --- a/packages/manager/.changeset/pr-9959-tech-stories-1701811015350.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -PaginationControls V7 story migration ([#9959](https://github.com/linode/manager/pull/9959)) diff --git a/packages/manager/.changeset/pr-9963-tech-stories-1701881397641.md b/packages/manager/.changeset/pr-9963-tech-stories-1701881397641.md deleted file mode 100644 index 0001d750737..00000000000 --- a/packages/manager/.changeset/pr-9963-tech-stories-1701881397641.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -TagsInput & TagsPanel Storybook v7 Stories ([#9963](https://github.com/linode/manager/pull/9963)) diff --git a/packages/manager/.changeset/pr-9965-upcoming-features-1701894892064.md b/packages/manager/.changeset/pr-9965-upcoming-features-1701894892064.md deleted file mode 100644 index 85d67567997..00000000000 --- a/packages/manager/.changeset/pr-9965-upcoming-features-1701894892064.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add AGLB Service Target Section to Full Create Flow ([#9965](https://github.com/linode/manager/pull/9965)) diff --git a/packages/manager/.changeset/pr-9969-upcoming-features-1701885967965.md b/packages/manager/.changeset/pr-9969-upcoming-features-1701885967965.md deleted file mode 100644 index d9e83d083e2..00000000000 --- a/packages/manager/.changeset/pr-9969-upcoming-features-1701885967965.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Change AGLB Rule Session Stickiness unit from milliseconds to seconds ([#9969](https://github.com/linode/manager/pull/9969)) diff --git a/packages/manager/.changeset/pr-9973-tech-stories-1701959520658.md b/packages/manager/.changeset/pr-9973-tech-stories-1701959520658.md deleted file mode 100644 index 84c7d915162..00000000000 --- a/packages/manager/.changeset/pr-9973-tech-stories-1701959520658.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Add Lint Github Action ([#9973](https://github.com/linode/manager/pull/9973)) diff --git a/packages/manager/.changeset/pr-9975-upcoming-features-1702056998142.md b/packages/manager/.changeset/pr-9975-upcoming-features-1702056998142.md deleted file mode 100644 index e5b7e90f014..00000000000 --- a/packages/manager/.changeset/pr-9975-upcoming-features-1702056998142.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Improve AGLB selects and other UX ([#9975](https://github.com/linode/manager/pull/9975)) diff --git a/packages/manager/.changeset/pr-9976-upcoming-features-1702579922373.md b/packages/manager/.changeset/pr-9976-upcoming-features-1702579922373.md deleted file mode 100644 index 532d45d6ed7..00000000000 --- a/packages/manager/.changeset/pr-9976-upcoming-features-1702579922373.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Ability to choose a single Compute Region ID (e.g., us-east) in Create Object Storage Bucket drawer ([#9976](https://github.com/linode/manager/pull/9976)) diff --git a/packages/manager/.changeset/pr-9977-upcoming-features-1701987666651.md b/packages/manager/.changeset/pr-9977-upcoming-features-1701987666651.md deleted file mode 100644 index 112e0e9d8a6..00000000000 --- a/packages/manager/.changeset/pr-9977-upcoming-features-1701987666651.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add mocks and update queries for new Parent/Child endpoints ([#9977](https://github.com/linode/manager/pull/9977)) diff --git a/packages/manager/.changeset/pr-9978-tech-stories-1702564889256.md b/packages/manager/.changeset/pr-9978-tech-stories-1702564889256.md deleted file mode 100644 index 819deb12aa2..00000000000 --- a/packages/manager/.changeset/pr-9978-tech-stories-1702564889256.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Complete @mui/styles to tss-react migration and remove @mui/styles ([#9978](https://github.com/linode/manager/pull/9978)) diff --git a/packages/manager/.changeset/pr-9980-added-1701986820847.md b/packages/manager/.changeset/pr-9980-added-1701986820847.md deleted file mode 100644 index 44dd2bd44d1..00000000000 --- a/packages/manager/.changeset/pr-9980-added-1701986820847.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Tests to power on/off and reboot Linodes ([#9980](https://github.com/linode/manager/pull/9980)) diff --git a/packages/manager/.changeset/pr-9981-tech-stories-1702058586434.md b/packages/manager/.changeset/pr-9981-tech-stories-1702058586434.md deleted file mode 100644 index 4e87351c838..00000000000 --- a/packages/manager/.changeset/pr-9981-tech-stories-1702058586434.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -ErrorState and FileUploader v7 storybook migrations ([#9981](https://github.com/linode/manager/pull/9981)) diff --git a/packages/manager/.changeset/pr-9983-upcoming-features-1702070124570.md b/packages/manager/.changeset/pr-9983-upcoming-features-1702070124570.md deleted file mode 100644 index 6d8acb79720..00000000000 --- a/packages/manager/.changeset/pr-9983-upcoming-features-1702070124570.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Replace NodeBalancer detail charts with Recharts ([#9983](https://github.com/linode/manager/pull/9983)) diff --git a/packages/manager/.changeset/pr-9987-upcoming-features-1702333742297.md b/packages/manager/.changeset/pr-9987-upcoming-features-1702333742297.md deleted file mode 100644 index 027b2044b47..00000000000 --- a/packages/manager/.changeset/pr-9987-upcoming-features-1702333742297.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Add setHeaders to GET profile/ endpoint ([#9987](https://github.com/linode/manager/pull/9987)) diff --git a/packages/manager/.changeset/pr-9988-tech-stories-1702336644030.md b/packages/manager/.changeset/pr-9988-tech-stories-1702336644030.md deleted file mode 100644 index 6aa010c3484..00000000000 --- a/packages/manager/.changeset/pr-9988-tech-stories-1702336644030.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Speed up code coverage Github Actions jobs by skipping Cloud Manager build ([#9988](https://github.com/linode/manager/pull/9988)) diff --git a/packages/manager/.changeset/pr-9989-changed-1702494798508.md b/packages/manager/.changeset/pr-9989-changed-1702494798508.md deleted file mode 100644 index fce9fa99624..00000000000 --- a/packages/manager/.changeset/pr-9989-changed-1702494798508.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -VLAN availability text on Linode Create ([#9989](https://github.com/linode/manager/pull/9989)) diff --git a/packages/manager/.changeset/pr-9990-upcoming-features-1702400746535.md b/packages/manager/.changeset/pr-9990-upcoming-features-1702400746535.md deleted file mode 100644 index 827fe4f351c..00000000000 --- a/packages/manager/.changeset/pr-9990-upcoming-features-1702400746535.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Revised copy for Private IP add-on in Linode Create flow ([#9990](https://github.com/linode/manager/pull/9990)) diff --git a/packages/manager/.changeset/pr-9992-changed-1703028128822.md b/packages/manager/.changeset/pr-9992-changed-1703028128822.md deleted file mode 100644 index bc20dc6ade7..00000000000 --- a/packages/manager/.changeset/pr-9992-changed-1703028128822.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Default access to `None` for all scopes when creating Personal Access Tokens ([#9992](https://github.com/linode/manager/pull/9992)) diff --git a/packages/manager/.changeset/pr-9992-upcoming-features-1702939785550.md b/packages/manager/.changeset/pr-9992-upcoming-features-1702939785550.md deleted file mode 100644 index 711c7142e3e..00000000000 --- a/packages/manager/.changeset/pr-9992-upcoming-features-1702939785550.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add `child_account` oauth scope to Personal Access Token drawers ([#9992](https://github.com/linode/manager/pull/9992)) diff --git a/packages/manager/.changeset/pr-9993-changed-1702418666735.md b/packages/manager/.changeset/pr-9993-changed-1702418666735.md deleted file mode 100644 index 9aea26ea95f..00000000000 --- a/packages/manager/.changeset/pr-9993-changed-1702418666735.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Filter already assigned services from firewall dropdowns ([#9993](https://github.com/linode/manager/pull/9993)) diff --git a/packages/manager/.changeset/pr-9994-tech-stories-1702486520396.md b/packages/manager/.changeset/pr-9994-tech-stories-1702486520396.md deleted file mode 100644 index c6567c790a0..00000000000 --- a/packages/manager/.changeset/pr-9994-tech-stories-1702486520396.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Radio and TextField v7 storybook migrations ([#9994](https://github.com/linode/manager/pull/9994)) diff --git a/packages/manager/.changeset/pr-9995-tests-1702420300908.md b/packages/manager/.changeset/pr-9995-tests-1702420300908.md deleted file mode 100644 index c7acfbdf028..00000000000 --- a/packages/manager/.changeset/pr-9995-tests-1702420300908.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Introduce Cypress test for the Volumes landing page empty state ([#9995](https://github.com/linode/manager/pull/9995)) diff --git a/packages/manager/.changeset/pr-9997-upcoming-features-1702492550625.md b/packages/manager/.changeset/pr-9997-upcoming-features-1702492550625.md deleted file mode 100644 index e952e58ab0e..00000000000 --- a/packages/manager/.changeset/pr-9997-upcoming-features-1702492550625.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add AGLB Routes section of full create page ([#9997](https://github.com/linode/manager/pull/9997)) diff --git a/packages/manager/.changeset/pr-9999-tech-stories-1702588492903.md b/packages/manager/.changeset/pr-9999-tech-stories-1702588492903.md deleted file mode 100644 index aa4e9696096..00000000000 --- a/packages/manager/.changeset/pr-9999-tech-stories-1702588492903.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Graphs stories v7 migration ([#9999](https://github.com/linode/manager/pull/9999)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index d207b1bf4ab..17dd41dba32 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,62 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-01-08] - v1.109.0 + + +### Changed: + +- Improve layout of User Permissions page ([#10005](https://github.com/linode/manager/pull/10005)) +- Update toast notifications for UserPermissions ([#10011](https://github.com/linode/manager/pull/10011)) +- VLAN availability text on Linode Create ([#9989](https://github.com/linode/manager/pull/9989)) +- Default access to `None` for all scopes when creating Personal Access Tokens ([#9992](https://github.com/linode/manager/pull/9992)) +- Filter already assigned services from firewall dropdowns ([#9993](https://github.com/linode/manager/pull/9993)) + +### Fixed: + +- User Permissions toggle and radio button accessibility ([#10009](https://github.com/linode/manager/pull/10009)) + +### Tech Stories: + +- Currency and DateTimeDisplay v7 storybook migrations ([#10007](https://github.com/linode/manager/pull/10007)) +- ColorPalette and CircleProgress v7 storybook migration ([#10015](https://github.com/linode/manager/pull/10015)) +- DebouncedSearchTextfield and EditableText v7 storybook migrations ([#10017](https://github.com/linode/manager/pull/10017)) +- Placeholder and EntityDetails v7 storybook migrations ([#10019](https://github.com/linode/manager/pull/10019)) +- PaginationControls V7 story migration ([#9959](https://github.com/linode/manager/pull/9959)) +- TagsInput & TagsPanel Storybook v7 Stories ([#9963](https://github.com/linode/manager/pull/9963)) +- Add Lint Github Action ([#9973](https://github.com/linode/manager/pull/9973)) +- Complete @mui/styles to tss-react migration and remove @mui/styles ([#9978](https://github.com/linode/manager/pull/9978)) +- ErrorState and FileUploader v7 storybook migrations ([#9981](https://github.com/linode/manager/pull/9981)) +- Speed up code coverage Github Actions jobs by skipping Cloud Manager build ([#9988](https://github.com/linode/manager/pull/9988)) +- Radio and TextField v7 storybook migrations ([#9994](https://github.com/linode/manager/pull/9994)) +- Graphs stories v7 migration ([#9999](https://github.com/linode/manager/pull/9999)) +- Add ability to pass headers to useProfile query ([#9987](https://github.com/linode/manager/pull/9987)) + +### Tests: + +- Add Cypress test for Firewalls empty state landing page ([#10000](https://github.com/linode/manager/pull/10000)) +- Add integration test for Domains empty landing page ([#10004](https://github.com/linode/manager/pull/10004)) +- Add Cypress integration tests for User Permissions page ([#10009](https://github.com/linode/manager/pull/10009)) +- Fix `CreditCard.test.tsx` failing unit test triggered by new year ([#10023](https://github.com/linode/manager/pull/10023)) +- Add integration test for AGLB Load Balancer delete flows. ([#9955](https://github.com/linode/manager/pull/9955)) +- Add Cypress test for Volumes empty state landing page ([#9995](https://github.com/linode/manager/pull/9995)) +- Tests to power on/off and reboot Linodes ([#9980](https://github.com/linode/manager/pull/9980)) + +### Upcoming Features: + +- Add child access user permissions for parent accounts ([#10005](https://github.com/linode/manager/pull/10005)) +- Update top menu for parent, child, and proxy accounts ([#10014](https://github.com/linode/manager/pull/10014)) +- Add AGLB Service Target Section to Full Create Flow ([#9965](https://github.com/linode/manager/pull/9965)) +- Change AGLB Rule Session Stickiness unit from milliseconds to seconds ([#9969](https://github.com/linode/manager/pull/9969)) +- Improve AGLB selects and other UX ([#9975](https://github.com/linode/manager/pull/9975)) +- Ability to choose a single Compute Region ID (e.g., us-east) in Create Object Storage Bucket drawer ([#9976](https://github.com/linode/manager/pull/9976)) +- Add mocks and update queries for new Parent/Child endpoints ([#9977](https://github.com/linode/manager/pull/9977)) +- Replace NodeBalancer detail charts with Recharts ([#9983](https://github.com/linode/manager/pull/9983)) +- Revised copy for Private IP add-on in Linode Create flow ([#9990](https://github.com/linode/manager/pull/9990)) +- Add `child_account` oauth scope to Personal Access Token drawers ([#9992](https://github.com/linode/manager/pull/9992)) +- Add AGLB Routes section of full create page ([#9997](https://github.com/linode/manager/pull/9997)) + + ## [2023-12-11] - v1.108.0 ### Added: diff --git a/packages/manager/package.json b/packages/manager/package.json index 04b9588406b..92c0eb300db 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.108.0", + "version": "1.109.0", "private": true, "type": "module", "bugs": { diff --git a/packages/validation/.changeset/pr-9973-tech-stories-1701959541076.md b/packages/validation/.changeset/pr-9973-tech-stories-1701959541076.md deleted file mode 100644 index 55b8344c6cf..00000000000 --- a/packages/validation/.changeset/pr-9973-tech-stories-1701959541076.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Tech Stories ---- - -Add Lint Github Action ([#9973](https://github.com/linode/manager/pull/9973)) diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index dc01893dc56..60b0c948499 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,10 @@ +## [2024-01-08] - v0.37.0 + + +### Tech Stories: + +- Add Lint Github Action ([#9973](https://github.com/linode/manager/pull/9973)) + ## [2023-12-11] - v0.36.0 diff --git a/packages/validation/package.json b/packages/validation/package.json index bd8e0791da9..99fb18d1fff 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.36.0", + "version": "0.37.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", From e66fcf697291ffb4f1537cf8ba6164d617f4e1de Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 8 Jan 2024 14:42:11 -0500 Subject: [PATCH 45/45] fix: Profile Query Keys, User Permissions loading state, and read_only PAT creation (#10040) * fix profile query keys * add extra bug fix * Fix loading state for User Permissions page * fix two factor invalidation * use `null` insted of empty object * Fix error for RO PAT creation --------- Co-authored-by: Banks Nussman Co-authored-by: mjac0bs --- .../APITokens/CreateAPITokenDrawer.tsx | 12 ++++ .../APITokens/ViewAPITokenDrawer.test.tsx | 14 +++-- .../Profile/APITokens/ViewAPITokenDrawer.tsx | 12 ++++ .../features/Profile/APITokens/utils.test.ts | 36 ++++++++---- .../src/features/Profile/APITokens/utils.ts | 6 +- .../src/features/Users/UserPermissions.tsx | 4 +- packages/manager/src/queries/profile.ts | 55 +++++++++++-------- 7 files changed, 94 insertions(+), 45 deletions(-) diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index f4f6d0781c3..5ff85319184 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -168,6 +168,18 @@ export const CreateAPITokenDrawer = (props: Props) => { const filteredPermissions = allPermissions.filter( (scopeTup) => basePermNameMap[scopeTup[0]] !== 'Child Account Access' ); + // TODO: Parent/Child - remove this conditional once code is in prod. + // Note: We couldn't include 'child_account' in our list of permissions in utils + // because it needs to be feature-flagged. Therefore, we're manually adding it here. + if (flags.parentChildAccountAccess && user?.user_type !== null) { + const childAccountIndex = allPermissions.findIndex( + ([scope]) => scope === 'child_account' + ); + if (childAccountIndex === -1) { + allPermissions.push(['child_account', 0]); + } + basePermNameMap.child_account = 'Child Account Access'; + } return ( diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx index 577d7d717ee..547d97aa214 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx @@ -20,7 +20,8 @@ vi.mock('src/queries/accountUsers', async () => { }; }); -const nonParentPerms = basePerms.filter((value) => value !== 'child_account'); +// TODO: Parent/Child - add back after API code is in prod. Replace basePerms with nonParentPerms. +// const nonParentPerms = basePerms.filter((value) => value !== 'child_account'); const token = appTokenFactory.build({ label: 'my-token', scopes: '*' }); const limitedToken = appTokenFactory.build({ @@ -43,7 +44,7 @@ describe('View API Token Drawer', () => { it('should show all permissions as read/write with wildcard scopes', () => { const { getByTestId } = renderWithTheme(); - for (const permissionName of nonParentPerms) { + for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( 'aria-label', `This token has 2 access for ${permissionName}` @@ -56,7 +57,7 @@ describe('View API Token Drawer', () => { , { flags: { parentChildAccountAccess: false } } ); - for (const permissionName of nonParentPerms) { + for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( 'aria-label', `This token has 0 access for ${permissionName}` @@ -71,7 +72,7 @@ describe('View API Token Drawer', () => { token={appTokenFactory.build({ scopes: 'account:read_write' })} /> ); - for (const permissionName of nonParentPerms) { + for (const permissionName of basePerms) { // We only expect account to have read/write for this test const expectedScopeLevel = permissionName === 'account' ? 2 : 0; expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( @@ -109,7 +110,7 @@ describe('View API Token Drawer', () => { volumes: 1, } as const; - for (const permissionName of nonParentPerms) { + for (const permissionName of basePerms) { const expectedScopeLevel = expectedScopeLevels[permissionName]; expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( 'aria-label', @@ -136,8 +137,9 @@ describe('View API Token Drawer', () => { ); const childScope = getByText('Child Account Access'); + // TODO: Parent/Child - confirm that this scope level shouldn't be 2 const expectedScopeLevels = { - child_account: 2, + child_account: 0, } as const; const childPermissionName = 'child_account'; diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx index e62b455b24d..e85f302f54f 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx @@ -41,6 +41,18 @@ export const ViewAPITokenDrawer = (props: Props) => { const filteredPermissions = allPermissions.filter( (scopeTup) => basePermNameMap[scopeTup[0]] !== 'Child Account Access' ); + // TODO: Parent/Child - remove this conditional once code is in prod. + // Note: We couldn't include 'child_account' in our list of permissions in utils + // because it needs to be feature-flagged. Therefore, we're manually adding it here. + if (flags.parentChildAccountAccess && user?.user_type !== null) { + const childAccountIndex = allPermissions.findIndex( + ([scope]) => scope === 'child_account' + ); + if (childAccountIndex === -1) { + allPermissions.push(['child_account', 0]); + } + basePermNameMap.child_account = 'Child Account Access'; + } return ( diff --git a/packages/manager/src/features/Profile/APITokens/utils.test.ts b/packages/manager/src/features/Profile/APITokens/utils.test.ts index fdf56838f95..9fe08a35819 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.test.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.test.ts @@ -26,7 +26,8 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('*'); const expected = [ ['account', 2], - ['child_account', 2], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 2], ['databases', 2], ['domains', 2], ['events', 2], @@ -50,7 +51,8 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples(''); const expected = [ ['account', 0], - ['child_account', 0], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -75,7 +77,8 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:none'); const expected = [ ['account', 0], - ['child_account', 0], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -100,7 +103,8 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:read_only'); const expected = [ ['account', 1], - ['child_account', 0], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -125,7 +129,8 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:read_write'); const expected = [ ['account', 2], - ['child_account', 0], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -152,7 +157,8 @@ describe('APIToken utils', () => { ); const expected = [ ['account', 0], - ['child_account', 0], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 0], ['databases', 0], ['domains', 1], ['events', 0], @@ -181,7 +187,8 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:none,tokens:read_write'); const expected = [ ['account', 2], - ['child_account', 0], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -210,7 +217,8 @@ describe('APIToken utils', () => { const result = scopeStringToPermTuples('account:read_only,tokens:none'); const expected = [ ['account', 1], - ['child_account', 0], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -235,7 +243,8 @@ describe('APIToken utils', () => { it('should return 0 if all scopes are 0', () => { const scopes: Permission[] = [ ['account', 0], - ['child_account', 0], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 0], ['databases', 0], ['domains', 0], ['events', 0], @@ -255,7 +264,8 @@ describe('APIToken utils', () => { it('should return 1 if all scopes are 1', () => { const scopes: Permission[] = [ ['account', 1], - ['child_account', 1], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 1], ['databases', 1], ['domains', 1], ['events', 1], @@ -275,7 +285,8 @@ describe('APIToken utils', () => { it('should return 2 if all scopes are 2', () => { const scopes: Permission[] = [ ['account', 2], - ['child_account', 2], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 2], ['databases', 2], ['domains', 2], ['events', 2], @@ -295,7 +306,8 @@ describe('APIToken utils', () => { it('should return null if all scopes are different', () => { const scopes: Permission[] = [ ['account', 1], - ['child_account', 0], + // TODO: Parent/Child - add this scope once code is in prod. + // ['child_account', 0], ['databases', 0], ['domains', 2], ['events', 0], diff --git a/packages/manager/src/features/Profile/APITokens/utils.ts b/packages/manager/src/features/Profile/APITokens/utils.ts index 27ad619c364..2d78d8699b4 100644 --- a/packages/manager/src/features/Profile/APITokens/utils.ts +++ b/packages/manager/src/features/Profile/APITokens/utils.ts @@ -6,7 +6,8 @@ export type Permission = [string, number]; export const basePerms = [ 'account', - 'child_account', + // TODO: Parent/Child - add this scope once API code is in prod. + // 'child_account', 'databases', 'domains', 'events', @@ -24,7 +25,8 @@ export const basePerms = [ export const basePermNameMap: Record = { account: 'Account', - child_account: 'Child Account Access', + // TODO: Parent/Child - add this scope once API code is in prod. + // child_account: 'Child Account Access', databases: 'Databases', domains: 'Domains', events: 'Events', diff --git a/packages/manager/src/features/Users/UserPermissions.tsx b/packages/manager/src/features/Users/UserPermissions.tsx index 34468dc98c8..33f84d80ff7 100644 --- a/packages/manager/src/features/Users/UserPermissions.tsx +++ b/packages/manager/src/features/Users/UserPermissions.tsx @@ -19,6 +19,7 @@ import { compose as recompose } from 'recompose'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Box } from 'src/components/Box'; +import { CircleProgress } from 'src/components/CircleProgress'; // import { Button } from 'src/components/Button/Button'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Item } from 'src/components/EnhancedSelect/Select'; @@ -113,12 +114,13 @@ class UserPermissions extends React.Component { } render() { + const { loading } = this.state; const { username } = this.props; return ( - {this.renderBody()} + {loading ? : this.renderBody()} ); } diff --git a/packages/manager/src/queries/profile.ts b/packages/manager/src/queries/profile.ts index 309c00f9386..93b95599dd5 100644 --- a/packages/manager/src/queries/profile.ts +++ b/packages/manager/src/queries/profile.ts @@ -41,11 +41,19 @@ import type { RequestOptions } from '@linode/api-v4'; export const queryKey = 'profile'; -export const useProfile = ({ headers }: RequestOptions = {}) => { - const key = [queryKey, headers]; - return useQuery(key, () => getProfile({ headers }), { - ...queryPresets.oneTimeFetch, - }); +export const useProfile = (options?: RequestOptions) => { + const key = [ + queryKey, + options?.headers ? { headers: options.headers } : null, + ]; + + return useQuery( + key, + () => getProfile({ headers: options?.headers }), + { + ...queryPresets.oneTimeFetch, + } + ); }; export const useMutateProfile = () => { @@ -60,7 +68,7 @@ export const updateProfileData = ( newData: Partial, queryClient: QueryClient ): void => { - queryClient.setQueryData([queryKey, undefined], (oldData: Profile) => ({ + queryClient.setQueryData([queryKey, null], (oldData: Profile) => ({ ...oldData, ...newData, })); @@ -68,20 +76,17 @@ export const updateProfileData = ( export const useGrants = () => { const { data: profile } = useProfile(); - return useQuery( - [queryKey, undefined, 'grants'], - listGrants, - { - ...queryPresets.oneTimeFetch, - enabled: Boolean(profile?.restricted), - } - ); + return useQuery([queryKey, 'grants'], listGrants, { + ...queryPresets.oneTimeFetch, + enabled: Boolean(profile?.restricted), + }); }; export const getProfileData = (queryClient: QueryClient) => - queryClient.getQueryData([queryKey, undefined]); + queryClient.getQueryData([queryKey, null]); + export const getGrantData = (queryClient: QueryClient) => - queryClient.getQueryData([queryKey, undefined, 'grants']); + queryClient.getQueryData([queryKey, 'grants']); export const useSMSOptOutMutation = () => { const queryClient = useQueryClient(); @@ -108,7 +113,7 @@ export const useSSHKeysQuery = ( enabled = true ) => useQuery( - [queryKey, {}, 'ssh-keys', 'paginated', params, filter], + [queryKey, 'ssh-keys', 'paginated', params, filter], () => getSSHKeys(params, filter), { enabled, @@ -122,7 +127,7 @@ export const useCreateSSHKeyMutation = () => { createSSHKey, { onSuccess() { - queryClient.invalidateQueries([queryKey, undefined, 'ssh-keys']); + queryClient.invalidateQueries([queryKey, 'ssh-keys']); // also invalidate the /account/users data because that endpoint returns some SSH key data queryClient.invalidateQueries([accountQueryKey, 'users']); }, @@ -136,7 +141,7 @@ export const useUpdateSSHKeyMutation = (id: number) => { (data) => updateSSHKey(id, data), { onSuccess() { - queryClient.invalidateQueries([queryKey, undefined, 'ssh-keys']); + queryClient.invalidateQueries([queryKey, 'ssh-keys']); // also invalidate the /account/users data because that endpoint returns some SSH key data queryClient.invalidateQueries([accountQueryKey, 'users']); }, @@ -148,7 +153,7 @@ export const useDeleteSSHKeyMutation = (id: number) => { const queryClient = useQueryClient(); return useMutation<{}, APIError[]>(() => deleteSSHKey(id), { onSuccess() { - queryClient.invalidateQueries([queryKey, undefined, 'ssh-keys']); + queryClient.invalidateQueries([queryKey, 'ssh-keys']); // also invalidate the /account/users data because that endpoint returns some SSH key data queryClient.invalidateQueries([accountQueryKey, 'users']); }, @@ -159,14 +164,14 @@ export const sshKeyEventHandler = (event: EventWithStore) => { // This event handler is a bit agressive and will over-fetch, but UX will // be great because this will ensure Cloud has up to date data all the time. - event.queryClient.invalidateQueries([queryKey, undefined, 'ssh-keys']); + event.queryClient.invalidateQueries([queryKey, 'ssh-keys']); // also invalidate the /account/users data because that endpoint returns some SSH key data event.queryClient.invalidateQueries([accountQueryKey, 'users']); }; export const useTrustedDevicesQuery = (params?: Params, filter?: Filter) => useQuery, APIError[]>( - [queryKey, {}, 'trusted-devices', 'paginated', params, filter], + [queryKey, 'trusted-devices', 'paginated', params, filter], () => getTrustedDevices(params, filter), { keepPreviousData: true, @@ -177,7 +182,7 @@ export const useRevokeTrustedDeviceMutation = (id: number) => { const queryClient = useQueryClient(); return useMutation<{}, APIError[]>(() => deleteTrustedDevice(id), { onSuccess() { - queryClient.invalidateQueries([queryKey, undefined, 'trusted-devices']); + queryClient.invalidateQueries([queryKey, 'trusted-devices']); }, }); }; @@ -186,7 +191,9 @@ export const useDisableTwoFactorMutation = () => { const queryClient = useQueryClient(); return useMutation<{}, APIError[]>(disableTwoFactor, { onSuccess() { - queryClient.invalidateQueries([queryKey, undefined]); + queryClient.invalidateQueries([queryKey, null]); + // also invalidate the /account/users data because that endpoint returns 2FA status for each user + queryClient.invalidateQueries([accountQueryKey, 'users']); }, }); };