diff --git a/package.json b/package.json index 45878d8fd..0c61895a2 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "react": "^16.13.1", "react-apollo": "^3.1.3", "react-apollo-hooks": "^0.5.0", + "react-content-loader": "^6.0.1", "react-datepicker": "^2.9.6", "react-dom": "^16.13.1", "react-gtm-module": "^2.0.11", diff --git a/src/common/components/skeletonLoader/SkeletonLoader.tsx b/src/common/components/skeletonLoader/SkeletonLoader.tsx new file mode 100644 index 000000000..2679c7890 --- /dev/null +++ b/src/common/components/skeletonLoader/SkeletonLoader.tsx @@ -0,0 +1,31 @@ +import React, { FunctionComponent } from 'react'; +import ContentLoader from 'react-content-loader'; + +interface Props { + width?: string; + height?: string; + backgroundColor?: string; + foregroundColor?: string; +} + +const SkeletonLoader: FunctionComponent = ({ + width = '200', + height = '18', + backgroundColor = '#f3f3f3', + foregroundColor = '#ecebeb', +}) => { + return ( + + + + ); +}; + +export default SkeletonLoader; diff --git a/src/domain/course/CoursePageContainer.tsx b/src/domain/course/CoursePageContainer.tsx index 781c72d2d..fa613a449 100644 --- a/src/domain/course/CoursePageContainer.tsx +++ b/src/domain/course/CoursePageContainer.tsx @@ -1,3 +1,4 @@ +import { useApolloClient } from '@apollo/react-hooks'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useParams } from 'react-router'; @@ -5,7 +6,10 @@ import { Link } from 'react-router-dom'; import ErrorHero from '../../common/components/error/ErrorHero'; import LoadingSpinner from '../../common/components/spinner/LoadingSpinner'; -import { useCourseDetailsQuery } from '../../generated/graphql'; +import { + CourseDetailsDocument, + useCourseDetailsQuery, +} from '../../generated/graphql'; import useLocale from '../../hooks/useLocale'; import isClient from '../../util/isClient'; import MainContent from '../app/layout/MainContent'; @@ -15,10 +19,10 @@ import EventClosedHero from '../event/eventClosedHero/EventClosedHero'; import EventContent from '../event/eventContent/EventContent'; import EventHero from '../event/eventHero/EventHero'; import EventPageMeta from '../event/eventPageMeta/EventPageMeta'; -import { isEventClosed } from '../event/EventUtils'; +import { getEventIdFromUrl, isEventClosed } from '../event/EventUtils'; import { useSimilarCoursesQuery } from '../event/queryUtils'; import SimilarEvents from '../event/similarEvents/SimilarEvents'; -import { EventFields } from '../event/types'; +import { EventFields, SuperEventResponse } from '../event/types'; import styles from './coursePage.module.scss'; interface RouteParams { @@ -26,11 +30,16 @@ interface RouteParams { } const CoursePageContainer: React.FC = () => { + const apolloClient = useApolloClient(); const { t } = useTranslation(); const { search } = useLocation(); const params = useParams(); const courseId = params.id; const locale = useLocale(); + const [superEvent, setSuperEvent] = React.useState({ + data: null, + status: 'pending', + }); const { data: courseData, loading } = useCourseDetailsQuery({ variables: { @@ -41,6 +50,29 @@ const CoursePageContainer: React.FC = () => { const course = courseData?.courseDetails; + const superEventId = getEventIdFromUrl(course?.superEvent?.internalId ?? ''); + React.useLayoutEffect(() => { + if (superEventId) { + getSuperEventData(); + } else if (course) { + setSuperEvent({ data: null, status: 'resolved' }); + } + async function getSuperEventData() { + try { + const { data } = await apolloClient.query({ + query: CourseDetailsDocument, + variables: { + id: superEventId, + include: ['in_language', 'keywords', 'location', 'audience'], + }, + }); + setSuperEvent({ data: data.courseDetails, status: 'resolved' }); + } catch (e) { + setSuperEvent({ data: null, status: 'resolved' }); + } + } + }, [apolloClient, course, superEventId]); + const courseClosed = !course || isEventClosed(course); return ( @@ -55,7 +87,11 @@ const CoursePageContainer: React.FC = () => { ) : ( <> - + )} diff --git a/src/domain/course/__tests__/CoursePageContainer.test.tsx b/src/domain/course/__tests__/CoursePageContainer.test.tsx index 3babf6e72..4faa7db1a 100644 --- a/src/domain/course/__tests__/CoursePageContainer.test.tsx +++ b/src/domain/course/__tests__/CoursePageContainer.test.tsx @@ -6,6 +6,7 @@ import { CourseDetailsDocument, CourseListDocument, } from '../../../generated/graphql'; +import getDateRangeStr from '../../../util/getDateRangeStr'; import { fakeEvent, fakeEvents, @@ -70,6 +71,14 @@ const courseRequest = { }, }; +const superEventRequest = { + query: CourseDetailsDocument, + variables: { + id: superEventId, + include: ['in_language', 'keywords', 'location', 'audience'], + }, +}; + const similarCoursesListRequest = { query: CourseListDocument, variables: { @@ -105,6 +114,16 @@ const otherCoursesRequest = { const courseResponse = { data: { courseDetails: course } }; +const superEventResponse = { + data: { + courseDetails: { + ...course, + startTime: '2020-06-22T07:00:00.000000Z', + endTime: '2020-06-25T07:00:00.000000Z', + }, + }, +}; + const similarCoursesResponse = { data: { courseList: fakeEvents( @@ -133,6 +152,25 @@ const mocks = [ }, ]; +const superEventMocks = [ + { + request: courseRequest, + result: courseResponse, + }, + { + request: superEventRequest, + result: superEventResponse, + }, + { + request: otherCoursesRequest, + result: otherCoursesResponse, + }, + { + request: similarCoursesListRequest, + result: similarCoursesResponse, + }, +]; + const testPath = ROUTES.COURSE.replace(':id', id); const routes = [testPath]; @@ -243,3 +281,29 @@ it('should link to courses search when clicking tags', async () => { search: '?text=Avouinti', }); }); + +it('should contain event hero with super event date', async () => { + advanceTo('2020-06-23'); + + renderWithRoute(, { + mocks: superEventMocks, + routes, + path: ROUTES.COURSE, + }); + + await waitFor(() => { + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument(); + }); + + const superDateStr = getDateRangeStr({ + start: superEventResponse.data.courseDetails.startTime, + end: superEventResponse.data.courseDetails.endTime, + locale: 'fi', + includeTime: true, + timeAbbreviation: translations.commons.timeAbbreviation, + }); + + expect( + screen.getByText((_content, el) => el.textContent === superDateStr) + ).toBeInTheDocument(); +}); diff --git a/src/domain/event/eventHero/EventHero.tsx b/src/domain/event/eventHero/EventHero.tsx index 5eff58fc3..a78be50da 100644 --- a/src/domain/event/eventHero/EventHero.tsx +++ b/src/domain/event/eventHero/EventHero.tsx @@ -7,6 +7,7 @@ import { useHistory, useLocation } from 'react-router-dom'; import buttonStyles from '../../../common/components/button/button.module.scss'; import IconButton from '../../../common/components/iconButton/IconButton'; import InfoWithIcon from '../../../common/components/infoWithIcon/InfoWithIcon'; +import SkeletonLoader from '../../../common/components/skeletonLoader/SkeletonLoader'; import Visible from '../../../common/components/visible/Visible'; import useLocale from '../../../hooks/useLocale'; import getDateRangeStr from '../../../util/getDateRangeStr'; @@ -16,15 +17,21 @@ import EventKeywords from '../eventKeywords/EventKeywords'; import LocationText from '../eventLocation/EventLocationText'; import EventName from '../eventName/EventName'; import { getEventFields, getEventPrice } from '../EventUtils'; -import { EventFields, EVENTS_ROUTE_MAPPER, EventType } from '../types'; +import { + EventFields, + EVENTS_ROUTE_MAPPER, + EventType, + SuperEventResponse, +} from '../types'; import styles from './eventHero.module.scss'; export interface Props { event: EventFields; eventType: EventType; + superEvent?: SuperEventResponse; } -const EventHero: React.FC = ({ event, eventType }) => { +const EventHero: React.FC = ({ event, eventType, superEvent }) => { const { t } = useTranslation(); const [showBackupImage, setShowBackupImage] = React.useState(false); const locale = useLocale(); @@ -33,13 +40,13 @@ const EventHero: React.FC = ({ event, eventType }) => { const eventsRoute = EVENTS_ROUTE_MAPPER[eventType]; const { - endTime, + endTime: eventEndTime, imageUrl, keywords, offerInfoUrl, placeholderImage, shortDescription, - startTime, + startTime: eventStartTime, today, thisWeek, showBuyButton, @@ -79,6 +86,15 @@ const EventHero: React.FC = ({ event, eventType }) => { } }, [imageUrl]); + const startTime = + superEvent?.status === 'pending' + ? '' + : superEvent?.data?.startTime || eventStartTime; + const endTime = + superEvent?.status === 'pending' + ? '' + : superEvent?.data?.endTime || eventEndTime; + return (
@@ -111,6 +127,7 @@ const EventHero: React.FC = ({ event, eventType }) => {
{shortDescription}
)} + {superEvent?.status === 'pending' && } {!!startTime && getDateRangeStr({ start: startTime, diff --git a/src/domain/event/eventHero/__tests__/EventHero.test.tsx b/src/domain/event/eventHero/__tests__/EventHero.test.tsx index b03da57b0..822a4e2d1 100644 --- a/src/domain/event/eventHero/__tests__/EventHero.test.tsx +++ b/src/domain/event/eventHero/__tests__/EventHero.test.tsx @@ -8,6 +8,7 @@ import { EventFieldsFragment, OfferFieldsFragment, } from '../../../../generated/graphql'; +import getDateRangeStr from '../../../../util/getDateRangeStr'; import { fakeEvent, fakeExternalLink, @@ -192,3 +193,42 @@ test('Register button should be visible and clickable', () => { expect(global.open).toBeCalledWith(registrationUrl); }); + +test('should have event dates when super event is not defined', () => { + const mockEvent = getFakeEvent(); + + render(); + + const dateStr = getDateRangeStr({ + start: mockEvent.startTime, + end: mockEvent.endTime, + locale: 'fi', + includeTime: true, + timeAbbreviation: translations.commons.timeAbbreviation, + }); + expect(screen.getByText(dateStr)).toBeInTheDocument(); +}); + +test('should have super event dates when super event is defined', () => { + const mockEvent = getFakeEvent(); + const mockSuperEvent = getFakeEvent({ + startTime: '2020-06-22T07:00:00.000000Z', + endTime: '2025-06-25T07:00:00.000000Z', + }); + render( + + ); + + const superDateStr = getDateRangeStr({ + start: mockSuperEvent.startTime, + end: mockSuperEvent.endTime, + locale: 'fi', + includeTime: true, + timeAbbreviation: translations.commons.timeAbbreviation, + }); + expect(screen.getByText(superDateStr)).toBeInTheDocument(); +}); diff --git a/src/domain/event/eventHero/eventHero.module.scss b/src/domain/event/eventHero/eventHero.module.scss index b39faab6f..cb9762e4e 100644 --- a/src/domain/event/eventHero/eventHero.module.scss +++ b/src/domain/event/eventHero/eventHero.module.scss @@ -80,6 +80,7 @@ $logoWidth: 5.5rem; margin-top: 0.5rem; font-size: var(--fontsize-body-l); line-height: 1.625rem; + min-height: 1.625rem; } .title { diff --git a/src/domain/event/eventInfo/otherEventTimes/OtherCourseTimesContainer.tsx b/src/domain/event/eventInfo/otherEventTimes/OtherCourseTimesContainer.tsx index 84b58ccb2..e6b26653b 100644 --- a/src/domain/event/eventInfo/otherEventTimes/OtherCourseTimesContainer.tsx +++ b/src/domain/event/eventInfo/otherEventTimes/OtherCourseTimesContainer.tsx @@ -10,11 +10,7 @@ const OtherCourseTimesContainer: React.FC<{ event: EventFields }> = ({ const { superEventId, ...props } = useOtherCourseTimes(event); return superEventId ? ( - + ) : null; }; diff --git a/src/domain/event/eventInfo/otherEventTimes/OtherEventTimes.tsx b/src/domain/event/eventInfo/otherEventTimes/OtherEventTimes.tsx index 223017fbd..f10172f97 100644 --- a/src/domain/event/eventInfo/otherEventTimes/OtherEventTimes.tsx +++ b/src/domain/event/eventInfo/otherEventTimes/OtherEventTimes.tsx @@ -10,6 +10,7 @@ import { useHistory, useLocation } from 'react-router-dom'; import InfoWithIcon from '../../../../common/components/infoWithIcon/InfoWithIcon'; import linkStyles from '../../../../common/components/link/link.module.scss'; +import SkeletonLoader from '../../../../common/components/skeletonLoader/SkeletonLoader'; import LoadingSpinner from '../../../../common/components/spinner/LoadingSpinner'; import useLocale from '../../../../hooks/useLocale'; import getDateRangeStr from '../../../../util/getDateRangeStr'; @@ -18,7 +19,6 @@ import styles from './otherEventTimes.module.scss'; interface Props { isFetchingMore: boolean; - superEventId: string; loading: boolean; events: EventFields[]; eventType: EventType; @@ -30,7 +30,6 @@ const OtherEventTimes: React.FC = ({ events, loading, isFetchingMore, - superEventId, eventType, }) => { const { t } = useTranslation(); @@ -51,7 +50,17 @@ const OtherEventTimes: React.FC = ({ history.push(eventUrl); }; - if (!superEventId || events.length === 0) return null; + if (loading) { + return ( +
+ +
+ ); + } + + if (events.length === 0) { + return null; + } return (
diff --git a/src/domain/event/eventInfo/otherEventTimes/OtherEventTimesContainer.tsx b/src/domain/event/eventInfo/otherEventTimes/OtherEventTimesContainer.tsx index 82ae10eae..2e9c14a43 100644 --- a/src/domain/event/eventInfo/otherEventTimes/OtherEventTimesContainer.tsx +++ b/src/domain/event/eventInfo/otherEventTimes/OtherEventTimesContainer.tsx @@ -9,9 +9,7 @@ const OtherEventTimesContainer: React.FC<{ event: EventFields }> = ({ }) => { const { superEventId, ...props } = useOtherEventTimes(event); - return superEventId ? ( - - ) : null; + return superEventId ? : null; }; export default OtherEventTimesContainer; diff --git a/src/domain/event/eventInfo/otherEventTimes/otherEventTimes.module.scss b/src/domain/event/eventInfo/otherEventTimes/otherEventTimes.module.scss index 91cffd75e..742b71f66 100644 --- a/src/domain/event/eventInfo/otherEventTimes/otherEventTimes.module.scss +++ b/src/domain/event/eventInfo/otherEventTimes/otherEventTimes.module.scss @@ -1,3 +1,8 @@ +.skeletonWrapper { + height: 2rem; + padding-left: 2.25rem; +} + .otherEventTimes { margin-bottom: 1rem; diff --git a/src/domain/event/types.ts b/src/domain/event/types.ts index a5b578477..dd6127c23 100644 --- a/src/domain/event/types.ts +++ b/src/domain/event/types.ts @@ -11,6 +11,11 @@ export type KeywordOption = { export type EventFields = EventFieldsFragment | CourseFieldsFragment; +export type SuperEventResponse = { + data: EventFields | null; + status: 'pending' | 'resolved'; +}; + export type EventType = 'event' | 'course'; export const EVENT_ROUTE_MAPPER: Record = { diff --git a/yarn.lock b/yarn.lock index c43af1f7f..381621300 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14914,6 +14914,11 @@ react-app-polyfill@^1.0.6: regenerator-runtime "^0.13.3" whatwg-fetch "^3.0.0" +react-content-loader@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/react-content-loader/-/react-content-loader-6.0.1.tgz#1c5ce4a6256d974fcd3ac85460eca24b33226920" + integrity sha512-djJUgGNze7YdWzJA1kYO1eKXAMpP+Z4sMulmEuTVi6vEXzfuQCJs6yD8hhgWj23vvJPZL5b8NyabxteyF8Hq/g== + react-datepicker@^2.9.6: version "2.9.6" resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-2.9.6.tgz#26190c9f71692149d0d163398aa19e08626444b1"