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,8 +1,8 @@
|
|
|
1
|
-
import { defineMessages, useIntl } from 'react-intl';
|
|
2
|
-
|
|
1
|
+
import { defineMessages, FormattedNumber, useIntl } from 'react-intl';
|
|
3
2
|
import { Icon, IconTypeEnum } from 'components/Icon';
|
|
4
3
|
import { CommonDataProps } from 'types/commonDataProps';
|
|
5
4
|
import { CourseGlimpseCourse } from 'components/CourseGlimpse/index';
|
|
5
|
+
import { CourseOffer } from 'types/Course';
|
|
6
6
|
|
|
7
7
|
const messages = defineMessages({
|
|
8
8
|
dateIconAlt: {
|
|
@@ -10,8 +10,46 @@ const messages = defineMessages({
|
|
|
10
10
|
description: 'Course date logo alternative text for screen reader users',
|
|
11
11
|
id: 'components.CourseGlimpseFooter.dateIconAlt',
|
|
12
12
|
},
|
|
13
|
+
enrollmentOfferIconFreeAlt: {
|
|
14
|
+
defaultMessage: 'The entire course can be completed for free.',
|
|
15
|
+
description: 'Course offers free alternative text',
|
|
16
|
+
id: 'components.CourseGlimpseFooter.enrollmentOfferIconFreeAlt',
|
|
17
|
+
},
|
|
18
|
+
enrollmentOfferIconPartiallyFreeAlt: {
|
|
19
|
+
defaultMessage: 'More than half of the course is for free.',
|
|
20
|
+
description: 'Course offers partially free alternative text',
|
|
21
|
+
id: 'components.CourseGlimpseFooter.enrollmentOfferIconPartiallyFreeAlt',
|
|
22
|
+
},
|
|
23
|
+
enrollmentOfferIconPaidAlt: {
|
|
24
|
+
defaultMessage: 'Course requires a payment.',
|
|
25
|
+
description: 'Course offers paid alternative text',
|
|
26
|
+
id: 'components.CourseGlimpseFooter.enrollmentOfferIconPaidAlt',
|
|
27
|
+
},
|
|
28
|
+
enrollmentOfferIconSubscriptionAlt: {
|
|
29
|
+
defaultMessage: 'Course requires to be a subscriber or a paid member.',
|
|
30
|
+
description: 'Course offers subscription alternative text',
|
|
31
|
+
id: 'components.CourseGlimpseFooter.enrollmentOfferIconSubscriptionAlt',
|
|
32
|
+
},
|
|
33
|
+
certificateOfferIconAlt: {
|
|
34
|
+
defaultMessage: 'The course offers a certification.',
|
|
35
|
+
description: 'Course certificate offer alternative text',
|
|
36
|
+
id: 'components.CourseGlimpseFooter.certificateOfferIconAlt',
|
|
37
|
+
},
|
|
13
38
|
});
|
|
14
39
|
|
|
40
|
+
const courseOfferMessages = {
|
|
41
|
+
[CourseOffer.FREE]: messages.enrollmentOfferIconFreeAlt,
|
|
42
|
+
[CourseOffer.PARTIALLY_FREE]: messages.enrollmentOfferIconPartiallyFreeAlt,
|
|
43
|
+
[CourseOffer.PAID]: messages.enrollmentOfferIconPaidAlt,
|
|
44
|
+
[CourseOffer.SUBSCRIPTION]: messages.enrollmentOfferIconSubscriptionAlt,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type OfferIconType =
|
|
48
|
+
| IconTypeEnum.OFFER_SUBSCRIPTION
|
|
49
|
+
| IconTypeEnum.OFFER_PAID
|
|
50
|
+
| IconTypeEnum.OFFER_PARTIALLY_FREE
|
|
51
|
+
| IconTypeEnum.OFFER_FREE;
|
|
52
|
+
|
|
15
53
|
/**
|
|
16
54
|
* <CourseGlimpseFooter />.
|
|
17
55
|
* This is spun off from <CourseGlimpse /> to allow easier override through webpack.
|
|
@@ -20,19 +58,52 @@ export const CourseGlimpseFooter: React.FC<{ course: CourseGlimpseCourse } & Com
|
|
|
20
58
|
course,
|
|
21
59
|
}) => {
|
|
22
60
|
const intl = useIntl();
|
|
61
|
+
const offer = course.offer ?? CourseOffer.FREE;
|
|
62
|
+
const certificateOffer = course.certificate_offer ?? null;
|
|
63
|
+
const hasCertificateOffer = certificateOffer !== null;
|
|
64
|
+
const hasEnrollmentOffer = offer !== CourseOffer.FREE;
|
|
65
|
+
const offerIcon = `icon-offer-${offer}` as OfferIconType;
|
|
66
|
+
const offerCertificateIcon = hasCertificateOffer && IconTypeEnum.SCHOOL;
|
|
67
|
+
const offerPrice = hasEnrollmentOffer && course.price;
|
|
68
|
+
|
|
23
69
|
return (
|
|
24
70
|
<div className="course-glimpse-footer">
|
|
25
|
-
<div className="course-glimpse-footer__date">
|
|
26
|
-
<Icon
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
71
|
+
<div className="course-glimpse-footer__column course-glimpse-footer__date">
|
|
72
|
+
<Icon
|
|
73
|
+
name={IconTypeEnum.CALENDAR}
|
|
74
|
+
title={intl.formatMessage(messages.dateIconAlt)}
|
|
75
|
+
size="small"
|
|
76
|
+
/>
|
|
77
|
+
<span>
|
|
78
|
+
{course.state.text.charAt(0).toUpperCase() +
|
|
79
|
+
course.state.text.substring(1) +
|
|
80
|
+
(course.state.datetime
|
|
81
|
+
? ` ${intl.formatDate(new Date(course.state.datetime!), {
|
|
82
|
+
year: 'numeric',
|
|
83
|
+
month: 'long',
|
|
84
|
+
day: 'numeric',
|
|
85
|
+
})}`
|
|
86
|
+
: '')}
|
|
87
|
+
</span>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="course-glimpse-footer__column course-glimpse-footer__price">
|
|
90
|
+
{offerCertificateIcon && (
|
|
91
|
+
<Icon
|
|
92
|
+
className="offer-certificate__icon"
|
|
93
|
+
name={offerCertificateIcon}
|
|
94
|
+
title={intl.formatMessage(messages.certificateOfferIconAlt)}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
<Icon
|
|
98
|
+
className="offer__icon"
|
|
99
|
+
name={offerIcon}
|
|
100
|
+
title={intl.formatMessage(courseOfferMessages[offer])}
|
|
101
|
+
/>
|
|
102
|
+
{offerPrice && (
|
|
103
|
+
<span className="offer__price">
|
|
104
|
+
<FormattedNumber value={offerPrice} currency={course.price_currency} style="currency" />
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
36
107
|
</div>
|
|
37
108
|
</div>
|
|
38
109
|
);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { render, screen, within } from '@testing-library/react';
|
|
2
2
|
import { IntlProvider } from 'react-intl';
|
|
3
3
|
import { MemoryRouter } from 'react-router';
|
|
4
4
|
import { CommonDataProps } from 'types/commonDataProps';
|
|
5
5
|
import { RichieContextFactory } from 'utils/test/factories/richie';
|
|
6
6
|
import { CourseStateTextEnum } from 'types';
|
|
7
|
+
import { CourseCertificateOffer, CourseOffer } from 'types/Course';
|
|
7
8
|
import { CourseGlimpse, CourseGlimpseCourse } from '.';
|
|
8
9
|
|
|
9
10
|
const renderCourseGlimpse = ({
|
|
@@ -53,6 +54,11 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
53
54
|
text: CourseStateTextEnum.STARTING_ON,
|
|
54
55
|
},
|
|
55
56
|
title: 'Course 42',
|
|
57
|
+
offer: CourseOffer.PAID,
|
|
58
|
+
price: 42.0,
|
|
59
|
+
certificate_offer: CourseCertificateOffer.FREE,
|
|
60
|
+
certificate_price: null,
|
|
61
|
+
price_currency: 'EUR',
|
|
56
62
|
};
|
|
57
63
|
|
|
58
64
|
const contextProps: CommonDataProps['context'] = RichieContextFactory().one();
|
|
@@ -63,6 +69,11 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
63
69
|
// first text we encounter should be the title, so that screen reader users get it first
|
|
64
70
|
expect(container.textContent?.indexOf('Course 42')).toBe(0);
|
|
65
71
|
|
|
72
|
+
// The course glimpse container should have the a variant class according to their offers
|
|
73
|
+
const containerElement = container.querySelector('.course-glimpse');
|
|
74
|
+
expect(containerElement).toHaveClass('course-glimpse--offer-paid');
|
|
75
|
+
expect(containerElement).toHaveClass('course-glimpse--offer-certificate');
|
|
76
|
+
|
|
66
77
|
// The link that wraps the course glimpse should have no title as its content is explicit enough
|
|
67
78
|
const link = container.querySelector('.course-glimpse__link');
|
|
68
79
|
expect(link).not.toHaveAttribute('title');
|
|
@@ -73,9 +84,9 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
73
84
|
screen.getByLabelText('Organization');
|
|
74
85
|
screen.getByText('Some Organization');
|
|
75
86
|
screen.getByText('Category');
|
|
76
|
-
// Matches on 'Starting on
|
|
87
|
+
// Matches on 'Starting on March 14, 2019', date is wrapped with intl <span>
|
|
77
88
|
screen.getByLabelText('Course date');
|
|
78
|
-
screen.getByText('Starting on
|
|
89
|
+
screen.getByText('Starting on March 14, 2019');
|
|
79
90
|
|
|
80
91
|
// Check course logo
|
|
81
92
|
const courseGlipseMedia = container.getElementsByClassName('course-glimpse__media');
|
|
@@ -97,6 +108,17 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
97
108
|
// The logo is rendered along with alt text "" as it is decorative and included in a link block
|
|
98
109
|
expect(orgImg).toHaveAttribute('alt', '');
|
|
99
110
|
expect(orgImg).toHaveAttribute('src', '/thumbs/org_small.png');
|
|
111
|
+
|
|
112
|
+
// Check certificate offer
|
|
113
|
+
within(container).getByRole('img', { name: 'The course offers a certification.' });
|
|
114
|
+
|
|
115
|
+
// Check offer information
|
|
116
|
+
const offerIcon = within(container).getByRole('img', { name: 'Course requires a payment.' });
|
|
117
|
+
const useElement = offerIcon.lastChild;
|
|
118
|
+
expect(useElement).toHaveAttribute('href', '#icon-offer-paid');
|
|
119
|
+
|
|
120
|
+
const offerPrice = offerIcon.nextSibling;
|
|
121
|
+
expect(offerPrice).toHaveTextContent('€42.00');
|
|
100
122
|
});
|
|
101
123
|
|
|
102
124
|
it('works when there is no call to action or datetime on the state (eg. an archived course)', () => {
|
|
@@ -115,8 +137,8 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
115
137
|
// Make sure the component renders and shows the state
|
|
116
138
|
screen.getByRole('heading', { name: 'Course 42', level: 3 });
|
|
117
139
|
const dateFormatter = Intl.DateTimeFormat('en', {
|
|
118
|
-
day: '
|
|
119
|
-
month: '
|
|
140
|
+
day: 'numeric',
|
|
141
|
+
month: 'long',
|
|
120
142
|
year: 'numeric',
|
|
121
143
|
});
|
|
122
144
|
const formatedDatetime = dateFormatter.format(new Date(course.state.datetime!));
|
|
@@ -145,4 +167,57 @@ describe('widgets/Search/components/CourseGlimpse', () => {
|
|
|
145
167
|
'course-glimpse__metadata--code',
|
|
146
168
|
);
|
|
147
169
|
});
|
|
170
|
+
|
|
171
|
+
it('does not show certificate offer if the course does not offer a certificate', () => {
|
|
172
|
+
const { container } = renderCourseGlimpse({
|
|
173
|
+
contextProps,
|
|
174
|
+
course: { ...course, certificate_offer: null },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const containerElement = container.querySelector('.course-glimpse');
|
|
178
|
+
expect(containerElement).not.toHaveClass('course-glimpse--offer-certificate');
|
|
179
|
+
|
|
180
|
+
const certificicateOfferIcon = within(container).queryByRole('img', {
|
|
181
|
+
name: 'The course offers a certification.',
|
|
182
|
+
});
|
|
183
|
+
expect(certificicateOfferIcon).not.toBeInTheDocument();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('does show free course offer if the course has no offer', () => {
|
|
187
|
+
const { container } = renderCourseGlimpse({
|
|
188
|
+
contextProps,
|
|
189
|
+
course: { ...course, offer: null },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const containerElement = container.querySelector('.course-glimpse');
|
|
193
|
+
expect(containerElement).toHaveClass('course-glimpse--offer-free');
|
|
194
|
+
|
|
195
|
+
const offerIcon = within(container).getByRole('img', {
|
|
196
|
+
name: 'The entire course can be completed for free.',
|
|
197
|
+
});
|
|
198
|
+
const useElement = offerIcon.lastChild;
|
|
199
|
+
expect(useElement).toHaveAttribute('href', '#icon-offer-free');
|
|
200
|
+
|
|
201
|
+
// And no price is shown
|
|
202
|
+
expect(offerIcon.nextSibling).not.toBeInTheDocument();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it.each([
|
|
206
|
+
[CourseOffer.FREE, 'The entire course can be completed for free.'],
|
|
207
|
+
[CourseOffer.PARTIALLY_FREE, 'More than half of the course is for free.'],
|
|
208
|
+
[CourseOffer.PAID, 'Course requires a payment.'],
|
|
209
|
+
[CourseOffer.SUBSCRIPTION, 'Course requires to be a subscriber or a paid member.'],
|
|
210
|
+
])('does show a specific course offer icon', (offer, altText) => {
|
|
211
|
+
const { container } = renderCourseGlimpse({
|
|
212
|
+
contextProps,
|
|
213
|
+
course: { ...course, offer },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const containerElement = container.querySelector('.course-glimpse');
|
|
217
|
+
expect(containerElement).toHaveClass(`course-glimpse--offer-${offer}`);
|
|
218
|
+
|
|
219
|
+
const offerIcon = within(container).getByRole('img', { name: altText });
|
|
220
|
+
const useElement = offerIcon.lastChild;
|
|
221
|
+
expect(useElement).toHaveAttribute('href', `#icon-offer-${offer}`);
|
|
222
|
+
});
|
|
148
223
|
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import React, { memo } from 'react';
|
|
2
2
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
3
|
+
import c from 'classnames';
|
|
3
4
|
|
|
4
5
|
import { Nullable } from 'types/utils';
|
|
5
6
|
import { CommonDataProps } from 'types/commonDataProps';
|
|
6
7
|
import { Icon, IconTypeEnum } from 'components/Icon';
|
|
7
8
|
import { CourseState } from 'types';
|
|
9
|
+
import { CourseCertificateOffer, CourseOffer } from 'types/Course';
|
|
8
10
|
import { CourseGlimpseFooter } from './CourseGlimpseFooter';
|
|
9
11
|
import CourseLink from './CourseLink';
|
|
10
12
|
|
|
@@ -40,6 +42,11 @@ export interface CourseGlimpseCourse {
|
|
|
40
42
|
duration?: string;
|
|
41
43
|
effort?: string;
|
|
42
44
|
categories?: string[];
|
|
45
|
+
certificate_offer: Nullable<CourseCertificateOffer>;
|
|
46
|
+
offer: Nullable<CourseOffer>;
|
|
47
|
+
certificate_price: Nullable<number>;
|
|
48
|
+
price: Nullable<number>;
|
|
49
|
+
price_currency: string;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
export interface CourseGlimpseProps {
|
|
@@ -71,91 +78,100 @@ const messages = defineMessages({
|
|
|
71
78
|
|
|
72
79
|
const CourseGlimpseBase = ({ context, course }: CourseGlimpseProps & CommonDataProps) => {
|
|
73
80
|
const intl = useIntl();
|
|
81
|
+
const offer = course.offer ?? CourseOffer.FREE;
|
|
82
|
+
const hasCertificateOffer = course.certificate_offer !== null;
|
|
74
83
|
return (
|
|
75
|
-
<div
|
|
76
|
-
{
|
|
84
|
+
<div
|
|
85
|
+
className={c('course-glimpse', `course-glimpse--offer-${offer}`, {
|
|
86
|
+
'course-glimpse--offer-certificate': hasCertificateOffer,
|
|
87
|
+
})}
|
|
88
|
+
data-testid="course-glimpse"
|
|
89
|
+
>
|
|
90
|
+
<div className="course-glimpse__body">
|
|
91
|
+
{/* the media link is only here for mouse users, so hide it for keyboard/screen reader users.
|
|
77
92
|
Keyboard/sr will focus the link on the title */}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<img
|
|
88
|
-
alt=""
|
|
89
|
-
sizes={course.cover_image.sizes}
|
|
90
|
-
src={course.cover_image.src}
|
|
91
|
-
srcSet={course.cover_image.srcset}
|
|
92
|
-
/>
|
|
93
|
-
) : (
|
|
94
|
-
<div className="course-glimpse__media__empty">
|
|
95
|
-
<FormattedMessage {...messages.cover} />
|
|
96
|
-
</div>
|
|
97
|
-
)}
|
|
98
|
-
</CourseLink>
|
|
99
|
-
</div>
|
|
100
|
-
<div className="course-glimpse__content">
|
|
101
|
-
<div className="course-glimpse__wrapper">
|
|
102
|
-
<h3 className="course-glimpse__title">
|
|
103
|
-
<CourseLink
|
|
104
|
-
className="course-glimpse__link"
|
|
105
|
-
href={course.course_url}
|
|
106
|
-
to={course.course_route}
|
|
107
|
-
>
|
|
108
|
-
<span className="course-glimpse__title-text">{course.title}</span>
|
|
109
|
-
</CourseLink>
|
|
110
|
-
</h3>
|
|
111
|
-
{course.organization.image ? (
|
|
112
|
-
<div className="course-glimpse__organization-logo">
|
|
113
|
-
{/* alt forced to empty string because the organization name is rendered after */}
|
|
93
|
+
<div aria-hidden="true" className="course-glimpse__media">
|
|
94
|
+
<CourseLink
|
|
95
|
+
tabIndex={-1}
|
|
96
|
+
className="course-glimpse__link"
|
|
97
|
+
href={course.course_url}
|
|
98
|
+
to={course.course_route}
|
|
99
|
+
>
|
|
100
|
+
{/* alt forced to empty string because it's a decorative image */}
|
|
101
|
+
{course.cover_image ? (
|
|
114
102
|
<img
|
|
115
103
|
alt=""
|
|
116
|
-
sizes={course.
|
|
117
|
-
src={course.
|
|
118
|
-
srcSet={course.
|
|
104
|
+
sizes={course.cover_image.sizes}
|
|
105
|
+
src={course.cover_image.src}
|
|
106
|
+
srcSet={course.cover_image.srcset}
|
|
119
107
|
/>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
size="small"
|
|
127
|
-
/>
|
|
128
|
-
<span className="title">{course.organization.title}</span>
|
|
129
|
-
</div>
|
|
130
|
-
<div className="course-glimpse__metadata course-glimpse__metadata--code">
|
|
131
|
-
<Icon
|
|
132
|
-
name={IconTypeEnum.BARCODE}
|
|
133
|
-
title={intl.formatMessage(messages.codeIconAlt)}
|
|
134
|
-
size="small"
|
|
135
|
-
/>
|
|
136
|
-
<span>{course.code || '-'}</span>
|
|
137
|
-
</div>
|
|
108
|
+
) : (
|
|
109
|
+
<div className="course-glimpse__media__empty">
|
|
110
|
+
<FormattedMessage {...messages.cover} />
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</CourseLink>
|
|
138
114
|
</div>
|
|
139
|
-
|
|
140
|
-
<div className="course-
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
115
|
+
<div className="course-glimpse__content">
|
|
116
|
+
<div className="course-glimpse__wrapper">
|
|
117
|
+
<h3 className="course-glimpse__title">
|
|
118
|
+
<CourseLink
|
|
119
|
+
className="course-glimpse__link"
|
|
120
|
+
href={course.course_url}
|
|
121
|
+
to={course.course_route}
|
|
122
|
+
>
|
|
123
|
+
<span className="course-glimpse__title-text">{course.title}</span>
|
|
124
|
+
</CourseLink>
|
|
125
|
+
</h3>
|
|
126
|
+
{course.organization.image ? (
|
|
127
|
+
<div className="course-glimpse__organization-logo">
|
|
128
|
+
{/* alt forced to empty string because the organization name is rendered after */}
|
|
129
|
+
<img
|
|
130
|
+
alt=""
|
|
131
|
+
sizes={course.organization.image.sizes}
|
|
132
|
+
src={course.organization.image.src}
|
|
133
|
+
srcSet={course.organization.image.srcset}
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
) : null}
|
|
137
|
+
<div className="course-glimpse__metadata course-glimpse__metadata--organization">
|
|
138
|
+
<Icon
|
|
139
|
+
name={IconTypeEnum.ORG}
|
|
140
|
+
title={intl.formatMessage(messages.organizationIconAlt)}
|
|
141
|
+
size="small"
|
|
149
142
|
/>
|
|
150
|
-
<span className="
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<
|
|
154
|
-
|
|
143
|
+
<span className="title">{course.organization.title}</span>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="course-glimpse__metadata course-glimpse__metadata--code">
|
|
146
|
+
<Icon
|
|
147
|
+
name={IconTypeEnum.BARCODE}
|
|
148
|
+
title={intl.formatMessage(messages.codeIconAlt)}
|
|
149
|
+
size="small"
|
|
150
|
+
/>
|
|
151
|
+
<span>{course.code || '-'}</span>
|
|
152
|
+
</div>
|
|
155
153
|
</div>
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
{course.icon ? (
|
|
155
|
+
<div className="course-glimpse__icon">
|
|
156
|
+
<span className="category-badge">
|
|
157
|
+
{/* alt forced to empty string because it's a decorative image */}
|
|
158
|
+
<img
|
|
159
|
+
alt=""
|
|
160
|
+
className="category-badge__icon"
|
|
161
|
+
sizes={course.icon.sizes}
|
|
162
|
+
src={course.icon.src}
|
|
163
|
+
srcSet={course.icon.srcset}
|
|
164
|
+
/>
|
|
165
|
+
<span className="offscreen">
|
|
166
|
+
<FormattedMessage {...messages.categoryLabel} />
|
|
167
|
+
</span>
|
|
168
|
+
<span className="category-badge__title">{course.icon.title}</span>
|
|
169
|
+
</span>
|
|
170
|
+
</div>
|
|
171
|
+
) : null}
|
|
172
|
+
</div>
|
|
158
173
|
</div>
|
|
174
|
+
<CourseGlimpseFooter context={context} course={course} />
|
|
159
175
|
</div>
|
|
160
176
|
);
|
|
161
177
|
};
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { IntlShape } from 'react-intl';
|
|
2
2
|
import { generatePath } from 'react-router';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
CourseCertificateOffer,
|
|
5
|
+
CourseOffer,
|
|
6
|
+
Course as RichieCourse,
|
|
7
|
+
isRichieCourse,
|
|
8
|
+
} from 'types/Course';
|
|
4
9
|
import {
|
|
5
10
|
CourseListItem as JoanieCourse,
|
|
6
11
|
CourseProductRelationLight,
|
|
7
12
|
isCourseProductRelation,
|
|
13
|
+
ProductType,
|
|
8
14
|
} from 'types/Joanie';
|
|
9
15
|
import { TeacherDashboardPaths } from 'widgets/Dashboard/utils/teacherDashboardPaths';
|
|
10
16
|
import { CourseGlimpseCourse } from '.';
|
|
@@ -40,6 +46,20 @@ const getCourseGlimpsePropsFromCourseProductRelation = (
|
|
|
40
46
|
product_id: courseProductRelation.product.id,
|
|
41
47
|
course_route: courseRoute,
|
|
42
48
|
state: courseProductRelation.product.state,
|
|
49
|
+
certificate_offer:
|
|
50
|
+
courseProductRelation.product.type === ProductType.CERTIFICATE
|
|
51
|
+
? CourseCertificateOffer.PAID
|
|
52
|
+
: null,
|
|
53
|
+
offer: courseProductRelation.product.type === ProductType.CREDENTIAL ? CourseOffer.PAID : null,
|
|
54
|
+
certificate_price:
|
|
55
|
+
courseProductRelation.product.type === ProductType.CERTIFICATE
|
|
56
|
+
? courseProductRelation.product.price
|
|
57
|
+
: null,
|
|
58
|
+
price:
|
|
59
|
+
courseProductRelation.product.type === ProductType.CREDENTIAL
|
|
60
|
+
? courseProductRelation.product.price
|
|
61
|
+
: null,
|
|
62
|
+
price_currency: courseProductRelation.product.price_currency,
|
|
43
63
|
};
|
|
44
64
|
};
|
|
45
65
|
|
|
@@ -59,6 +79,11 @@ const getCourseGlimpsePropsFromRichieCourse = (course: RichieCourse): CourseGlim
|
|
|
59
79
|
effort: course.effort,
|
|
60
80
|
categories: course.categories,
|
|
61
81
|
organizations: course.organizations,
|
|
82
|
+
price: course.price,
|
|
83
|
+
price_currency: course.price_currency,
|
|
84
|
+
certificate_offer: course.certificate_offer,
|
|
85
|
+
offer: course.offer,
|
|
86
|
+
certificate_price: course.certificate_price,
|
|
62
87
|
});
|
|
63
88
|
|
|
64
89
|
const getCourseGlimpsePropsFromJoanieCourse = (
|
|
@@ -91,6 +116,11 @@ const getCourseGlimpsePropsFromJoanieCourse = (
|
|
|
91
116
|
},
|
|
92
117
|
state: course.state,
|
|
93
118
|
nb_course_runs: course.course_run_ids.length,
|
|
119
|
+
price: null,
|
|
120
|
+
price_currency: 'EUR',
|
|
121
|
+
certificate_offer: null,
|
|
122
|
+
offer: null,
|
|
123
|
+
certificate_price: null,
|
|
94
124
|
};
|
|
95
125
|
};
|
|
96
126
|
|
|
@@ -35,6 +35,7 @@ export enum IconTypeEnum {
|
|
|
35
35
|
CHEVRON_RIGHT_OUTLINE = 'icon-chevron-right-outline',
|
|
36
36
|
CHEVRON_UP_OUTLINE = 'icon-chevron-up-outline',
|
|
37
37
|
CLOCK = 'icon-clock',
|
|
38
|
+
COURSES = 'icon-courses',
|
|
38
39
|
CREDIT_CARD = 'icon-creditCard',
|
|
39
40
|
CROSS = 'icon-cross',
|
|
40
41
|
DURATION = 'icon-duration',
|
|
@@ -49,6 +50,7 @@ export enum IconTypeEnum {
|
|
|
49
50
|
LOGOUT_SQUARE = 'icon-logout-square',
|
|
50
51
|
MAGNIFYING_GLASS = 'icon-magnifying-glass',
|
|
51
52
|
MENU = 'icon-menu',
|
|
53
|
+
MONEY = 'icon-money',
|
|
52
54
|
MORE = 'icon-more',
|
|
53
55
|
ORG = 'icon-org',
|
|
54
56
|
PACE = 'icon-pace',
|
|
@@ -62,6 +64,11 @@ export enum IconTypeEnum {
|
|
|
62
64
|
TWITTER = 'icon-twitter',
|
|
63
65
|
UNIVERSITY = 'icon-univerity',
|
|
64
66
|
WARNING = 'icon-warning',
|
|
67
|
+
VIDEO_PLAY = 'icon-video-play',
|
|
68
|
+
OFFER_PAID = 'icon-offer-paid',
|
|
69
|
+
OFFER_FREE = 'icon-offer-free',
|
|
70
|
+
OFFER_PARTIALLY_FREE = 'icon-offer-partially_free',
|
|
71
|
+
OFFER_SUBSCRIPTION = 'icon-offer-subscription',
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
export const Icon = ({ name, title, className = '', size = 'medium', ...props }: Props) => {
|
|
@@ -15,7 +15,7 @@ import { OpenEdxApiProfileFactory } from 'utils/test/factories/openEdx';
|
|
|
15
15
|
import { createTestQueryClient } from 'utils/test/createTestQueryClient';
|
|
16
16
|
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
|
17
17
|
import { AppWrapperProps } from 'utils/test/wrappers/types';
|
|
18
|
-
import {
|
|
18
|
+
import { expectAlertError, expectAlertWarning, expectNoAlertWarning } from 'utils/test/expectAlert';
|
|
19
19
|
|
|
20
20
|
jest.mock('utils/context', () => ({
|
|
21
21
|
__esModule: true,
|
|
@@ -71,7 +71,7 @@ describe('OpenEdxFullNameForm', () => {
|
|
|
71
71
|
wrapper: Wrapper,
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
const $input = await screen.findByRole('textbox', { name: '
|
|
74
|
+
const $input = await screen.findByRole('textbox', { name: 'First name and last name' });
|
|
75
75
|
expect($input).toHaveValue('');
|
|
76
76
|
});
|
|
77
77
|
|
|
@@ -93,7 +93,7 @@ describe('OpenEdxFullNameForm', () => {
|
|
|
93
93
|
wrapper: Wrapper,
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
const $input = await screen.findByRole('textbox', { name: '
|
|
96
|
+
const $input = await screen.findByRole('textbox', { name: 'First name and last name' });
|
|
97
97
|
expect($input).toHaveValue(user.full_name);
|
|
98
98
|
});
|
|
99
99
|
|
|
@@ -119,15 +119,25 @@ describe('OpenEdxFullNameForm', () => {
|
|
|
119
119
|
|
|
120
120
|
expect(submitCallbacks.hasOwnProperty('openEdxFullNameForm')).toBe(true);
|
|
121
121
|
|
|
122
|
-
const $input = await screen.findByRole('textbox', { name: '
|
|
122
|
+
const $input = await screen.findByRole('textbox', { name: 'First name and last name' });
|
|
123
123
|
expect($input).toHaveValue('');
|
|
124
124
|
|
|
125
|
+
await expectAlertWarning(
|
|
126
|
+
'Please check that your first name and last name are correct. They will be used on official document (e.g: certificate, contract, etc.)',
|
|
127
|
+
);
|
|
128
|
+
|
|
125
129
|
// Submit the form
|
|
126
130
|
await act(async () => {
|
|
127
131
|
await expect(submitCallbacks.openEdxFullNameForm()).rejects.not.toBeUndefined();
|
|
128
132
|
});
|
|
129
133
|
|
|
130
134
|
screen.getByText('This field is required.');
|
|
135
|
+
await expectNoAlertWarning(
|
|
136
|
+
'Please check that your first name and last name are correct. They will be used on official document (e.g: certificate, contract, etc.)',
|
|
137
|
+
);
|
|
138
|
+
await expectAlertError(
|
|
139
|
+
'Please check that your first name and last name are correct. They will be used on official document (e.g: certificate, contract, etc.)',
|
|
140
|
+
);
|
|
131
141
|
});
|
|
132
142
|
|
|
133
143
|
it('should require a value with at least 3 chars to submit the form', async () => {
|
|
@@ -152,7 +162,7 @@ describe('OpenEdxFullNameForm', () => {
|
|
|
152
162
|
|
|
153
163
|
expect(submitCallbacks.hasOwnProperty('openEdxFullNameForm')).toBe(true);
|
|
154
164
|
|
|
155
|
-
const $input = await screen.findByRole('textbox', { name: '
|
|
165
|
+
const $input = await screen.findByRole('textbox', { name: 'First name and last name' });
|
|
156
166
|
expect($input).toHaveValue('');
|
|
157
167
|
|
|
158
168
|
const eventHandler = userEvent.setup();
|
|
@@ -189,7 +199,7 @@ describe('OpenEdxFullNameForm', () => {
|
|
|
189
199
|
|
|
190
200
|
expect(submitCallbacks.hasOwnProperty('openEdxFullNameForm')).toBe(true);
|
|
191
201
|
|
|
192
|
-
const $input = await screen.findByRole('textbox', { name: '
|
|
202
|
+
const $input = await screen.findByRole('textbox', { name: 'First name and last name' });
|
|
193
203
|
expect($input).toHaveValue('');
|
|
194
204
|
|
|
195
205
|
const eventHandler = userEvent.setup();
|
|
@@ -224,6 +234,6 @@ describe('OpenEdxFullNameForm', () => {
|
|
|
224
234
|
wrapper: Wrapper,
|
|
225
235
|
});
|
|
226
236
|
|
|
227
|
-
await
|
|
237
|
+
await expectAlertError('An error occurred while fetching your profile. Please retry later.');
|
|
228
238
|
});
|
|
229
239
|
});
|