richie-education 2.25.0-b2.dev111 → 2.25.0-b2.dev115

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.
@@ -0,0 +1,13 @@
1
+ import { Address } from 'types/Joanie';
2
+
3
+ export const AddressView = ({ address }: { address: Address }) => {
4
+ return (
5
+ <address>
6
+ {address.first_name}&nbsp;{address.last_name}
7
+ <br />
8
+ {address.address}
9
+ <br />
10
+ {address.postcode} {address.city}, {address.country}
11
+ </address>
12
+ );
13
+ };
@@ -11,6 +11,7 @@ import type { Maybe, Nullable } from 'types/utils';
11
11
  import { Icon, IconTypeEnum } from 'components/Icon';
12
12
  import { useSaleTunnelContext } from 'components/SaleTunnel/context';
13
13
  import { UserHelper } from 'utils/UserHelper';
14
+ import { AddressView } from 'components/Address';
14
15
  import { RegisteredCreditCard } from '../RegisteredCreditCard';
15
16
 
16
17
  const messages = defineMessages({
@@ -229,13 +230,9 @@ export const SaleTunnelStepPayment = ({ next }: SaleTunnelStepPaymentProps) => {
229
230
  value: id,
230
231
  }))}
231
232
  />
232
- <address className="SaleTunnelStepPayment__block--buyer__address-selection__address">
233
- {selectedAddress!.first_name}&nbsp;{selectedAddress!.last_name}
234
- <br />
235
- {selectedAddress!.address}
236
- <br />
237
- {selectedAddress!.postcode} {selectedAddress!.city}, {selectedAddress!.country}
238
- </address>
233
+ <div className="SaleTunnelStepPayment__block--buyer__address-selection__address">
234
+ <AddressView address={selectedAddress!} />
235
+ </div>
239
236
  </Fragment>
240
237
  ) : (
241
238
  <Fragment>
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Available users:
3
+ * * admin
4
+ * * user0
5
+ * * user1
6
+ * * user2
7
+ * * user3
8
+ * * user4
9
+ * * organization_owner
10
+ * * student_user
11
+ */
12
+ export const CURRENT_JOANIE_DEV_DEMO_USER = 'student_user';
@@ -30,6 +30,10 @@ export interface Organization {
30
30
  code: string;
31
31
  title: string;
32
32
  logo: Nullable<JoanieFile>;
33
+ contact_email: Nullable<string>;
34
+ contact_phone: Nullable<string>;
35
+ dpo_email: Nullable<string>;
36
+ address?: Address;
33
37
  }
34
38
 
35
39
  export interface OrganizationResourceQuery extends ResourcesQuery {
@@ -263,6 +267,7 @@ export interface Order {
263
267
  course: Maybe<CourseLight>;
264
268
  enrollment: Maybe<EnrollmentLight>;
265
269
  organization_id: Organization['id'];
270
+ organization: Organization;
266
271
  order_group_id?: OrderGroup['id'];
267
272
  }
268
273
 
@@ -1,4 +1,4 @@
1
- import { getByText, screen, waitFor } from '@testing-library/react';
1
+ import { screen, waitFor } from '@testing-library/react';
2
2
  import { BannerType, getBannerTestId } from 'components/Banner';
3
3
 
4
4
  export const expectBannerError = async (message: string, rootElement: ParentNode = document) => {
@@ -16,7 +16,7 @@ export const expectBanner = async (
16
16
  await waitFor(async () => {
17
17
  const banner = rootElement.querySelector('.banner--' + type) as HTMLElement;
18
18
  expect(banner).not.toBeNull();
19
- getByText(banner!, message);
19
+ expect(banner).toHaveTextContent(message);
20
20
  });
21
21
  };
22
22
 
@@ -157,6 +157,10 @@ export const OrganizationFactory = factory((): Organization => {
157
157
  code: faker.string.alphanumeric(5),
158
158
  title: FactoryHelper.unique(faker.lorem.words, { args: [1] }),
159
159
  logo: JoanieFileFactory().one(),
160
+ contact_email: faker.internet.email(),
161
+ dpo_email: faker.internet.email(),
162
+ contact_phone: faker.phone.number(),
163
+ address: AddressFactory().one(),
160
164
  };
161
165
  });
162
166
 
@@ -400,6 +404,7 @@ const AbstractOrderFactory = factory((): Order => {
400
404
  enrollment: undefined,
401
405
  course: undefined,
402
406
  organization_id: faker.string.uuid(),
407
+ organization: OrganizationFactory().one(),
403
408
  };
404
409
  });
405
410
 
@@ -8,6 +8,7 @@ import {
8
8
  render,
9
9
  screen,
10
10
  waitFor,
11
+ within,
11
12
  } from '@testing-library/react';
12
13
  import { IntlProvider } from 'react-intl';
13
14
  import { faker } from '@faker-js/faker';
@@ -858,4 +859,27 @@ describe('<DashboardItemOrder/>', () => {
858
859
  screen.queryByTestId('dashboard-item__course-enrolling__run__' + courseRun.id),
859
860
  ).toBeNull();
860
861
  });
