From d099bccfa5712965d127d91214526aad1470c0bd Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Wed, 13 Dec 2023 12:10:03 -0500 Subject: [PATCH] initial routes features on AGLB create --- .../LoadBalancerCreate/AddRouteDrawer.tsx | 233 ++++++++++++++++++ .../LoadBalancerCreate/EditRouteDrawer.tsx | 97 ++++++++ .../LoadBalancerConfiguration.tsx | 6 +- .../LoadBalancerCreate/LoadBalancerCreate.tsx | 2 +- .../LoadBalancerCreate/Routes.tsx | 146 +++++++++++ .../Routes/EditRouteDrawer.tsx | 2 +- 6 files changed, 480 insertions(+), 6 deletions(-) create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/AddRouteDrawer.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/EditRouteDrawer.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerCreate/Routes.tsx diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/AddRouteDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/AddRouteDrawer.tsx new file mode 100644 index 00000000000..aae3ebc69a2 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/AddRouteDrawer.tsx @@ -0,0 +1,233 @@ +import { CreateRouteSchema } from '@linode/validation'; +import { useFormik, useFormikContext, yupToFormErrors } from 'formik'; +import React, { useState } from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Drawer } from 'src/components/Drawer'; +import { FormControlLabel } from 'src/components/FormControlLabel'; +import { Radio } from 'src/components/Radio/Radio'; +import { RadioGroup } from 'src/components/RadioGroup'; +import { Stack } from 'src/components/Stack'; +import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; + +import { ROUTE_COPY } from '../LoadBalancerDetail/Routes/constants'; +import { getRouteProtocolFromConfigurationProtocol } from '../LoadBalancerDetail/Routes/utils'; +import { LoadBalancerCreateFormData } from './LoadBalancerCreate'; + +import type { Configuration, Route, RoutePayload } from '@linode/api-v4'; + +export interface Props { + configurationIndex: number; + onClose: () => void; + open: boolean; + protocol: Configuration['protocol']; +} + +type Mode = 'existing' | 'new'; + +export const AddRouteDrawer = (props: Props) => { + const { configurationIndex, onClose, open, protocol } = props; + + const { + setFieldValue, + values, + } = useFormikContext(); + + const [mode, setMode] = useState('new'); + + const routeProtocol = getRouteProtocolFromConfigurationProtocol(protocol); + + const existingRoutesInThisConfiguration = values.configurations![ + configurationIndex + ].routes!; + + const onAdd = (route: RoutePayload) => { + setFieldValue(`configurations[${configurationIndex}].routes`, [ + ...values.configurations![configurationIndex].routes!, + route, + ]); + onClose(); + }; + + return ( + + + {ROUTE_COPY.Description.main} + {ROUTE_COPY.Description[routeProtocol]} + + setMode(value as Mode)} value={mode}> + } + label={`Create New ${routeProtocol.toUpperCase()} Route`} + value="new" + /> + } + label="Add Existing Route" + value="existing" + /> + + {mode === 'existing' ? ( + + ) : ( + + )} + + ); +}; + +interface AddExistingRouteFormProps { + existingRoutes: RoutePayload[]; + onAdd: (route: RoutePayload) => void; + onClose: () => void; +} + +interface RouteWithConfig extends RoutePayload { + configurationIndex: number; +} + +const AddExistingRouteForm = (props: AddExistingRouteFormProps) => { + const { existingRoutes, onAdd, onClose } = props; + + const { values } = useFormikContext(); + + const allRoutesAcrossConfurations = values.configurations!.reduce< + RouteWithConfig[] + >((acc, configuration, index) => { + return [ + ...acc, + ...configuration.routes!.map((r) => ({ + ...r, + configurationIndex: index, + })), + ]; + }, []); + + const formik = useFormik<{ route: RoutePayload | null }>({ + initialValues: { + route: null, + }, + onSubmit({ route }) { + if (!route) { + throw new Error('No route selected'); + } + const hasRouteWithSameLabelInConfigration = existingRoutes.some( + (r) => r.label === route.label + ); + if (hasRouteWithSameLabelInConfigration) { + onAdd({ + ...route, + label: `${route.label}-clone`, + }); + } else { + onAdd(route); + } + onClose(); + }, + validate(values) { + if (!values.route) { + return { route: 'Please select an existing route.' }; + } + return {}; + }, + }); + + return ( +
+ + `${option.label} (Configuration ${ + values.configurations![option.configurationIndex].label + ? values.configurations![option.configurationIndex].label + : option.configurationIndex + })` + } + errorText={formik.errors.route} + label="Route" + noMarginTop + onChange={(_, route) => formik.setFieldValue('route', route)} + options={allRoutesAcrossConfurations} + /> + + + ); +}; + +interface AddNewRouteFormProps { + existingRoutes: RoutePayload[]; + onAdd: (route: RoutePayload) => void; + onClose: () => void; + protocol: Route['protocol']; +} + +const AddNewRouteForm = (props: AddNewRouteFormProps) => { + const { existingRoutes, onAdd, onClose, protocol } = props; + + const formik = useFormik({ + initialValues: { + label: '', + protocol, + rules: [], + }, + async onSubmit(route) { + onAdd(route); + }, + validate(values) { + if (existingRoutes.some((route) => route.label === values.label)) { + return { + label: 'Routes must have unique labels within each configuration.', + }; + } + + try { + CreateRouteSchema.validateSync(values, { abortEarly: false }); + return {}; + } catch (error) { + return yupToFormErrors(error); + } + }, + }); + + return ( +
+ + + + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/EditRouteDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/EditRouteDrawer.tsx new file mode 100644 index 00000000000..5c5ada7f684 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/EditRouteDrawer.tsx @@ -0,0 +1,97 @@ +import { RoutePayload } from '@linode/api-v4'; +import { UpdateRouteSchema } from '@linode/validation'; +import { useFormik, useFormikContext, yupToFormErrors } from 'formik'; +import React from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { TextField } from 'src/components/TextField'; + +import { LoadBalancerCreateFormData } from './LoadBalancerCreate'; + +interface Props { + configurationIndex: number; + onClose: () => void; + open: boolean; + routeIndex: number | undefined; +} + +export const EditRouteDrawer = (props: Props) => { + const { configurationIndex, onClose: _onClose, open, routeIndex } = props; + + const { + setFieldValue, + values, + } = useFormikContext(); + + const existingRoutesInThisConfiguration = values.configurations![ + configurationIndex + ].routes!; + + const formik = useFormik({ + enableReinitialize: true, + initialValues: + routeIndex !== undefined && + routeIndex >= 0 && + routeIndex < values.configurations![configurationIndex].routes!.length + ? values.configurations![configurationIndex].routes![routeIndex] + : { label: '', protocol: 'http', rules: [] }, + onSubmit(route) { + setFieldValue( + `configurations[${configurationIndex}].routes[${routeIndex}]`, + route + ); + onClose(); + }, + validate(values) { + if ( + existingRoutesInThisConfiguration.some( + (route) => route.label === values.label + ) + ) { + return { + label: 'Routes must have unique labels within each configuration.', + }; + } + // We must use `validate` insted of validationSchema because Formik decided to convert + // "" to undefined before passing the values to yup. This makes it hard to validate `label`. + // See https://github.com/jaredpalmer/formik/issues/805 + try { + UpdateRouteSchema.validateSync(values, { abortEarly: false }); + return {}; + } catch (error) { + return yupToFormErrors(error); + } + }, + }); + + const onClose = () => { + _onClose(); + formik.resetForm(); + }; + + return ( + +
+ + + +
+ ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.tsx index c129ec20dd3..ad868f6f26f 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerConfiguration.tsx @@ -7,6 +7,7 @@ import { Typography } from 'src/components/Typography'; import { VerticalLinearStepper } from 'src/components/VerticalLinearStepper/VerticalLinearStepper'; import { ConfigurationDetails } from './ConfigurationDetails'; +import { Routes } from './Routes'; import { ServiceTargets } from './ServiceTargets'; import type { LoadBalancerCreateFormData } from './LoadBalancerCreate'; @@ -19,17 +20,14 @@ export const LoadBalancerConfiguration = ({ index }: Props) => { const configurationSteps = [ { content: , - handler: () => null, label: 'Details', }, { content: , - handler: () => null, label: 'Service Targets', }, { - content:
TODO: AGLB - Implement Routes Configuration.
, - handler: () => null, + content: , label: 'Routes', }, ]; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerCreate.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerCreate.tsx index b3587063070..1b574a8de6a 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerCreate.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerCreate.tsx @@ -27,7 +27,7 @@ export interface LoadBalancerCreateFormData extends CreateLoadbalancerPayload { export const initialValues: LoadBalancerCreateFormData = { configurations: [ - { certificates: [], label: '', port: 443, protocol: 'https' }, + { certificates: [], label: '', port: 443, protocol: 'https', routes: [] }, ], label: '', regions: [], diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/Routes.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/Routes.tsx new file mode 100644 index 00000000000..341abff9be3 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/Routes.tsx @@ -0,0 +1,146 @@ +import CloseIcon from '@mui/icons-material/Close'; +import { useFormikContext } from 'formik'; +import React, { useState } from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { Button } from 'src/components/Button/Button'; +import { IconButton } from 'src/components/IconButton'; +import { InputAdornment } from 'src/components/InputAdornment'; +import { Stack } from 'src/components/Stack'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; + +import { AddRouteDrawer } from './AddRouteDrawer'; +import { EditRouteDrawer } from './EditRouteDrawer'; + +import type { LoadBalancerCreateFormData } from './LoadBalancerCreate'; + +interface Props { + configurationIndex: number; +} + +export const Routes = ({ configurationIndex }: Props) => { + const { + setFieldValue, + values, + } = useFormikContext(); + + const [isAddDrawerOpen, setIsAddDrawerOpen] = useState(false); + const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false); + const [selectedRouteIndex, setSelectedRouteIndex] = useState(); + + const [query, setQuery] = useState(); + + const handleRemoveRoute = (index: number) => { + values.configurations![configurationIndex].routes!.splice(index, 1); + setFieldValue( + `configurations[${configurationIndex}].routes`, + values.configurations?.[configurationIndex].routes + ); + }; + + const handleEditRoute = (index: number) => { + setSelectedRouteIndex(index); + setIsEditDrawerOpen(true); + }; + + return ( + + Routes + + + Load balancer uses traffic routing rules to select the service target + for the incoming request. + + + + + setQuery('')} + size="small" + sx={{ padding: 'unset' }} + > + + + + ), + }} + hideLabel + label="Filter" + onChange={(e) => setQuery(e.target.value)} + placeholder="Filter" + value={query} + /> + + + + + Route Label + Rules + + + + + {values.configurations![configurationIndex].routes!.length === + 0 && } + {values.configurations?.[configurationIndex].routes + ?.filter((route) => { + if (query) { + return route.label.includes(query); + } + return true; + }) + .map((route, index) => ( + + {route.label} + {route.rules.length} + + handleEditRoute(index), + title: 'Edit Label', + }, + { + onClick: () => handleRemoveRoute(index), + title: 'Remove', + }, + ]} + ariaLabel={`Action Menu for Route ${route.label}`} + /> + + + ))} + +
+
+ setIsAddDrawerOpen(false)} + open={isAddDrawerOpen} + protocol={values.configurations![configurationIndex].protocol} + /> + setIsEditDrawerOpen(false)} + open={isEditDrawerOpen} + routeIndex={selectedRouteIndex} + /> +
+ ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/EditRouteDrawer.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/EditRouteDrawer.tsx index e1d49a0170f..e66b51d24e3 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/EditRouteDrawer.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Routes/EditRouteDrawer.tsx @@ -1,4 +1,5 @@ import { UpdateRoutePayload } from '@linode/api-v4'; +import { UpdateRouteSchema } from '@linode/validation'; import { useFormik, yupToFormErrors } from 'formik'; import React from 'react'; @@ -16,7 +17,6 @@ import { capitalize } from 'src/utilities/capitalize'; import { getFormikErrorsFromAPIErrors } from 'src/utilities/formikErrorUtils'; import type { Route } from '@linode/api-v4'; -import { UpdateRouteSchema } from '@linode/validation'; interface Props { loadbalancerId: number;