diff --git a/src/frontend/magnify/package.json b/src/frontend/magnify/package.json index 48f438398..bb84e9dcb 100644 --- a/src/frontend/magnify/package.json +++ b/src/frontend/magnify/package.json @@ -36,6 +36,7 @@ "@testing-library/react": "13.3.0", "@testing-library/user-event": "14.2.1", "@types/jest": "28.1.1", + "@types/luxon": "^3.1.0", "@types/react": "18.0.12", "@types/react-time-picker": "^4.0.2", "@types/styled-components": "5.1.25", @@ -57,6 +58,7 @@ "jest": "28.1.1", "jest-css-modules": "2.1.0", "jest-environment-jsdom": "28.1.1", + "luxon": "^3.1.1", "msw": "0.47.4", "postcss": "8.4.14", "prettier": "2.7.0", diff --git a/src/frontend/magnify/src/components/design-system/Formik/FormikDatePicker/FormikDatePicker.stories.tsx b/src/frontend/magnify/src/components/design-system/Formik/FormikDatePicker/FormikDatePicker.stories.tsx deleted file mode 100644 index 93041f703..000000000 --- a/src/frontend/magnify/src/components/design-system/Formik/FormikDatePicker/FormikDatePicker.stories.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import withFormik from '@bbbtech/storybook-formik'; -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import React from 'react'; -import FormikDatePicker from './FormikDatePicker'; - -export default { - title: 'Formik/DatePicker', - component: FormikDatePicker, - decorators: [withFormik], - initialValues: { date: new Date() }, -} as ComponentMeta; - -const Template: ComponentStory = (args, context) => ( -
- {context.parameters.title} - -
-); - -export const basicDatePicker = Template.bind({}); - -basicDatePicker.args = { - name: 'date', -}; diff --git a/src/frontend/magnify/src/components/design-system/Formik/FormikDatePicker/FormikDatePicker.tsx b/src/frontend/magnify/src/components/design-system/Formik/FormikDatePicker/FormikDatePicker.tsx deleted file mode 100644 index 8c61ec7a7..000000000 --- a/src/frontend/magnify/src/components/design-system/Formik/FormikDatePicker/FormikDatePicker.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useField, useFormikContext } from 'formik'; -import { DateInput, Box, Text, ThemeContext, ThemeType } from 'grommet'; -import { Alert } from 'grommet-icons'; -import { normalizeColor } from 'grommet/utils'; -import React, { useState, FunctionComponent } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; -import { useTheme } from 'styled-components'; - -const messages = defineMessages({ - invalidStartingAt: { - defaultMessage: 'Input date is not valid: Starting date should be set in the future.', - description: 'Error message when event scheduling date time update is in the past.', - id: 'components.design-system.Formik.FormikDatePicker.invalidStartingAt', - }, -}); - -const customDateInputTheme = (theme: ThemeType) => { - return { - dateInput: { - container: { - background: normalizeColor('light-2', theme), - round: 'medium', - }, - }, - calendar: { - day: { extend: () => `border-radius: 2em; color: brand` }, - extend: `border-radius: 1em; padding: 1em`, - }, - maskedInput: { - extend: `font-family: roboto`, - }, - }; -}; - -export interface formikDatePickerProps { - name: string; - onChange: (date: string) => void; -} - -const nextYear = new Date(); -nextYear.setFullYear(new Date().getFullYear() + 1); -const today = new Date(); -today.setHours(0, 0, 0, 0); - -const FormikDatePicker: FunctionComponent = ({ ...props }) => { - const [field] = useField(props.name); - const [startingAtError, setStartingAtError] = useState(false); - - const formikContext = useFormikContext(); - const intl = useIntl(); - - const theme = useTheme(); - const onDateChange = (event: { value: string | string[] }) => { - setStartingAtError(false); - let value: string; - if (Array.isArray(event.value)) { - value = ''; - if (event.value.length > 0) { - value = event.value[0]; - } - } else { - value = event.value; - } - setStartingAtError(value != '' && value < today.toISOString()); - formikContext.setFieldValue(props.name, value); - }; - - return ( - - - - {startingAtError && ( - - - - {intl.formatMessage(messages.invalidStartingAt)} - - - )} - - - ); -}; - -export default FormikDatePicker; diff --git a/src/frontend/magnify/src/components/design-system/Formik/FormikDatePicker/index.ts b/src/frontend/magnify/src/components/design-system/Formik/FormikDatePicker/index.ts deleted file mode 100644 index 8d04f8044..000000000 --- a/src/frontend/magnify/src/components/design-system/Formik/FormikDatePicker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, formikDatePickerProps } from './FormikDatePicker'; diff --git a/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/FormikDateTimePicker.stories.tsx b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/FormikDateTimePicker.stories.tsx new file mode 100644 index 000000000..a9d63022f --- /dev/null +++ b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/FormikDateTimePicker.stories.tsx @@ -0,0 +1,30 @@ +import withFormik from '@bbbtech/storybook-formik'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; +import { useIntl } from 'react-intl'; +import { getSuggestions } from '../../../meetings/RegisterMeetingForm/utils'; +import FormikDateTimePicker from './FormikDateTimePicker'; + +export default { + title: 'Formik/DateTimePicker', + component: FormikDateTimePicker, + decorators: [withFormik], + initialValues: { date: new Date() }, +} as ComponentMeta; + +const Template: ComponentStory = (args, context) => ( +
+ {context.parameters.title} + +
+); + +export const basicDateTimePicker = Template.bind({}); +const intl = useIntl(); + +basicDateTimePicker.args = { + timeName: 'time', + dateName: 'date', + frenchSuggestions: getSuggestions('fr'), + localTimeSuggestions: getSuggestions(intl.locale), +}; diff --git a/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/FormikDateTimePicker.tsx b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/FormikDateTimePicker.tsx new file mode 100644 index 000000000..b83086256 --- /dev/null +++ b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/FormikDateTimePicker.tsx @@ -0,0 +1,152 @@ +import { ErrorMessage, useField, useFormikContext } from 'formik'; +import { DateInput, DropButton, Box, Text } from 'grommet'; +import { DateTime, Settings } from 'luxon'; +import React, { FunctionComponent, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import TimePicker, { TimePickerValue } from 'react-time-picker'; +import SuggestionButton from './SuggestionButton'; +import { mergeDateTime } from './utils'; + +const messages = defineMessages({ + invalidTime: { + defaultMessage: 'Input time is not valid: Starting time should be set in the future.', + description: 'Error message when event scheduling date time update is in the past.', + id: 'components.design-system.Formik.FormikDateTimePicker.invalidTime', + }, +}); + +export interface formikDateTimePickerProps { + dateName: string; + timeName: string; + frenchSuggestions: string[]; + localTimeSuggestions: string[]; +} + +const nextYear = new Date(); +nextYear.setFullYear(new Date().getFullYear() + 1); + +const FormikDateTimePicker: FunctionComponent = ({ ...props }) => { + const [open, setOpen] = useState(undefined); + const [dateField] = useField(props.dateName); + const [timeField] = useField(props.timeName); + const [timeError, setTimeError] = useState(false); + + const formikContext = useFormikContext(); + const intl = useIntl(); + Settings.defaultLocale = intl.locale; + + const isToday = + DateTime.fromISO(dateField.value).toFormat('MM-dd-yyyy') == + DateTime.now().toFormat('MM-dd-yyyy'); + const beforeToday = + DateTime.fromISO(dateField.value).toFormat('MM-dd-yyyy') < + DateTime.now().toFormat('MM-dd-yyyy'); + + const onDateChange = (event: { value: string | string[] }) => { + let value: string; + if (Array.isArray(event.value)) { + value = ''; + if (event.value.length > 0) { + value = event.value[0]; + } + } else { + value = event.value; + } + formikContext.setFieldValue(props.dateName, value); + }; + + const onTimeChange = (value: TimePickerValue | string) => { + formikContext.setFieldValue(props.timeName, value.toString()); + console.log(formikContext.errors, formikContext.values); + }; + + const determineIfTimeError = () => { + const chosenDateTime = mergeDateTime(dateField.value, timeField.value); + if ( + timeField.value == undefined + ? new Date(dateField.value).setHours(0, 0, 0, 0) < new Date().setHours(0, 0, 0, 0) + : timeField.value == null + ? true + : chosenDateTime + ? chosenDateTime < DateTime.local().toISO() + : false + ) { + console.log('coucou'); + formikContext.setFieldError(props.timeName, 'nein !!'); + } + }; + + const onTimeSelectChange = (value: string) => { + onTimeChange(value); + setOpen(false); + }; + + React.useEffect(() => { + determineIfTimeError(); + console.log(`date: ${dateField.value}`); + console.log(`time:${timeField.value}`); + console.log(`dateTime: ${mergeDateTime(dateField.value, timeField.value)}`); + console.log(formikContext); + }, [dateField.value, timeField.value]); + + const suggestionButtons = props.localTimeSuggestions.map((value: string, index: number) => ( + + )); + + return ( + + + + setOpen(false)} + open={open} + dropContent={ + + {suggestionButtons} + + } + onOpen={() => { + setOpen(true); + }} + > + + + + { + return ( + + {msg} + + ); + }} + > + + ); +}; + +export default FormikDateTimePicker; diff --git a/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/SuggestionButton.tsx b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/SuggestionButton.tsx new file mode 100644 index 000000000..4754b41a8 --- /dev/null +++ b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/SuggestionButton.tsx @@ -0,0 +1,44 @@ +import { Box, Button, Text } from 'grommet'; +import { normalizeColor } from 'grommet/utils'; +import React, { FunctionComponent, ReactElement } from 'react'; +import { useTheme } from 'styled-components'; +import { mergeDateTime } from './utils'; + +const today = new Date().toISOString(); + +export interface suggestionButtonProps { + buttonValue: string; + frenchButtonValue: string; + choiceValue: string; + onClick: (value: string) => void; + isToday: boolean; + beforeToday: boolean; +} + +const SuggestionButton: FunctionComponent = ({ ...props }): ReactElement => { + const isChosenButton: boolean = props.buttonValue == props.choiceValue; + const chosenDateTime = mergeDateTime(today, props.buttonValue); + const isButtonBeforeNow: boolean = chosenDateTime ? chosenDateTime < today : false; + const theme = useTheme(); + return ( + + ); +}; + +export default SuggestionButton; diff --git a/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/index.ts b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/index.ts new file mode 100644 index 000000000..d91c9912d --- /dev/null +++ b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/index.ts @@ -0,0 +1 @@ +export { default, formikDateTimePickerProps } from '../FormikDateTimePicker/FormikDateTimePicker'; diff --git a/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/utils.tsx b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/utils.tsx new file mode 100644 index 000000000..80ce64964 --- /dev/null +++ b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/utils.tsx @@ -0,0 +1,31 @@ +import { DateTime, Duration } from 'luxon'; + +export const splitDateTime = (dateTimeISO: string | null): { date: string; time: string } => { + if (!dateTimeISO) { + return { date: '', time: '' }; + } + const dateTime = DateTime.fromISO(dateTimeISO); + return { + date: dateTime.toISODate(), + time: dateTime.toLocaleString(DateTime.TIME_24_SIMPLE), + }; +}; + +export const mergeDateTime = ( + dateString: string | null, + timeString: string | null, +): string | null => { + if (!dateString || !timeString) { + return null; + } + try { + const time = Duration.fromISOTime(timeString); + const dateTime = DateTime.fromISO(dateString).set({ + hour: time.hours, + minute: time.minutes, + }); + return dateTime.toISO(); + } catch (e) { + return null; + } +}; diff --git a/src/frontend/magnify/src/components/design-system/Formik/FormikTimePicker/FormikTimePicker.stories.tsx b/src/frontend/magnify/src/components/design-system/Formik/FormikTimePicker/FormikTimePicker.stories.tsx deleted file mode 100644 index 7078b42d3..000000000 --- a/src/frontend/magnify/src/components/design-system/Formik/FormikTimePicker/FormikTimePicker.stories.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import withFormik from '@bbbtech/storybook-formik'; -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import React from 'react'; -import FormikTimePicker from './FormikTimePicker'; - -export default { - title: 'Formik/TimePicker', - component: FormikTimePicker, - decorators: [withFormik], -} as ComponentMeta; - -const Template: ComponentStory = (args, context) => ( -
- {context.parameters.title} - -
-); - -export const basicTimePicker = Template.bind({}); - -basicTimePicker.args = { - name: 'time', - isToday: true, -}; diff --git a/src/frontend/magnify/src/components/design-system/Formik/FormikTimePicker/FormikTimePicker.tsx b/src/frontend/magnify/src/components/design-system/Formik/FormikTimePicker/FormikTimePicker.tsx deleted file mode 100644 index b9f5794d4..000000000 --- a/src/frontend/magnify/src/components/design-system/Formik/FormikTimePicker/FormikTimePicker.tsx +++ /dev/null @@ -1,142 +0,0 @@ -/* eslint-disable react/no-unknown-property */ -import { useField, useFormikContext } from 'formik'; -import { Box, DropButton, Button, Text } from 'grommet'; -import { Alert } from 'grommet-icons'; -import { normalizeColor } from 'grommet/utils'; -import React, { FunctionComponent, ReactElement, useState } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; -import TimePicker, { TimePickerValue } from 'react-time-picker'; -import { useTheme } from 'styled-components'; - -const messages = defineMessages({ - invalidTime: { - defaultMessage: 'Input time is not valid: it should be set in the future.', - description: 'Error message when event scheduling time update is in the past.', - id: 'components.design-system.Formik.FormikTimePicker.invalidTime', - }, -}); - -export interface FormikTimePickerProps { - name: string; - isToday: boolean; - onChange: (event: React.ChangeEvent) => void; -} - -const computeSuggestions = (): string[] => { - let suggestions = ['00:00']; - let minutes: string = '00'; - let hours: string = '00'; - - while (`${hours}:${minutes}` < '23:45') { - console.log(`${hours}:${minutes}`); - if (minutes == '45') { - hours = +hours >= 9 ? (+hours + 1).toString() : `0${(+hours + 1).toString()}`; - minutes = '00'; - } else { - minutes = (+minutes + 15).toString(); - } - suggestions.push(`${hours}:${minutes}`); - } - return suggestions; -}; - -const allSuggestions = computeSuggestions(); - -interface suggestionButtonProps { - buttonValue: string; - choiceValue: string; - onClick: (value: string) => void; -} - -const SuggestionButton: FunctionComponent = ({ ...props }): ReactElement => { - const isChosenButton: boolean = props.buttonValue == props.choiceValue; - const theme = useTheme(); - return ( - - ); -}; - -const FormikTimePicker: FunctionComponent = ({ ...props }) => { - const [field] = useField(props.name); - const formikContext = useFormikContext(); - const [open, setOpen] = useState(undefined); - const [timeError, setTimeError] = useState(false); - const intl = useIntl(); - - const onTimeChange = (value: TimePickerValue | string) => { - const today = new Date(); - if (props.isToday && value < `${today.getHours()}:${today.getMinutes()}`) { - setTimeError(true); - } else { - setTimeError(false); - formikContext.setFieldValue(props.name, value); - } - }; - - const onTimeSelectChange = (value: string) => { - onTimeChange(value); - setOpen(false); - }; - - const suggestionButtons = allSuggestions.map((value: string) => ( - - )); - - return ( - - setOpen(false)} - open={open} - dropContent={ - - {suggestionButtons} - - } - onOpen={() => { - setOpen(true); - }} - > - - - {timeError && ( - - - - {intl.formatMessage(messages.invalidTime)} - - - )} - - ); -}; - -export default FormikTimePicker; diff --git a/src/frontend/magnify/src/components/design-system/Formik/FormikTimePicker/index.ts b/src/frontend/magnify/src/components/design-system/Formik/FormikTimePicker/index.ts deleted file mode 100644 index b25c3106a..000000000 --- a/src/frontend/magnify/src/components/design-system/Formik/FormikTimePicker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, FormikTimePickerProps } from './FormikTimePicker'; diff --git a/src/frontend/magnify/src/components/design-system/Formik/Select/MeetingRecurrence/index.tsx b/src/frontend/magnify/src/components/design-system/Formik/Select/MeetingRecurrence/index.tsx new file mode 100644 index 000000000..8685c03cc --- /dev/null +++ b/src/frontend/magnify/src/components/design-system/Formik/Select/MeetingRecurrence/index.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { FormikSelect } from '../index'; + +type recurrence = 'daily' | 'weekly' | 'monthly' | 'yearly'; + +interface MeetingRecurrenceSelectOption { + value?: recurrence; + label: string; +} + +const messages = defineMessages({ + noRecurrence: { + defaultMessage: "Doesn't repeat itself", + description: 'Define a non-recurrent meeting', + id: 'components.design-system.Formik.Select.MeetingRecurrence.noRecurrence', + }, + dailyRecurrence: { + defaultMessage: 'Every day of the week', + description: 'Define a daily meeting', + id: 'components.design-system.Formik.Select.MeetingRecurrence.dailyRecurrence', + }, + weeklyRecurrence: { + defaultMessage: 'Every week', + description: 'Define a weekly meeting', + id: 'components.design-system.Formik.Select.MeetingRecurrence.weekyRecurrence', + }, + monthlyRecurrence: { + defaultMessage: 'Every month', + description: 'Define a montlhy meeting', + id: 'components.design-system.Formik.Select.MeetingRecurrence.monthlyRecurrence', + }, + yearlyRecurrence: { + defaultMessage: 'Every year', + description: 'Define a yearly meeting', + id: 'components.design-system.Formik.Select.MeetingRecurrence.yearlyRecurrence', + }, +}); + +export interface FormikSelectMeetingRecurrenceProps { + changeCallback: (recurrence: string) => void; +} + +function FormikSelectLanguage({ ...props }: FormikSelectMeetingRecurrenceProps) { + const intl = useIntl(); + + const getAllOptions = (): MeetingRecurrenceSelectOption[] => { + return [ + { value: undefined, label: intl.formatMessage(messages.noRecurrence) }, + { value: 'daily', label: intl.formatMessage(messages.dailyRecurrence) }, + { value: 'weekly', label: intl.formatMessage(messages.weeklyRecurrence) }, + { value: 'monthly', label: intl.formatMessage(messages.monthlyRecurrence) }, + { value: 'yearly', label: intl.formatMessage(messages.yearlyRecurrence) }, + ]; + }; + + return ( + + ); +} + +export default FormikSelectLanguage; diff --git a/src/frontend/magnify/src/components/meetings/RegisterMeetingForm/RegisterMeetingForm.stories.tsx b/src/frontend/magnify/src/components/meetings/RegisterMeetingForm/RegisterMeetingForm.stories.tsx new file mode 100644 index 000000000..388e01ba6 --- /dev/null +++ b/src/frontend/magnify/src/components/meetings/RegisterMeetingForm/RegisterMeetingForm.stories.tsx @@ -0,0 +1,18 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import React from 'react'; +import RegisterMeetingForm from './RegisterMeetingForm'; + +export default { + title: 'Meetings/RegisterMeetingForm', + component: RegisterMeetingForm, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + +); + +// create the template and stories +export const basicRegisterMeetingForm = Template.bind({}); +basicRegisterMeetingForm.args = { + onSuccess: () => alert('Success!'), +}; diff --git a/src/frontend/magnify/src/components/meetings/RegisterMeetingForm/RegisterMeetingForm.tsx b/src/frontend/magnify/src/components/meetings/RegisterMeetingForm/RegisterMeetingForm.tsx new file mode 100644 index 000000000..df10ccc33 --- /dev/null +++ b/src/frontend/magnify/src/components/meetings/RegisterMeetingForm/RegisterMeetingForm.tsx @@ -0,0 +1,147 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { Form, Formik, FormikHelpers } from 'formik'; +import { Box } from 'grommet'; +import React, { useMemo } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import * as Yup from 'yup'; +import { formLabelMessages } from '../../../i18n/Messages/formLabelMessages'; +import { MeetingsRepository } from '../../../services'; +import { Meeting } from '../../../types'; +import { Maybe } from '../../../types/misc'; +import { MagnifyQueryKeys } from '../../../utils/constants/react-query'; +import FormikDateTimePicker from '../../design-system/Formik/FormikDateTimePicker'; +import FormikInput from '../../design-system/Formik/Input'; +import { FormikSubmitButton } from '../../design-system/Formik/SubmitButton/FormikSubmitButton'; +import { getSuggestions } from './utils'; + +const messages = defineMessages({ + namePlaceholder: { + id: 'components.meetings.registerMeetingForm.namePlaceholder', + defaultMessage: 'Meeting name (ex: Biology 101)', + description: 'Placeholder for the name field', + }, + registerMeetingDialogLabel: { + id: 'components.meetings.registerMeetingForm.registerMeetingDialogLabel', + defaultMessage: 'Register new meeting', + description: 'Label for the dialog to register a new meeting', + }, + registerMeetingSubmitLabel: { + id: 'components.meeting.registerMeetingForm.registerMeetingSubmitLabel', + defaultMessage: 'Register meeting', + description: 'Label for the submit button to register a new meeting', + }, +}); + +export interface RegisterMeetingFormProps { + /** + * Function to call when the form is successfully submited, + * after the request to register the meeting has succeeded + */ + onSuccess: (meeting?: Meeting) => void; +} + +export interface RegisterMeetingFormValues { + name: string; + startDate: string | undefined; + startTime: string | undefined; + endDate: string | undefined; + endTime: string | undefined; +} + +interface FormErrors { + slug?: string[]; +} + +const RegisterMeetingForm = ({ onSuccess }: RegisterMeetingFormProps) => { + const intl = useIntl(); + const validationSchema = Yup.object().shape({ + name: Yup.string().required(), + startDate: Yup.string().required(), + endDate: Yup.string().required(), + startTime: Yup.string().required(), + endTime: Yup.string().required(), + }); + const queryClient = useQueryClient(); + + const mutation = useMutation( + MeetingsRepository.create, + { + onSuccess: (newMeeting) => { + queryClient.setQueryData([MagnifyQueryKeys.MEETINGS], (meetings: Meeting[] = []) => { + return [...meetings, newMeeting]; + }); + onSuccess(newMeeting); + }, + }, + ); + + const allSuggestions = getSuggestions(intl.locale); + const allFrenchSuggestions = getSuggestions('fr'); + + const initialValues: RegisterMeetingFormValues = useMemo( + () => ({ + name: '', + startDate: undefined, + endDate: undefined, + startTime: undefined, + endTime: undefined, + }), + [], + ); + + const handleSubmit = ( + values: RegisterMeetingFormValues, + actions: FormikHelpers, + ) => { + mutation.mutate(values, { + onError: (error) => { + const formErrors = error?.response?.data as Maybe; + if (formErrors?.slug) { + actions.setFieldError('name', formErrors.slug.join(',')); + } + }, + }); + }; + + return ( + +
+ + + + + + + + + + + + +
+ ); +}; + +export default RegisterMeetingForm; diff --git a/src/frontend/magnify/src/components/meetings/RegisterMeetingForm/utils.tsx b/src/frontend/magnify/src/components/meetings/RegisterMeetingForm/utils.tsx new file mode 100644 index 000000000..061e79c4e --- /dev/null +++ b/src/frontend/magnify/src/components/meetings/RegisterMeetingForm/utils.tsx @@ -0,0 +1,221 @@ +import { ArrayHelper } from '../../../utils/helpers/array'; + +export const getSuggestions = (locale: string): string[] => { + const frenchSuggestions = [ + '00:00', + '00:15', + '00:30', + '00:45', + '01:00', + '01:15', + '01:30', + '01:45', + '02:00', + '02:15', + '02:30', + '02:45', + '03:00', + '03:15', + '03:30', + '03:45', + '04:00', + '04:15', + '04:30', + '04:45', + '05:00', + '05:15', + '05:30', + '05:45', + '06:00', + '06:15', + '06:30', + '06:45', + '07:00', + '07:15', + '07:30', + '07:45', + '08:00', + '08:15', + '08:30', + '08:45', + '09:00', + '09:15', + '09:30', + '09:45', + '10:00', + '10:15', + '10:30', + '10:45', + '11:00', + '11:15', + '11:30', + '11:45', + '12:00', + '12:15', + '12:30', + '12:45', + '13:00', + '13:15', + '13:30', + '13:45', + '14:00', + '14:15', + '14:30', + '14:45', + '15:00', + '15:15', + '15:30', + '15:45', + '16:00', + '16:15', + '16:30', + '16:45', + '17:00', + '17:15', + '17:30', + '17:45', + '18:00', + '18:15', + '18:30', + '18:45', + '19:00', + '19:15', + '19:30', + '19:45', + '20:00', + '20:15', + '20:30', + '20:45', + '21:00', + '21:15', + '21:30', + '21:45', + '22:00', + '22:15', + '22:30', + '22:45', + '23:00', + '23:15', + '23:30', + '23:45', + ]; + + const otherSuggestions = [ + '12:00 AM', + '12:15 AM', + '12:30 AM', + '12:45 AM', + '01:00 AM', + '01:15 AM', + '01:30 AM', + '01:45 AM', + '02:00 AM', + '02:15 AM', + '02:30 AM', + '02:45 AM', + '03:00 AM', + '03:15 AM', + '03:30 AM', + '03:45 AM', + '04:00 AM', + '04:15 AM', + '04:30 AM', + '04:45 AM', + '05:00 AM', + '05:15 AM', + '05:30 AM', + '05:45 AM', + '06:00 AM', + '06:15 AM', + '06:30 AM', + '06:45 AM', + '07:00 AM', + '07:15 AM', + '07:30 AM', + '07:45 AM', + '08:00 AM', + '08:15 AM', + '08:30 AM', + '08:45 AM', + '09:00 AM', + '09:15 AM', + '09:30 AM', + '09:45 AM', + '10:00 AM', + '10:15 AM', + '10:30 AM', + '10:45 AM', + '11:00 AM', + '11:15 AM', + '11:30 AM', + '11:45 AM', + '12:00 PM', + '12:15 PM', + '12:30 PM', + '12:45 PM', + '01:00 PM', + '01:15 PM', + '01:30 PM', + '01:45 PM', + '02:00 PM', + '02:15 PM', + '02:30 PM', + '02:45 PM', + '03:00 PM', + '03:15 PM', + '03:30 PM', + '03:45 PM', + '04:00 PM', + '04:15 PM', + '04:30 PM', + '04:45 PM', + '05:00 PM', + '05:15 PM', + '05:30 PM', + '05:45 PM', + '06:00 PM', + '06:15 PM', + '06:30 PM', + '06:45 PM', + '07:00 PM', + '07:15 PM', + '07:30 PM', + '07:45 PM', + '08:00 PM', + '08:15 PM', + '08:30 PM', + '08:45 PM', + '09:00 PM', + '09:15 PM', + '09:30 PM', + '09:45 PM', + '10:00 PM', + '10:15 PM', + '10:30 PM', + '10:45 PM', + '11:00 PM', + '11:15 PM', + '11:30 PM', + '11:45 PM', + ]; + + const suggestions = locale == 'fr' ? frenchSuggestions : otherSuggestions; + return suggestions; +}; + +export const computeFirstSuggestion = (suggestionsArray: string[]): number => { + let result = null; + const today = new Date(); + const minuteQuarter = Math.floor(today.getMinutes() / 15); + if (minuteQuarter != 3) { + result = ArrayHelper.findElementIndex( + suggestionsArray, + `${today.getHours()}:${(minuteQuarter + 1) * 15}`, + ); + } else { + result = ArrayHelper.findElementIndex(suggestionsArray, `${today.getHours() + 1}:00`); + } + + let firstSuggestionIndex = result ? result : 0; + return firstSuggestionIndex; +}; diff --git a/src/frontend/magnify/src/services/index.ts b/src/frontend/magnify/src/services/index.ts index a22aecbe3..e7e575360 100644 --- a/src/frontend/magnify/src/services/index.ts +++ b/src/frontend/magnify/src/services/index.ts @@ -1,3 +1,4 @@ +export * from './meetings'; export * from './rooms'; export * from './users'; export * from './routes'; diff --git a/src/frontend/magnify/src/services/meetings/index.ts b/src/frontend/magnify/src/services/meetings/index.ts new file mode 100644 index 000000000..967445b3d --- /dev/null +++ b/src/frontend/magnify/src/services/meetings/index.ts @@ -0,0 +1 @@ +export * from './meetings.repository'; diff --git a/src/frontend/magnify/src/services/meetings/meetings.repository.ts b/src/frontend/magnify/src/services/meetings/meetings.repository.ts new file mode 100644 index 000000000..213ffb011 --- /dev/null +++ b/src/frontend/magnify/src/services/meetings/meetings.repository.ts @@ -0,0 +1,57 @@ +import { AxiosResponse } from 'axios'; +import { CreateMeetingData, MeetingResponse, UpdateMeetingData } from '../../types/api/meeting'; +import { Meeting } from '../../types/entities/meeting'; +import { MeetingsApiRoutes } from '../../utils/routes/api'; +import { MagnifyApi, MagnifyAuthApi } from '../http/http.service'; +import { RoutesBuilderService } from '../routes/RoutesBuilder.service'; + +export class MeetingsRepository { + public static async create(data: CreateMeetingData): Promise { + const response = await MagnifyApi.post(MeetingsApiRoutes.CREATE, data); + return response.data; + } + + public static async get( + meetingId?: string, + asAuthenticatedUser: boolean = true, + ): Promise { + if (!meetingId) { + console.error('MeetingsRepository - get, meetingId is null'); + return null; + } + const url = RoutesBuilderService.build(MeetingsApiRoutes.GET, { id: meetingId }); + let response: AxiosResponse; + if (asAuthenticatedUser) { + response = await MagnifyApi.get(url); + } else { + response = await MagnifyAuthApi.get(url); + } + return response.data; + } + + public static async getAll(): Promise { + const response = await MagnifyApi.get(MeetingsApiRoutes.GET_ALL); + return response.data; + } + + public static async update( + meetingId: string, + data: Partial, + ): Promise { + const url = RoutesBuilderService.build(MeetingsApiRoutes.UPDATE, { id: meetingId }); + const response = await MagnifyApi.patch(url, data); + return response.data; + } + + public static async delete(meetingId?: string): Promise { + if (!meetingId) { + console.error('MeetingsRepository - delete, meetingId is null'); + return null; + } + const url = RoutesBuilderService.build(MeetingsApiRoutes.DELETE, { id: meetingId }); + const response = await MagnifyApi.delete(url); + return response.data; + } +} + +export default MeetingsRepository; diff --git a/src/frontend/magnify/src/types/api/index.ts b/src/frontend/magnify/src/types/api/index.ts index b339918a0..5ce4ce51a 100644 --- a/src/frontend/magnify/src/types/api/index.ts +++ b/src/frontend/magnify/src/types/api/index.ts @@ -1,3 +1,4 @@ export * from './auth'; export { LoginResponse } from './auth'; export * from './room'; +export * from './meeting'; diff --git a/src/frontend/magnify/src/types/api/meeting/index.ts b/src/frontend/magnify/src/types/api/meeting/index.ts new file mode 100644 index 000000000..e031a684f --- /dev/null +++ b/src/frontend/magnify/src/types/api/meeting/index.ts @@ -0,0 +1,7 @@ +import { Meeting } from '../../entities'; + +export interface MeetingResponse extends Meeting {} +export interface CreateMeetingData { + name: string; +} +export interface UpdateMeetingData extends Meeting {} diff --git a/src/frontend/magnify/src/utils/constants/react-query/index.ts b/src/frontend/magnify/src/utils/constants/react-query/index.ts index c834077c2..f65a32382 100644 --- a/src/frontend/magnify/src/utils/constants/react-query/index.ts +++ b/src/frontend/magnify/src/utils/constants/react-query/index.ts @@ -1,4 +1,6 @@ export enum MagnifyQueryKeys { + MEETINGS = 'meetings', + MEETING = 'meeting', ROOMS = 'rooms', ROOM = 'room', AUTH_USER = 'authUser', diff --git a/src/frontend/magnify/src/utils/routes/api/index.ts b/src/frontend/magnify/src/utils/routes/api/index.ts index 6c93494b2..3ff92113d 100644 --- a/src/frontend/magnify/src/utils/routes/api/index.ts +++ b/src/frontend/magnify/src/utils/routes/api/index.ts @@ -1,2 +1,3 @@ +export * from './meetings'; export * from './rooms'; export * from './users'; diff --git a/src/frontend/magnify/src/utils/routes/api/meetings/index.ts b/src/frontend/magnify/src/utils/routes/api/meetings/index.ts new file mode 100644 index 000000000..7136af44f --- /dev/null +++ b/src/frontend/magnify/src/utils/routes/api/meetings/index.ts @@ -0,0 +1,7 @@ +export enum MeetingsApiRoutes { + GET_ALL = '/meetings/', + CREATE = '/meetings/', + UPDATE = '/meeting/:id/', + GET = '/meeting/:id/', + DELETE = '/meeting/:id/', +} diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 704ab0978..d3f6d78c8 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -543,7 +543,7 @@ "license": "MIT", "dependencies": { "@tanstack/react-query-devtools": "^4.8.0", - "react-router-dom": "6.4.1", + "react-router-dom": "6.3.0", "use-debounce": "8.0.4" }, "devDependencies": { @@ -568,6 +568,7 @@ "@testing-library/react": "13.3.0", "@testing-library/user-event": "14.2.1", "@types/jest": "28.1.1", + "@types/luxon": "^3.1.0", "@types/react": "18.0.12", "@types/react-time-picker": "^4.0.2", "@types/styled-components": "5.1.25", @@ -589,6 +590,7 @@ "jest": "28.1.1", "jest-css-modules": "2.1.0", "jest-environment-jsdom": "28.1.1", + "luxon": "^3.1.1", "msw": "0.47.4", "postcss": "8.4.14", "prettier": "2.7.0", @@ -1889,6 +1891,30 @@ "dev": true, "license": "MIT" }, + "magnify/node_modules/react-router": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz", + "integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==", + "dependencies": { + "history": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "magnify/node_modules/react-router-dom": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz", + "integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==", + "dependencies": { + "history": "^5.2.0", + "react-router": "6.3.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "magnify/node_modules/rollup-plugin-postcss": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.2.tgz", @@ -12956,6 +12982,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.1.0.tgz", + "integrity": "sha512-gCd/HcCgjqSxfMrgtqxCgYk/22NBQfypwFUG7ZAyG/4pqs51WLTcUzVp1hqTbieDYeHS3WoVEh2Yv/2l+7B0Vg==", + "dev": true + }, "node_modules/@types/mdast": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", @@ -22821,6 +22853,14 @@ "node": "*" } }, + "node_modules/history": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "dependencies": { + "@babel/runtime": "^7.7.6" + } + }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -27958,6 +27998,15 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.1.tgz", + "integrity": "sha512-Ah6DloGmvseB/pX1cAmjbFvyU/pKuwQMQqz7d0yvuDlVYLTs2WeDHQMpC8tGjm1da+BriHROW/OEIT/KfYg6xw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", @@ -42850,8 +42899,9 @@ "@testing-library/react": "13.3.0", "@testing-library/user-event": "14.2.1", "@types/jest": "28.1.1", + "@types/luxon": "*", "@types/react": "18.0.12", - "@types/react-time-picker": "*", + "@types/react-time-picker": "^4.0.2", "@types/styled-components": "5.1.25", "@types/validator": "13.7.3", "@typescript-eslint/eslint-plugin": "5.36.2", @@ -42871,10 +42921,11 @@ "jest": "28.1.1", "jest-css-modules": "2.1.0", "jest-environment-jsdom": "28.1.1", + "luxon": "^3.1.1", "msw": "0.47.4", "postcss": "8.4.14", "prettier": "2.7.0", - "react-router-dom": "6.4.1", + "react-router-dom": "6.3.0", "react-time-picker": "^5.1.0", "rollup": "2.75.6", "rollup-plugin-dts": "4.2.2", @@ -43667,6 +43718,23 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "react-router": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz", + "integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==", + "requires": { + "history": "^5.2.0" + } + }, + "react-router-dom": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz", + "integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==", + "requires": { + "history": "^5.2.0", + "react-router": "6.3.0" + } + }, "rollup-plugin-postcss": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.2.tgz", @@ -49175,6 +49243,12 @@ "integrity": "sha512-evMDG1bC4rgQg4ku9tKpuMh5iBNEwNa3tf9zRHdP1qlv+1WUg44xat4IxCE14gIpZRGUUWAx2VhItCZc25NfMA==", "dev": true }, + "@types/luxon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.1.0.tgz", + "integrity": "sha512-gCd/HcCgjqSxfMrgtqxCgYk/22NBQfypwFUG7ZAyG/4pqs51WLTcUzVp1hqTbieDYeHS3WoVEh2Yv/2l+7B0Vg==", + "dev": true + }, "@types/mdast": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", @@ -56222,6 +56296,14 @@ "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", "dev": true }, + "history": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "requires": { + "@babel/runtime": "^7.7.6" + } + }, "hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -59890,6 +59972,12 @@ "yallist": "^4.0.0" } }, + "luxon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.1.tgz", + "integrity": "sha512-Ah6DloGmvseB/pX1cAmjbFvyU/pKuwQMQqz7d0yvuDlVYLTs2WeDHQMpC8tGjm1da+BriHROW/OEIT/KfYg6xw==", + "dev": true + }, "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 10cbb10a2..c2b9ad64d 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -3958,6 +3958,11 @@ "resolved" "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.185.tgz" "version" "4.14.185" +"@types/luxon@^3.1.0": + "integrity" "sha512-gCd/HcCgjqSxfMrgtqxCgYk/22NBQfypwFUG7ZAyG/4pqs51WLTcUzVp1hqTbieDYeHS3WoVEh2Yv/2l+7B0Vg==" + "resolved" "https://registry.npmjs.org/@types/luxon/-/luxon-3.1.0.tgz" + "version" "3.1.0" + "@types/mdast@^3.0.0": "integrity" "sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==" "resolved" "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz" @@ -9328,6 +9333,13 @@ "resolved" "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz" "version" "10.7.3" +"history@^5.2.0": + "integrity" "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==" + "resolved" "https://registry.npmjs.org/history/-/history-5.3.0.tgz" + "version" "5.3.0" + dependencies: + "@babel/runtime" "^7.7.6" + "hmac-drbg@^1.0.1": "integrity" "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==" "resolved" "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz" @@ -11766,6 +11778,11 @@ dependencies: "yallist" "^4.0.0" +"luxon@^3.1.1": + "integrity" "sha512-Ah6DloGmvseB/pX1cAmjbFvyU/pKuwQMQqz7d0yvuDlVYLTs2WeDHQMpC8tGjm1da+BriHROW/OEIT/KfYg6xw==" + "resolved" "https://registry.npmjs.org/luxon/-/luxon-3.1.1.tgz" + "version" "3.1.1" + "lz-string@^1.4.4": "integrity" "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==" "resolved" "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz" @@ -14323,7 +14340,23 @@ "resolved" "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz" "version" "0.11.0" -"react-router-dom@^6.3.0", "react-router-dom@6.4.1": +"react-router-dom@^6.3.0": + "integrity" "sha512-MY7NJCrGNVJtGp8ODMOBHu20UaIkmwD2V3YsAOUQoCXFk7Ppdwf55RdcGyrSj+ycSL9Uiwrb3gTLYSnzcRoXww==" + "resolved" "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.1.tgz" + "version" "6.4.1" + dependencies: + "@remix-run/router" "1.0.1" + "react-router" "6.4.1" + +"react-router-dom@6.3.0": + "integrity" "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==" + "resolved" "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz" + "version" "6.3.0" + dependencies: + "history" "^5.2.0" + "react-router" "6.3.0" + +"react-router-dom@6.4.1": "integrity" "sha512-MY7NJCrGNVJtGp8ODMOBHu20UaIkmwD2V3YsAOUQoCXFk7Ppdwf55RdcGyrSj+ycSL9Uiwrb3gTLYSnzcRoXww==" "resolved" "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.1.tgz" "version" "6.4.1" @@ -14338,6 +14371,13 @@ dependencies: "@remix-run/router" "1.0.1" +"react-router@6.3.0": + "integrity" "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==" + "resolved" "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz" + "version" "6.3.0" + dependencies: + "history" "^5.2.0" + "react-scripts@5.0.1": "integrity" "sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==" "resolved" "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz"