diff --git a/packages/api-v4/.changeset/pr-10268-changed-1710182395545.md b/packages/api-v4/.changeset/pr-10268-changed-1710182395545.md new file mode 100644 index 00000000000..d449b689c52 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10268-changed-1710182395545.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Make `type` and `region` required in `CreateLinodeRequest` ([#10268](https://github.com/linode/manager/pull/10268)) diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index cfbd0dd089b..40249c26f9c 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -350,8 +350,8 @@ export interface CreateLinodePlacementGroupPayload { } export interface CreateLinodeRequest { - type?: string; - region?: string; + type: string; + region: string; stackscript_id?: number; backup_id?: number; swap_size?: number; diff --git a/packages/manager/.changeset/pr-10268-tech-stories-1709929859267.md b/packages/manager/.changeset/pr-10268-tech-stories-1709929859267.md new file mode 100644 index 00000000000..ad6ca3e522e --- /dev/null +++ b/packages/manager/.changeset/pr-10268-tech-stories-1709929859267.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Linode Create Refactor - Part 1 ([#10268](https://github.com/linode/manager/pull/10268)) diff --git a/packages/manager/cypress/support/api/linodes.ts b/packages/manager/cypress/support/api/linodes.ts index 1e0b2dabcca..7b8135af09b 100644 --- a/packages/manager/cypress/support/api/linodes.ts +++ b/packages/manager/cypress/support/api/linodes.ts @@ -18,7 +18,7 @@ export const createMockLinodeList = (data?: {}, listNumber: number = 1) => { ); }; -const defaultLinodeRequestBody: Partial = { +const defaultLinodeRequestBody = { authorized_users: [], backups_enabled: false, booted: true, diff --git a/packages/manager/package.json b/packages/manager/package.json index 4773be9940c..5b59d08dabd 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -57,6 +57,7 @@ "react-csv": "^2.0.3", "react-dom": "^18.2.0", "react-dropzone": "~11.2.0", + "react-hook-form": "^7.51.0", "react-number-format": "^3.5.0", "react-redux": "~7.1.3", "react-router-dom": "~5.3.4", diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 124169402ae..4f7ecade4c8 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx @@ -1,4 +1,4 @@ -import { Capabilities, Region } from '@linode/api-v4/lib/regions'; +import { Capabilities } from '@linode/api-v4/lib/regions'; import { useTheme } from '@mui/material'; import * as React from 'react'; import { useLocation } from 'react-router-dom'; @@ -11,6 +11,7 @@ import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperT import { Typography } from 'src/components/Typography'; import { CROSS_DATA_CENTER_CLONE_WARNING } from 'src/features/Linodes/LinodesCreate/constants'; import { useFlags } from 'src/hooks/useFlags'; +import { useRegionsQuery } from 'src/queries/regions'; import { useTypeQuery } from 'src/queries/types'; import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics'; import { @@ -32,7 +33,6 @@ interface SelectRegionPanelProps { error?: string; handleSelection: (id: string) => void; helperText?: string; - regions: Region[]; selectedId?: string; /** * Include a `selectedLinodeTypeId` so we can tell if the region selection will have an affect on price @@ -47,7 +47,6 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { error, handleSelection, helperText, - regions, selectedId, selectedLinodeTypeId, } = props; @@ -56,6 +55,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { const location = useLocation(); const theme = useTheme(); const params = getQueryParamsFromQueryString(location.search); + const { data: regions } = useRegionsQuery(); const isCloning = /clone/i.test(params.type); @@ -84,28 +84,27 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { const showEdgeIconHelperText = Boolean( !hideEdgeRegions && currentCapability && - regions.find( + regions?.find( (region) => region.site_type === 'edge' && region.capabilities.includes(currentCapability) ) ); - if (props.regions.length === 0) { + if (regions?.length === 0) { return null; } return ( ({ + sx={{ '& svg': { '& g': { // Super hacky fix for Firefox rendering of some flag icons that had a clip-path property. clipPath: 'none !important', }, }, - marginTop: theme.spacing(3), - })} + }} > @@ -138,7 +137,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { handleSelection={handleSelection} helperText={helperText} regionFilter={hideEdgeRegions ? 'core' : undefined} - regions={regions} + regions={regions ?? []} selectedId={selectedId || null} showEdgeIconHelperText={showEdgeIconHelperText} /> diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index fa08c1c2cc8..f322e9a5235 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -29,6 +29,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'recharts', label: 'Recharts' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, { flag: 'placementGroups', label: 'Placement Groups' }, + { flag: 'linodeCreateRefactor', label: 'Linode Create v2' }, ]; export const FeatureFlagTool = withFeatureFlagProvider(() => { diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index e4edef7746c..212c8a375bb 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -55,6 +55,7 @@ export interface Flags { gecko: boolean; ipv6Sharing: boolean; linodeCloneUiChanges: boolean; + linodeCreateRefactor: boolean; linodeCreateWithFirewall: boolean; mainContentBanner: MainContentBanner; metadata: boolean; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx new file mode 100644 index 00000000000..866fb1a314a --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx @@ -0,0 +1,24 @@ +import { Typography } from '@mui/material'; +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +export const Error = () => { + const { formState } = useFormContext(); + + if (!formState.errors.root?.message) { + return null; + } + + return ( + + + {formState.errors.root.message} + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Plan.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Plan.tsx new file mode 100644 index 00000000000..4b5f8aa2c7f --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Plan.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useController, useFormContext } from 'react-hook-form'; + +import { DocsLink } from 'src/components/DocsLink/DocsLink'; +import { PlansPanel } from 'src/features/components/PlansPanel/PlansPanel'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { useRegionsQuery } from 'src/queries/regions'; +import { useAllTypes } from 'src/queries/types'; +import { sendLinodeCreateFlowDocsClickEvent } from 'src/utilities/analytics'; +import { extendType } from 'src/utilities/extendType'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +export const Plan = () => { + const { watch } = useFormContext(); + const { field, fieldState } = useController({ + name: 'type', + }); + + const { data: regions } = useRegionsQuery(); + const { data: types } = useAllTypes(); + + const regionId = watch('region'); + + const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_linodes', + }); + + return ( + { + sendLinodeCreateFlowDocsClickEvent('Choosing a Plan'); + }} + href="https://www.linode.com/docs/guides/choosing-a-compute-instance-plan/" + label="Choosing a Plan" + /> + } + data-qa-select-plan + disabled={isLinodeCreateRestricted} + error={fieldState.error?.message} + isCreate + linodeID={undefined} // @todo add cloning support + onSelect={field.onChange} + regionsData={regions} // @todo move this query deeper if possible + selectedId={field.value} + selectedRegionID={regionId} + showTransfer + types={types?.map(extendType) ?? []} + /> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx new file mode 100644 index 00000000000..92090c96788 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useController } from 'react-hook-form'; + +import { SelectRegionPanel } from 'src/components/SelectRegionPanel/SelectRegionPanel'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +export const Region = () => { + const { field, formState } = useController({ + name: 'region', + }); + + const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_linodes', + }); + + return ( + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx new file mode 100644 index 00000000000..6ab9e2448b5 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx @@ -0,0 +1,22 @@ +import { CreateLinodeRequest } from '@linode/api-v4'; +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { Button } from 'src/components/Button/Button'; +import { Paper } from 'src/components/Paper'; + +export const Summary = () => { + const { formState } = useFormContext(); + + return ( + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx new file mode 100644 index 00000000000..324db813f30 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useHistory } from 'react-router-dom'; + +import { Stack } from 'src/components/Stack'; +import { useCreateLinodeMutation } from 'src/queries/linodes/linodes'; + +import { Error } from './Error'; +import { Plan } from './Plan'; +import { Region } from './Region'; +import { Summary } from './Summary'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; +import type { SubmitHandler } from 'react-hook-form'; + +export const LinodeCreatev2 = () => { + const methods = useForm(); + const history = useHistory(); + + const { mutateAsync: createLinode } = useCreateLinodeMutation(); + + const onSubmit: SubmitHandler = async (data) => { + try { + const linode = await createLinode(data); + + history.push(`/linodes/${linode.id}`); + } catch (errors) { + // @todo this is temporary API error handling. We will develop a more + // robust helper that can convert API errors to react-hook-form errors + for (const error of errors) { + if (error.field) { + methods.setError(error.field, { message: error.reason }); + } else { + methods.setError('root', { message: error.reason }); + } + } + } + }; + + return ( + +
+ + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.test.tsx index 235651efc50..bb991732053 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.test.tsx @@ -9,7 +9,7 @@ import { ApiAwarenessModal, ApiAwarenessModalProps } from './ApiAwarenessModal'; const defaultProps: ApiAwarenessModalProps = { isOpen: false, onClose: vi.fn(), - payLoad: {}, + payLoad: { region: '', type: '' }, route: '', }; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index b4896c4f509..71944344f0f 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -25,6 +25,7 @@ import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { SelectRegionPanel } from 'src/components/SelectRegionPanel/SelectRegionPanel'; +import { Stack } from 'src/components/Stack'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; @@ -511,157 +512,158 @@ export class LinodeCreate extends React.PureComponent< variant="error" /> )} - - - - - - - - - - - - - Create From: - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + Create From: + + + + + + + + + + + + + + + + + + + + + + + + + {this.props.createType !== 'fromBackup' && ( + + )} + { + sendLinodeCreateFlowDocsClickEvent('Choosing a Plan'); + }} + href="https://www.linode.com/docs/guides/choosing-a-compute-instance-plan/" + label="Choosing a Plan" /> - - - - - {this.props.createType !== 'fromBackup' && ( - - )} - { - sendLinodeCreateFlowDocsClickEvent('Choosing a Plan'); - }} - href="https://www.linode.com/docs/guides/choosing-a-compute-instance-plan/" - label="Choosing a Plan" - /> - } - data-qa-select-plan - disabled={userCannotCreateLinode} - disabledClasses={this.props.disabledClasses} - error={hasErrorFor.type} - isCreate - key={this.state.planKey} - linodeID={this.props.selectedLinodeID} - onSelect={this.props.updateTypeID} - regionsData={regionsData!} - selectedId={this.props.selectedTypeID} - selectedRegionID={selectedRegionID} - showTransfer - types={this.filterTypes()} - /> + eachTag.label) : [], - type: this.props.selectedTypeID, + type: this.props.selectedTypeID ?? '', }; if ( @@ -985,7 +987,7 @@ export class LinodeCreate extends React.PureComponent< image: this.props.selectedImageID, label: this.props.label, private_ip: this.props.privateIPEnabled, - region: this.props.selectedRegionID, + region: this.props.selectedRegionID ?? '', root_pass: this.props.password, stackscript_data: this.props.selectedUDFs, // StackScripts @@ -994,7 +996,7 @@ export class LinodeCreate extends React.PureComponent< tags: this.props.tags ? this.props.tags.map((eachTag) => eachTag.label) : [], - type: this.props.selectedTypeID, + type: this.props.selectedTypeID ?? '', }; sendApiAwarenessClickEvent('Button', 'Create Using Command Line'); this.props.checkValidation(payload); diff --git a/packages/manager/src/features/Linodes/index.tsx b/packages/manager/src/features/Linodes/index.tsx index e7bd952a866..4f5bc42d27d 100644 --- a/packages/manager/src/features/Linodes/index.tsx +++ b/packages/manager/src/features/Linodes/index.tsx @@ -2,10 +2,12 @@ import * as React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { useFlags } from 'src/hooks/useFlags'; import { useAllAccountMaintenanceQuery } from 'src/queries/accountMaintenance'; import { useInProgressEvents } from 'src/queries/events/events'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { addMaintenanceToLinodes } from 'src/utilities/linodes'; + import { linodesInTransition } from './transitions'; const LinodesLanding = React.lazy( @@ -15,12 +17,23 @@ const LinodesDetail = React.lazy(() => import('./LinodesDetail/LinodesDetail')); const LinodesCreate = React.lazy( () => import('./LinodesCreate/LinodeCreateContainer') ); +const LinodesCreatev2 = React.lazy(() => + import('./LinodeCreatev2').then((module) => ({ + default: module.LinodeCreatev2, + })) +); -const LinodesRoutes: React.FC = () => { +const LinodesRoutes = () => { + const flags = useFlags(); return ( }> - + @@ -36,7 +49,7 @@ export default LinodesRoutes; // mapStateToProps, but since I wanted to use a query (for accountMaintenance) // I needed a Function Component. It seemed safer to do it this way instead of // refactoring LinodesLanding. -const LinodesLandingWrapper: React.FC = React.memo(() => { +const LinodesLandingWrapper = React.memo(() => { const { data: accountMaintenanceData } = useAllAccountMaintenanceQuery( {}, { status: { '+or': ['pending, started'] } } diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 07ca7deb82a..b89a283c674 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -504,7 +504,6 @@ const NodeBalancerCreate = () => { disabled={isRestricted} error={hasErrorFor('region')} handleSelection={regionChange} - regions={regions ?? []} selectedId={nodeBalancerFields.region} /> {flags.firewallNodebalancer && ( diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index 72ac7f06cd7..51d861124bd 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -1,4 +1,3 @@ -import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { useLocation } from 'react-router-dom'; @@ -66,7 +65,6 @@ export const PlansPanel = (props: Props) => { } = props; const flags = useFlags(); - const theme = useTheme(); const location = useLocation(); const params = getQueryParamsFromQueryString(location.search); @@ -176,7 +174,7 @@ export const PlansPanel = (props: Props) => { innerClass={props.tabbedPanelInnerClass} renderTable={tabs[0].render} rootClass={`${className} tabbedPanel`} - sx={{ marginTop: theme.spacing(3), width: '100%' }} + sx={{ width: '100%' }} /> ); } @@ -191,7 +189,7 @@ export const PlansPanel = (props: Props) => { initTab={initialTab >= 0 ? initialTab : 0} innerClass={props.tabbedPanelInnerClass} rootClass={`${className} tabbedPanel`} - sx={{ marginTop: theme.spacing(3), width: '100%' }} + sx={{ width: '100%' }} tabDisabledMessage={props.tabDisabledMessage} tabs={tabs} /> diff --git a/yarn.lock b/yarn.lock index def2ec61922..e3992646f1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11297,6 +11297,11 @@ react-fast-compare@^2.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== +react-hook-form@^7.51.0: + version "7.51.0" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.51.0.tgz#757ae71b37c26e00590bd3788508287dcc5ecdaf" + integrity sha512-BggOy5j58RdhdMzzRUHGOYhSz1oeylFAv6jUSG86OvCIvlAvS7KvnRY7yoAf2pfEiPN7BesnR0xx73nEk3qIiw== + react-input-autosize@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.2.tgz#fcaa7020568ec206bc04be36f4eb68e647c4d8c2" @@ -13984,9 +13989,9 @@ yallist@^4.0.0: integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== yaml@2.3.1, yaml@^1.10.0, yaml@^1.7.2, yaml@^2.2.2, yaml@^2.3.0, yaml@^2.3.4: - version "2.4.0" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.0.tgz#2376db1083d157f4b3a452995803dbcf43b08140" - integrity sha512-j9iR8g+/t0lArF4V6NE/QCfT+CO7iLqrXAHZbJdo+LfjqP1vR8Fg5bSiaq6Q2lOD1AUEVrEVIgABvBFYojJVYQ== + version "2.4.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.1.tgz#2e57e0b5e995292c25c75d2658f0664765210eed" + integrity sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg== yargs-parser@^11.1.1, yargs-parser@^18.1.3, yargs-parser@^21.1.1: version "18.1.3"