862
+
863
+ it('renders a writable order with organization details', async () => {
864
+ const order: CredentialOrder = CredentialOrderFactory().one();
865
+ const { product } = mockCourseProductWithOrder(order);
866
+
867
+ render(<DashboardItemOrder order={order} writable={true} showDetailsButton={false} />, {
868
+ wrapper,
869
+ });
870
+
871
+ await screen.findByRole('heading', { level: 5, name: product.title });
872
+
873
+ const block = screen.getByTestId('organization-block');
874
+ within(block).getByText(order.organization!.title);
875
+ within(block).getByRole('link', { name: order.organization!.contact_email! });
876
+ within(block).getByRole('link', { name: order.organization!.dpo_email! });
877
+ within(block).getByRole('link', { name: order.organization!.contact_phone! });
878
+ within(block).getByText(new RegExp(order.organization?.address?.first_name!));
879
+ within(block).getByText(new RegExp(order.organization?.address?.last_name!));
880
+ within(block).getByText(new RegExp(order.organization?.address?.address!));
881
+ within(block).getByText(new RegExp(order.organization?.address?.city!));
882
+ within(block).getByText(new RegExp(order.organization?.address?.postcode!));
883
+ within(block).getByText(new RegExp(order.organization?.address?.country!));
884
+ });
861
885
  });
@@ -1,4 +1,6 @@
1
1
  import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
2
+ import { Button } from '@openfun/cunningham-react';
3
+ import classNames from 'classnames';
2
4
  import { CourseLight, CredentialOrder, Product } from 'types/Joanie';
3
5
  import { Icon, IconTypeEnum } from 'components/Icon';
4
6
  import { CoursesHelper } from 'utils/CoursesHelper';
@@ -11,7 +13,9 @@ import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRouteMessa
11
13
  import { getDashboardRoutePath } from 'widgets/Dashboard/utils/dashboardRoutes';
12
14
  import { useCourseProduct } from 'hooks/useCourseProducts';
13
15
  import { OrderHelper } from 'utils/OrderHelper';
14
-
16
+ import ContractStatus from 'components/ContractStatus';
17
+ import SignContractButton from 'components/SignContractButton';
18
+ import { AddressView } from 'components/Address';
15
19
  import { DashboardSubItemsList } from '../DashboardSubItemsList';
16
20
  import { DashboardItemCourseEnrolling } from '../CourseEnrolling';
17
21
  import { DashboardItem } from '../index';
@@ -34,6 +38,46 @@ const messages = defineMessages({
34
38
  description: 'Syllabus link label on order details',
35
39
  defaultMessage: 'Go to syllabus',
36
40
  },
41
+ contactDescription: {
42
+ id: 'components.DashboardItemOrder.contactDescription',
43
+ description: 'Description of the contact information for the organization',
44
+ defaultMessage: 'Your training reference is {name} - {email}.',
45
+ },
46
+ contactButton: {
47
+ id: 'components.DashboardItemOrder.contactButton',
48
+ description: 'Button to contact the organization',
49
+ defaultMessage: 'Contact',
50
+ },
51
+ organizationHeader: {
52
+ id: 'components.DashboardItemOrder.organizationHeader',
53
+ description: 'Header of the organization section',
54
+ defaultMessage: 'This training is provided by',
55
+ },
56
+ organizationLogoAlt: {
57
+ id: 'components.DashboardItemOrder.organizationLogoAlt',
58
+ description: 'Alt text for the organization logo',
59
+ defaultMessage: 'Logo of the organization',
60
+ },
61
+ trainingContractTitle: {
62
+ id: 'components.DashboardItemOrder.trainingContractTitle',
63
+ description: 'Title of the training contract section',
64
+ defaultMessage: 'Training contract',
65
+ },
66
+ organizationMailContactLabel: {
67
+ id: 'components.DashboardItemOrder.organizationMailContactLabel',
68
+ description: 'Label for the organization mail contact',
69
+ defaultMessage: 'Email',
70
+ },
71
+ organizationPhoneContactLabel: {
72
+ id: 'components.DashboardItemOrder.organizationPhoneContactLabel',
73
+ description: 'Label for the organization phone contact',
74
+ defaultMessage: 'Phone',
75
+ },
76
+ organizationDpoContactLabel: {
77
+ id: 'components.DashboardItemOrder.organizationDpoContactLabel',
78
+ description: 'Label for the organization DPO contact',
79
+ defaultMessage: 'Data protection email',
80
+ },
37
81
  });
