diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 694493768e3..80611783fa2 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -163,12 +163,10 @@ interface ConnectionStrings { value: string; } -export type UpdatesFrequency = 'monthly' | 'weekly'; - export interface UpdatesSchedule { day_of_week: number; duration: number; - frequency: UpdatesFrequency; + frequency: 'monthly' | 'weekly'; hour_of_day: number; pending?: PendingUpdates[]; week_of_month: null | number; diff --git a/packages/manager/.changeset/pr-13060-tech-stories-1762357716157.md b/packages/manager/.changeset/pr-13060-tech-stories-1762357716157.md new file mode 100644 index 00000000000..04211c454c6 --- /dev/null +++ b/packages/manager/.changeset/pr-13060-tech-stories-1762357716157.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace Formik with React Hook Form in MaintenanceWindow ([#13060](https://github.com/linode/manager/pull/13060)) diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx index 5b559edd92e..d9b8c332039 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx @@ -27,7 +27,7 @@ import { DatabaseSettingsMaintenance } from './DatabaseSettingsMaintenance'; import DatabaseSettingsMenuItem from './DatabaseSettingsMenuItem'; import DatabaseSettingsResetPasswordDialog from './DatabaseSettingsResetPasswordDialog'; import { DatabaseSettingsSuspendClusterDialog } from './DatabaseSettingsSuspendClusterDialog'; -import MaintenanceWindow from './MaintenanceWindow'; +import { MaintenanceWindow } from './MaintenanceWindow'; export const DatabaseSettings = () => { const { database, disabled } = useDatabaseDetailContext(); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx index a40bfd06db4..ba944b7927e 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx @@ -1,3 +1,4 @@ +import { yupResolver } from '@hookform/resolvers/yup'; import { useDatabaseMutation } from '@linode/queries'; import { Autocomplete, @@ -6,64 +7,23 @@ import { Notice, Radio, RadioGroup, + Stack, TooltipIcon, Typography, } from '@linode/ui'; +import { updateMaintenanceSchema } from '@linode/validation'; +import { styled } from '@mui/material/styles'; import { Button } from 'akamai-cds-react-components'; -import { useFormik } from 'formik'; import { DateTime } from 'luxon'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; +import { useWatch } from 'react-hook-form'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; import { Link } from 'src/components/Link'; import type { Database, UpdatesSchedule } from '@linode/api-v4/lib/databases'; -import type { APIError } from '@linode/api-v4/lib/types'; import type { SelectOption } from '@linode/ui'; -import type { Theme } from '@mui/material/styles'; - -const useStyles = makeStyles()((theme: Theme) => ({ - formControlDropdown: { - '& label': { - overflow: 'visible', - }, - marginRight: '3rem', - minWidth: '125px', - }, - sectionButton: { - alignSelf: 'end', - marginBottom: '1rem', - marginTop: '1rem', - minWidth: 214, - [theme.breakpoints.down('md')]: { - alignSelf: 'flex-start', - }, - }, - sectionText: { - [theme.breakpoints.down('md')]: { - marginBottom: '1rem', - }, - [theme.breakpoints.down('sm')]: { - width: '100%', - }, - width: '65%', - }, - sectionTitle: { - marginBottom: '0.25rem', - }, - sectionTitleAndText: { - width: '100%', - }, - topSection: { - alignItems: 'center', - display: 'flex', - justifyContent: 'space-between', - [theme.breakpoints.down('lg')]: { - flexDirection: 'column', - }, - }, -})); interface Props { database: Database; @@ -74,17 +34,9 @@ interface Props { export const MaintenanceWindow = (props: Props) => { const { database, disabled, timezone } = props; - const [maintenanceUpdateError, setMaintenanceUpdateError] = - React.useState(); - - // This will be set to `true` once a form field has been touched. This is used to disable the - // "Save Changes" button unless there have been changes to the form. - const [formTouched, setFormTouched] = React.useState(false); - const [modifiedWeekSelectionMap, setModifiedWeekSelectionMap] = React.useState[]>([]); - const { classes } = useStyles(); const { enqueueSnackbar } = useSnackbar(); const { mutateAsync: updateDatabase } = useDatabaseMutation( @@ -116,32 +68,21 @@ export const MaintenanceWindow = (props: Props) => { weekSelectionModifier(dayOfWeek.label, weekSelectionMap); }, []); - const handleSaveMaintenanceWindow = ( - values: Omit, - { - setSubmitting, - }: { - setSubmitting: (isSubmitting: boolean) => void; - } - ) => { + const onSubmit = async (values: Partial) => { // @TODO Update this to only send 'updates' and not 'allow_list' when the API supports it. // Additionally, at that time, enable the validationSchema which currently does not work // because allow_list is a required field in the schema. - updateDatabase({ - allow_list: database.allow_list, - updates: values as UpdatesSchedule, - }) - .then(() => { - setSubmitting(false); - enqueueSnackbar('Maintenance Window settings saved successfully.', { - variant: 'success', - }); - setFormTouched(false); - }) - .catch((e: APIError[]) => { - setMaintenanceUpdateError(e); - setSubmitting(false); + try { + await updateDatabase({ + allow_list: database.allow_list, + updates: values as UpdatesSchedule, + }); + enqueueSnackbar('Maintenance Window settings saved successfully.', { + variant: 'success', }); + } catch (errors) { + setError('root', { message: errors[0].reason }); + } }; const utcOffsetInHours = timezone @@ -155,17 +96,29 @@ export const MaintenanceWindow = (props: Props) => { return null; }; - const { errors, handleSubmit, isSubmitting, setFieldValue, touched, values } = - useFormik({ - initialValues: { - day_of_week: database.updates?.day_of_week ?? 1, - frequency: database.updates?.frequency ?? 'weekly', - hour_of_day: database.updates?.hour_of_day ?? 20, - week_of_month: getInitialWeekOfMonth(), - }, - // validationSchema: updateDatabaseSchema, - onSubmit: handleSaveMaintenanceWindow, - }); + const form = useForm>({ + defaultValues: { + day_of_week: database.updates?.day_of_week ?? 1, + frequency: database.updates?.frequency ?? 'weekly', + hour_of_day: database.updates?.hour_of_day ?? 20, + week_of_month: getInitialWeekOfMonth(), + }, + mode: 'onBlur', + resolver: yupResolver(updateMaintenanceSchema), + }); + + const { + control, + formState: { isSubmitting, isDirty, errors }, + handleSubmit, + setValue, + setError, + } = form; + + const [dayOfWeek, hourOfDay, frequency, weekOfMonth] = useWatch({ + control, + name: ['day_of_week', 'hour_of_day', 'frequency', 'week_of_month'], + }); const isLegacy = database.platform === 'rdbms-legacy'; @@ -176,202 +129,215 @@ export const MaintenanceWindow = (props: Props) => { "OS and database engine updates will be performed on the schedule below. Select the frequency, day, and time you'd prefer maintenance to occur."; return ( -
-
-
- - {isLegacy - ? 'Maintenance Window' - : 'Set a Weekly Maintenance Window'} - - {maintenanceUpdateError ? ( - - {maintenanceUpdateError[0].reason} - - ) : null} - - {isLegacy ? typographyLegacyDatabase : typographyDatabase}{' '} - {database.cluster_size !== 3 - ? 'For non-HA plans, expect downtime during this window.' - : null} - -
- - - option.value === value.value - } - label="Day of Week" - noMarginTop - onChange={(_, day) => { - setFormTouched(true); - setFieldValue('day_of_week', day.value); - weekSelectionModifier(day.label, weekSelectionMap); - // If week_of_month is not null (i.e., the user has selected a value for "Repeats on" already), - // refresh the field value so that the selected option displays the chosen day. - if (values.week_of_month) { - setFieldValue('week_of_month', values.week_of_month); - } - }} - options={daySelectionMap} - placeholder="Choose a day" - renderOption={(props, option) => ( -
  • {option.label}
  • - )} - textFieldProps={{ - dataAttrs: { - 'data-qa-weekday-select': true, - }, - }} - value={daySelectionMap.find( - (thisOption) => thisOption.value === values.day_of_week - )} - /> -
    - -
    - option.value === 20 - )} - disableClearable - disabled={disabled} - errorText={ - touched.hour_of_day ? errors.hour_of_day : undefined - } - label="Time" - noMarginTop - onChange={(_, hour) => { - setFormTouched(true); - setFieldValue('hour_of_day', hour?.value); - }} - options={hourSelectionMap} - placeholder="Choose a time" - renderOption={(props, option) => ( -
  • {option.label}
  • + + + + + + {isLegacy + ? 'Maintenance Window' + : 'Set a Weekly Maintenance Window'} + + {errors.root?.message && ( + + {errors.root?.message} + + )} + + {isLegacy ? typographyLegacyDatabase : typographyDatabase}{' '} + {database.cluster_size !== 3 && + 'For non-HA plans, expect downtime during this window.'} + + + + ( + + option.value === value.value + } + label="Day of Week" + noMarginTop + onChange={(_, day) => { + field.onChange(day.value); + weekSelectionModifier(day.label, weekSelectionMap); + }} + options={daySelectionMap} + placeholder="Choose a day" + renderOption={(props, option) => ( +
  • {option.label}
  • + )} + textFieldProps={{ + dataAttrs: { + 'data-qa-weekday-select': true, + }, + }} + value={daySelectionMap.find( + (thisOption) => thisOption.value === dayOfWeek + )} + /> )} - textFieldProps={{ - dataAttrs: { - 'data-qa-time-select': true, - }, - }} - value={hourSelectionMap.find( - (thisOption) => thisOption.value === values.hour_of_day - )} - /> - - UTC is {utcOffsetText(utcOffsetInHours)} hours compared to - your local timezone. Click{' '} - here to view or change - your timezone settings. - - } /> -
    -
    -
    - {isLegacy && ( - ) => { - setFormTouched(true); - setFieldValue('frequency', e.target.value); - if (e.target.value === 'weekly') { - // If the frequency is weekly, set the 'week_of_month' field to null since that should only be specified for a monthly frequency. - setFieldValue('week_of_month', null); - } + + +
    + ( + option.value === 20 + )} + disableClearable + disabled={disabled} + errorText={fieldState.error?.message} + label="Time" + noMarginTop + onChange={(_, hour) => { + field.onChange(hour?.value); + }} + options={hourSelectionMap} + placeholder="Choose a time" + renderOption={(props, option) => ( +
  • {option.label}
  • + )} + textFieldProps={{ + dataAttrs: { + 'data-qa-time-select': true, + }, + }} + value={hourSelectionMap.find( + (thisOption) => thisOption.value === hourOfDay + )} + /> + )} + /> + + UTC is {utcOffsetText(utcOffsetInHours)} hours compared + to your local timezone. Click{' '} + here to view or + change your timezone settings. + + } + /> +
    +
    + + {isLegacy && ( + ( + ) => { + field.onChange(e.target.value); + if (e.target.value === 'weekly') { + // If the frequency is weekly, set the 'week_of_month' field to null since that should only be specified for a monthly frequency. + setValue('week_of_month', null); + } - if (e.target.value === 'monthly') { - const dayOfWeek = - daySelectionMap.find( - (option) => option.value === values.day_of_week - ) ?? daySelectionMap[0]; + if (e.target.value === 'monthly') { + const _dayOfWeek = + daySelectionMap.find( + (option) => option.value === dayOfWeek + ) ?? daySelectionMap[0]; - weekSelectionModifier(dayOfWeek.label, weekSelectionMap); - setFieldValue( - 'week_of_month', - modifiedWeekSelectionMap[0].value - ); - } - }} - > - - {maintenanceFrequencyMap.map((option) => ( - } - key={option.value} - label={option.key} - value={option.value} - /> - ))} - - - )} -
    - {values.frequency === 'monthly' ? ( - - { - setFormTouched(true); - setFieldValue('week_of_month', week?.value); - }} - options={modifiedWeekSelectionMap} - placeholder="Repeats on" - renderOption={(props, option) => ( -
  • {option.label}
  • - )} - textFieldProps={{ - dataAttrs: { - 'data-qa-week-in-month-select': true, - }, - }} - value={modifiedWeekSelectionMap.find( - (thisOption) => thisOption.value === values.week_of_month + weekSelectionModifier( + _dayOfWeek.label, + weekSelectionMap + ); + setValue( + 'week_of_month', + modifiedWeekSelectionMap[0].value + ); + } + }} + > + + {maintenanceFrequencyMap.map((option) => ( + } + key={option.value} + label={option.key} + value={option.value} + /> + ))} + +
    + )} + /> + )} +
    + {frequency === 'monthly' && ( + ( + + { + field.onChange(week.value); + }} + options={modifiedWeekSelectionMap} + placeholder="Repeats on" + renderOption={(props, option) => ( +
  • {option.label}
  • + )} + textFieldProps={{ + dataAttrs: { + 'data-qa-week-in-month-select': true, + }, + }} + value={modifiedWeekSelectionMap.find( + (thisOption) => thisOption.value === weekOfMonth + )} + /> +
    )} /> - - ) : null} -
    -
    - -
    - + )} +
    + + + + + + + ); }; @@ -436,4 +402,35 @@ const utcOffsetText = (utcOffsetInHours: number) => { : `-${utcOffsetInHours}`; }; -export default MaintenanceWindow; +const StyledTypography = styled(Typography, { + label: 'StyledTypography', +})(({ theme }) => ({ + [theme.breakpoints.down('md')]: { + marginBottom: '1rem', + }, + [theme.breakpoints.down('sm')]: { + width: '100%', + }, + width: '65%', +})); + +const StyledStack = styled(Stack, { + label: 'StyledStack', +})(({ theme }) => ({ + flexDirection: 'row', + [theme.breakpoints.down('md')]: { + flexDirection: 'column', + }, +})); + +const StyledButtonStack = styled(Stack, { + label: 'StyledButtonStack', +})(({ theme }) => ({ + alignSelf: 'end', + marginBottom: '1rem', + marginTop: '1rem', + minWidth: 214, + [theme.breakpoints.down('md')]: { + alignSelf: 'flex-start', + }, +})); diff --git a/packages/validation/src/databases.schema.ts b/packages/validation/src/databases.schema.ts index 6e1dc16382a..5cbead4508d 100644 --- a/packages/validation/src/databases.schema.ts +++ b/packages/validation/src/databases.schema.ts @@ -28,6 +28,13 @@ export const getDynamicDatabaseSchema = (isVPCSelected: boolean) => { }); }; +export const updateMaintenanceSchema = object({ + frequency: string().oneOf(['weekly', 'monthly']).optional(), + hour_of_day: number(), + day_of_week: number(), + week_of_month: number().nullable(), +}); + export const updateDatabaseSchema = object({ label: string().notRequired().min(3, LABEL_MESSAGE).max(32, LABEL_MESSAGE), allow_list: array().of(string()).notRequired(),