diff --git a/src/frontend/.eslintrc b/src/frontend/.eslintrc index bbba3f0a0..33d3a90e3 100644 --- a/src/frontend/.eslintrc +++ b/src/frontend/.eslintrc @@ -3,9 +3,9 @@ "eslint:recommended", "plugin:jsx-a11y/recommended", "plugin:prettier/recommended", - "plugin:react/recommended" + ], - "plugins": ["@typescript-eslint","import","jsx-a11y","react"], + "plugins": ["@typescript-eslint","import","jsx-a11y"], "rules": { "prettier/prettier": "error", "arrow-body-style": "off", diff --git a/src/frontend/demo/package.json b/src/frontend/demo/package.json index 1b3e61500..c875b1294 100644 --- a/src/frontend/demo/package.json +++ b/src/frontend/demo/package.json @@ -8,7 +8,7 @@ "@jitsi-magnify/core": "0.1.0", "@jitsi/react-sdk": "1.1.0", "@tanstack/react-query": "4.7.2", - "@tanstack/react-query-devtools": "^4.8.0", + "@tanstack/react-query-devtools": "4.8.0", "axios": "0.27.2", "grommet": "2.25.3", "polished": "4.2.2", @@ -21,7 +21,9 @@ "stream-browserify": "3.0.0", "styled-components": "5.3.5", "validator": "13.7.0", - "web-vitals": "2.1.0" + "web-vitals": "2.1.0", + "@types/react-time-picker": "4.0.2", + "react-time-picker": "5.1.0" }, "devDependencies": { "@testing-library/jest-dom": "5.16.4", diff --git a/src/frontend/demo/src/components/AppRouter/index.tsx b/src/frontend/demo/src/components/AppRouter/index.tsx index fe5d30232..7d853c743 100644 --- a/src/frontend/demo/src/components/AppRouter/index.tsx +++ b/src/frontend/demo/src/components/AppRouter/index.tsx @@ -10,6 +10,7 @@ import { import { getAccountRoutes } from '../../utils/routes/account'; import { getAuthRoute } from '../../utils/routes/auth'; import { getJitsiRoutes } from '../../utils/routes/jitsi'; +import { getMeetingsRoutes } from '../../utils/routes/meetings'; import { getRoomsRoutes, RoomsPath } from '../../utils/routes/rooms'; import { getRootRoute } from '../../utils/routes/root'; import { DefaultProvider } from '../DefaultProvider'; @@ -31,6 +32,7 @@ export const AppRouter = () => { { path: '/app/meetings', element: }, { ...getAccountRoutes(intl) }, { ...getRoomsRoutes(intl) }, + { ...getMeetingsRoutes(intl) }, ]), }, { ...getAuthRoute() }, diff --git a/src/frontend/demo/src/components/DefaultProvider/index.tsx b/src/frontend/demo/src/components/DefaultProvider/index.tsx index 8db03f9fd..58e455af2 100644 --- a/src/frontend/demo/src/components/DefaultProvider/index.tsx +++ b/src/frontend/demo/src/components/DefaultProvider/index.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { AccountPath } from '../../utils/routes/account'; import { AuthPath } from '../../utils/routes/auth'; import { JitsiPath } from '../../utils/routes/jitsi'; +import { MeetingsPath } from '../../utils/routes/meetings'; import { RoomsPath } from '../../utils/routes/rooms'; import { RootPath } from '../../utils/routes/root'; @@ -26,6 +27,7 @@ export const DefaultProvider = ({ ...props }: DefaultProviderProps) => { navigate(JitsiPath.WEB_CONF.replace(':id', roomId)); }, goToRoomsList: () => navigate(RoomsPath.ROOMS), + goToMeetingList: () => navigate(MeetingsPath.MEETINGS), goToRoomSettings: (roomId?: string) => { if (roomId) { navigate(RoomsPath.ROOMS_SETTINGS.replace(':id', roomId)); diff --git a/src/frontend/demo/src/utils/routes/meetings/index.tsx b/src/frontend/demo/src/utils/routes/meetings/index.tsx new file mode 100644 index 000000000..137e785f6 --- /dev/null +++ b/src/frontend/demo/src/utils/routes/meetings/index.tsx @@ -0,0 +1,32 @@ +import { defineMessages, IntlShape } from 'react-intl'; +import { Link, RouteObject } from 'react-router-dom'; +import { MeetingsListView } from '../../../views/meetings/list'; +import { RoomsListView } from '../../../views/rooms/list'; +import { RoomSettingsView } from '../../../views/rooms/settings'; + +export enum MeetingsPath { + MEETINGS = '/app/meetings', + MEETINGS_SETTINGS = '/app/meetings/:id/settings', +} + +const meetingsRouteLabels = defineMessages({ + [MeetingsPath.MEETINGS]: { + defaultMessage: 'Meeting', + description: 'The label of the mettings view.', + id: 'utils.routes.meetings.meetings.label', + }, +}); + +export const getMeetingsRoutes = (intl: IntlShape): RouteObject => { + return { + path: MeetingsPath.MEETINGS, + handle: { + crumb: () => ( + + {intl.formatMessage(meetingsRouteLabels[MeetingsPath.MEETINGS])} + + ), + }, + children: [{ element: , index: true }], + }; +}; diff --git a/src/frontend/demo/src/views/meetings/list/index.tsx b/src/frontend/demo/src/views/meetings/list/index.tsx new file mode 100644 index 000000000..d25abe487 --- /dev/null +++ b/src/frontend/demo/src/views/meetings/list/index.tsx @@ -0,0 +1,17 @@ +import { MyMeetings, createRandomMeeting } from '@jitsi-magnify/core'; +import * as React from 'react'; +import { DefaultPage } from '../../../components/DefaultPage'; + +export function MeetingsListView() { + const myMeetings: string | null = localStorage.getItem('meetings'); + const myMeetingsList: any = myMeetings ? JSON.parse(myMeetings) : []; + const mySortedMeetingsList = myMeetingsList.sort( + (a: any, b: any) => new Date(a.startDateTime).getTime() - new Date(b.startDateTime).getTime(), + ); + + return ( + + + + ); +} diff --git a/src/frontend/magnify/package.json b/src/frontend/magnify/package.json index aed28771c..313056f91 100644 --- a/src/frontend/magnify/package.json +++ b/src/frontend/magnify/package.json @@ -36,7 +36,9 @@ "@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", "@types/validator": "13.7.3", "@typescript-eslint/eslint-plugin": "5.36.2", @@ -56,15 +58,17 @@ "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-time-picker": "5.1.0", "rollup": "2.75.6", "rollup-plugin-dts": "4.2.2", "rollup-plugin-peer-deps-external": "2.2.4", "rollup-plugin-postcss": "4.0.2", "rollup-plugin-terser": "7.0.2", - "storybook-addon-react-router-v6": "^0.2.1", + "storybook-addon-react-router-v6": "0.2.1", "ts-jest": "28.0.4", "ts-node": "10.8.1", "tslib": "2.4.0", @@ -93,11 +97,12 @@ "react-router-dom": "6.3.0", "styled-components": "5.3.5", "validator": "13.7.0", - "yup": "0.32.11" + "yup": "0.32.11", + "react-time-picker": "5.1.0" }, "dependencies": { - "@tanstack/react-query-devtools": "^4.8.0", - "react-router-dom": "6.4.1", + "@tanstack/react-query-devtools": "4.8.0", + "react-router-dom": "6.3.0", "use-debounce": "8.0.4" } } diff --git a/src/frontend/magnify/src/components/app/MagnifyTestingProvider/index.tsx b/src/frontend/magnify/src/components/app/MagnifyTestingProvider/index.tsx index c5b2f832a..f386a934c 100644 --- a/src/frontend/magnify/src/components/app/MagnifyTestingProvider/index.tsx +++ b/src/frontend/magnify/src/components/app/MagnifyTestingProvider/index.tsx @@ -41,15 +41,16 @@ export const MagnifyTestingProvider = (props: MagnifyTestingProviderProps) => { goToAccount: () => console.log('goToAccount'), goToJitsiRoom: () => console.log('goToJitsiRoom'), goToRoomsList: () => console.log('goToRoomsList'), + goToMeetingList: () => console.log('goToRoomsList'), goToRoomSettings: () => console.log('goToRoomSettings'), }; }; return ( - + - + ; + +const Template: ComponentStory = (args, context) => ( +
+ {context.parameters.title} + +
+); + +export const basicDateTimePicker = Template.bind({}); + +basicDateTimePicker.args = { + timeName: 'time', + dateName: 'date', + frenchSuggestions: ['14:00', '15:00'], + localTimeSuggestions: ['2:00 PM', '3:00 PM'], +}; 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..5d86090a4 --- /dev/null +++ b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/FormikDateTimePicker.tsx @@ -0,0 +1,140 @@ +import { ErrorMessage, useField, useFormikContext } from 'formik'; +import { DateInput, DropButton, Box, Text } from 'grommet'; +import { CaretDown } from 'grommet-icons'; +import { DateTime, Settings } from 'luxon'; +import React, { FunctionComponent, useState } from 'react'; +import { useIntl } from 'react-intl'; +import TimePicker, { TimePickerValue } from 'react-time-picker'; +import SuggestionButton from './SuggestionButton'; +import { mergeDateTime } from './utils'; + +export interface formikDateTimePickerProps { + dateName: string; + timeName: string; + frenchSuggestions: string[]; + localTimeSuggestions: string[]; + label: 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 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 onTimeChange = (value: string | undefined) => { + formikContext.setFieldValue(props.timeName, value ? value.toString() : undefined); + + setOpen(false); + }; + + 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); + }; + + React.useEffect(() => { + if (timeField.value === undefined || timeField.value.length === 0) { + return; + } + formikContext.setFieldTouched(props.timeName, true); + }, [timeField.value, dateField.value]); + + const suggestionButtons = props.localTimeSuggestions.map((value: string, index: number) => ( + + )); + + return ( + + {props.label != '' && ( + + )} +
+ + + + + onTimeChange(value ? value.toString() : undefined) + } + > + 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..553570368 --- /dev/null +++ b/src/frontend/magnify/src/components/design-system/Formik/FormikDateTimePicker/SuggestionButton.tsx @@ -0,0 +1,45 @@ +import { Box, Button, Text } from 'grommet'; +import { normalizeColor } from 'grommet/utils'; +import { DateTime } from 'luxon'; +import React, { FunctionComponent, ReactElement } from 'react'; +import { useTheme } from 'styled-components'; +import { mergeDateTime } from './utils'; + +const today = DateTime.now().toISO(); + +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.frenchButtonValue == props.choiceValue; + const chosenDateTime = mergeDateTime(today, props.frenchButtonValue); + 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..748bf10c8 --- /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 | undefined, + timeString: string | undefined, +): string | undefined => { + if (!dateString || !timeString) { + return undefined; + } + try { + const time = Duration.fromISOTime(timeString); + const dateTime = DateTime.fromISO(dateString).set({ + hour: time.hours, + minute: time.minutes, + }); + return dateTime.toISO(); + } catch (e) { + return undefined; + } +}; 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..60518a91c --- /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/design-system/Layout/Header/Nav/ResponsiveLayoutHeaderNav.tsx b/src/frontend/magnify/src/components/design-system/Layout/Header/Nav/ResponsiveLayoutHeaderNav.tsx index 3666e0128..897c2b2f2 100644 --- a/src/frontend/magnify/src/components/design-system/Layout/Header/Nav/ResponsiveLayoutHeaderNav.tsx +++ b/src/frontend/magnify/src/components/design-system/Layout/Header/Nav/ResponsiveLayoutHeaderNav.tsx @@ -1,5 +1,5 @@ import { Box } from 'grommet'; -import { AppsRounded } from 'grommet-icons'; +import { AppsRounded, Calendar } from 'grommet-icons'; import * as React from 'react'; import { FunctionComponent } from 'react'; import { useRouting } from '../../../../../context/routing'; @@ -23,6 +23,11 @@ export const ResponsiveLayoutHeaderNav: FunctionComponent, + label: 'Meetings', + goToRoute: routing.goToMeetingList, + }, ]; return ( diff --git a/src/frontend/magnify/src/components/index.ts b/src/frontend/magnify/src/components/index.ts index 3c4359de6..81d04adf2 100644 --- a/src/frontend/magnify/src/components/index.ts +++ b/src/frontend/magnify/src/components/index.ts @@ -4,3 +4,4 @@ export * from './profile'; export * from './rooms'; export * from './jitsi'; export * from './app'; +export * from './meetings'; diff --git a/src/frontend/magnify/src/components/meetings/MeetingConfig/MeetingConfig.tsx b/src/frontend/magnify/src/components/meetings/MeetingConfig/MeetingConfig.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/frontend/magnify/src/components/meetings/MeetingConfig/index.ts b/src/frontend/magnify/src/components/meetings/MeetingConfig/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/frontend/magnify/src/components/meetings/MeetingRow/MeetingRow.stories.tsx b/src/frontend/magnify/src/components/meetings/MeetingRow/MeetingRow.stories.tsx new file mode 100644 index 000000000..b2c617dee --- /dev/null +++ b/src/frontend/magnify/src/components/meetings/MeetingRow/MeetingRow.stories.tsx @@ -0,0 +1,22 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import React from 'react'; +import { withRouter } from 'storybook-addon-react-router-v6'; +import {createRandomMeeting} from '../../../factories/meetings'; +import createRandomRoom from '../../../factories/rooms'; +import { Meeting } from '../../../types/entities/meeting'; +import MeetingRow, { MeetingRowProps } from './MeetingRow'; + +export default { + title: 'Meetings/MeetingRow', + component: MeetingRow, + decorators: [withRouter], +} as ComponentMeta; + +// Template +const Template: ComponentStory = (args: MeetingRowProps) => ( + +); + +// Stories +export const Simple = Template.bind({}); +Simple.args = { meeting: createRandomMeeting(false, createRandomRoom()) }; diff --git a/src/frontend/magnify/src/components/meetings/MeetingRow/MeetingRow.tsx b/src/frontend/magnify/src/components/meetings/MeetingRow/MeetingRow.tsx new file mode 100644 index 000000000..69be154bd --- /dev/null +++ b/src/frontend/magnify/src/components/meetings/MeetingRow/MeetingRow.tsx @@ -0,0 +1,111 @@ +import { defineMessage } from '@formatjs/intl'; +import { Box, Button, ButtonExtendedProps, Card, Menu, Notification, Spinner, Text } from 'grommet'; +import { MoreVertical } from 'grommet-icons'; +import { Interval } from 'luxon'; +import React from 'react'; +import { useIntl } from 'react-intl'; +import { useRouting } from '../../../context'; + +import { useIsSmallSize } from '../../../hooks/useIsMobile'; +import { Meeting } from '../../../types/entities/meeting'; + +export interface MeetingRowProps { + meeting: Meeting; +} + +const messages = defineMessage({ + join: { + id: 'components.meetings.MeetingRow.join', + defaultMessage: 'Join', + description: 'Join the meeting', + }, +}); + +export default function MeetingRow({ meeting }: MeetingRowProps) { + const intl = useIntl(); + const isSmallSize = useIsSmallSize(); + const menuItems: ButtonExtendedProps[] = []; + const startDateTime: Date = new Date(meeting.startDateTime); + const endDateTime: Date = new Date(meeting.endDateTime); + const routing = useRouting(); + + const meetingDay = startDateTime.toLocaleDateString(intl.locale, { + year: '2-digit', + month: '2-digit', + day: '2-digit', + }); + + const meetingHour = startDateTime.toLocaleTimeString(intl.locale, { + timeStyle: 'short', + }); + + const expectedDuration = Interval.fromDateTimes(startDateTime, endDateTime).toDuration([ + 'hours', + 'minutes', + ]); + + return ( + + + + + + {meetingDay} + + + + + {meetingHour} + + + {`${expectedDuration.hours}h ${Math.floor(expectedDuration.minutes)}min`} + + + + + + + {meeting.room ? meeting.room.name : meeting.name} + + + + + +