38
82
 
39
83
  interface DashboardItemOrderProps {
@@ -88,27 +132,16 @@ export const DashboardItemOrder = ({
88
132
  const course = order.course as CourseLight;
89
133
 
90
134
  const intl = useIntl();
91
- const {
92
- item: courseProductRelation,
93
- states: { isFetched: isCourseProductRelationFetched },
94
- } = useCourseProduct({ product_id: order.product_id, course_id: course.code });
135
+ const { item: courseProductRelation } = useCourseProduct({
136
+ product_id: order.product_id,
137
+ course_id: course.code,
138
+ });
95
139
  const { product } = courseProductRelation || {};
96
140
  const needsSignature = OrderHelper.orderNeedsSignature(order, product?.contract_definition);
97
141
  const getRoutePath = getDashboardRoutePath(useIntl());
98
142
 
99
143
  return (
100
144
  <div className="dashboard-item-order">
101
- {writable && needsSignature && (
102
- <DashboardItemContract
103
- key={`DashboardItemOrderContract_${order.id}`}
104
- title={product.title}
105
- order={order}
106
- contract_definition={product?.contract_definition!}
107
- contract={order.contract}
108
- writable={writable}
109
- mode="compact"
110
- />
111
- )}
112
145
  <DashboardItem
113
146
  data-testid={`dashboard-item-order-${order.id}`}
114
147
  title={product?.title ?? ''}
@@ -183,17 +216,127 @@ export const DashboardItemOrder = ({
183
216
  {showCertificate && !!product?.certificate_definition && (
184
217
  <DashboardItemOrderCertificate order={order} product={product} />
185
218
  )}
186
- {writable && isCourseProductRelationFetched && order.contract?.student_signed_on && (
187
- <DashboardItemContract
188
- key={`DashboardItemOrderContract_${order.id}`}
189
- title={product.title}
219
+ {writable && <OrganizationBlock order={order} product={product} />}
220
+ </div>
221
+ );
222
+ };
223
+
224
+ const OrganizationBlock = ({ order, product }: { order: CredentialOrder; product: Product }) => {
225
+ const { organization } = order;
226
+ if (!organization) {
227
+ return null;
228
+ }
229
+
230
+ const showContactsBlock =
231
+ organization.contact_email || organization.contact_phone || organization.dpo_email;
232
+
233
+ return (
234
+ <div className="dashboard-splitted-card mt-s" data-testid="organization-block">
235
+ <div className="dashboard-splitted-card__column order-organization__caption">
236
+ <div className="dashboard-item-order__organization">
237
+ <div className="dashboard-item-order__organization__header">
238
+ <FormattedMessage {...messages.organizationHeader} />
239
+ </div>
240
+ <div
241
+ className="dashboard-item-order__organization__logo"
242
+ style={{
243
+ backgroundImage: `url(${organization.logo?.src})`,
244
+ }}
245
+ />
246
+ <div className="dashboard-item-order__organization__name">{organization.title}</div>
247
+ </div>
248
+ </div>
249
+ <div className="dashboard-splitted-card__separator order-organization__separator" />
250
+ <div className="dashboard-splitted-card__column order-organization__items">
251
+ <ContractItem order={order} product={product} />
252
+ {showContactsBlock && (
253
+ <div className="dashboard-splitted-card__item">
254
+ <div className="dashboard-splitted-card__item__title">Contacts</div>
255
+ <div className="dashboard-splitted-card__item__description">
256
+ {organization.contact_email && (
257
+ <div className="organization-block__contact__item">
258
+ <FormattedMessage {...messages.organizationMailContactLabel} />
259
+ <Button
260
+ size="small"
261
+ color="tertiary"
262
+ href={'mailto:' + (organization.contact_email ?? '')}
263
+ >
264
+ {organization.contact_email}
265
+ </Button>
266
+ </div>
267
+ )}
268
+ {organization.contact_phone && (
269
+ <div className="organization-block__contact__item">
270
+ <FormattedMessage {...messages.organizationPhoneContactLabel} />
271
+ <Button
272
+ size="small"
273
+ color="tertiary"
274
+ href={'tel:' + (organization.contact_phone ?? '')}
275
+ >
276
+ {organization.contact_phone}
277
+ </Button>
278
+ </div>
279
+ )}
280
+ {organization.dpo_email && (
281
+ <div className="organization-block__contact__item">
282
+ <FormattedMessage {...messages.organizationDpoContactLabel} />
283
+ <Button
284
+ size="small"
285
+ color="tertiary"
286
+ href={'mailto:' + (organization.dpo_email ?? '')}
287
+ >
288
+ {organization.dpo_email}
289
+ </Button>
290
+ </div>
291
+ )}
292
+ </div>
293
+ </div>
294
+ )}
295
+ {organization.address && (
296
+ <div className="dashboard-splitted-card__item dashboard-splitted-card__item__address">
297
+ <div className="dashboard-splitted-card__item__title">Address</div>
298
+ <div className="dashboard-splitted-card__item__description">
299
+ <AddressView address={organization.address} />
300
+ </div>
301
+ </div>
302
+ )}
303
+ </div>
304
+ </div>
305
+ );
306
+ };
307
+
308
+ const ContractItem = ({ product, order }: { order: CredentialOrder; product: Product }) => {
309
+ if (!product?.contract_definition) {
310
+ return;
311
+ }
312
+
313
+ const needsSignature = OrderHelper.orderNeedsSignature(order, product.contract_definition);
314
+ return (
315
+ <div
316
+ id={`dashboard-item-contract-${order.id}`}
317
+ className="dashboard-splitted-card__item"
318
+ data-testid={`dashboard-item-contract-${order.id}`}
319
+ >
320
+ <div
321
+ className={classNames('dashboard-splitted-card__item__title', {
322
+ 'dashboard-splitted-card__item__title--dot': needsSignature,
323
+ })}
324
+ >
325
+ <span>
326
+ <FormattedMessage {...messages.trainingContractTitle} />
327
+ </span>
328
+ </div>
329
+ <div className="dashboard-splitted-card__item__description">
330
+ <ContractStatus contract={order.contract} />
331
+ </div>
332
+ <div className="dashboard-splitted-card__item__actions">
333
+ <SignContractButton
190
334
  order={order}
191
- contract_definition={product?.contract_definition!}
192
335
  contract={order.contract}
193
- writable={writable}
194
- mode="compact"
336
+ writable={true}
337
+ className="dashboard-item__button"
195
338
  />
196
- )}
339
+ </div>
197
340
  </div>
198
341
  );
199
342
  };
@@ -41,3 +41,132 @@
41
41
  }
42
42
  }
43
43
  }
44
+
45
+ .dashboard-splitted-card {
46
+ background-color: r-theme-val(dashboard-splitted-card, background-color);
47
+ border-radius: 0.25rem;
48
+ box-shadow: r-theme-val(dashboard-splitted-card, base-shadow);
49
+ padding: 2rem;
50
+ display: flex;
51
+ color: r-theme-val(dashboard-splitted-card, color);
52
+
53
+ @include media-breakpoint-down(sm) {
54
+ flex-direction: column;
55
+ }
56
+
57
+ &__separator {
58
+ margin: 0 2rem;
59
+ border-right: 1px r-theme-val(dashboard-splitted-card, separator-color) solid;
60
+
61
+ @include media-breakpoint-down(sm) {
62
+ margin: 2rem 0;
63
+ border-right: none;
64
+ border-bottom: 1px r-theme-val(dashboard-splitted-card, separator-color) solid;
65
+ }
66
+ }
67
+
68
+ &__column {
69
+ width: 50%;
70
+ display: flex;
71
+ flex-direction: column;
72
+ gap: 2rem;
73
+
74
+ @include media-breakpoint-down(sm) {
75
+ width: auto;
76
+ }
77
+
78
+ &:last-child {
79
+ justify-content: center;
80
+ }
81
+ }
82
+
83
+ &__item {
84
+ &__title {
85
+ font-weight: 800;
86
+ font-size: 1rem;
87
+ font-family: $r-font-family-montserrat;
88
+ margin-bottom: 0.5rem;
89
+
90
+ span {
91
+ position: relative;
92
+ }
93
+
94
+ &--dot {
95
+ span::after {
96
+ content: '';
97
+ margin-left: 0.25rem;
98
+ background-color: r-theme-val(dashboard-splitted-card, dot-color);
99
+ width: 8px;
100
+ height: 8px;
101
+ display: block;
102
+ border-radius: 100%;
103
+ position: absolute;
104
+ right: -12px;
105
+ top: -4px;
106
+ }
107
+ }
108
+ }
109
+
110
+ &__description {
111
+ font-size: 0.875rem;
112
+ }
113
+
114
+ &__actions {
115
+ margin-top: 0.25rem;
116
+ }
117
+ }
118
+ }
119
+
120
+ .dashboard-item-order__organization {
121
+ display: flex;
122
+ flex-direction: column;
123
+ align-items: center;
124
+ justify-content: center;
125
+ gap: 2rem;
126
+ margin-bottom: 0.5rem;
127
+ font-size: 0.875rem;
128
+ flex-grow: 1;
129
+
130
+ &__name {
131
+ font-size: 24px;
132
+ font-weight: 800;
133
+ }
134
+
135
+ &__logo {
136
+ min-height: 64px;
137
+ max-height: 128px;
138
+ width: 100%;
139
+ flex-grow: 1;
140
+ background-size: contain;
141
+ background-repeat: no-repeat;
142
+ background-position: center;
143
+ }
144
+ }
145
+
146
+ .dashboard-splitted-card__item__address {
147
+ address {
148
+ margin: 0;
149
+ }
150
+ }
151
+
152
+ .organization-block__contact__item {
153
+ font-weight: bold;
154
+
155
+ a {
156
+ margin-left: 0.25rem;
157
+ }
158
+ }
159
+
160
+ .order-organization {
161
+ &__caption {
162
+ order: 3;
163
+ }
164
+
165
+ &__separator {
166
+ order: 2;
167
+ }
168
+
169
+ &__misc {
170
+ order: 1;
171
+ }
172
+ }
@@ -1,5 +1,14 @@
1
1
  .dashboard-order-loader__banners {
2
2
  .banner {
3
3
  margin-top: 0;
4
+
5
+ a {
6
+ color: white;
7
+ text-decoration: underline;
8
+
9
+ &:hover {
10
+ color: white;
11
+ }
12
+ }
4
13
  }
5
14
  }
@@ -17,10 +17,15 @@ const messages = defineMessages({
17
17
  id: 'components.DashboardOrderLoader.loading',
18
18
  },
19
19
  signatureNeeded: {
20
- defaultMessage: 'You need to sign your contract before enrolling in a course run',
20
+ defaultMessage: 'You need to {signLink} before enrolling in a course run',
21
21
  description: 'Banner displayed when the contract is not signed',
22
22
  id: 'components.DashboardOrderLoader.signatureNeeded',
23
23
  },
24
+ signLink: {
25
+ defaultMessage: 'sign your contract',
26
+ description: 'Link to sign the contract',
27
+ id: 'components.DashboardOrderLoader.signLink',
28
+ },
24
29
  wrongLinkedProductError: {
25
30
  defaultMessage: 'This page is not available for this order.',
26
31
  description: "Error message displayed when order's linked product type is not handle.",
@@ -48,7 +53,6 @@ export const DashboardOrderLoader = () => {
48
53
  }
49
54
  }, [credentialOrder]);
50
55
  const error = errorOrder || errorCourseProduct || wrongLinkedProductError;
51
-
52
56
  const fetching = fetchingOrder || fetchingCourseProduct;
53
57
  const needsSignature = OrderHelper.orderNeedsSignature(
54
58
  order,
@@ -66,8 +70,19 @@ export const DashboardOrderLoader = () => {
66
70
  )}
67
71
  <div className="dashboard-order-loader__banners">
68
72
  {error && <Banner message={error} type={BannerType.ERROR} />}
69
- {needsSignature && (
70
- <Banner message={intl.formatMessage(messages.signatureNeeded)} type={BannerType.ERROR} />
73
+ {order && needsSignature && (
74
+ <Banner
75
+ message={
76
+ intl.formatMessage(messages.signatureNeeded, {
77
+ signLink: (
78
+ <a href={'#dashboard-item-contract-' + order.id}>
79
+ <FormattedMessage {...messages.signLink} />
80
+ </a>
81
+ ),
82
+ }) as any
83
+ }
84
+ type={BannerType.ERROR}
85
+ />
71
86
  )}
72
87
  </div>
73
88
  {credentialOrder && (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "2.25.0-b2.dev111",
3
+ "version": "2.25.0-b2.dev115",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {
@@ -276,6 +276,13 @@ $r-theme: (
276
276
  base-border: r-color('light-grey'),
277
277
  menu-link-inline-padding: 16px,
278
278
  ),
279
+ dashboard-splitted-card: (
280
+ color: r-color('charcoal'),
281
+ base-shadow: 0 0 6px r-color('light-grey'),
282
+ background-color: r-color('white'),
283
+ dot-color: r-color('firebrick6'),
284
+ separator-color: r-color('grey87'),
285
+ ),
279
286
  dashboard-list: (
280
287
  background-color-loading: r-color('smoke'),
281
288
  ),