richie-education 2.34.1-dev5 → 2.34.1-dev51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/js/components/ContractFrame/AbstractContractFrame.spec.tsx +1 -1
- package/js/components/CourseGlimpse/CourseGlimpseFooter.tsx +84 -13
- package/js/components/CourseGlimpse/index.spec.tsx +80 -5
- package/js/components/CourseGlimpse/index.tsx +92 -76
- package/js/components/CourseGlimpse/utils.ts +31 -1
- package/js/components/Icon/index.tsx +7 -0
- package/js/components/OpenEdxFullNameForm/index.spec.tsx +17 -7
- package/js/components/OpenEdxFullNameForm/index.tsx +13 -16
- package/js/components/SaleTunnel/index.full-process.spec.tsx +1 -1
- package/js/pages/DashboardCreditCardsManagement/index.spec.tsx +9 -8
- package/js/types/Course.ts +18 -0
- package/js/types/index.ts +5 -0
- package/js/utils/test/expectAlert.ts +63 -0
- package/js/utils/test/factories/richie.ts +31 -1
- package/js/widgets/Slider/components/Slide.tsx +20 -0
- package/js/widgets/Slider/components/SlidePanel.tsx +83 -0
- package/js/widgets/Slider/components/Slideshow.tsx +58 -0
- package/js/widgets/Slider/index.spec.tsx +167 -0
- package/js/widgets/Slider/index.tsx +119 -0
- package/js/widgets/Slider/types/index.ts +8 -0
- package/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRun/index.tsx +107 -0
- package/js/widgets/SyllabusCourseRunsList/components/SyllabusCourseRunCompacted/index.tsx +107 -0
- package/js/widgets/SyllabusCourseRunsList/index.spec.tsx +450 -5
- package/js/widgets/index.tsx +3 -0
- package/package.json +45 -43
- package/scss/colors/_theme.scss +26 -7
- package/scss/components/_header.scss +108 -14
- package/scss/components/_subheader.scss +35 -0
- package/scss/components/templates/courses/cms/_program_detail.scss +71 -0
- package/scss/components/templates/richie/slider/_slider.scss +165 -99
- package/scss/objects/_course_glimpses.scss +103 -8
- package/scss/objects/_selector.scss +1 -0
- package/scss/settings/_variables.scss +4 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ButtonElement, Input } from '@openfun/cunningham-react';
|
|
1
|
+
import { ButtonElement, Input, Alert, VariantType } from '@openfun/cunningham-react';
|
|
2
2
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
|
3
3
|
import { FormProvider, useForm } from 'react-hook-form';
|
|
4
4
|
import * as Yup from 'yup';
|
|
@@ -8,7 +8,6 @@ import { useSession } from 'contexts/SessionContext';
|
|
|
8
8
|
import useOpenEdxProfile from 'hooks/useOpenEdxProfile';
|
|
9
9
|
import Form, { getLocalizedCunninghamErrorProp } from 'components/Form';
|
|
10
10
|
import { Spinner } from 'components/Spinner';
|
|
11
|
-
import Banner, { BannerType } from 'components/Banner';
|
|
12
11
|
import { useSaleTunnelContext } from 'components/SaleTunnel/GenericSaleTunnel';
|
|
13
12
|
|
|
14
13
|
const messages = defineMessages({
|
|
@@ -19,14 +18,15 @@ const messages = defineMessages({
|
|
|
19
18
|
},
|
|
20
19
|
fullNameInputLabel: {
|
|
21
20
|
id: 'components.OpenEdxFullNameForm.fullNameInputLabel',
|
|
22
|
-
description: 'Label of "
|
|
23
|
-
defaultMessage: '
|
|
21
|
+
description: 'Label of "First name and last name" field of the openEdx full name form',
|
|
22
|
+
defaultMessage: 'First name and last name',
|
|
24
23
|
},
|
|
25
24
|
fullNameInputDescription: {
|
|
26
25
|
id: 'components.OpenEdxFullNameForm.fullNameInputDescription',
|
|
27
|
-
description:
|
|
26
|
+
description:
|
|
27
|
+
'Description about the "First name and last name" field of the openEdx full name form.',
|
|
28
28
|
defaultMessage:
|
|
29
|
-
'Please check that your
|
|
29
|
+
'Please check that your first name and last name are correct. They will be used on official document (e.g: certificate, contract, etc.)',
|
|
30
30
|
},
|
|
31
31
|
submitButtonLabel: {
|
|
32
32
|
id: 'components.OpenEdxFullNameForm.submitButtonLabel',
|
|
@@ -128,27 +128,24 @@ const OpenEdxFullNameForm = () => {
|
|
|
128
128
|
|
|
129
129
|
if (error) {
|
|
130
130
|
// display get error message
|
|
131
|
-
return <
|
|
131
|
+
return <Alert type={VariantType.ERROR}>{error}</Alert>;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
return (
|
|
135
135
|
<FormProvider {...form}>
|
|
136
136
|
<Form name="openedx-fullname-form" noValidate>
|
|
137
|
+
<Alert type={formState.errors.name?.message ? VariantType.ERROR : VariantType.WARNING}>
|
|
138
|
+
<FormattedMessage {...messages.fullNameInputDescription} />
|
|
139
|
+
</Alert>
|
|
137
140
|
<Input
|
|
138
141
|
{...register('name')}
|
|
139
|
-
className="form-field"
|
|
142
|
+
className="form-field mt-s"
|
|
140
143
|
required
|
|
141
144
|
fullWidth
|
|
142
145
|
label={intl.formatMessage(messages.fullNameInputLabel)}
|
|
143
146
|
value={formState.defaultValues?.name}
|
|
144
|
-
state={
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
text={
|
|
148
|
-
error ||
|
|
149
|
-
getLocalizedCunninghamErrorProp(intl, formState.errors.name?.message).text ||
|
|
150
|
-
intl.formatMessage(messages.fullNameInputDescription)
|
|
151
|
-
}
|
|
147
|
+
state={error || formState.errors.name?.message ? 'error' : 'default'}
|
|
148
|
+
text={error || getLocalizedCunninghamErrorProp(intl, formState.errors.name?.message).text}
|
|
152
149
|
/>
|
|
153
150
|
</Form>
|
|
154
151
|
</FormProvider>
|
|
@@ -184,7 +184,7 @@ describe('SaleTunnel', () => {
|
|
|
184
184
|
*/
|
|
185
185
|
screen.getByText('Information');
|
|
186
186
|
|
|
187
|
-
const nameInput = screen.getByLabelText('
|
|
187
|
+
const nameInput = screen.getByLabelText('First name and last name');
|
|
188
188
|
await user.type(nameInput, 'John Doe');
|
|
189
189
|
|
|
190
190
|
/**
|
|
@@ -134,13 +134,14 @@ describe('<DashboardCreditCardsManagement/>', () => {
|
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
it('renders the correct label for an expiration date that will soon expire', async () => {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
const now = new Date();
|
|
138
|
+
const expirationDate = faker.date.between({
|
|
139
|
+
from: now,
|
|
140
|
+
to: new Date(now.getFullYear(), now.getMonth() + 4, 0, 23, 59, 59),
|
|
141
|
+
});
|
|
141
142
|
const creditCard: CreditCard = CreditCardFactory({
|
|
142
|
-
expiration_month:
|
|
143
|
-
expiration_year:
|
|
143
|
+
expiration_month: expirationDate.getMonth() + 1,
|
|
144
|
+
expiration_year: expirationDate.getFullYear(),
|
|
144
145
|
}).one();
|
|
145
146
|
|
|
146
147
|
fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard]);
|
|
@@ -151,11 +152,11 @@ describe('<DashboardCreditCardsManagement/>', () => {
|
|
|
151
152
|
expect(screen.queryByText('An error occurred', { exact: false })).toBeNull();
|
|
152
153
|
const element = await screen.findByText(
|
|
153
154
|
'Expires on ' +
|
|
154
|
-
(
|
|
155
|
+
(expirationDate.getMonth() + 1).toLocaleString(undefined, {
|
|
155
156
|
minimumIntegerDigits: 2,
|
|
156
157
|
}) +
|
|
157
158
|
'/' +
|
|
158
|
-
|
|
159
|
+
expirationDate.getFullYear(),
|
|
159
160
|
);
|
|
160
161
|
expect(element.classList).toContain('dashboard-credit-card__expiration');
|
|
161
162
|
expect(element.classList).not.toContain('dashboard-credit-card__expiration--expired');
|
package/js/types/Course.ts
CHANGED
|
@@ -3,6 +3,19 @@ import { Resource } from 'types/Resource';
|
|
|
3
3
|
import { Nullable } from 'types/utils';
|
|
4
4
|
import { CourseListItem as JoanieCourse } from 'types/Joanie';
|
|
5
5
|
|
|
6
|
+
export enum CourseOffer {
|
|
7
|
+
PAID = 'paid',
|
|
8
|
+
FREE = 'free',
|
|
9
|
+
PARTIALLY_FREE = 'partially_free',
|
|
10
|
+
SUBSCRIPTION = 'subscription',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export enum CourseCertificateOffer {
|
|
14
|
+
PAID = 'paid',
|
|
15
|
+
FREE = 'free',
|
|
16
|
+
SUBSCRIPTION = 'subscription',
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
export interface Course extends Resource {
|
|
7
20
|
absolute_url: string;
|
|
8
21
|
categories: string[];
|
|
@@ -29,6 +42,11 @@ export interface Course extends Resource {
|
|
|
29
42
|
}>;
|
|
30
43
|
organizations: string[];
|
|
31
44
|
state: CourseState;
|
|
45
|
+
certificate_offer: Nullable<CourseCertificateOffer>;
|
|
46
|
+
offer: Nullable<CourseOffer>;
|
|
47
|
+
certificate_price: Nullable<number>;
|
|
48
|
+
price: Nullable<number>;
|
|
49
|
+
price_currency: string;
|
|
32
50
|
}
|
|
33
51
|
|
|
34
52
|
export function isRichieCourse(course: Course | JoanieCourse): course is Course {
|
package/js/types/index.ts
CHANGED
|
@@ -35,6 +35,11 @@ export interface CourseRun {
|
|
|
35
35
|
title?: string;
|
|
36
36
|
snapshot?: string;
|
|
37
37
|
display_mode: CourseRunDisplayMode;
|
|
38
|
+
price?: number;
|
|
39
|
+
price_currency?: string;
|
|
40
|
+
offer?: string;
|
|
41
|
+
certificate_price?: number;
|
|
42
|
+
certificate_offer?: string;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
export enum Priority {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { waitFor } from '@testing-library/react';
|
|
2
|
+
import { VariantType } from '@openfun/cunningham-react';
|
|
3
|
+
|
|
4
|
+
export const expectAlertError = (message: string, rootElement: ParentNode = document) => {
|
|
5
|
+
return expectAlert(VariantType.ERROR, message, rootElement);
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const expectAlertInfo = (message: string, rootElement: ParentNode = document) => {
|
|
9
|
+
return expectAlert(VariantType.INFO, message, rootElement);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const expectAlertSuccess = (message: string, rootElement: ParentNode = document) => {
|
|
13
|
+
return expectAlert(VariantType.SUCCESS, message, rootElement);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const expectAlertWarning = (message: string, rootElement: ParentNode = document) => {
|
|
17
|
+
return expectAlert(VariantType.WARNING, message, rootElement);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const expectAlert = (
|
|
21
|
+
type: VariantType,
|
|
22
|
+
message: string,
|
|
23
|
+
rootElement: ParentNode = document,
|
|
24
|
+
) => {
|
|
25
|
+
return waitFor(async () => {
|
|
26
|
+
// Cunningham Alert has a class with the alert variant type
|
|
27
|
+
const alert = rootElement.querySelector(`.c__alert--${type}`) as HTMLElement;
|
|
28
|
+
expect(alert).not.toBeNull();
|
|
29
|
+
expect(alert).toHaveTextContent(message);
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const expectNoAlert = (
|
|
34
|
+
type: VariantType,
|
|
35
|
+
message: string,
|
|
36
|
+
rootElement: ParentNode = document,
|
|
37
|
+
) => {
|
|
38
|
+
return waitFor(() => {
|
|
39
|
+
// Check that no alert exists with this message and type
|
|
40
|
+
const alerts = rootElement.querySelectorAll('.c__alert');
|
|
41
|
+
const matchingAlert = Array.from(alerts).find(
|
|
42
|
+
(alert) =>
|
|
43
|
+
alert.classList.contains(`c__alert--${type}`) && alert.textContent?.includes(message),
|
|
44
|
+
);
|
|
45
|
+
expect(matchingAlert).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const expectNoAlertError = (message: string, rootElement: ParentNode = document) => {
|
|
50
|
+
return expectNoAlert(VariantType.ERROR, message, rootElement);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const expectNoAlertInfo = (message: string, rootElement: ParentNode = document) => {
|
|
54
|
+
return expectNoAlert(VariantType.INFO, message, rootElement);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const expectNoAlertSuccess = (message: string, rootElement: ParentNode = document) => {
|
|
58
|
+
return expectNoAlert(VariantType.SUCCESS, message, rootElement);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const expectNoAlertWarning = (message: string, rootElement: ParentNode = document) => {
|
|
62
|
+
return expectNoAlert(VariantType.WARNING, message, rootElement);
|
|
63
|
+
};
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
PacedCourse,
|
|
11
11
|
Priority,
|
|
12
12
|
} from 'types';
|
|
13
|
-
import { Course } from 'types/Course';
|
|
13
|
+
import { CourseCertificateOffer, Course, CourseOffer } from 'types/Course';
|
|
14
14
|
import { FactoryHelper } from 'utils/test/factories/helper';
|
|
15
15
|
import { factory } from './factories';
|
|
16
16
|
|
|
@@ -45,7 +45,27 @@ export const CourseStateFutureOpenFactory = factory<CourseState>(() => {
|
|
|
45
45
|
};
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
enum OfferType {
|
|
49
|
+
PAID = 'PAID',
|
|
50
|
+
FREE = 'FREE',
|
|
51
|
+
PARTIALLY_FREE = 'PARTIALLY_FREE',
|
|
52
|
+
SUBSCRIPTION = 'SUBSCRIPTION',
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
export const CourseRunFactory = factory<CourseRun>(() => {
|
|
56
|
+
const offerValues = Object.values(OfferType);
|
|
57
|
+
const offer = offerValues[Math.floor(Math.random() * offerValues.length)];
|
|
58
|
+
const certificateOfferValues = [OfferType.PAID, OfferType.FREE, OfferType.SUBSCRIPTION];
|
|
59
|
+
const certificateOffer =
|
|
60
|
+
certificateOfferValues[Math.floor(Math.random() * certificateOfferValues.length)];
|
|
61
|
+
const currency = faker.finance.currency().code;
|
|
62
|
+
const price = [OfferType.FREE, OfferType.PARTIALLY_FREE].includes(offer)
|
|
63
|
+
? 0
|
|
64
|
+
: parseFloat(faker.finance.amount({ min: 1, max: 100, symbol: currency, autoFormat: true }));
|
|
65
|
+
const certificatePrice =
|
|
66
|
+
certificateOffer === OfferType.FREE
|
|
67
|
+
? 0
|
|
68
|
+
: parseFloat(faker.finance.amount({ min: 1, max: 100, symbol: currency, autoFormat: true }));
|
|
49
69
|
return {
|
|
50
70
|
id: faker.number.int(),
|
|
51
71
|
resource_link: FactoryHelper.unique(faker.internet.url),
|
|
@@ -58,6 +78,11 @@ export const CourseRunFactory = factory<CourseRun>(() => {
|
|
|
58
78
|
dashboard_link: null,
|
|
59
79
|
title: faker.lorem.sentence(3),
|
|
60
80
|
display_mode: CourseRunDisplayMode.DETAILED,
|
|
81
|
+
price,
|
|
82
|
+
price_currency: currency,
|
|
83
|
+
offer,
|
|
84
|
+
certificate_price: certificatePrice,
|
|
85
|
+
certificate_offer: certificateOffer,
|
|
61
86
|
};
|
|
62
87
|
});
|
|
63
88
|
|
|
@@ -214,5 +239,10 @@ export const CourseLightFactory = factory<Course>(() => {
|
|
|
214
239
|
},
|
|
215
240
|
organizations: [organizationName],
|
|
216
241
|
state: CourseStateFactory().one(),
|
|
242
|
+
certificate_offer: CourseCertificateOffer.FREE,
|
|
243
|
+
offer: CourseOffer.FREE,
|
|
244
|
+
certificate_price: null,
|
|
245
|
+
price: null,
|
|
246
|
+
price_currency: 'EUR',
|
|
217
247
|
};
|
|
218
248
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Slide as SlideType } from '../types';
|
|
2
|
+
|
|
3
|
+
const Slide = ({ slide }: { slide: SlideType }) => (
|
|
4
|
+
<div className="slider__slide">
|
|
5
|
+
{slide.link_url ? (
|
|
6
|
+
<a
|
|
7
|
+
href={slide.link_url}
|
|
8
|
+
target={slide.link_open_blank ? '_blank' : '_self'}
|
|
9
|
+
rel="noopener noreferrer"
|
|
10
|
+
title={`Go to ${slide.link_url}`}
|
|
11
|
+
>
|
|
12
|
+
<img src={slide.image} alt={slide.title} loading="lazy" />
|
|
13
|
+
</a>
|
|
14
|
+
) : (
|
|
15
|
+
<img src={slide.image} alt={slide.title} loading="lazy" />
|
|
16
|
+
)}
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export default Slide;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
3
|
+
import { Slide } from '../types';
|
|
4
|
+
|
|
5
|
+
const messages = defineMessages({
|
|
6
|
+
goToSlide: {
|
|
7
|
+
id: 'widgets.Slider.components.SlidePanel.goToSlide',
|
|
8
|
+
defaultMessage: 'Go to slide {slideIndex}',
|
|
9
|
+
description: 'Aria label for the bullet buttons to go to a given slide.',
|
|
10
|
+
},
|
|
11
|
+
slideAriaLabel: {
|
|
12
|
+
id: 'widgets.Slider.components.SlidePanel.slideAriaLabel',
|
|
13
|
+
defaultMessage: 'Slide {slideNumber}: {slideTitle}',
|
|
14
|
+
description: 'Aria label for the current slide.',
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
type SlidePanelProps = {
|
|
19
|
+
slides: readonly Slide[];
|
|
20
|
+
activeSlideIndex: number;
|
|
21
|
+
isTransitioning: boolean;
|
|
22
|
+
onBulletClick: (index: number) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* This component is used to display the panel for the slideshow.
|
|
27
|
+
* It renders the textual content of the current slide and also the bullet points.
|
|
28
|
+
*/
|
|
29
|
+
const SlidePanel = ({
|
|
30
|
+
slides,
|
|
31
|
+
activeSlideIndex,
|
|
32
|
+
onBulletClick,
|
|
33
|
+
isTransitioning,
|
|
34
|
+
}: SlidePanelProps) => {
|
|
35
|
+
const intl = useIntl();
|
|
36
|
+
const hasSlideContent = slides.some((slide) => slide.content);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<section className="slider__panel" aria-label="Slide information">
|
|
40
|
+
<div
|
|
41
|
+
className={classNames('slide__content', {
|
|
42
|
+
'slide__content--transitioning': isTransitioning,
|
|
43
|
+
})}
|
|
44
|
+
role="group"
|
|
45
|
+
aria-roledescription="slide"
|
|
46
|
+
aria-label={intl.formatMessage(messages.slideAriaLabel, {
|
|
47
|
+
slideNumber: activeSlideIndex + 1,
|
|
48
|
+
slideTitle: slides[activeSlideIndex].title,
|
|
49
|
+
})}
|
|
50
|
+
>
|
|
51
|
+
<strong className="slide__title">
|
|
52
|
+
<span>{slides[activeSlideIndex].title}</span>
|
|
53
|
+
</strong>
|
|
54
|
+
{hasSlideContent && (
|
|
55
|
+
<div
|
|
56
|
+
className="slide__description"
|
|
57
|
+
// eslint-disable-next-line react/no-danger
|
|
58
|
+
dangerouslySetInnerHTML={{ __html: slides[activeSlideIndex].content }}
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
<div className="slider__bullet-list" role="tablist" aria-label="Slide navigation">
|
|
63
|
+
{slides.map((slide, index) => (
|
|
64
|
+
<button
|
|
65
|
+
key={slide.pk}
|
|
66
|
+
className={classNames('slider__bullet-item', {
|
|
67
|
+
'slider__bullet-item--active': activeSlideIndex === index,
|
|
68
|
+
})}
|
|
69
|
+
onClick={() => onBulletClick(index)}
|
|
70
|
+
role="tab"
|
|
71
|
+
aria-selected={activeSlideIndex === index}
|
|
72
|
+
>
|
|
73
|
+
<span className="offscreen">
|
|
74
|
+
<FormattedMessage {...messages.goToSlide} values={{ slideIndex: index + 1 }} />
|
|
75
|
+
</span>
|
|
76
|
+
</button>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</section>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default SlidePanel;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
2
|
+
import { Icon, IconTypeEnum } from 'components/Icon';
|
|
3
|
+
import { Slide as SlideType } from '../types';
|
|
4
|
+
import Slide from './Slide';
|
|
5
|
+
|
|
6
|
+
const messages = defineMessages({
|
|
7
|
+
nextSlide: {
|
|
8
|
+
id: 'widgets.Slider.components.Slideshow.nextSlide',
|
|
9
|
+
defaultMessage: 'Next slide',
|
|
10
|
+
description: 'Aria label to navigate to the next slide.',
|
|
11
|
+
},
|
|
12
|
+
previousSlide: {
|
|
13
|
+
id: 'widgets.Slider.components.Slideshow.previousSlide',
|
|
14
|
+
defaultMessage: 'Previous slide',
|
|
15
|
+
description: 'Aria label to navigate to the previous slide.',
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
type SlideshowProps = {
|
|
19
|
+
slides: readonly SlideType[];
|
|
20
|
+
onNextSlide: () => void;
|
|
21
|
+
onPreviousSlide: () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* This component is used to display the slideshow.
|
|
26
|
+
* It renders the slides and the navigation buttons.
|
|
27
|
+
*/
|
|
28
|
+
const Slideshow = ({ slides, onNextSlide, onPreviousSlide }: SlideshowProps) => {
|
|
29
|
+
const intl = useIntl();
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
<div className="slider__slideshow">
|
|
34
|
+
{slides.map((slide) => (
|
|
35
|
+
<Slide key={slide.pk} slide={slide} />
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
<div className="slider__slideshow-overlay">
|
|
39
|
+
<button
|
|
40
|
+
className="slider__navigation-button"
|
|
41
|
+
onClick={onPreviousSlide}
|
|
42
|
+
aria-label={intl.formatMessage(messages.previousSlide)}
|
|
43
|
+
>
|
|
44
|
+
<Icon name={IconTypeEnum.CHEVRON_LEFT_OUTLINE} aria-hidden="true" />
|
|
45
|
+
</button>
|
|
46
|
+
<button
|
|
47
|
+
className="slider__navigation-button"
|
|
48
|
+
onClick={onNextSlide}
|
|
49
|
+
aria-label={intl.formatMessage(messages.nextSlide)}
|
|
50
|
+
>
|
|
51
|
+
<Icon name={IconTypeEnum.CHEVRON_RIGHT_OUTLINE} aria-hidden="true" />
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default Slideshow;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { screen, fireEvent, render, within } from '@testing-library/react';
|
|
2
|
+
import { IntlProvider } from 'react-intl';
|
|
3
|
+
import useEmblaCarousel from 'embla-carousel-react';
|
|
4
|
+
import { Slide } from './types';
|
|
5
|
+
import Slider from '.';
|
|
6
|
+
|
|
7
|
+
// Mock the embla-carousel-react hook
|
|
8
|
+
jest.mock('embla-carousel-react', () => {
|
|
9
|
+
const mockEmblaApi = {
|
|
10
|
+
scrollTo: jest.fn(),
|
|
11
|
+
scrollNext: jest.fn(),
|
|
12
|
+
scrollPrev: jest.fn(),
|
|
13
|
+
on: jest.fn(),
|
|
14
|
+
off: jest.fn(),
|
|
15
|
+
selectedScrollSnap: jest.fn(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return () => [
|
|
19
|
+
jest.fn(), // ref
|
|
20
|
+
mockEmblaApi,
|
|
21
|
+
];
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const mockUseEmblaCarousel = useEmblaCarousel as jest.Mocked<typeof useEmblaCarousel>;
|
|
25
|
+
|
|
26
|
+
describe('<Slider />', () => {
|
|
27
|
+
const mockSlides: Slide[] = [
|
|
28
|
+
{
|
|
29
|
+
pk: '1',
|
|
30
|
+
title: 'Slide 1',
|
|
31
|
+
content: 'Content 1',
|
|
32
|
+
image: 'image1.jpg',
|
|
33
|
+
link_url: 'https://example.com/1',
|
|
34
|
+
link_open_blank: false,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
pk: '2',
|
|
38
|
+
title: 'Slide 2',
|
|
39
|
+
content: 'Content 2',
|
|
40
|
+
image: 'image2.jpg',
|
|
41
|
+
link_url: 'https://example.com/2',
|
|
42
|
+
link_open_blank: false,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
pk: '3',
|
|
46
|
+
title: 'Slide 3',
|
|
47
|
+
content: 'Content 3',
|
|
48
|
+
image: 'image3.jpg',
|
|
49
|
+
link_url: 'https://example.com/3',
|
|
50
|
+
link_open_blank: false,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const defaultProps = {
|
|
55
|
+
pk: '1',
|
|
56
|
+
title: 'Test Slider',
|
|
57
|
+
slides: mockSlides,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
jest.resetAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('renders the slider with all slides', () => {
|
|
65
|
+
render(
|
|
66
|
+
<IntlProvider locale="en">
|
|
67
|
+
<Slider {...defaultProps} />
|
|
68
|
+
</IntlProvider>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Check if all slides are rendered
|
|
72
|
+
mockSlides.forEach((slide) => {
|
|
73
|
+
expect(screen.getByRole('img', { name: slide.title })).toBeInTheDocument();
|
|
74
|
+
// Check if the link is rendered
|
|
75
|
+
const link = screen.queryByRole('link', { name: slide.title });
|
|
76
|
+
if (slide.link_url) {
|
|
77
|
+
expect(link).toHaveAttribute('href', slide.link_url);
|
|
78
|
+
expect(link).toBeInTheDocument();
|
|
79
|
+
} else {
|
|
80
|
+
expect(link).not.toBeInTheDocument();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Check if navigation elements are present
|
|
85
|
+
expect(screen.getByRole('button', { name: 'Previous slide' })).toBeInTheDocument();
|
|
86
|
+
expect(screen.getByRole('button', { name: 'Next slide' })).toBeInTheDocument();
|
|
87
|
+
|
|
88
|
+
// Only the active slide content should be visible
|
|
89
|
+
const activeSlide = screen.getByRole('group', { name: /Slide 1:.*/ });
|
|
90
|
+
expect(activeSlide).toBeInTheDocument();
|
|
91
|
+
within(activeSlide).getByText(mockSlides[0].title);
|
|
92
|
+
within(activeSlide).getByText(mockSlides[0].content);
|
|
93
|
+
expect(screen.queryByRole('group', { name: /Slide 2:.*/ })).not.toBeInTheDocument();
|
|
94
|
+
expect(screen.queryByRole('group', { name: /Slide 3:.*/ })).not.toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('handles keyboard navigation', () => {
|
|
98
|
+
const mockEmblaApi = mockUseEmblaCarousel()[1]!;
|
|
99
|
+
render(
|
|
100
|
+
<IntlProvider locale="en">
|
|
101
|
+
<Slider {...defaultProps} />
|
|
102
|
+
</IntlProvider>,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const slider = screen.getByRole('button', { name: /test slider/i });
|
|
106
|
+
|
|
107
|
+
// Test arrow key navigation
|
|
108
|
+
fireEvent.keyDown(slider, { key: 'ArrowLeft' });
|
|
109
|
+
expect(mockEmblaApi.scrollPrev).toHaveBeenNthCalledWith(1);
|
|
110
|
+
|
|
111
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' });
|
|
112
|
+
expect(mockEmblaApi.scrollNext).toHaveBeenNthCalledWith(1);
|
|
113
|
+
|
|
114
|
+
// Test home/end key navigation
|
|
115
|
+
fireEvent.keyDown(slider, { key: 'Home' });
|
|
116
|
+
expect(mockEmblaApi.scrollTo).toHaveBeenNthCalledWith(1, 0);
|
|
117
|
+
|
|
118
|
+
fireEvent.keyDown(slider, { key: 'End' });
|
|
119
|
+
expect(mockEmblaApi.scrollTo).toHaveBeenNthCalledWith(2, mockSlides.length - 1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('provides accessible navigation controls', () => {
|
|
123
|
+
render(
|
|
124
|
+
<IntlProvider locale="en">
|
|
125
|
+
<Slider {...defaultProps} />
|
|
126
|
+
</IntlProvider>,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Check if navigation buttons are properly labeled
|
|
130
|
+
expect(screen.getByRole('button', { name: 'Previous slide' })).toHaveAttribute(
|
|
131
|
+
'aria-label',
|
|
132
|
+
'Previous slide',
|
|
133
|
+
);
|
|
134
|
+
expect(screen.getByRole('button', { name: 'Next slide' })).toHaveAttribute(
|
|
135
|
+
'aria-label',
|
|
136
|
+
'Next slide',
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Check if slider has proper ARIA attributes
|
|
140
|
+
const slider = screen.getByRole('button', { name: defaultProps.title });
|
|
141
|
+
expect(slider).toHaveAttribute('aria-roledescription', 'carousel');
|
|
142
|
+
const presentation = screen.getByRole('presentation');
|
|
143
|
+
expect(presentation).toBeInTheDocument();
|
|
144
|
+
expect(presentation).toHaveAttribute('aria-live', 'polite');
|
|
145
|
+
expect(presentation).toHaveAttribute('aria-atomic', 'true');
|
|
146
|
+
expect(presentation).toHaveTextContent(/Slide 1 of 3:.*/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('renders bullets navigation', () => {
|
|
150
|
+
render(
|
|
151
|
+
<IntlProvider locale="en">
|
|
152
|
+
<Slider {...defaultProps} />
|
|
153
|
+
</IntlProvider>,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const bullets = screen.getAllByRole('tab', { name: /Go to slide [1-3]{1}/ });
|
|
157
|
+
expect(bullets).toHaveLength(mockSlides.length);
|
|
158
|
+
|
|
159
|
+
expect(bullets[0]).toHaveAttribute('aria-selected', 'true');
|
|
160
|
+
expect(bullets[1]).toHaveAttribute('aria-selected', 'false');
|
|
161
|
+
expect(bullets[2]).toHaveAttribute('aria-selected', 'false');
|
|
162
|
+
|
|
163
|
+
// Click the second bullet
|
|
164
|
+
fireEvent.click(bullets[1]);
|
|
165
|
+
expect(mockUseEmblaCarousel()[1]!.scrollTo).toHaveBeenCalledWith(1);
|
|
166
|
+
});
|
|
167
|
+
});
|