richie-education 2.29.3-dev48 → 2.29.3-dev52
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/hooks/useCourseRunOrder/index.tsx +1 -1
- package/js/hooks/useCreditCards/index.spec.tsx +3 -1
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +5 -10
- package/js/widgets/SyllabusCourseRunsList/components/CourseRunItemWithEnrollment/index.product.spec.tsx +40 -9
- package/js/widgets/SyllabusCourseRunsList/components/CourseRunItemWithEnrollment/index.tsx +9 -7
- package/package.json +2 -2
- package/scss/colors/_theme.scss +12 -0
- package/scss/components/_content.scss +14 -0
- package/scss/components/_index.scss +1 -0
- package/scss/components/templates/richie/large_banner/_compacted_banner.scss +149 -0
|
@@ -21,7 +21,7 @@ const useCourseRunOrder = (courseRun: CourseRun) => {
|
|
|
21
21
|
product_id: resourceLinkResources?.product,
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
return { item: orders
|
|
24
|
+
return { item: orders?.[0], states: { fetching, isFetched } };
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
export default useCourseRunOrder;
|
|
@@ -145,7 +145,9 @@ describe('useCreditCards', () => {
|
|
|
145
145
|
responseDeferred.resolve({});
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(result.current.states.tokenizing).toBe(false);
|
|
150
|
+
});
|
|
149
151
|
expect(result.current.states.isPending).toBe(false);
|
|
150
152
|
expect(result.current.states.error).toBe(undefined);
|
|
151
153
|
});
|
|
@@ -995,10 +995,7 @@ describe('<DashboardItemOrder/>', () => {
|
|
|
995
995
|
order.payment_schedule![1].state = PaymentScheduleState.REFUSED;
|
|
996
996
|
|
|
997
997
|
const formatPrice = (price: number, currency: string) =>
|
|
998
|
-
new Intl.NumberFormat('en', {
|
|
999
|
-
currency,
|
|
1000
|
-
style: 'currency',
|
|
1001
|
-
}).format(price);
|
|
998
|
+
new Intl.NumberFormat('en', { currency, style: 'currency' }).format(price);
|
|
1002
999
|
|
|
1003
1000
|
const { product } = mockCourseProductWithOrder(order);
|
|
1004
1001
|
fetchMock.get(
|
|
@@ -1038,12 +1035,10 @@ describe('<DashboardItemOrder/>', () => {
|
|
|
1038
1035
|
|
|
1039
1036
|
// Click on pay button.
|
|
1040
1037
|
const payButton = screen.getByTestId('order-payment-retry-modal-submit-button');
|
|
1041
|
-
expect(payButton
|
|
1038
|
+
expect(payButton).toHaveTextContent(
|
|
1042
1039
|
'Pay ' +
|
|
1043
|
-
formatPrice(failedInstallment.amount, failedInstallment.currency).
|
|
1044
|
-
|
|
1045
|
-
' ',
|
|
1046
|
-
),
|
|
1040
|
+
formatPrice(failedInstallment.amount, failedInstallment.currency).replaceAll(/\s/g, ' '),
|
|
1041
|
+
{ normalizeWhitespace: true },
|
|
1047
1042
|
);
|
|
1048
1043
|
await user.click(payButton);
|
|
1049
1044
|
// Pay via mocked payment interface
|
|
@@ -1054,7 +1049,7 @@ describe('<DashboardItemOrder/>', () => {
|
|
|
1054
1049
|
expect(screen.queryByText('Retry payment')).not.toBeInTheDocument();
|
|
1055
1050
|
|
|
1056
1051
|
// Success modal is shown, close it.
|
|
1057
|
-
screen.
|
|
1052
|
+
await screen.findByText('Payment successful');
|
|
1058
1053
|
screen.getByText('The payment was successful');
|
|
1059
1054
|
const okButton = screen.getByRole('button', { name: 'Ok' });
|
|
1060
1055
|
await user.click(okButton);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import fetchMock from 'fetch-mock';
|
|
3
|
+
import { faker } from '@faker-js/faker';
|
|
3
4
|
import {
|
|
4
5
|
CourseRunFactory,
|
|
5
6
|
RichieContextFactory as mockRichieContextFactory,
|
|
@@ -11,12 +12,12 @@ import { render } from 'utils/test/render';
|
|
|
11
12
|
import {
|
|
12
13
|
CourseLightFactory,
|
|
13
14
|
CredentialOrderFactory,
|
|
14
|
-
EnrollmentFactory,
|
|
15
15
|
ProductFactory,
|
|
16
16
|
} from 'utils/test/factories/joanie';
|
|
17
17
|
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
|
18
18
|
import { mockPaginatedResponse } from 'utils/test/mockPaginatedResponse';
|
|
19
19
|
import { expectNoSpinner } from 'utils/test/expectSpinner';
|
|
20
|
+
import { ACTIVE_ORDER_STATES, PURCHASABLE_ORDER_STATES } from 'types/Joanie';
|
|
20
21
|
import CourseRunItemWithEnrollment from '.';
|
|
21
22
|
|
|
22
23
|
jest.mock('utils/context', () => ({
|
|
@@ -61,10 +62,9 @@ describe("CourseRunItemWithEnrollment for joanie's product's course run", () =>
|
|
|
61
62
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
62
63
|
});
|
|
63
64
|
|
|
64
|
-
it('should not render enrollment information when user
|
|
65
|
+
it('should not render enrollment information when user has no order for the product', async () => {
|
|
65
66
|
const course = CourseLightFactory().one();
|
|
66
67
|
const product = ProductFactory().one();
|
|
67
|
-
const order = CredentialOrderFactory().one();
|
|
68
68
|
const user = UserFactory().one();
|
|
69
69
|
const courseRun = CourseRunFactory({
|
|
70
70
|
title: 'run',
|
|
@@ -73,6 +73,39 @@ describe("CourseRunItemWithEnrollment for joanie's product's course run", () =>
|
|
|
73
73
|
resource_link: `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}`,
|
|
74
74
|
}).one();
|
|
75
75
|
|
|
76
|
+
fetchMock.get(
|
|
77
|
+
`https://joanie.endpoint/api/v1.0/orders/?course_code=${course.code}&product_id=${product.id}`,
|
|
78
|
+
mockPaginatedResponse([], 0, false),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
render(<CourseRunItemWithEnrollment item={courseRun} />, {
|
|
82
|
+
wrapper: BaseJoanieAppWrapper,
|
|
83
|
+
queryOptions: { client: createTestQueryClient({ user }) },
|
|
84
|
+
});
|
|
85
|
+
await expectNoSpinner();
|
|
86
|
+
|
|
87
|
+
// Only dates should be displayed.
|
|
88
|
+
screen.getByText('Run, from Jan 01, 2023 to Dec 31, 2023');
|
|
89
|
+
expect(fetchMock.called()).toBe(true);
|
|
90
|
+
const link = screen.queryByTitle('Go to course') as HTMLAnchorElement;
|
|
91
|
+
expect(link).not.toBeInTheDocument();
|
|
92
|
+
expect(screen.queryByLabelText('You are enrolled in this course run')).not.toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should not render enrollment information when user has no active order for the product', async () => {
|
|
96
|
+
const course = CourseLightFactory().one();
|
|
97
|
+
const product = ProductFactory().one();
|
|
98
|
+
const user = UserFactory().one();
|
|
99
|
+
const order = CredentialOrderFactory({
|
|
100
|
+
state: faker.helpers.arrayElement(PURCHASABLE_ORDER_STATES),
|
|
101
|
+
}).one();
|
|
102
|
+
const courseRun = CourseRunFactory({
|
|
103
|
+
title: 'run',
|
|
104
|
+
start: new Date('2023-01-01').toISOString(),
|
|
105
|
+
end: new Date('2023-12-31').toISOString(),
|
|
106
|
+
resource_link: `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}`,
|
|
107
|
+
}).one();
|
|
108
|
+
|
|
76
109
|
fetchMock.get(
|
|
77
110
|
`https://joanie.endpoint/api/v1.0/orders/?course_code=${course.code}&product_id=${product.id}`,
|
|
78
111
|
mockPaginatedResponse([order], 1, false),
|
|
@@ -84,20 +117,19 @@ describe("CourseRunItemWithEnrollment for joanie's product's course run", () =>
|
|
|
84
117
|
});
|
|
85
118
|
await expectNoSpinner();
|
|
86
119
|
|
|
87
|
-
// Only dates should
|
|
120
|
+
// Only dates should be displayed.
|
|
88
121
|
screen.getByText('Run, from Jan 01, 2023 to Dec 31, 2023');
|
|
89
122
|
expect(fetchMock.called()).toBe(true);
|
|
90
123
|
const link = screen.queryByTitle('Go to course') as HTMLAnchorElement;
|
|
91
|
-
expect(link).toBeInTheDocument();
|
|
92
|
-
expect(link.href).toBe(`https://localhost/en/dashboard/courses/orders/${order.id}`);
|
|
124
|
+
expect(link).not.toBeInTheDocument();
|
|
93
125
|
expect(screen.queryByLabelText('You are enrolled in this course run')).not.toBeInTheDocument();
|
|
94
126
|
});
|
|
95
127
|
|
|
96
|
-
it('should render enrollment information when user
|
|
128
|
+
it('should render enrollment information when user has an active order for the product', async () => {
|
|
97
129
|
const course = CourseLightFactory().one();
|
|
98
130
|
const product = ProductFactory().one();
|
|
99
131
|
const order = CredentialOrderFactory({
|
|
100
|
-
|
|
132
|
+
state: faker.helpers.arrayElement(ACTIVE_ORDER_STATES),
|
|
101
133
|
}).one();
|
|
102
134
|
const user = UserFactory().one();
|
|
103
135
|
const courseRun = CourseRunFactory({
|
|
@@ -121,7 +153,6 @@ describe("CourseRunItemWithEnrollment for joanie's product's course run", () =>
|
|
|
121
153
|
expect(screen.queryByText('loading...')).not.toBeInTheDocument();
|
|
122
154
|
});
|
|
123
155
|
|
|
124
|
-
// Only dates should have been displayed.
|
|
125
156
|
screen.getByText('Run, from Jan 01, 2023 to Dec 31, 2023');
|
|
126
157
|
expect(fetchMock.called()).toBe(true);
|
|
127
158
|
|
|
@@ -8,6 +8,8 @@ import { getDashboardBasename } from 'widgets/Dashboard/hooks/useDashboardRouter
|
|
|
8
8
|
import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
|
|
9
9
|
import useCourseRunOrder from 'hooks/useCourseRunOrder';
|
|
10
10
|
import { Spinner } from 'components/Spinner';
|
|
11
|
+
import { OrderHelper } from 'utils/OrderHelper';
|
|
12
|
+
import { extractResourceMetadata } from 'api/lms/joanie';
|
|
11
13
|
|
|
12
14
|
const messages = defineMessages({
|
|
13
15
|
goToCourse: {
|
|
@@ -33,6 +35,8 @@ type Props = {
|
|
|
33
35
|
|
|
34
36
|
const CourseRunItemWithEnrollment = ({ item }: Props) => {
|
|
35
37
|
const intl = useIntl();
|
|
38
|
+
const resourceLinkResources = extractResourceMetadata(item.resource_link);
|
|
39
|
+
const isProduct = !!(resourceLinkResources?.course && resourceLinkResources?.product);
|
|
36
40
|
const {
|
|
37
41
|
item: order,
|
|
38
42
|
states: { isFetched },
|
|
@@ -43,14 +47,12 @@ const CourseRunItemWithEnrollment = ({ item }: Props) => {
|
|
|
43
47
|
|
|
44
48
|
const { enrollmentIsActive: courseRunEnrollmentIsActive } = useCourseEnrollment(
|
|
45
49
|
item.resource_link,
|
|
46
|
-
|
|
50
|
+
!isProduct,
|
|
47
51
|
);
|
|
48
52
|
|
|
49
|
-
// user is
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
});
|
|
53
|
-
const enrollmentIsActive = !!(courseRunEnrollmentIsActive || productEnrollmentIsActive);
|
|
53
|
+
// user is enrolled to a product if there is an active order for the product
|
|
54
|
+
const orderIsActive = OrderHelper.isActive(order);
|
|
55
|
+
const enrollmentIsActive = !!(courseRunEnrollmentIsActive || orderIsActive);
|
|
54
56
|
|
|
55
57
|
if (!isFetched) {
|
|
56
58
|
return <Spinner />;
|
|
@@ -58,7 +60,7 @@ const CourseRunItemWithEnrollment = ({ item }: Props) => {
|
|
|
58
60
|
|
|
59
61
|
return (
|
|
60
62
|
<>
|
|
61
|
-
{enrollmentIsActive
|
|
63
|
+
{enrollmentIsActive ? (
|
|
62
64
|
// eslint-disable-next-line jsx-a11y/control-has-associated-label
|
|
63
65
|
<a href={courseUrl} title={intl.formatMessage(messages.goToCourse)}>
|
|
64
66
|
<CourseRunItem item={item} />
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "richie-education",
|
|
3
|
-
"version": "2.29.3-
|
|
3
|
+
"version": "2.29.3-dev52",
|
|
4
4
|
"description": "A CMS to build learning portals for Open Education",
|
|
5
5
|
"main": "sandbox/manage.py",
|
|
6
6
|
"scripts": {
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@lyracom/embedded-form-glue": "1.4.2",
|
|
52
52
|
"@openfun/cunningham-react": "2.9.4",
|
|
53
53
|
"@openfun/cunningham-tokens": "2.1.1",
|
|
54
|
-
"@sentry/browser": "8.
|
|
54
|
+
"@sentry/browser": "8.33.0",
|
|
55
55
|
"@sentry/types": "8.32.0",
|
|
56
56
|
"@storybook/addon-actions": "8.3.4",
|
|
57
57
|
"@storybook/addon-essentials": "8.3.4",
|
package/scss/colors/_theme.scss
CHANGED
|
@@ -128,6 +128,18 @@ $r-theme: (
|
|
|
128
128
|
cta-color: r-color('firebrick6'),
|
|
129
129
|
cta-border: r-color('white'),
|
|
130
130
|
),
|
|
131
|
+
compacted-banner: (
|
|
132
|
+
title-color: r-color('charcoal'),
|
|
133
|
+
title-alt-color: r-color('firebrick6'),
|
|
134
|
+
content-color: r-color('black'),
|
|
135
|
+
search-input-background: rgba(r-color('white'), 0.7),
|
|
136
|
+
search-btn-background: r-color('firebrick6'),
|
|
137
|
+
search-icon-fill: r-color('white'),
|
|
138
|
+
cta-variant-from: rgba(r-color('white'), 0.8),
|
|
139
|
+
cta-variant-to: rgba(r-color('white'), 0.8),
|
|
140
|
+
cta-color: r-color('firebrick6'),
|
|
141
|
+
cta-border: r-color('white'),
|
|
142
|
+
),
|
|
131
143
|
section-plugin: (
|
|
132
144
|
title-emphased-color: r-color('firebrick6'),
|
|
133
145
|
),
|
|
@@ -28,6 +28,20 @@ body {
|
|
|
28
28
|
background-size: 100% 100%;
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
// Apply the top padding behavior with topbar over mode only on the first child which
|
|
33
|
+
// is the only variant that can be exposed to issue with topbar over
|
|
34
|
+
.compacted-banner:first-child {
|
|
35
|
+
.compacted-banner__inner {
|
|
36
|
+
@include media-breakpoint-up(lg) {
|
|
37
|
+
@if $r-topbar-height {
|
|
38
|
+
padding: $r-topbar-height 0 1rem;
|
|
39
|
+
} @else {
|
|
40
|
+
padding: 1rem 0 1rem;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
// Container relative to some modal components
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
@import './templates/courses/plugins/licence_plugin';
|
|
62
62
|
@import './templates/richie/section/section';
|
|
63
63
|
@import './templates/richie/large_banner/large_banner';
|
|
64
|
+
@import './templates/richie/large_banner/compacted_banner';
|
|
64
65
|
@import './templates/richie/nesteditem/nesteditem';
|
|
65
66
|
@import './templates/richie/glimpse/glimpse';
|
|
66
67
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// A compact banner without image contents
|
|
2
|
+
// Opposed to 'hero-intro' this variant delegates the top padding for topbar in 'over'
|
|
3
|
+
// mode in content component
|
|
4
|
+
//
|
|
5
|
+
.compacted-banner {
|
|
6
|
+
position: relative;
|
|
7
|
+
padding: 0;
|
|
8
|
+
|
|
9
|
+
&__inner {
|
|
10
|
+
padding: 1rem 0;
|
|
11
|
+
|
|
12
|
+
@include media-breakpoint-up(lg) {
|
|
13
|
+
display: flex;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
&__body {
|
|
18
|
+
@include make-container();
|
|
19
|
+
@include make-container-max-widths();
|
|
20
|
+
padding: 1rem;
|
|
21
|
+
text-align: center;
|
|
22
|
+
|
|
23
|
+
@include media-breakpoint-up(lg) {
|
|
24
|
+
display: flex;
|
|
25
|
+
padding: 2rem;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
justify-content: space-between;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// NOTE: Force disabling of hardcoded hero title class from some already save
|
|
32
|
+
// contents. Sadly we can not disable the huge font size
|
|
33
|
+
.hero-intro__title {
|
|
34
|
+
margin-bottom: 1rem !important;
|
|
35
|
+
width: auto;
|
|
36
|
+
color: inherit;
|
|
37
|
+
|
|
38
|
+
strong {
|
|
39
|
+
color: inherit;
|
|
40
|
+
font-weight: inherit;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// NOTE: Apply the color+weight behavior with 'strong' element alike in
|
|
45
|
+
// 'hero-intro__title' but naturally on title elements without any class
|
|
46
|
+
h1,
|
|
47
|
+
h2,
|
|
48
|
+
h3,
|
|
49
|
+
h4,
|
|
50
|
+
h5,
|
|
51
|
+
h6 {
|
|
52
|
+
color: r-theme-val(compacted-banner, title-color);
|
|
53
|
+
margin-bottom: 0.5em;
|
|
54
|
+
|
|
55
|
+
strong {
|
|
56
|
+
color: r-theme-val(compacted-banner, title-alt-color);
|
|
57
|
+
font-weight: inherit;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// NOTE: Implement again the 'hero-intro__title' equivalent behavior
|
|
62
|
+
&__title {
|
|
63
|
+
@include responsive-spacer('margin-bottom', 1);
|
|
64
|
+
@include font-size($extra-font-size);
|
|
65
|
+
color: r-theme-val(compacted-banner, title-color);
|
|
66
|
+
|
|
67
|
+
strong {
|
|
68
|
+
color: r-theme-val(compacted-banner, title-alt-color);
|
|
69
|
+
font-weight: inherit;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
&__content {
|
|
74
|
+
@include font-size($h5-font-size);
|
|
75
|
+
color: r-theme-val(compacted-banner, content-color);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
&__search {
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-wrap: wrap;
|
|
81
|
+
align-items: center;
|
|
82
|
+
justify-content: center;
|
|
83
|
+
|
|
84
|
+
.richie-react--root-search-suggest-field {
|
|
85
|
+
@include sv-flex(1, 0, 100%);
|
|
86
|
+
position: relative;
|
|
87
|
+
|
|
88
|
+
@include media-breakpoint-up(lg) {
|
|
89
|
+
@include sv-flex(1, 0, 320px);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.react-autosuggest__container {
|
|
93
|
+
margin-bottom: 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
input {
|
|
97
|
+
background: r-theme-val(compacted-banner, search-input-background);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.search-input {
|
|
101
|
+
&__btn {
|
|
102
|
+
background: r-theme-val(compacted-banner, search-btn-background);
|
|
103
|
+
border-top-right-radius: 3rem;
|
|
104
|
+
border-bottom-right-radius: 3rem;
|
|
105
|
+
|
|
106
|
+
&__icon {
|
|
107
|
+
fill: r-theme-val(compacted-banner, search-icon-fill);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
&__cta {
|
|
115
|
+
@include sv-flex(0, 0, auto);
|
|
116
|
+
@include button-size(
|
|
117
|
+
$btn-padding-y,
|
|
118
|
+
$btn-padding-x,
|
|
119
|
+
$btn-font-size,
|
|
120
|
+
$btn-line-height,
|
|
121
|
+
$btn-border-radius
|
|
122
|
+
);
|
|
123
|
+
@include button-variant(
|
|
124
|
+
r-theme-val(compacted-banner, cta-variant-from),
|
|
125
|
+
r-theme-val(compacted-banner, cta-variant-to)
|
|
126
|
+
);
|
|
127
|
+
margin: 1.2rem 0 0;
|
|
128
|
+
font-size: $font-size-base;
|
|
129
|
+
color: r-theme-val(compacted-banner, cta-color);
|
|
130
|
+
border-radius: 2rem;
|
|
131
|
+
@if r-theme-val(compacted-banner, cta-border) {
|
|
132
|
+
border: 1px solid r-theme-val(compacted-banner, cta-border);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@include media-breakpoint-up(lg) {
|
|
136
|
+
margin-top: 0;
|
|
137
|
+
@include responsive-spacer('margin-left', 0, $breakpoints: ('lg': 3));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
&:after {
|
|
141
|
+
content: '→';
|
|
142
|
+
margin-left: 1rem;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
&:hover {
|
|
146
|
+
text-decoration: none;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|