From f26f788874b46a5bb2fc995b1f575b4ce279a90e Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 25 Oct 2023 18:59:36 -0400 Subject: [PATCH 01/43] fix: [M3-7249] - Linode Landing flickering (#9836) * dont block children from rendering * Added changeset: Linodes Landing flickering * clean up more * fix preference toggle * comment and clean up types * remove ambiguous test * show children after inital fetches * fix AuthenticationWrapper logic and test * revert change * removed unused state * fix lish * fix AuthenticationWrapper more * fix flags flickering * add some e2e coverage * Update packages/manager/src/IdentifyUser.tsx Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --------- Co-authored-by: Banks Nussman Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> --- .../.changeset/pr-9836-fixed-1698158641264.md | 5 + .../core/general/account-activation.spec.ts | 27 +++ packages/manager/src/IdentifyUser.tsx | 8 +- ...inContent.test.tsx => MainContent.test.ts} | 39 +--- .../AuthenticationWrapper.test.tsx | 7 +- .../AuthenticationWrapper.tsx | 24 +-- .../PreferenceToggle/PreferenceToggle.tsx | 184 ++---------------- .../Linodes/LinodesLanding/LinodesLanding.tsx | 17 +- .../TopMenu/AddNewMenu/AddNewMenu.test.tsx | 10 +- 9 files changed, 82 insertions(+), 239 deletions(-) create mode 100644 packages/manager/.changeset/pr-9836-fixed-1698158641264.md create mode 100644 packages/manager/cypress/e2e/core/general/account-activation.spec.ts rename packages/manager/src/{MainContent.test.tsx => MainContent.test.ts} (55%) diff --git a/packages/manager/.changeset/pr-9836-fixed-1698158641264.md b/packages/manager/.changeset/pr-9836-fixed-1698158641264.md new file mode 100644 index 00000000000..bccebf54927 --- /dev/null +++ b/packages/manager/.changeset/pr-9836-fixed-1698158641264.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Linodes Landing flickering ([#9836](https://github.com/linode/manager/pull/9836)) diff --git a/packages/manager/cypress/e2e/core/general/account-activation.spec.ts b/packages/manager/cypress/e2e/core/general/account-activation.spec.ts new file mode 100644 index 00000000000..c6ab1764182 --- /dev/null +++ b/packages/manager/cypress/e2e/core/general/account-activation.spec.ts @@ -0,0 +1,27 @@ +import { apiMatcher } from 'support/util/intercepts'; + +describe('account activation', () => { + /** + * The API will return 403 with the body below for most endpoint except `/v4/profile`. + * + * { "errors": [ { "reason": "Your account must be activated before you can use this endpoint" } ] } + */ + it('should render an activation landing page if the customer is not activated', () => { + cy.intercept('GET', apiMatcher('*'), { + statusCode: 403, + body: { + errors: [ + { + reason: + 'Your account must be activated before you can use this endpoint', + }, + ], + }, + }); + + cy.visitWithLogin('/'); + + cy.findByText('Your account is currently being reviewed.'); + cy.findByText('open a support ticket', { exact: false }); + }); +}); diff --git a/packages/manager/src/IdentifyUser.tsx b/packages/manager/src/IdentifyUser.tsx index 8dfb036dd04..983454d9bb7 100644 --- a/packages/manager/src/IdentifyUser.tsx +++ b/packages/manager/src/IdentifyUser.tsx @@ -80,9 +80,15 @@ export const IdentifyUser = () => { // in maintenance mode, we can't fetch the user's username. // Therefore, the `if` above won't "setFeatureFlagsLoaded". + // We also need to make sure client is defined. Launch Darkly has a weird API. + // If client is undefined, that means flags are loading. Even if flags fail to load, + // client will become defined and we and allow the app to render. + // If we're being honest, featureFlagsLoading shouldn't be tracked by Redux // and this code should go away eventually. - setFeatureFlagsLoaded(); + if (client) { + setFeatureFlagsLoaded(); + } } } }, [client, username, account, accountError]); diff --git a/packages/manager/src/MainContent.test.tsx b/packages/manager/src/MainContent.test.ts similarity index 55% rename from packages/manager/src/MainContent.test.tsx rename to packages/manager/src/MainContent.test.ts index 298d7762e32..676fc7a38d6 100644 --- a/packages/manager/src/MainContent.test.tsx +++ b/packages/manager/src/MainContent.test.ts @@ -1,16 +1,7 @@ -import { render, waitFor } from '@testing-library/react'; -import * as React from 'react'; - -import { rest, server } from 'src/mocks/testServer'; -import { wrapWithTheme } from 'src/utilities/testHelpers'; - -import MainContent, { +import { checkFlagsForMainContentBanner, checkPreferencesForBannerDismissal, } from './MainContent'; -import { queryClientFactory } from './queries/base'; - -const queryClient = queryClientFactory(); const mainContentBanner = { key: 'Test Text Key', @@ -52,31 +43,3 @@ describe('checkPreferencesForBannerDismissal', () => { expect(checkPreferencesForBannerDismissal({}, 'key1')).toBe(false); }); }); - -describe('Databases menu item for a restricted user', () => { - it('should not render the menu item', async () => { - server.use( - rest.get('*/account', (req, res, ctx) => { - return res(ctx.json({})); - }) - ); - const { getByText } = render( - wrapWithTheme( - , - { - flags: { databases: false }, - queryClient, - } - ) - ); - - await waitFor(() => { - let el; - try { - el = getByText('Databases'); - } catch (e) { - expect(el).not.toBeDefined(); - } - }); - }); -}); diff --git a/packages/manager/src/components/AuthenticationWrapper/AuthenticationWrapper.test.tsx b/packages/manager/src/components/AuthenticationWrapper/AuthenticationWrapper.test.tsx index 1ac9a4db84f..2fd604c8616 100644 --- a/packages/manager/src/components/AuthenticationWrapper/AuthenticationWrapper.test.tsx +++ b/packages/manager/src/components/AuthenticationWrapper/AuthenticationWrapper.test.tsx @@ -27,15 +27,14 @@ describe('AuthenticationWrapper', () => { }); it('should render one child when showChildren state is true', () => { component.setState({ showChildren: true }); - expect(component.childAt(0)).toHaveLength(1); + expect(component.childAt(0)).toBeDefined(); }); it('should invoke props.initSession when the component is mounted', () => { expect(component.instance().props.initSession).toHaveBeenCalledTimes(1); }); - - it('should set showChildren state to true when the isAuthenticated prop goes from false to true', () => { + it('should not showChildren initially because they should be shown after makeInitialRequests', () => { component.setState({ showChildren: false }); component.setProps({ isAuthenticated: true }); - expect(component.state('showChildren')).toBeTruthy(); + expect(component.state('showChildren')).toBeFalsy(); }); }); diff --git a/packages/manager/src/components/AuthenticationWrapper/AuthenticationWrapper.tsx b/packages/manager/src/components/AuthenticationWrapper/AuthenticationWrapper.tsx index 17b771a8c12..c9ca6872336 100644 --- a/packages/manager/src/components/AuthenticationWrapper/AuthenticationWrapper.tsx +++ b/packages/manager/src/components/AuthenticationWrapper/AuthenticationWrapper.tsx @@ -22,6 +22,7 @@ import { handleInitTokens } from 'src/store/authentication/authentication.action import { handleLoadingDone } from 'src/store/initialLoad/initialLoad.actions'; import { State as PendingUploadState } from 'src/store/pendingUpload'; import { MapState } from 'src/store/types'; +import SplashScreen from '../SplashScreen'; interface Props { children: React.ReactNode; @@ -34,11 +35,6 @@ type CombinedProps = Props & WithApplicationStoreProps; export class AuthenticationWrapper extends React.Component { - state = { - hasEnsuredAllTypes: false, - showChildren: false, - }; - componentDidMount() { const { initSession } = this.props; /** @@ -54,8 +50,6 @@ export class AuthenticationWrapper extends React.Component { * to show the children onMount */ if (this.props.isAuthenticated) { - this.setState({ showChildren: true }); - this.makeInitialRequests(); } } @@ -72,8 +66,6 @@ export class AuthenticationWrapper extends React.Component { !this.state.showChildren ) { this.makeInitialRequests(); - - return this.setState({ showChildren: true }); } /** basically handles for the case where our token is expired or we got a 401 error */ @@ -90,8 +82,12 @@ export class AuthenticationWrapper extends React.Component { render() { const { children } = this.props; const { showChildren } = this.state; - // eslint-disable-next-line - return {showChildren ? children : null}; + + if (showChildren) { + return children; + } + + return ; } static defaultProps = { @@ -109,6 +105,7 @@ export class AuthenticationWrapper extends React.Component { makeInitialRequests = async () => { // When loading Lish we avoid all this extra data loading if (window.location?.pathname?.match(/linodes\/[0-9]+\/lish/)) { + this.setState({ showChildren: true }); return; } @@ -142,8 +139,13 @@ export class AuthenticationWrapper extends React.Component { /** We choose to do nothing, relying on the Redux error state. */ } finally { this.props.markAppAsDoneLoading(); + this.setState({ showChildren: true }); } }; + + state = { + showChildren: false, + }; } interface StateProps { diff --git a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx index de1dd66f538..b279ec36f83 100644 --- a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx +++ b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx @@ -1,10 +1,4 @@ -import { path } from 'ramda'; -import * as React from 'react'; - import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; -import { isNullOrUndefined } from 'src/utilities/nullOrUndefined'; - -type PreferenceValue = boolean | number | string; export interface PreferenceToggleProps { preference: T; @@ -18,171 +12,43 @@ interface RenderChildrenProps { type RenderChildren = (props: RenderChildrenProps) => JSX.Element; -interface Props { +interface Props { children: RenderChildren; initialSetCallbackFn?: (value: T) => void; - localStorageKey?: string; preferenceKey: string; preferenceOptions: [T, T]; toggleCallbackFn?: (value: T) => void; - toggleCallbackFnDebounced?: (value: T) => void; value?: T; } -export const PreferenceToggle = (props: Props) => { +export const PreferenceToggle = (props: Props) => { const { children, preferenceKey, preferenceOptions, toggleCallbackFn, - toggleCallbackFnDebounced, value, } = props; - /** will be undefined and render-block children unless otherwise specified */ - const [currentlySetPreference, setPreference] = React.useState( - value - ); - const [lastUpdated, setLastUpdated] = React.useState(0); - - const { - data: preferences, - error: preferencesError, - refetch: refetchUserPreferences, - } = usePreferences(); + const { data: preferences } = usePreferences(); const { mutateAsync: updateUserPreferences } = useMutatePreferences(); - React.useEffect(() => { - /** - * This useEffect is strictly for when the app first loads - * whether we have a preference error or preference data - */ - - /** - * if for whatever reason we failed to get the preferences data - * just fallback to some default (the first in the list of options). - * - * Do NOT try and PUT to the API - we don't want to overwrite other unrelated preferences - */ - if ( - isNullOrUndefined(currentlySetPreference) && - !!preferencesError && - lastUpdated === 0 - ) { - /** - * get the first set of options - */ - const preferenceToSet = preferenceOptions[0]; - setPreference(preferenceToSet); - - if (props.initialSetCallbackFn) { - props.initialSetCallbackFn(preferenceToSet); - } - } - - /** - * In the case of when we successfully retrieved preferences for the FIRST time, - * set the state to what we got from the server. If the preference - * doesn't exist yet in this user's payload, set defaults in local state. - */ - if ( - isNullOrUndefined(currentlySetPreference) && - !!preferences && - lastUpdated === 0 - ) { - const preferenceFromAPI = path([preferenceKey], preferences); - - /** - * this is the first time the user is setting the user preference - * - * if the API value is null or undefined, default to the first value that was passed to this component from props. - */ - const preferenceToSet = isNullOrUndefined(preferenceFromAPI) - ? preferenceOptions[0] - : preferenceFromAPI; - - setPreference(preferenceToSet); - - /** run callback function if passed one */ - if (props.initialSetCallbackFn) { - props.initialSetCallbackFn(preferenceToSet); - } - } - }, [preferences, preferencesError]); - - React.useEffect(() => { - /** - * we only want to update local state if we already have something set in local state - * setting the initial state is the responsibility of the first useEffect - */ - if (!isNullOrUndefined(currentlySetPreference)) { - const debouncedErrorUpdate = setTimeout(() => { - /** - * we have a preference error, so first GET the preferences - * before trying to PUT them. - * - * Don't update anything if the GET fails - */ - if (!!preferencesError && lastUpdated !== 0) { - /** invoke our callback prop if we have one */ - if ( - toggleCallbackFnDebounced && - !isNullOrUndefined(currentlySetPreference) - ) { - toggleCallbackFnDebounced(currentlySetPreference); - } - refetchUserPreferences() - .then((response) => { - updateUserPreferences({ - ...response.data, - [preferenceKey]: currentlySetPreference, - }).catch(() => /** swallow the error */ null); - }) - .catch(() => /** swallow the error */ null); - } else if ( - !!preferences && - !isNullOrUndefined(currentlySetPreference) && - lastUpdated !== 0 - ) { - /** - * PUT to /preferences on every toggle, debounced. - */ - updateUserPreferences({ - [preferenceKey]: currentlySetPreference, - }).catch(() => /** swallow the error */ null); - - /** invoke our callback prop if we have one */ - if ( - toggleCallbackFnDebounced && - !isNullOrUndefined(currentlySetPreference) - ) { - toggleCallbackFnDebounced(currentlySetPreference); - } - } else if (lastUpdated === 0) { - /** - * this is the case where the app has just been mounted and the preferences are - * being set in local state for the first time - */ - setLastUpdated(Date.now()); - } - }, 500); - - return () => clearTimeout(debouncedErrorUpdate); - } - - return () => null; - }, [currentlySetPreference]); - const togglePreference = () => { - /** first set local state to the opposite option */ - const newPreferenceToSet = - currentlySetPreference === preferenceOptions[0] - ? preferenceOptions[1] - : preferenceOptions[0]; + let newPreferenceToSet: T; + + if (preferences?.[preferenceKey] === undefined) { + // Because we default to preferenceOptions[0], toggling with no preference should pick preferenceOptions[1] + newPreferenceToSet = preferenceOptions[1]; + } else if (preferences[preferenceKey] === preferenceOptions[0]) { + newPreferenceToSet = preferenceOptions[1]; + } else { + newPreferenceToSet = preferenceOptions[0]; + } - /** set the preference in local state */ - setPreference(newPreferenceToSet); + updateUserPreferences({ + [preferenceKey]: newPreferenceToSet, + }).catch(() => /** swallow the error */ null); /** invoke our callback prop if we have one */ if (toggleCallbackFn) { @@ -192,18 +58,8 @@ export const PreferenceToggle = (props: Props) => { return newPreferenceToSet; }; - /** - * render-block the children. We can prevent - * render-blocking by passing a default value as a prop - * - * So if you want to handle local state outside of this component, - * you can do so and pass the value explicitly with the _value_ prop - */ - if (isNullOrUndefined(currentlySetPreference)) { - return null; - } - - return typeof children === 'function' - ? children({ preference: currentlySetPreference, togglePreference }) - : null; + return children({ + preference: value ?? preferences?.[preferenceKey] ?? preferenceOptions[0], + togglePreference, + }); }; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx index 000134592ea..49f314ddc3b 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx @@ -188,10 +188,9 @@ class ListLinodes extends React.Component { - localStorageKey="GROUP_LINODES" preferenceKey="linodes_group_by_tag" preferenceOptions={[false, true]} - toggleCallbackFnDebounced={sendGroupByAnalytic} + toggleCallbackFn={sendGroupByAnalytic} > {({ preference: linodesAreGrouped, @@ -199,11 +198,9 @@ class ListLinodes extends React.Component { }: PreferenceToggleProps) => { return ( - localStorageKey="LINODE_VIEW" preferenceKey="linodes_view_style" preferenceOptions={['list', 'grid']} - toggleCallbackFn={this.changeViewInstant} - toggleCallbackFnDebounced={this.changeViewDelayed} + toggleCallbackFn={this.changeView} /** * we want the URL query param to take priority here, but if it's * undefined, just use the user preference @@ -338,17 +335,9 @@ class ListLinodes extends React.Component { ); } - /** - * when you change the linode view, send analytics event, debounced. - */ - changeViewDelayed = (style: 'grid' | 'list') => { + changeView = (style: 'grid' | 'list') => { sendLinodesViewEvent(eventCategory, style); - }; - /** - * when you change the linode view, instantly update the query params - */ - changeViewInstant = (style: 'grid' | 'list') => { const { history, location } = this.props; const query = new URLSearchParams(location.search); diff --git a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx index 44529a312ef..eb7d193439d 100644 --- a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx +++ b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.test.tsx @@ -66,13 +66,9 @@ describe('AddNewMenu', () => { }); test('does not render hidden menu item - databases', () => { - const mockedUseFlags = jest.fn().mockReturnValue({ databases: false }); - jest.mock('src/hooks/useFlags', () => ({ - __esModule: true, - useFlags: mockedUseFlags, - })); - - const { getByText, queryByText } = renderWithTheme(); + const { getByText, queryByText } = renderWithTheme(, { + flags: { databases: false }, + }); const createButton = getByText('Create'); fireEvent.click(createButton); const hiddenMenuItem = queryByText('Create Database'); From 9cd7af231110dbf3fb8229dcea24eb813d485d9b Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 26 Oct 2023 09:08:04 -0500 Subject: [PATCH 02/43] feat: [M3-7288] - AGLB create page with Actions buttons. (#9825) * feat: [M3-7288] - AGLB create page with Actions buttons. * Add configuration header section * Add Actions buttons * Render Regions as per the mockup * Added changeset: AGLB create page with Actions buttons. * Unit test coverage for LoadBalancerLabel * Unit test for LoadBalancerConfiguration * Update packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerRegions.tsx Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Update packages/manager/.changeset/pr-9825-upcoming-features-1698093221284.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * PR - Feedback * PR - feedback * Update packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.tsx Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> * Update packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerRegions.tsx Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> * PR - Feedback * Fix alignment for small screen * Override breadcrumb label * Fix tests * Adjust actions buttons order for small screens. * Update packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.test.tsx Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> * Rename file name - LoadBalancerLabel * simplify styles and use less grid * Wrap stepper component for mobile view * add real regions * Use util - convertToKebabCase --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Co-authored-by: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman --- ...pr-9825-upcoming-features-1698093221284.md | 5 ++ .../VerticalLinearStepper.styles.ts | 4 +- .../VerticalLinearStepper.tsx | 34 +++++++--- .../LoadBalancerConfiguration.test.tsx | 67 +++++++++++++++++++ .../LoadBalancerConfiguration.tsx | 44 ++++++++++++ .../LoadBalancerCreate/LoadBalancerCreate.tsx | 52 +++++++++++++- .../LoadBalancerLabel.test.tsx | 49 ++++++++++++++ .../LoadBalancerCreate/LoadBalancerLabel.tsx | 36 ++++++++++ .../LoadBalancerRegions.tsx | 49 ++++++++++++++ 9 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-9825-upcoming-features-1698093221284.md create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.test.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerLabel.test.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerLabel.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerRegions.tsx diff --git a/packages/manager/.changeset/pr-9825-upcoming-features-1698093221284.md b/packages/manager/.changeset/pr-9825-upcoming-features-1698093221284.md new file mode 100644 index 00000000000..b11a0b1b6bf --- /dev/null +++ b/packages/manager/.changeset/pr-9825-upcoming-features-1698093221284.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add AGLB create page with Actions buttons ([#9825](https://github.com/linode/manager/pull/9825)) diff --git a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.styles.ts b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.styles.ts index 03eb7eff8c1..4d8bc9d619c 100644 --- a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.styles.ts +++ b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.styles.ts @@ -44,10 +44,10 @@ export const CustomStepIcon = styled(StepIcon, { label: 'StyledCircleIcon' })( export const StyledColorlibConnector = styled(StepConnector, { label: 'StyledColorlibConnector', -})(() => ({ +})(({ theme }) => ({ '& .MuiStepConnector-line': { borderColor: '#eaeaf0', borderLeftWidth: '3px', - minHeight: '28px', + minHeight: theme.spacing(2), }, })); diff --git a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx index 7efed690d24..e76f6732885 100644 --- a/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx +++ b/packages/manager/src/components/VerticalLinearStepper/VerticalLinearStepper.tsx @@ -7,9 +7,12 @@ import { } from '@mui/material'; import Box from '@mui/material/Box'; import { Theme } from '@mui/material/styles'; +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 { CustomStepIcon, @@ -23,7 +26,7 @@ type VerticalLinearStep = { label: string; }; -interface VerticalLinearStepperProps { +export interface VerticalLinearStepperProps { steps: VerticalLinearStep[]; } @@ -31,6 +34,9 @@ export const VerticalLinearStepper = ({ steps, }: VerticalLinearStepperProps) => { const [activeStep, setActiveStep] = useState(0); + const theme = useTheme(); + + const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); const handleNext = () => { setActiveStep((prevActiveStep) => prevActiveStep + 1); @@ -45,9 +51,8 @@ export const VerticalLinearStepper = ({ sx={(theme: Theme) => ({ backgroundColor: theme.bg.bgPaper, display: 'flex', - margin: 'auto', - maxWidth: 800, - p: `${theme.spacing(2)}`, + flexDirection: matchesSmDown ? 'column' : 'row', + p: matchesSmDown ? `${theme.spacing(2)}px 0px` : `${theme.spacing(2)}`, })} > {/* Left Column - Vertical Steps */} @@ -101,7 +106,16 @@ export const VerticalLinearStepper = ({ {steps.map(({ content, handler, label }, index) => ( {index === activeStep ? ( - + ({ bgcolor: theme.bg.app, @@ -116,9 +130,13 @@ export const VerticalLinearStepper = ({ primaryButtonProps={ index !== 2 ? { - 'data-testid': steps[ - index + 1 - ]?.label.toLocaleLowerCase(), + /** Generate a 'data-testid' attribute value based on the label of the next step. + * 1. toLocaleLowerCase(): Converts the label to lowercase for consistency. + * 2. replace(/\s/g, ''): Removes spaces from the label to create a valid test ID. + */ + 'data-testid': convertToKebabCase( + steps[index + 1]?.label + ), label: `Next: ${steps[index + 1]?.label}`, onClick: () => { handleNext(); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.test.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.test.tsx new file mode 100644 index 00000000000..014364fbcca --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.test.tsx @@ -0,0 +1,67 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { LoadBalancerConfiguration } from './LoadBalancerConfiguration'; + +describe('LoadBalancerConfiguration', () => { + test('Should render Details content', () => { + renderWithTheme(); + expect( + screen.getByText('TODO: AGLB - Implement Details step content.') + ).toBeInTheDocument(); + expect( + screen.queryByText( + 'TODO: AGLB - Implement Service Targets Configuration.' + ) + ).toBeNull(); + expect( + screen.queryByText('TODO: AGLB - Implement Routes Confiugataion.') + ).toBeNull(); + expect(screen.getByText('Next: Service Targets')).toBeInTheDocument(); + expect(screen.queryByText('Previous: Details')).toBeNull(); + }); + test('Should navigate to Service Targets content', () => { + renderWithTheme(); + userEvent.click(screen.getByTestId('service-targets')); + expect( + screen.getByText('TODO: AGLB - Implement Service Targets Configuration.') + ).toBeInTheDocument(); + expect( + screen.queryByText('TODO: AGLB - Implement Details step content.') + ).toBeNull(); + expect( + screen.queryByText('TODO: AGLB - Implement Routes Confiugataion.') + ).toBeNull(); + expect(screen.getByText('Next: Routes')).toBeInTheDocument(); + expect(screen.getByText('Previous: Details')).toBeInTheDocument(); + expect(screen.queryByText('Previous: Service Targets')).toBeNull(); + }); + test('Should navigate to Routes content', () => { + renderWithTheme(); + userEvent.click(screen.getByTestId('service-targets')); + userEvent.click(screen.getByTestId('routes')); + expect( + screen.queryByText('TODO: AGLB - Implement Details step content.') + ).toBeNull(); + expect( + screen.queryByText( + 'TODO: AGLB - Implement Service Targets Configuration.' + ) + ).toBeNull(); + expect( + screen.getByText('TODO: AGLB - Implement Routes Confiugataion.') + ).toBeInTheDocument(); + expect(screen.getByText('Previous: Service Targets')).toBeInTheDocument(); + }); + test('Should be able to go previous step', () => { + renderWithTheme(); + userEvent.click(screen.getByTestId('service-targets')); + userEvent.click(screen.getByText('Previous: Details')); + expect( + screen.getByText('TODO: AGLB - Implement Details step content.') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.tsx new file mode 100644 index 00000000000..a9eadc0b2b2 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.tsx @@ -0,0 +1,44 @@ +import Stack from '@mui/material/Stack'; +import * as React from 'react'; + +import { Paper } from 'src/components/Paper'; +import { Typography } from 'src/components/Typography'; +import { VerticalLinearStepper } from 'src/components/VerticalLinearStepper/VerticalLinearStepper'; + +export const configurationSteps = [ + { + content:
TODO: AGLB - Implement Details step content.
, + handler: () => null, + label: 'Details', + }, + { + content:
TODO: AGLB - Implement Service Targets Configuration.
, + handler: () => null, + label: 'Service Targets', + }, + { + content:
TODO: AGLB - Implement Routes Confiugataion.
, + handler: () => null, + label: 'Routes', + }, +]; + +export const LoadBalancerConfiguration = () => { + return ( + + ({ marginBottom: theme.spacing(2) })} + variant="h2" + > + Configuration -{' '} + + + + A Configuration listens on a port and uses Route Rules to forward + request to Service Target Endpoints + + + + + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerCreate.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerCreate.tsx index 6d9e2f9e479..c7b90a87917 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerCreate.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerCreate.tsx @@ -1,12 +1,60 @@ +import Stack from '@mui/material/Stack'; import * as React from 'react'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; import { DocumentTitleSegment } from 'src/components/DocumentTitle/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; + +import { LoadBalancerConfiguration } from './LoadBalancerConfiguration'; +import { LoadBalancerLabel } from './LoadBalancerLabel'; +import { LoadBalancerRegions } from './LoadBalancerRegions'; const LoadBalancerCreate = () => { return ( <> - - TODO: AGLB M3-6815: Load Balancer Create + + + + null, + value: '', + }} + /> + + + {/* TODO: AGLB - + * Implement Review Load Balancer Action Behavior + * Implement Add Another Configuration Behavior + */} + + + + + ); }; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerLabel.test.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerLabel.test.tsx new file mode 100644 index 00000000000..c99ba36bfe2 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerLabel.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { LoadBalancerLabel } from './LoadBalancerLabel'; + +describe('LoadBalancerLabel', () => { + it('should render the component with a label and no error', () => { + const labelFieldProps = { + disabled: false, + errorText: '', + label: 'Load Balancer Label', + onChange: jest.fn(), + value: 'Test Label', + }; + + const { getByTestId, queryByText } = renderWithTheme( + + ); + + const labelInput = getByTestId('textfield-input'); + const errorNotice = queryByText('Error Text'); + + expect(labelInput).toBeInTheDocument(); + expect(labelInput).toHaveAttribute('placeholder', 'Enter a label'); + expect(labelInput).toHaveValue('Test Label'); + expect(errorNotice).toBeNull(); + }); + + it('should render the component with an error message', () => { + const labelFieldProps = { + disabled: false, + errorText: 'This is an error', + label: 'Load Balancer Label', + onChange: jest.fn(), + value: 'Test Label', + }; + + const { getByTestId, getByText } = renderWithTheme( + + ); + + const labelInput = getByTestId('textfield-input'); + const errorNotice = getByText('This is an error'); + + expect(labelInput).toBeInTheDocument(); + expect(errorNotice).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerLabel.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerLabel.tsx new file mode 100644 index 00000000000..1bff990ab83 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerLabel.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { TextField, TextFieldProps } from 'src/components/TextField'; + +interface LabelProps { + error?: string; + labelFieldProps: TextFieldProps; +} + +export const LoadBalancerLabel = (props: LabelProps) => { + const { error, labelFieldProps } = props; + + return ( + + {error && } + labelFieldProps.onChange} + placeholder="Enter a label" + value={labelFieldProps.value} + /> + + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerRegions.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerRegions.tsx new file mode 100644 index 00000000000..e1242d97fda --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerRegions.tsx @@ -0,0 +1,49 @@ +import Stack from '@mui/material/Stack'; +import * as React from 'react'; + +import { BetaChip } from 'src/components/BetaChip/BetaChip'; +import { Country } from 'src/components/EnhancedSelect/variants/RegionSelect/utils'; +import { Flag } from 'src/components/Flag'; +import { Paper } from 'src/components/Paper'; +import { Typography } from 'src/components/Typography'; + +const regions = [ + { country: 'us', id: 'us-iad', label: 'Washington, DC' }, + { country: 'us', id: 'us-lax', label: 'Los Angeles, CA' }, + { country: 'fr', id: 'fr-par', label: 'Paris, FR' }, + { country: 'jp', id: 'jp-osa', label: 'Osaka, JP' }, + { country: 'au', id: 'ap-southeast', label: 'Sydney, AU' }, +]; + +export const LoadBalancerRegions = () => { + return ( + + + Regions + + + Where this Load Balancer instance will be deployed. + + + Load Balancers will + be automatically provisioned in these 5 Regions. No charges with be + incurred. + + + + {regions.map((region) => ( + + } /> + {`${region.label} (${region.id})`} + + ))} + + + + ); +}; From 55b1205608eb05fae755ae50b25f0e94637d976d Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Thu, 26 Oct 2023 10:10:17 -0400 Subject: [PATCH 03/43] change: [M3-7182] - Update RemovableSelectionsList default maximum height to cut off the last list item (#9827) * update removable list height to cut off last item if scrolling is necessary * add changeset * box shadow for removable selections list * fix safari box shadow display (css really really got me ;-;) * cleanup, make dropshadow appear more dynamically --- ...pr-9827-upcoming-features-1698091273479.md | 5 + .../RemovableSelectionsList.style.ts | 90 +++++++++++ .../RemovableSelectionsList.test.tsx | 8 + .../RemovableSelectionsList.tsx | 146 +++++++----------- .../Linodes/LinodesCreate/SelectAppPanel.tsx | 1 - .../TabbedContent/FromAppsContent.tsx | 48 ++++-- 6 files changed, 193 insertions(+), 105 deletions(-) create mode 100644 packages/manager/.changeset/pr-9827-upcoming-features-1698091273479.md create mode 100644 packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts diff --git a/packages/manager/.changeset/pr-9827-upcoming-features-1698091273479.md b/packages/manager/.changeset/pr-9827-upcoming-features-1698091273479.md new file mode 100644 index 00000000000..7b5861003ea --- /dev/null +++ b/packages/manager/.changeset/pr-9827-upcoming-features-1698091273479.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update `RemovableSelectionsList` default maximum height to cut off last list item and indicate scrolling ([#9827](https://github.com/linode/manager/pull/9827)) diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts new file mode 100644 index 00000000000..9753f7173f2 --- /dev/null +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.style.ts @@ -0,0 +1,90 @@ +import { styled } from '@mui/material/styles'; + +import { Box } from 'src/components/Box'; +import { List } from 'src/components/List'; +import { ListItem } from 'src/components/ListItem'; +import { omittedProps } from 'src/utilities/omittedProps'; + +import type { RemovableSelectionsListProps } from './RemovableSelectionsList'; + +export const StyledNoAssignedLinodesBox = styled(Box, { + label: 'StyledNoAssignedLinodesBox', + shouldForwardProp: omittedProps(['maxWidth']), +})(({ maxWidth, theme }) => ({ + background: theme.name === 'light' ? theme.bg.main : theme.bg.app, + display: 'flex', + flexDirection: 'column', + height: '52px', + justifyContent: 'center', + maxWidth: maxWidth ? `${maxWidth}px` : '416px', + paddingLeft: theme.spacing(2), + width: '100%', +})); + +export const SelectedOptionsHeader = styled('h4', { + label: 'SelectedOptionsHeader', +})(({ theme }) => ({ + color: theme.color.headline, + fontFamily: theme.font.bold, + fontSize: '14px', + textTransform: 'initial', +})); + +export const SelectedOptionsList = styled(List, { + label: 'SelectedOptionsList', + shouldForwardProp: omittedProps(['isRemovable']), +})<{ isRemovable?: boolean }>(({ isRemovable, theme }) => ({ + background: theme.name === 'light' ? theme.bg.main : theme.bg.app, + padding: !isRemovable ? `${theme.spacing(2)} 0` : '5px 0', + width: '100%', +})); + +export const SelectedOptionsListItem = styled(ListItem, { + label: 'SelectedOptionsListItem', +})(() => ({ + justifyContent: 'space-between', + paddingBottom: 0, + paddingRight: 4, + paddingTop: 0, +})); + +export const StyledLabel = styled('span', { label: 'StyledLabel' })( + ({ theme }) => ({ + color: theme.color.label, + fontFamily: theme.font.semiBold, + fontSize: '14px', + }) +); + +type StyledBoxShadowWrapperBoxProps = Pick< + RemovableSelectionsListProps, + 'maxHeight' | 'maxWidth' +>; + +export const StyledBoxShadowWrapper = styled(Box, { + label: 'StyledBoxShadowWrapper', + shouldForwardProp: omittedProps(['displayShadow']), +})<{ displayShadow: boolean; maxWidth: number }>( + ({ displayShadow, maxWidth, theme }) => ({ + '&:after': { + bottom: 0, + content: '""', + height: '15px', + position: 'absolute', + width: '100%', + ...(displayShadow && { + boxShadow: `${theme.color.boxShadow} 0px -15px 10px -10px inset`, + }), + }, + maxWidth: `${maxWidth}px`, + position: 'relative', + }) +); + +export const StyledScrollBox = styled(Box, { + label: 'StyledScrollBox', +})(({ maxHeight, maxWidth }) => ({ + maxHeight: `${maxHeight}px`, + maxWidth: `${maxWidth}px`, + overflow: 'auto', +})); diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx index 916b7c96175..26cb3be5bca 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.test.tsx @@ -81,4 +81,12 @@ describe('Removable Selections List', () => { fireEvent.click(removeButton); expect(props.onRemove).toHaveBeenCalled(); }); + + it('should not display the remove button for a list item', () => { + const screen = renderWithTheme( + + ); + const removeButton = screen.queryByLabelText(`remove my-linode-1`); + expect(removeButton).not.toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx index f97bb62af5c..706d4ada54a 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx @@ -1,12 +1,17 @@ import Close from '@mui/icons-material/Close'; -import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { Box } from 'src/components/Box'; import { IconButton } from 'src/components/IconButton'; -import { List } from 'src/components/List'; -import { ListItem } from 'src/components/ListItem'; -import { omittedProps } from 'src/utilities/omittedProps'; + +import { + SelectedOptionsHeader, + SelectedOptionsList, + SelectedOptionsListItem, + StyledBoxShadowWrapper, + StyledLabel, + StyledNoAssignedLinodesBox, + StyledScrollBox, +} from './RemovableSelectionsList.style'; export type RemovableItem = { id: number; @@ -16,7 +21,7 @@ export type RemovableItem = { // Trying to type them as 'unknown' led to type errors. } & { [key: string]: any }; -interface Props { +export interface RemovableSelectionsListProps { /** * The descriptive text to display above the list */ @@ -26,11 +31,11 @@ interface Props { */ isRemovable?: boolean; /** - * The maxHeight of the list component, in px + * The maxHeight of the list component, in px. The default max height is 427px. */ maxHeight?: number; /** - * The maxWidth of the list component, in px + * The maxWidth of the list component, in px. The default max width is 416px. */ maxWidth?: number; /** @@ -53,18 +58,30 @@ interface Props { selectionData: RemovableItem[]; } -export const RemovableSelectionsList = (props: Props) => { +export const RemovableSelectionsList = ( + props: RemovableSelectionsListProps +) => { const { headerText, isRemovable = true, - maxHeight, - maxWidth, + maxHeight = 427, + maxWidth = 416, noDataText, onRemove, preferredDataLabel, selectionData, } = props; + // used to determine when to display a box-shadow to indicate scrollability + const listRef = React.useRef(null); + const [listHeight, setListHeight] = React.useState(0); + + React.useEffect(() => { + if (listRef.current) { + setListHeight(listRef.current.clientHeight); + } + }, [selectionData]); + const handleOnClick = (selection: RemovableItem) => { onRemove(selection); }; @@ -73,37 +90,38 @@ export const RemovableSelectionsList = (props: Props) => { <> {headerText} {selectionData.length > 0 ? ( - maxHeight} + maxWidth={maxWidth} > - {selectionData.map((selection) => ( - - - {preferredDataLabel - ? selection[preferredDataLabel] - : selection.label} - - {isRemovable && ( - + + {selectionData.map((selection) => ( + + + {preferredDataLabel ? selection[preferredDataLabel] - : selection.label - }`} - disableRipple - onClick={() => handleOnClick(selection)} - size="medium" - > - - - )} - - ))} - + : selection.label} + + {isRemovable && ( + handleOnClick(selection)} + size="medium" + > + + + )} + + ))} + + + ) : ( {noDataText} @@ -112,51 +130,3 @@ export const RemovableSelectionsList = (props: Props) => { ); }; - -const StyledNoAssignedLinodesBox = styled(Box, { - label: 'StyledNoAssignedLinodesBox', - shouldForwardProp: omittedProps(['maxWidth']), -})(({ maxWidth, theme }) => ({ - background: theme.name === 'light' ? theme.bg.main : theme.bg.app, - display: 'flex', - flexDirection: 'column', - height: '52px', - justifyContent: 'center', - maxWidth: maxWidth ? `${maxWidth}px` : '416px', - paddingLeft: theme.spacing(2), - width: '100%', -})); - -const SelectedOptionsHeader = styled('h4', { - label: 'SelectedOptionsHeader', -})(({ theme }) => ({ - color: theme.color.headline, - fontFamily: theme.font.bold, - fontSize: '14px', - textTransform: 'initial', -})); - -const SelectedOptionsList = styled(List, { - label: 'SelectedOptionsList', - shouldForwardProp: omittedProps(['isRemovable']), -})<{ isRemovable?: boolean }>(({ isRemovable, theme }) => ({ - background: theme.name === 'light' ? theme.bg.main : theme.bg.app, - overflow: 'auto', - padding: !isRemovable ? '16px 0' : '5px 0', - width: '100%', -})); - -const SelectedOptionsListItem = styled(ListItem, { - label: 'SelectedOptionsListItem', -})(() => ({ - justifyContent: 'space-between', - paddingBottom: 0, - paddingRight: 4, - paddingTop: 0, -})); - -const StyledLabel = styled('span', { label: 'StyledLabel' })(({ theme }) => ({ - color: theme.color.label, - fontFamily: theme.font.semiBold, - fontSize: '14px', -})); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx index f7dad5c3cf9..a575d67e217 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx @@ -182,7 +182,6 @@ class SelectAppPanel extends React.PureComponent { } const commonStyling = (theme: Theme) => ({ - boxShadow: `${theme.color.boxShadow} 0px -15px 10px -10px inset`, height: 450, marginBottom: theme.spacing(3), overflowY: 'auto' as const, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx index ed63f2cb351..0b5c3fb8a9c 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx @@ -233,22 +233,24 @@ export class FromAppsContent extends React.Component { - + + + {!userCannotCreateLinode && selectedStackScriptLabel ? ( ({ + '&:after': { + bottom: 0, + boxShadow: `${theme.color.boxShadow} 0px -15px 10px -10px inset`, + content: '""', + height: '15px', + position: 'absolute', + width: '100%', + }, + position: 'relative', +})); From e780ffcc44f5dbda8601606093950e29a86996a7 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Thu, 26 Oct 2023 11:25:27 -0400 Subject: [PATCH 04/43] fix: [M3-7268] - Unassign multiple Linodes from Subnet (#9820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Fix bug where selecting a single Linode in the Unassign Linodes drawer visually selects everything in the dropdown ## How to test 🧪 ### Prerequisites - Point to the dev env and ensure your account has vpc customer tags ### Reproduction steps - Ensure you are on the develop branch or the remote dev environment - Create a VPC and Subnet if you don't already have one - Go to the VPC's details page and assign some Linodes to the Subnet via the kebab action menu in the table - Click on Unassign Linodes via the kebab action menu and try to select a Linode - You should see that all Linodes are visually selected instead of just the one ### Verification steps - Confirm that you are able to select Linodes as normal and then click Unassign Linodes - Confirm that the Subnet table removes the unassigned Linodes - Confirm that the inline singular Unassign Linode still works as expected --------- Co-authored-by: Jaalah Ramos --- ...pr-9820-upcoming-features-1698170376454.md | 5 + .../components/Autocomplete/Autocomplete.tsx | 2 - .../VPCDetail/SubnetUnassignLinodesDrawer.tsx | 184 +++++++----------- .../VPCs/VPCDetail/VPCSubnetsTable.tsx | 2 +- 4 files changed, 79 insertions(+), 114 deletions(-) create mode 100644 packages/manager/.changeset/pr-9820-upcoming-features-1698170376454.md diff --git a/packages/manager/.changeset/pr-9820-upcoming-features-1698170376454.md b/packages/manager/.changeset/pr-9820-upcoming-features-1698170376454.md new file mode 100644 index 00000000000..11c50366776 --- /dev/null +++ b/packages/manager/.changeset/pr-9820-upcoming-features-1698170376454.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Fix Unassign multiple Linodes from Subnet ([#9820](https://github.com/linode/manager/pull/9820)) diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index 7a2b744de77..dc731e4d0d0 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -66,7 +66,6 @@ export const Autocomplete = < disablePortal = true, errorText = '', helperText, - isOptionEqualToValue, label, limitTags = 2, loading = false, @@ -136,7 +135,6 @@ export const Autocomplete = < defaultValue={defaultValue} disableCloseOnSelect={multiple} disablePortal={disablePortal} - isOptionEqualToValue={isOptionEqualToValue} limitTags={limitTags} loading={loading} loadingText={loadingText || 'Loading...'} diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index 63c09ffe58e..d0375311efa 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx @@ -3,7 +3,6 @@ import { Stack, Typography } from '@mui/material'; import { useFormik } from 'formik'; import * as React from 'react'; import { useQueryClient } from 'react-query'; -import { debounce } from 'throttle-debounce'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; @@ -13,7 +12,6 @@ import { Notice } from 'src/components/Notice/Notice'; import { RemovableSelectionsList } from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; import { SUBNET_UNASSIGN_LINODES_WARNING } from 'src/features/VPCs/constants'; import { useFormattedDate } from 'src/hooks/useFormattedDate'; -import { usePrevious } from 'src/hooks/usePrevious'; import { useUnassignLinode } from 'src/hooks/useUnassignLinode'; import { queryKey as linodesQueryKey, @@ -33,15 +31,16 @@ import type { interface Props { onClose: () => void; open: boolean; - selectedLinode?: Linode; + singleLinodeToBeUnassigned?: Linode; subnet?: Subnet; vpcId: number; } export const SubnetUnassignLinodesDrawer = React.memo( - ({ onClose, open, selectedLinode, subnet, vpcId }: Props) => { + ({ onClose, open, singleLinodeToBeUnassigned, subnet, vpcId }: Props) => { const { data: profile } = useProfile(); const { data: grants } = useGrants(); + const subnetId = subnet?.id; const vpcPermissions = grants?.vpc.find((v) => v.id === vpcId); const queryClient = useQueryClient(); @@ -54,9 +53,8 @@ export const SubnetUnassignLinodesDrawer = React.memo( const csvRef = React.useRef(); const formattedDate = useFormattedDate(); const [selectedLinodes, setSelectedLinodes] = React.useState( - selectedLinode ? [selectedLinode] : [] + singleLinodeToBeUnassigned ? [singleLinodeToBeUnassigned] : [] ); - const prevSelectedLinodes = usePrevious(selectedLinodes); const hasError = React.useRef(false); // This flag is used to prevent the drawer from closing if an error occurs. const [ @@ -94,106 +92,74 @@ export const SubnetUnassignLinodesDrawer = React.memo( if (linodes) { setLinodeOptionsToUnassign(findAssignedLinodes() ?? []); } - return () => { - setLinodeOptionsToUnassign([]); - }; }, [linodes, setLinodeOptionsToUnassign, findAssignedLinodes]); - // 3. Everytime our selection changes, we need to either add or remove the linode from the configInterfacesToDelete state. - React.useEffect(() => { - const prevSelectedSet = new Set(prevSelectedLinodes || []); - const selectedSet = new Set(selectedLinodes); - - // Get the linodes that were added. - const updatedSelectedLinodes = selectedLinodes.filter( - (linode) => !prevSelectedSet.has(linode) - ); - - // If a linode was removed, remove the corresponding configInterfaceToDelete. - if (prevSelectedSet.size > selectedSet.size) { - const linodesToRemove = Array.from(prevSelectedSet).filter( - (linode) => !selectedSet.has(linode) - ); - - // Filter the config interfaces to delete, removing those associated with Linodes to be removed. - const updatedConfigInterfacesToDelete = configInterfacesToDelete.filter( - (_interface) => { - const linodeToRemove = linodesToRemove.find( - (linode) => linode.id === _interface.linodeId - ); - - if (linodeToRemove) { - return false; - } - - return true; - } - ); - - return setConfigInterfacesToDelete(updatedConfigInterfacesToDelete); - } - - debouncedGetConfigWithInterface(updatedSelectedLinodes); - - // We only want to run this effect when the selectedLinodes changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedLinodes]); + // 3. When a linode is selected, we need to get the configs with VPC interfaces. + const getConfigWithVPCInterface = React.useCallback( + async (selectedLinodes: Linode[]) => { + try { + const updatedConfigInterfaces = await Promise.all( + selectedLinodes.map(async (linode) => { + const response = await queryClient.fetchQuery( + [linodesQueryKey, 'linode', linode.id, 'configs'], + () => getAllLinodeConfigs(linode.id) + ); - // 4. When a linode is selected, we need to get the configs with VPC interfaces. - const getConfigWithVPCInterface = async (selectedLinodes: Linode[]) => { - try { - const updatedConfigInterfaces = await Promise.all( - selectedLinodes.map(async (linode) => { - const response = await queryClient.fetchQuery( - [linodesQueryKey, 'linode', linode.id, 'configs'], - () => getAllLinodeConfigs(linode.id) - ); + if (response) { + const configWithVpcInterface = response.find((config) => + config.interfaces.some( + (_interface) => + _interface.subnet_id === subnetId && + _interface.purpose === 'vpc' + ) + ); - if (response) { - const configWithVpcInterface = response.find((config) => - config.interfaces.some( + const vpcInterface = configWithVpcInterface?.interfaces?.find( (_interface) => - _interface.subnet_id === subnet?.id && + _interface.subnet_id === subnetId && _interface.purpose === 'vpc' - ) - ); + ); - const vpcInterface = configWithVpcInterface?.interfaces?.find( - (_interface) => - _interface.subnet_id === subnet?.id && - _interface.purpose === 'vpc' - ); + if (!vpcInterface || !configWithVpcInterface) { + return null; + } - if (!vpcInterface || !configWithVpcInterface) { - return null; + return { + configId: configWithVpcInterface.id, + interfaceId: vpcInterface.id, + linodeId: linode.id, + }; } + return null; + }) + ); + + // Filter out any null values and ensure item conforms to type using `is` type guard. + const filteredConfigInterfaces = updatedConfigInterfaces.filter( + (item): item is DeleteLinodeConfigInterfacePayload => item !== null + ); + + // Update the state with the new data + setConfigInterfacesToDelete([...filteredConfigInterfaces]); + } catch (error) { + // Capture errors if the promise.all fails + hasError.current = true; + setUnassignLinodesErrors(error as APIError[]); + } + }, + [ + queryClient, + setConfigInterfacesToDelete, + setUnassignLinodesErrors, + subnetId, + ] + ); - return { - configId: configWithVpcInterface.id, - interfaceId: vpcInterface.id, - linodeId: linode.id, - }; - } - return null; - }) - ); - - // Filter out any null values and ensure item conforms to type using `is` type guard. - const filteredConfigInterfaces = updatedConfigInterfaces.filter( - (item): item is DeleteLinodeConfigInterfacePayload => item !== null - ); - - // Update the state with the new data - setConfigInterfacesToDelete([ - ...configInterfacesToDelete, - ...filteredConfigInterfaces, - ]); - } catch (error) { - // Capture errors if the promise.all fails - hasError.current = true; - setUnassignLinodesErrors(error as APIError[]); + React.useEffect(() => { + if (singleLinodeToBeUnassigned) { + getConfigWithVPCInterface([singleLinodeToBeUnassigned]); } - }; + }, [singleLinodeToBeUnassigned, getConfigWithVPCInterface]); const downloadCSV = async (e: React.MouseEvent) => { e.preventDefault(); @@ -207,12 +173,6 @@ export const SubnetUnassignLinodesDrawer = React.memo( ); }; - // Debounce the getConfigWithVPCInterface function to prevent rapid API calls - const debouncedGetConfigWithInterface = React.useCallback( - debounce(200, false, getConfigWithVPCInterface), - [getConfigWithVPCInterface] - ); - const processUnassignLinodes = async () => { try { const promises = configInterfacesToDelete.map(async (_interface) => { @@ -221,7 +181,7 @@ export const SubnetUnassignLinodesDrawer = React.memo( configId: _interface.configId, interfaceId: _interface.interfaceId, linodeId: _interface.linodeId, - subnetId: subnet?.id ?? -1, + subnetId: subnetId ?? -1, vpcId, }); } catch (error) { @@ -242,7 +202,7 @@ export const SubnetUnassignLinodesDrawer = React.memo( } }; - // 5. When the user submits the form, we need to process the unassign linodes. + // 4. When the user submits the form, we need to process the unassign linodes. const handleUnassignLinode = async () => { await processUnassignLinodes(); @@ -263,11 +223,11 @@ export const SubnetUnassignLinodesDrawer = React.memo( ); const handleOnClose = () => { - onClose(); resetForm(); setSelectedLinodes([]); setConfigInterfacesToDelete([]); setUnassignLinodesErrors([]); + onClose(); }; return ( @@ -289,11 +249,11 @@ export const SubnetUnassignLinodesDrawer = React.memo( )} - {!selectedLinode && ( + {!singleLinodeToBeUnassigned && ( Select the Linodes you would like to unassign from this subnet. Only Linodes in this VPC’s region are displayed. @@ -301,14 +261,16 @@ export const SubnetUnassignLinodesDrawer = React.memo( )}
- {!selectedLinode && ( + {!singleLinodeToBeUnassigned && ( { + setSelectedLinodes(value); + getConfigWithVPCInterface(value); + }} disabled={userCannotUnassignLinodes} errorText={linodesError ? linodesError[0].reason : undefined} - isOptionEqualToValue={() => true} // Ignore the multi-select warning since it isn't helpful https://github.com/mui/material-ui/issues/29727 label="Linodes" multiple - onChange={(_, value) => setSelectedLinodes(value)} options={linodeOptionsToUnassign} placeholder="Select Linodes or type to search" renderTags={() => null} @@ -317,7 +279,7 @@ export const SubnetUnassignLinodesDrawer = React.memo( )} { setSelectedLinode(undefined); }} open={subnetUnassignLinodesDrawerOpen} - selectedLinode={selectedLinode} + singleLinodeToBeUnassigned={selectedLinode} subnet={selectedSubnet} vpcId={vpcId} /> From 5033391366b74e3eb53d2ffb1fc8b72ce7f5c856 Mon Sep 17 00:00:00 2001 From: sujai-git <136849150+sujai-git@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:35:54 -0400 Subject: [PATCH 05/43] test: [DBASS1-574] - Adding test coverage for disk metrics added to UI. (#9833) * Adding test coverage for disk metrics added to UI * Add changeset --------- Co-authored-by: Joe D'Amore --- packages/manager/.changeset/pr-9833-tests-1698335199212.md | 5 +++++ .../cypress/e2e/core/databases/update-database.spec.ts | 2 ++ .../cypress/e2e/core/notificationsAndEvents/events.spec.ts | 1 + 3 files changed, 8 insertions(+) create mode 100644 packages/manager/.changeset/pr-9833-tests-1698335199212.md diff --git a/packages/manager/.changeset/pr-9833-tests-1698335199212.md b/packages/manager/.changeset/pr-9833-tests-1698335199212.md new file mode 100644 index 00000000000..9049e95d0e2 --- /dev/null +++ b/packages/manager/.changeset/pr-9833-tests-1698335199212.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add DBaaS test coverage for disk metrics ([#9833](https://github.com/linode/manager/pull/9833)) diff --git a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts index 2aaee4bd0d7..5c7e492b8a5 100644 --- a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts @@ -186,6 +186,8 @@ describe('Update database clusters', () => { cy.get('[data-qa-cluster-config]').within(() => { cy.findByText(configuration.region.label).should('be.visible'); + cy.findByText(database.used_disk_size_gb + " GB").should('be.visible'); + cy.findByText(database.total_disk_size_gb + " GB").should('be.visible'); }); cy.get('[data-qa-connection-details]').within(() => { diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts index d0e78d7ff77..3bd441c7a18 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts @@ -16,6 +16,7 @@ const eventActions: RecPartial[] = [ 'disk_duplicate', 'disk_resize', 'disk_update', + 'database_low_disk_space', 'entity_transfer_accept', 'entity_transfer_cancel', 'entity_transfer_create', From 25ff801e366e3074a25c078095f974b2fe5a6a0e Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Thu, 26 Oct 2023 17:20:17 -0400 Subject: [PATCH 06/43] =?UTF-8?q?fix:=20[M3-7285]=20=E2=80=93=20Tweak=20wh?= =?UTF-8?q?en=20and=20where=20`public`=20interfaces=20are=20included=20in?= =?UTF-8?q?=20Linode=20Create=20payload=20(#9834)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pr-9834-changed-1698111109868.md | 5 +++ .../Linodes/LinodesCreate/LinodeCreate.tsx | 31 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-9834-changed-1698111109868.md diff --git a/packages/manager/.changeset/pr-9834-changed-1698111109868.md b/packages/manager/.changeset/pr-9834-changed-1698111109868.md new file mode 100644 index 00000000000..b9ea3861022 --- /dev/null +++ b/packages/manager/.changeset/pr-9834-changed-1698111109868.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Logic governing inclusion of public interfaces in Linode Create payload ([#9834](https://github.com/linode/manager/pull/9834)) diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 77d96377a6f..00d5f26d2bb 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -813,7 +813,7 @@ export class LinodeCreate extends React.PureComponent< vpc_id: this.props.selectedVPCId, }; - interfaces.push(vpcInterfaceData); + interfaces.unshift(vpcInterfaceData); } if ( @@ -828,11 +828,6 @@ export class LinodeCreate extends React.PureComponent< label: this.props.vlanLabel, purpose: 'vlan', }); - - // If there are no VPC interfaces, insert a default public interface in interfaces[0] - if (!interfaces.some((_interface) => _interface.purpose === 'vpc')) { - interfaces.unshift(defaultPublicInterface); - } } if (this.props.userData) { @@ -841,9 +836,33 @@ export class LinodeCreate extends React.PureComponent< }; } + const vpcAssigned = interfaces.some( + (_interface) => _interface.purpose === 'vpc' + ); + const vlanAssigned = interfaces.some( + (_interface) => _interface.purpose === 'vlan' + ); + // Only submit 'interfaces' in the payload if there are VPCs // or VLANs if (interfaces.length > 0) { + // Determine position of the default public interface + + // Case 1: VLAN assigned, no VPC assigned + if (!vpcAssigned) { + interfaces.unshift(defaultPublicInterface); + } + + // Case 2: VPC assigned, no VLAN assigned, Private IP enabled + if (!vlanAssigned && this.props.privateIPEnabled) { + interfaces.push(defaultPublicInterface); + } + + // Case 3: VPC and VLAN assigned + Private IP enabled + if (vpcAssigned && vlanAssigned && this.props.privateIPEnabled) { + interfaces.push(defaultPublicInterface); + } + payload['interfaces'] = interfaces; } From 654d1bf4b147b38b58d9fe38c3202d50e0de8d61 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 26 Oct 2023 22:07:15 -0400 Subject: [PATCH 07/43] refactor: [M3-7329] - MUI Migration - `SRC > Components > Crumbs` (#9841) * run tss migration and find and replace import * fix breadcrumb story * Added changeset: MUI Migration - SRC > Components > Crumbs * remove outdated docs and feedback --------- Co-authored-by: Banks Nussman --- .../pr-9841-tech-stories-1698261567678.md | 5 + .../Breadcrumb/Breadcrumb.stories.mdx | 137 ------------------ .../Breadcrumb/Breadcrumb.stories.tsx | 27 ++++ .../src/components/Breadcrumb/Breadcrumb.tsx | 36 ++++- .../src/components/Breadcrumb/Crumbs.tsx | 30 ++-- .../src/components/Breadcrumb/README.md | 45 ------ .../CheckoutSummary/CheckoutSummary.tsx | 2 +- .../DatabaseLanding/DatabaseActionMenu.tsx | 2 +- .../src/features/Domains/DomainActionMenu.tsx | 2 +- .../LinodeTransferTable.tsx | 2 +- .../Rules/FirewallRuleActionMenu.tsx | 2 +- .../FirewallLanding/FirewallActionMenu.tsx | 2 +- .../ClusterList/ClusterActionMenu.tsx | 2 +- .../LinodeConfigs/LinodeConfigActionMenu.tsx | 2 +- .../LinodeNetworkingActionMenu.tsx | 2 +- .../LinodesLanding/LinodeActionMenu.tsx | 2 +- .../MigrateLinode/MigrationPricing.tsx | 2 +- .../Credentials/CredentialActionMenu.tsx | 2 +- .../NodeBalancers/NodeBalancerCreate.tsx | 2 +- .../NodeBalancerActionMenu.tsx | 2 +- .../BucketDetail/FolderActionMenu.tsx | 2 +- .../BucketDetail/ObjectActionMenu.tsx | 2 +- .../Profile/APITokens/APITokenMenu.tsx | 2 +- .../OAuthClients/OAuthClientActionMenu.tsx | 2 +- .../StackScriptActionMenu.tsx | 2 +- .../src/features/Users/UsersActionMenu.tsx | 2 +- .../features/Volumes/VolumesActionMenu.tsx | 2 +- 27 files changed, 95 insertions(+), 227 deletions(-) create mode 100644 packages/manager/.changeset/pr-9841-tech-stories-1698261567678.md delete mode 100644 packages/manager/src/components/Breadcrumb/Breadcrumb.stories.mdx create mode 100644 packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx delete mode 100644 packages/manager/src/components/Breadcrumb/README.md diff --git a/packages/manager/.changeset/pr-9841-tech-stories-1698261567678.md b/packages/manager/.changeset/pr-9841-tech-stories-1698261567678.md new file mode 100644 index 00000000000..9c822a88838 --- /dev/null +++ b/packages/manager/.changeset/pr-9841-tech-stories-1698261567678.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +MUI Migration - SRC > Components > Crumbs ([#9841](https://github.com/linode/manager/pull/9841)) diff --git a/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.mdx b/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.mdx deleted file mode 100644 index 30dd5d58888..00000000000 --- a/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.mdx +++ /dev/null @@ -1,137 +0,0 @@ -import React from 'react'; -import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs'; -import { Breadcrumb } from './Breadcrumb'; -import UserIcon from 'src/assets/icons/user.svg'; - - - -export const BreadcrumbTemplate = (args, context) => ; - -export const EditableBreadcrumbTemplate = (args, context) => { - const [text, setText] = React.useState('Editable Text!'); - return ( - Promise.resolve(setText(value)), - onCancel: () => {}, - }} - {...args} - /> - ); -}; - -# Breadcrumb - -## Usage - -- Include the current page as the last item in the breadcrumb trail. -- In the breadcrumb trail, the breadcrumb corresponding the the current page should not be a link. - - - {BreadcrumbTemplate.bind({})} - - - - -### Breadcrumb with Subtitle - ---- - - - - {BreadcrumbTemplate.bind({})} - - - -### Breadcrumb with Prefix Component - ---- - - - - ), - }, - }} - > - {BreadcrumbTemplate.bind({})} - - - -### Breadcrumb with Editable Text - ---- - - - - {EditableBreadcrumbTemplate.bind({})} - - diff --git a/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx b/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx new file mode 100644 index 00000000000..9317926073b --- /dev/null +++ b/packages/manager/src/components/Breadcrumb/Breadcrumb.stories.tsx @@ -0,0 +1,27 @@ +import { action } from '@storybook/addon-actions'; +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { Breadcrumb } from './Breadcrumb'; + +const meta: Meta = { + component: Breadcrumb, + title: 'Components/Breadcrumb', +}; + +type Story = StoryObj; + +export const Default: Story = { + args: { + firstAndLastOnly: false, + onEditHandlers: { + editableTextTitle: 'test', + onCancel: () => action('onCancel'), + onEdit: async () => action('onEdit'), + }, + pathname: '/linodes/9872893679817/test/lastcrumb', + }, + render: (args) => , +}; + +export default meta; diff --git a/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx b/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx index c45672bac29..3b964df0229 100644 --- a/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx +++ b/packages/manager/src/components/Breadcrumb/Breadcrumb.tsx @@ -6,14 +6,41 @@ import { CrumbOverridesProps, Crumbs } from './Crumbs'; import { EditableProps, LabelProps } from './types'; export interface BreadcrumbProps { + /** + * Data attributes passed to the root div for testing. + */ breadcrumbDataAttrs?: { [key: string]: boolean }; + /** + * Optional className passed to the root div. + */ className?: string; + /** + * An array of objects that can be used to customize any crumb. + */ crumbOverrides?: CrumbOverridesProps[]; + /** + * A boolean that if true will only show the first and last crumb. + */ firstAndLastOnly?: boolean; + /** + * An object that can be used to configure the final crumb. + */ labelOptions?: LabelProps; + /** + * A string that can be used to set a custom title for the last crumb. + */ labelTitle?: string; + /** + * An object that can be used to define functions, errors, and crumb title for an editable final crumb. + */ onEditHandlers?: EditableProps; + /* + * A string representation of the path of a resource. Each crumb is separated by a `/` character. + */ pathname: string; + /** + * A number indicating the position of the crumb to remove. Not zero indexed. + */ removeCrumbX?: number; } @@ -42,7 +69,12 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -const Breadcrumb = (props: BreadcrumbProps) => { +/** + * ## Usage + * - Include the current page as the last item in the breadcrumb trail. + * - In the breadcrumb trail, the breadcrumb corresponding the the current page should not be a link. + */ +export const Breadcrumb = (props: BreadcrumbProps) => { const { classes, cx } = useStyles(); const { @@ -101,5 +133,3 @@ const removeByIndex = (list: string[], indexToRemove: number) => { return index !== indexToRemove; }); }; - -export { Breadcrumb }; diff --git a/packages/manager/src/components/Breadcrumb/Crumbs.tsx b/packages/manager/src/components/Breadcrumb/Crumbs.tsx index aad932bd1a5..129376bead4 100644 --- a/packages/manager/src/components/Breadcrumb/Crumbs.tsx +++ b/packages/manager/src/components/Breadcrumb/Crumbs.tsx @@ -1,9 +1,8 @@ import { Theme } from '@mui/material/styles'; -import { makeStyles } from '@mui/styles'; -import classNames from 'classnames'; import { LocationDescriptor } from 'history'; import * as React from 'react'; import { Link } from 'react-router-dom'; +import { makeStyles } from 'tss-react/mui'; import { Typography } from 'src/components/Typography'; @@ -11,7 +10,7 @@ import { FinalCrumb } from './FinalCrumb'; import { FinalCrumbPrefix } from './FinalCrumbPrefix'; import { EditableProps, LabelProps } from './types'; -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles()((theme: Theme) => ({ crumb: { fontSize: '1.125rem', lineHeight: 'normal', @@ -56,7 +55,7 @@ interface Props { } export const Crumbs = React.memo((props: Props) => { - const classes = useStyles(); + const { classes, cx } = useStyles(); const { crumbOverrides, @@ -94,9 +93,7 @@ export const Crumbs = React.memo((props: Props) => { data-qa-link > { ); })} - - {/* - for prepending some SVG or other element before the final crumb. - See users detail page for example - */} + {/* for prepending some SVG or other element before the final crumb. */} {labelOptions && labelOptions.prefixComponent && ( )} - - {/* - the final crumb has the possibility of being a link, editable text - or just static text - */} + {/* the final crumb has the possibility of being a link, editable text or just static text */} - {/* - for appending some SVG or other element after the final crumb. - See support ticket detail as an example - */} + for appending some SVG or other element after the final crumb. + See support ticket detail as an example + */} {labelOptions && labelOptions.suffixComponent && labelOptions.suffixComponent} diff --git a/packages/manager/src/components/Breadcrumb/README.md b/packages/manager/src/components/Breadcrumb/README.md deleted file mode 100644 index f7de19527b2..00000000000 --- a/packages/manager/src/components/Breadcrumb/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Breadcrumb - -The purpose of the Breadcrumb component is to add a versatile and dynamic solution for the manager navigation patterns. - -## Description - -The component utilizes the prop.location provided by RouteComponentProps to build the breadCrumb (based on `location.pathname`). It provides a few props for customizing the output. The last item of the crumbs is removed by default in order to pick either a custom one, an editable text component, or the default crumb we slice back in. - -### Props - -- `pathname` (string, required). Should be the props.location provided by react-router-dom. -- `labelTitle` (string, optional). Customize the last crumb text. -- `labelOptions` (LabelProps, optional). Provide options to customize the label overridden above. See labelOptions Props below. -- `removeCrumbX` (number, optional). Remove a crumb by specifying its actual position in the array/url. -- `crumbOverrides` (CrumbOverridesProps[], optional). The override for a crumb works by the position (required) of the crumb (index + 1). Just provide the actual position of the crumb in the array. We can either override the label or link or both. Omitted values will inherit the path default. It is an array, so we can replace as many as needed. -- `onEditHandlers` (EditableProps, optional). Provide an editable text field for the last crumb (ex: linodes and nodeBalancers details). - -### labelOptions Props - -- `linkTo` (string, optional). Make label a link. -- `prefixComponent` (JSX.Element | null, optional). Provides a prefix component. ex: user detail avatar. -- `prefixStyle` (CSSProperties, optional), customize the styles for the `prefixComponent`. -- `suffixComponent`: (JSX.Element | null, optional). Provides a suffix component. ex: ticket detail status chip. -- `subtitle`: (string, optional). Provide a subtitle to the label. ex: ticket detail submission information. -- `noCap`: (boolean, optional). Override the default capitalization for the label. - -## Usage - -The only required prop is `pathname`. Since we need it to be a string passed from RouteComponentProps, we do need to import the props along with `withRouter` for the export. ex: - -```jsx -import { RouteComponentProps, withRouter } from 'react-router-dom'; - -type Props = RouteComponentProps<{}>; - -class MyComponent extends React.Component { - render() { - return ; - } -} - -export default withRouter(MyComponent); -``` - -You can otherwise refer to the storybook examples to implement the breadcrumb as needed. diff --git a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx index 5fac4667a1a..40ad820d84f 100644 --- a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx +++ b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx @@ -1,6 +1,6 @@ +import { useTheme } from '@mui/material'; import { Theme, styled } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; import * as React from 'react'; import { Grid } from '../Grid'; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx index f2aa9005a72..ef09ed73fcb 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Domains/DomainActionMenu.tsx b/packages/manager/src/features/Domains/DomainActionMenu.tsx index f8a0d14dd22..9fec86feb9f 100644 --- a/packages/manager/src/features/Domains/DomainActionMenu.tsx +++ b/packages/manager/src/features/Domains/DomainActionMenu.tsx @@ -1,7 +1,7 @@ import { Domain } from '@linode/api-v4/lib/domains'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import { splitAt } from 'ramda'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx index f52c7073a7c..3c700bfb30a 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx @@ -1,7 +1,7 @@ import { Linode } from '@linode/api-v4/lib/linodes'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx index 2879fe76b02..f4155f593a6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx index 9609e3415dd..0f00cae0113 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx @@ -1,7 +1,7 @@ import { FirewallStatus } from '@linode/api-v4/lib/firewalls'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { Action, ActionMenu } from 'src/components/ActionMenu'; diff --git a/packages/manager/src/features/Kubernetes/ClusterList/ClusterActionMenu.tsx b/packages/manager/src/features/Kubernetes/ClusterList/ClusterActionMenu.tsx index 13064fa4f38..4c6885cd89c 100644 --- a/packages/manager/src/features/Kubernetes/ClusterList/ClusterActionMenu.tsx +++ b/packages/manager/src/features/Kubernetes/ClusterList/ClusterActionMenu.tsx @@ -1,7 +1,7 @@ import { getKubeConfig } from '@linode/api-v4/lib/kubernetes'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import { useSnackbar } from 'notistack'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx index 5f559e1bad7..dd12c2fa8ee 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigActionMenu.tsx @@ -1,7 +1,7 @@ import { Config } from '@linode/api-v4/lib/linodes'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import { splitAt } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx index 024346b4111..a1897950faa 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx @@ -1,7 +1,7 @@ import { IPAddress, IPRange } from '@linode/api-v4/lib/networking'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import { isEmpty } from 'ramda'; import * as React from 'react'; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu.tsx index 2d0d597cbfb..36a297730bf 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu.tsx @@ -2,7 +2,7 @@ import { LinodeBackups, LinodeType } from '@linode/api-v4/lib/linodes'; import { Region } from '@linode/api-v4/lib/regions'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx index aa4e4322fe5..936017a4655 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrationPricing.tsx @@ -1,6 +1,6 @@ import { PriceObject } from '@linode/api-v4'; import { styled } from '@mui/material/styles'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { Box } from 'src/components/Box'; diff --git a/packages/manager/src/features/Managed/Credentials/CredentialActionMenu.tsx b/packages/manager/src/features/Managed/Credentials/CredentialActionMenu.tsx index 04cab62d328..40a88af1771 100644 --- a/packages/manager/src/features/Managed/Credentials/CredentialActionMenu.tsx +++ b/packages/manager/src/features/Managed/Credentials/CredentialActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { ActionMenu, Action } from 'src/components/ActionMenu'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 37be0c2a2b5..ae40e9e4a7e 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import { append, clone, diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx index 21c36e91bc2..dd5e2e85c8e 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx index 35044326b53..27259e0c091 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/FolderActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { ActionMenu, Action } from 'src/components/ActionMenu'; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx index 6949b6cc140..ee01f16219c 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { ActionMenu, Action } from 'src/components/ActionMenu'; diff --git a/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx b/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx index cd9bb11bd9a..0f9ae41f728 100644 --- a/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx +++ b/packages/manager/src/features/Profile/APITokens/APITokenMenu.tsx @@ -1,7 +1,7 @@ import { Token } from '@linode/api-v4/lib/profile'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { ActionMenu, Action } from 'src/components/ActionMenu'; diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx index 354b1509f24..65841d984a4 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClientActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { ActionMenu, Action } from 'src/components/ActionMenu'; diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx index 1160c2004d3..4afd1a31f1e 100644 --- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; diff --git a/packages/manager/src/features/Users/UsersActionMenu.tsx b/packages/manager/src/features/Users/UsersActionMenu.tsx index e2deb1ef9d3..59a0d3e2128 100644 --- a/packages/manager/src/features/Users/UsersActionMenu.tsx +++ b/packages/manager/src/features/Users/UsersActionMenu.tsx @@ -1,6 +1,6 @@ import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx index 8956265be33..fbae965da1c 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx @@ -1,7 +1,7 @@ import { Volume } from '@linode/api-v4'; import { Theme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useTheme } from '@mui/styles'; +import { useTheme } from '@mui/material'; import { splitAt } from 'ramda'; import * as React from 'react'; From 9d323efa71e5565f24c2a7ad1f5560028189a856 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Fri, 27 Oct 2023 14:37:46 -0400 Subject: [PATCH 08/43] feat: [M3-7169, M3-7193, M3-7211, M3-7255, M3-7258] - VPC UX feedback (#9832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Address UX feedback for VPC ## Changes 🔄 - Updated VPC empty landing text - New VPC icon - Minor UI changes to Assign Linode to Subnet drawer - Updated tooltip text for the Auto-assign a VPC IPv4 address checkbox - Minor text UI changes to VPC details page ## How to test 🧪 ### Prerequisites - Point to the dev env and ensure your account has vpc customer tags ### Verification steps Verify the ticket changes requested and check these flows: - VPC empty landing (set line 87 to true in `VPCLanding`) - VPC side menu and create menu icons - Assign Linode to Subnet drawer - VPC details page (changes are more noticeable in Safari) --- ...pr-9832-upcoming-features-1698162070035.md | 5 ++ .../e2e/core/vpc/vpc-details-page.spec.ts | 2 +- .../e2e/core/vpc/vpc-landing-page.spec.ts | 11 ++- .../src/assets/icons/entityIcons/vpc.svg | 7 +- .../Linodes/LinodesCreate/VPCPanel.tsx | 7 +- .../src/features/OneClickApps/oneClickApps.ts | 21 +++--- .../VPCDetail/SubnetAssignLinodesDrawer.tsx | 68 ++++++++++++------- .../VPCs/VPCDetail/SubnetLinodeRow.tsx | 4 +- .../VPCDetail/SubnetUnassignLinodesDrawer.tsx | 34 +++++----- .../src/features/VPCs/VPCDetail/VPCDetail.tsx | 23 +++++-- .../VPCs/VPCLanding/VPCEmptyState.tsx | 3 +- .../VPCs/VPCLanding/VPCEmptyStateData.tsx | 9 ++- .../VPCs/VPCLanding/VPCLanding.test.tsx | 2 +- .../features/VPCs/VPCLanding/VPCLanding.tsx | 5 +- .../manager/src/features/VPCs/constants.ts | 5 ++ 15 files changed, 123 insertions(+), 83 deletions(-) create mode 100644 packages/manager/.changeset/pr-9832-upcoming-features-1698162070035.md diff --git a/packages/manager/.changeset/pr-9832-upcoming-features-1698162070035.md b/packages/manager/.changeset/pr-9832-upcoming-features-1698162070035.md new file mode 100644 index 00000000000..204313ac97b --- /dev/null +++ b/packages/manager/.changeset/pr-9832-upcoming-features-1698162070035.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +VPC UX feedback ([#9832](https://github.com/linode/manager/pull/9832)) diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts index d91f38034de..cc8341ef251 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts @@ -117,7 +117,7 @@ describe('VPC details page', () => { // Confirm that user is redirected to VPC landing page. cy.url().should('endWith', '/vpcs'); - cy.findByText('Create a private and isolated network.'); + cy.findByText('Create a private and isolated network'); }); /** diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts index 40639e974f7..855eb7ff1b5 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts @@ -12,6 +12,7 @@ import { vpcFactory } from '@src/factories'; import { ui } from 'support/ui'; import { randomLabel, randomPhrase } from 'support/util/random'; import { chooseRegion, getRegionById } from 'support/util/regions'; +import { VPC_LABEL } from 'src/features/VPCs/constants'; // TODO Remove feature flag mocks when feature flag is removed from codebase. describe('VPC landing page', () => { @@ -65,10 +66,8 @@ describe('VPC landing page', () => { cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); // Confirm that empty state is shown and that each section is present. - cy.findByText('VPCs').should('be.visible'); - cy.findByText('Create a private and isolated network.').should( - 'be.visible' - ); + cy.findByText(VPC_LABEL).should('be.visible'); + cy.findByText('Create a private and isolated network').should('be.visible'); cy.findByText('Getting Started Guides').should('be.visible'); cy.findByText('Video Playlist').should('be.visible'); @@ -268,9 +267,7 @@ describe('VPC landing page', () => { cy.wait(['@deleteVPC', '@getVPCs']); ui.toast.assertMessage('VPC deleted successfully.'); cy.findByText(mockVPCs[1].label).should('not.exist'); - cy.findByText('Create a private and isolated network.').should( - 'be.visible' - ); + cy.findByText('Create a private and isolated network').should('be.visible'); }); /* diff --git a/packages/manager/src/assets/icons/entityIcons/vpc.svg b/packages/manager/src/assets/icons/entityIcons/vpc.svg index 14ca73da488..c58f06cd668 100644 --- a/packages/manager/src/assets/icons/entityIcons/vpc.svg +++ b/packages/manager/src/assets/icons/entityIcons/vpc.svg @@ -1,5 +1,4 @@ - - - - + + + diff --git a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx index e3837b7f1a4..4feaba4a8ef 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/VPCPanel.tsx @@ -1,4 +1,3 @@ -import { Stack } from 'src/components/Stack'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; @@ -9,10 +8,12 @@ import Select, { Item } from 'src/components/EnhancedSelect'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Link } from 'src/components/Link'; import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { APP_ROOT } from 'src/constants'; +import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP } from 'src/features/VPCs/constants'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useRegionsQuery } from 'src/queries/regions'; @@ -255,10 +256,8 @@ export const VPCPanel = (props: VPCPanelProps) => { Auto-assign a VPC IPv4 address for this Linode in the VPC } diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index 08345a499a2..9536dd263dd 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -1137,11 +1137,11 @@ export const oneClickApps: OCA[] = [ { href: 'https://www.linode.com/docs/products/tools/marketplace/guides/mcffmpegplugins/', - title: 'Deploy MainConcept FFmpeg Plugins through the Linode Marketplace', + title: + 'Deploy MainConcept FFmpeg Plugins through the Linode Marketplace', }, ], - summary: - 'MainConcept FFmpeg Plugins are advanced video encoding tools.', + summary: 'MainConcept FFmpeg Plugins are advanced video encoding tools.', website: 'https://www.mainconcept.com/ffmpeg', }, { @@ -1162,8 +1162,7 @@ export const oneClickApps: OCA[] = [ title: 'Deploy MainConcept Live Encoder through the Linode Marketplace', }, ], - summary: - 'MainConcept Live Encoder is a real time video encoding engine.', + summary: 'MainConcept Live Encoder is a real time video encoding engine.', website: 'https://www.mainconcept.com/live-encoder', }, { @@ -1181,7 +1180,8 @@ export const oneClickApps: OCA[] = [ { href: 'https://www.linode.com/docs/products/tools/marketplace/guides/mcp2avcultratranscoder/', - title: 'Deploy MainConcept P2 AVC Ultra Transcoder through the Linode Marketplace', + title: + 'Deploy MainConcept P2 AVC Ultra Transcoder through the Linode Marketplace', }, ], summary: @@ -1203,7 +1203,8 @@ export const oneClickApps: OCA[] = [ { href: 'https://www.linode.com/docs/products/tools/marketplace/guides/mcp2xavc/', - title: 'Deploy MainConcept XAVC Transcoder through the Linode Marketplace', + title: + 'Deploy MainConcept XAVC Transcoder through the Linode Marketplace', }, ], summary: @@ -1225,7 +1226,8 @@ export const oneClickApps: OCA[] = [ { href: 'https://www.linode.com/docs/products/tools/marketplace/guides/mcp2xdcam/', - title: 'Deploy MainConcept XDCAM Transcoder through the Linode Marketplace', + title: + 'Deploy MainConcept XDCAM Transcoder through the Linode Marketplace', }, ], summary: @@ -2250,8 +2252,7 @@ export const oneClickApps: OCA[] = [ website: 'https://docs.splunk.com/Documentation/Splunk', }, { - alt_description: - 'A private by design messaging platform.', + alt_description: 'A private by design messaging platform.', alt_name: 'Anonymous messaging platform.', categories: ['Productivity'], colors: { diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 6b19eb9c872..35bd7fd5c15 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -3,15 +3,20 @@ import { useFormik } from 'formik'; import * as React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Checkbox } from 'src/components/Checkbox'; import { DownloadCSV } from 'src/components/DownloadCSV/DownloadCSV'; import { Drawer } from 'src/components/Drawer'; +import { FormControlLabel } from 'src/components/FormControlLabel'; import { FormHelperText } from 'src/components/FormHelperText'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { RemovableSelectionsList } from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; import { TextField } from 'src/components/TextField'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { Typography } from 'src/components/Typography'; +import { VPC_AUTO_ASSIGN_IPV4_TOOLTIP } from 'src/features/VPCs/constants'; import { useFormattedDate } from 'src/hooks/useFormattedDate'; import { useUnassignLinode } from 'src/hooks/useUnassignLinode'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; @@ -371,16 +376,25 @@ export const SubnetAssignLinodesDrawer = ( sx={{ marginBottom: '8px' }} value={values.selectedLinode || null} /> - + + + } + label={ + + Auto-assign a VPC IPv4 address for this Linode + + } + data-testid="vpc-ipv4-checkbox" + disabled={userCannotAssignLinodes} + sx={{ marginRight: 0 }} + /> + + {!autoAssignIPv4 && ( { @@ -450,22 +464,24 @@ export const SubnetAssignLinodesDrawer = ( preferredDataLabel="linodeConfigLabel" selectionData={assignedLinodesAndConfigData} /> - + {assignedLinodesAndConfigData.length > 0 && ( + + )}