richie-education 2.25.0-b2.dev49 → 2.25.0-b2.dev62

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/.nvmrc CHANGED
@@ -1 +1 @@
1
- 18.19.0
1
+ 20.11.0
@@ -3,7 +3,7 @@ import { useState } from 'react';
3
3
  import { defineMessages, useIntl } from 'react-intl';
4
4
  import { PaymentErrorMessageId } from 'components/PaymentButton/index';
5
5
  import { Product } from 'types/Joanie';
6
- import { useJoanieApi } from 'contexts/JoanieApiContext';
6
+ import context from 'utils/context';
7
7
 
8
8
  const messages = defineMessages({
9
9
  termsMessage: {
@@ -33,7 +33,6 @@ export const useTerms = ({
33
33
  error?: PaymentErrorMessageId;
34
34
  }) => {
35
35
  const intl = useIntl();
36
- const api = useJoanieApi();
37
36
  const [termsAccepted, setTermsAccepted] = useState(false);
38
37
  const validateTerms = () => {
39
38
  if (!product.contract_definition) {
@@ -44,18 +43,6 @@ export const useTerms = ({
44
43
  }
45
44
  };
46
45
 
47
- const openContract = async (e: React.MouseEvent) => {
48
- if (!product.contract_definition) {
49
- return;
50
- }
51
- e.stopPropagation();
52
- e.preventDefault();
53
- const blob = await api.contractDefinitions.previewTemplate(product.contract_definition.id);
54
- // eslint-disable-next-line compat/compat
55
- const file = window.URL.createObjectURL(blob);
56
- window.open(file);
57
- };
58
-
59
46
  return {
60
47
  termsAccepted,
61
48
  validateTerms,
@@ -66,12 +53,14 @@ export const useTerms = ({
66
53
  label={
67
54
  <>
68
55
  {intl.formatMessage(messages.termsMessage)}{' '}
69
- <button
70
- onClick={openContract}
56
+ <a
57
+ href={context.site_urls.terms_and_conditions ?? '#'}
58
+ target="_blank"
59
+ rel="noopener noreferrer"
71
60
  title={intl.formatMessage(messages.termsMessageLinkTitle)}
72
61
  >
73
62
  {intl.formatMessage(messages.termsMessageLink)}
74
- </button>
63
+ </a>
75
64
  </>
76
65
  }
77
66
  onChange={(e) => setTermsAccepted(e.target.checked)}
@@ -54,6 +54,9 @@ jest.mock('utils/context', () => ({
54
54
  joanie_backend: {
55
55
  endpoint: 'https://joanie.test',
56
56
  },
57
+ site_urls: {
58
+ terms_and_conditions: '/en/about/terms-and-conditions/',
59
+ },
57
60
  }).one(),
58
61
  }));
59
62
 
@@ -177,7 +180,9 @@ describe.each([
177
180
  </Wrapper>,
178
181
  );
179
182
 
180
- const $terms = screen.getByLabelText('By checking this box, you accept the');
183
+ const $terms = screen.getByLabelText(
184
+ 'By checking this box, you accept the General Terms of Sale',
185
+ );
181
186
  await act(async () => {
182
187
  fireEvent.click($terms);
183
188
  });
@@ -330,7 +335,9 @@ describe.each([
330
335
  nbApiCalls += 1; // fetch order for useProductOrder
331
336
  expect(fetchMock.calls()).toHaveLength(nbApiCalls);
332
337
 
333
- const $terms = screen.getByLabelText('By checking this box, you accept the');
338
+ const $terms = screen.getByLabelText(
339
+ 'By checking this box, you accept the General Terms of Sale',
340
+ );
334
341
  await act(async () => {
335
342
  fireEvent.click($terms);
336
343
  });
@@ -455,7 +462,9 @@ describe.each([
455
462
  expect(screen.getByTestId('payment-button-order-loaded')).toBeInTheDocument();
456
463
  });
457
464
 
458
- const $terms = screen.getByLabelText('By checking this box, you accept the');
465
+ const $terms = screen.getByLabelText(
466
+ 'By checking this box, you accept the General Terms of Sale',
467
+ );
459
468
  await act(async () => {
460
469
  fireEvent.click($terms);
461
470
  });
@@ -590,7 +599,9 @@ describe.each([
590
599
  `https://joanie.test/api/v1.0/orders/?${queryString.stringify(fetchOrderQueryParams)}`,
591
600
  );
592
601
 
593
- const $terms = screen.getByLabelText('By checking this box, you accept the');
602
+ const $terms = screen.getByLabelText(
603
+ 'By checking this box, you accept the General Terms of Sale',
604
+ );
594
605
  await act(async () => {
595
606
  fireEvent.click($terms);
596
607
  });
@@ -703,7 +714,9 @@ describe.each([
703
714
  nbApiCalls += 1; // useProductOrder get order with filters
704
715
  expect(fetchMock.calls()).toHaveLength(nbApiCalls);
705
716
 
706
- const $terms = screen.getByLabelText('By checking this box, you accept the');
717
+ const $terms = screen.getByLabelText(
718
+ 'By checking this box, you accept the General Terms of Sale',
719
+ );
707
720
  await act(async () => {
708
721
  fireEvent.click($terms);
709
722
  });
@@ -802,17 +815,9 @@ describe.each([
802
815
  });
803
816
 
804
817
  it('should be able to preview the contract if product has a contract definition', async () => {
805
- // eslint-disable-next-line compat/compat
806
- URL.createObjectURL = jest.fn((blob) => blob) as any;
807
- window.open = jest.fn();
808
-
809
818
  const product: Joanie.Product = ProductFactory().one();
810
819
  const billingAddress: Joanie.Address = AddressFactory().one();
811
820
 
812
- const PREVIEW_URL = `https://joanie.test/api/v1.0/contract_definitions/${
813
- product.contract_definition!.id
814
- }/preview_template/`;
815
-
816
821
  const fetchOrderQueryParams =
817
822
  product.type === ProductType.CREDENTIAL
818
823
  ? {
@@ -826,12 +831,10 @@ describe.each([
826
831
  state: ['pending', 'validated', 'submitted'],
827
832
  };
828
833
 
829
- fetchMock
830
- .get(
831
- `https://joanie.test/api/v1.0/orders/?${queryString.stringify(fetchOrderQueryParams)}`,
832
- [],
833
- )
834
- .get(PREVIEW_URL, 'preview content');
834
+ fetchMock.get(
835
+ `https://joanie.test/api/v1.0/orders/?${queryString.stringify(fetchOrderQueryParams)}`,
836
+ [],
837
+ );
835
838
 
836
839
  render(
837
840
  <Wrapper client={createTestQueryClient({ user: true })} product={product}>
@@ -839,25 +842,8 @@ describe.each([
839
842
  </Wrapper>,
840
843
  );
841
844
 
842
- const $terms = screen.getByRole('button', { name: 'General Terms of Sale' });
843
-
844
- // eslint-disable-next-line compat/compat
845
- expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
846
- expect(window.open).toHaveBeenCalledTimes(0);
847
- expect(fetchMock.called(PREVIEW_URL)).toBe(false);
848
-
849
- // console.log($terms);
850
- await act(async () => {
851
- fireEvent.click($terms);
852
- });
853
-
854
- expect(fetchMock.called(PREVIEW_URL)).toBe(true);
855
- // eslint-disable-next-line compat/compat
856
- expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
857
- // eslint-disable-next-line compat/compat
858
- expect(URL.createObjectURL).toHaveBeenCalledWith('preview content');
859
- expect(window.open).toHaveBeenCalledTimes(1);
860
- expect(window.open).toHaveBeenCalledWith('preview content');
845
+ const $terms = screen.getByRole('link', { name: 'General Terms of Sale' });
846
+ expect($terms).toHaveAttribute('href', '/en/about/terms-and-conditions/');
861
847
  });
862
848
 
863
849
  it('should not show terms checkbox if the product does not have a contract definition', async () => {
@@ -959,7 +945,9 @@ describe.each([
959
945
  name: `Pay ${formatPrice(product.price, product.price_currency)}`,
960
946
  }) as HTMLButtonElement;
961
947
 
962
- const $terms = screen.getByLabelText('By checking this box, you accept the');
948
+ const $terms = screen.getByLabelText(
949
+ 'By checking this box, you accept the General Terms of Sale',
950
+ );
963
951
  await act(async () => {
964
952
  fireEvent.click($terms);
965
953
  });
@@ -12,7 +12,7 @@ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
12
12
  import { ContractFactory, OrganizationFactory } from 'utils/test/factories/joanie';
13
13
  import { expectNoSpinner } from 'utils/test/expectSpinner';
14
14
  import { expectBannerError } from 'utils/test/expectBanner';
15
- import { HttpError } from 'utils/errors/HttpError';
15
+ import { HttpStatusCode } from 'utils/errors/HttpError';
16
16
  import TeacherDashboardContracts from '.';
17
17
 
18
18
  jest.mock('utils/context', () => ({
@@ -82,12 +82,12 @@ describe('pages/TeacherDashboardContracts', () => {
82
82
  fetchMock.get(`https://joanie.test/api/v1.0/organizations/`, [organization]);
83
83
  // TeacherDashboardContracts request a paginated list of contracts to display
84
84
  fetchMock.get(
85
- `https://joanie.test/api/v1.0/organizations/${organization.id}/contracts/?signature_state=signed&course_product_relation_id=2&page=1&page_size=25`,
85
+ `https://joanie.test/api/v1.0/organizations/${organization.id}/contracts/?course_product_relation_id=2&signature_state=signed&page=1&page_size=25`,
86
86
  { results: contracts, count: 0, previous: null, next: null },
87
87
  );
88
88
  // useTeacherContractsToSign request all contract to sign, without pagination
89
89
  fetchMock.get(
90
- `https://joanie.test/api/v1.0/organizations/${organization.id}/contracts/?signature_state=half_signed&course_product_relation_id=2`,
90
+ `https://joanie.test/api/v1.0/organizations/${organization.id}/contracts/?course_product_relation_id=2&signature_state=half_signed`,
91
91
  { results: [], count: 0, previous: null, next: null },
92
92
  );
93
93
 
@@ -244,6 +244,10 @@ describe('pages/TeacherDashboardContracts', () => {
244
244
  abilities: { sign: true },
245
245
  }).many(3);
246
246
 
247
+ fetchMock.get(
248
+ `https://joanie.test/api/v1.0/organizations/1/contracts/?signature_state=signed&page=1&page_size=25`,
249
+ { results: [], count: 0, previous: null, next: null },
250
+ );
247
251
  fetchMock.get(
248
252
  `https://joanie.test/api/v1.0/organizations/1/contracts/?signature_state=half_signed&page=1&page_size=25`,
249
253
  { results: contracts, count: 3, previous: null, next: null },
@@ -309,15 +313,11 @@ describe('pages/TeacherDashboardContracts', () => {
309
313
  it('should render an error banner if an error occured during contracts fetching', async () => {
310
314
  fetchMock.get(
311
315
  `https://joanie.test/api/v1.0/organizations/1/contracts/?signature_state=signed&page=1&page_size=25`,
312
- () => {
313
- throw new HttpError(404, 'Not found');
314
- },
316
+ HttpStatusCode.NOT_FOUND,
315
317
  );
316
318
  fetchMock.get(
317
319
  `https://joanie.test/api/v1.0/organizations/1/contracts/?signature_state=half_signed`,
318
- () => {
319
- throw new HttpError(404, 'Not found');
320
- },
320
+ HttpStatusCode.NOT_FOUND,
321
321
  );
322
322
 
323
323
  render(
@@ -87,10 +87,7 @@ describe('TeacherDashboardContractsLayout/ContractActionsBar', () => {
87
87
 
88
88
  render(
89
89
  <Wrapper>
90
- <ContractActionsBar
91
- courseProductRelationId={faker.string.uuid()}
92
- organizationId={faker.string.uuid()}
93
- />
90
+ <ContractActionsBar organizationId={faker.string.uuid()} />
94
91
  </Wrapper>,
95
92
  );
96
93
 
@@ -99,26 +96,47 @@ describe('TeacherDashboardContractsLayout/ContractActionsBar', () => {
99
96
  expect(screen.getByRole('button', { name: /Request contracts archive/ })).toBeInTheDocument();
100
97
  });
101
98
 
102
- it("shouldn't only display sign button", () => {
103
- mockHasContractToDownload = false;
104
- mockCanSignContracts = true;
105
- mockContractsToSignCount = 1;
106
-
107
- render(
108
- <Wrapper>
109
- <ContractActionsBar
110
- courseProductRelationId={faker.string.uuid()}
111
- organizationId={faker.string.uuid()}
112
- />
113
- </Wrapper>,
114
- );
115
-
116
- expect(screen.getByTestId('teacher-contracts-list-actionsBar')).toBeInTheDocument();
117
- expect(screen.getByRole('button', { name: /Sign all pending contracts/ })).toBeInTheDocument();
118
- expect(
119
- screen.queryByRole('button', { name: /Request contracts archive/ }),
120
- ).not.toBeInTheDocument();
121
- });
99
+ it.each([
100
+ {
101
+ label: "doesn't have contract to download",
102
+ hasContractToDownload: false,
103
+ courseProductRelationId: undefined,
104
+ },
105
+ {
106
+ label: 'has contract to download and courseProductRelationId',
107
+ hasContractToDownload: true,
108
+ courseProductRelationId: faker.string.uuid(),
109
+ },
110
+ {
111
+ label: "doesn't have contract to download and courseProductRelationId",
112
+ hasContractToDownload: false,
113
+ courseProductRelationId: faker.string.uuid(),
114
+ },
115
+ ])(
116
+ "shouldn't only display sign button when $label",
117
+ ({ hasContractToDownload, courseProductRelationId }) => {
118
+ mockHasContractToDownload = hasContractToDownload;
119
+ mockCanSignContracts = true;
120
+ mockContractsToSignCount = 1;
121
+
122
+ render(
123
+ <Wrapper>
124
+ <ContractActionsBar
125
+ courseProductRelationId={courseProductRelationId}
126
+ organizationId={faker.string.uuid()}
127
+ />
128
+ </Wrapper>,
129
+ );
130
+
131
+ expect(screen.getByTestId('teacher-contracts-list-actionsBar')).toBeInTheDocument();
132
+ expect(
133
+ screen.getByRole('button', { name: /Sign all pending contracts/ }),
134
+ ).toBeInTheDocument();
135
+ expect(
136
+ screen.queryByRole('button', { name: /Request contracts archive/ }),
137
+ ).not.toBeInTheDocument();
138
+ },
139
+ );
122
140
 
123
141
  it("shouldn't only display download button", () => {
124
142
  mockHasContractToDownload = true;
@@ -127,10 +145,7 @@ describe('TeacherDashboardContractsLayout/ContractActionsBar', () => {
127
145
 
128
146
  render(
129
147
  <Wrapper>
130
- <ContractActionsBar
131
- courseProductRelationId={faker.string.uuid()}
132
- organizationId={faker.string.uuid()}
133
- />
148
+ <ContractActionsBar organizationId={faker.string.uuid()} />
134
149
  </Wrapper>,
135
150
  );
136
151
 
@@ -141,15 +156,25 @@ describe('TeacherDashboardContractsLayout/ContractActionsBar', () => {
141
156
  ).not.toBeInTheDocument();
142
157
  });
143
158
 
144
- it('should return nothing when no actions are available', () => {
145
- mockHasContractToDownload = false;
159
+ it.each([
160
+ {
161
+ label: 'only download is available but we have a courseProductRelationId',
162
+ hasContractToDownload: true,
163
+ courseProductRelationId: faker.string.uuid(),
164
+ },
165
+ {
166
+ label: 'no actions are available',
167
+ hasContractToDownload: false,
168
+ },
169
+ ])('should return nothing when $label', ({ hasContractToDownload, courseProductRelationId }) => {
170
+ mockHasContractToDownload = hasContractToDownload;
146
171
  mockCanSignContracts = false;
147
172
  mockContractsToSignCount = 0;
148
173
 
149
174
  render(
150
175
  <Wrapper>
151
176
  <ContractActionsBar
152
- courseProductRelationId={faker.string.uuid()}
177
+ courseProductRelationId={courseProductRelationId}
153
178
  organizationId={faker.string.uuid()}
154
179
  />
155
180
  </Wrapper>,
@@ -15,9 +15,10 @@ const ContractActionsBar = ({ organizationId, courseProductRelationId }: Contrac
15
15
  organizationId,
16
16
  courseProductRelationId,
17
17
  });
18
- const hasContractToDownload = useHasContractToDownload(organizationId);
18
+ const hasContractToDownload = useHasContractToDownload(organizationId, courseProductRelationId);
19
19
 
20
- const nbAvailableActions = [canSignContracts, hasContractToDownload].filter((val) => val).length;
20
+ const canDownloadContracts = hasContractToDownload && !courseProductRelationId;
21
+ const nbAvailableActions = [canSignContracts, canDownloadContracts].filter((val) => val).length;
21
22
  return (
22
23
  nbAvailableActions > 0 && (
23
24
  <div
@@ -35,7 +36,7 @@ const ContractActionsBar = ({ organizationId, courseProductRelationId }: Contrac
35
36
  />
36
37
  </div>
37
38
  )}
38
- {hasContractToDownload && <BulkDownloadContractButton organizationId={organizationId} />}
39
+ {canDownloadContracts && <BulkDownloadContractButton organizationId={organizationId} />}
39
40
  </div>
40
41
  )
41
42
  );
@@ -0,0 +1,134 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query';
2
+ import { PropsWithChildren } from 'react';
3
+ import { IntlProvider } from 'react-intl';
4
+ import { MemoryRouter, Route, Routes } from 'react-router-dom';
5
+ import { renderHook, waitFor } from '@testing-library/react';
6
+ import fetchMock from 'fetch-mock';
7
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
8
+ import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider';
9
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
10
+ import { OrganizationFactory } from 'utils/test/factories/joanie';
11
+ import { Organization } from 'types/Joanie';
12
+ import useDefaultOrganizationId from '.';
13
+
14
+ jest.mock('utils/context', () => ({
15
+ __esModule: true,
16
+ default: mockRichieContextFactory({
17
+ authentication: { backend: 'fonzie', endpoint: 'https://demo.test' },
18
+ joanie_backend: { endpoint: 'https://joanie.test' },
19
+ }).one(),
20
+ }));
21
+
22
+ interface WrapperProps {
23
+ routePath: string;
24
+ initialEntry: string;
25
+ }
26
+
27
+ describe('useDefaultOrganizationId', () => {
28
+ const organizations: {
29
+ routeOrganization: Organization;
30
+ queryOrganization: Organization;
31
+ userOrganizationList: Organization[];
32
+ } = {
33
+ routeOrganization: OrganizationFactory().one(),
34
+ queryOrganization: OrganizationFactory().one(),
35
+ userOrganizationList: OrganizationFactory().many(2),
36
+ };
37
+
38
+ const Wrapper = ({ children, routePath, initialEntry }: PropsWithChildren<WrapperProps>) => {
39
+ return (
40
+ <IntlProvider locale="en">
41
+ <QueryClientProvider client={createTestQueryClient({ user: true })}>
42
+ <JoanieSessionProvider>
43
+ <MemoryRouter initialEntries={[initialEntry]}>
44
+ <Routes>
45
+ <Route path={routePath} element={children} />
46
+ </Routes>
47
+ </MemoryRouter>
48
+ </JoanieSessionProvider>
49
+ </QueryClientProvider>
50
+ </IntlProvider>
51
+ );
52
+ };
53
+
54
+ beforeEach(() => {
55
+ // Joanie provider's calls
56
+ fetchMock.get('https://joanie.test/api/v1.0/orders/', []);
57
+ fetchMock.get('https://joanie.test/api/v1.0/credit-cards/', []);
58
+ fetchMock.get('https://joanie.test/api/v1.0/addresses/', []);
59
+ });
60
+
61
+ afterEach(() => {
62
+ fetchMock.restore();
63
+ });
64
+
65
+ it.each([
66
+ {
67
+ testLabel: 'route organization before query',
68
+ routeOrganization: organizations.routeOrganization,
69
+ queryOrganization: organizations.queryOrganization,
70
+ userOrganizationList: organizations.userOrganizationList,
71
+ expectedOrganizationId: organizations.routeOrganization.id,
72
+ },
73
+ {
74
+ testLabel: 'query organization before first element of list',
75
+ routeOrganization: undefined,
76
+ queryOrganization: organizations.queryOrganization,
77
+ userOrganizationList: organizations.userOrganizationList,
78
+ expectedOrganizationId: organizations.queryOrganization.id,
79
+ },
80
+ {
81
+ testLabel: 'first element of list when nothing else is found',
82
+ routeOrganization: undefined,
83
+ queryOrganization: undefined,
84
+ userOrganizationList: organizations.userOrganizationList,
85
+ expectedOrganizationId: organizations.userOrganizationList[0].id,
86
+ },
87
+ {
88
+ testLabel: 'undefined when user have no organization in his list',
89
+ routeOrganization: undefined,
90
+ queryOrganization: undefined,
91
+ userOrganizationList: [],
92
+ expectedOrganizationId: undefined,
93
+ },
94
+ ])(
95
+ 'should return $testLabel',
96
+ async ({
97
+ routeOrganization,
98
+ queryOrganization,
99
+ userOrganizationList,
100
+ expectedOrganizationId,
101
+ }) => {
102
+ let routePath = '/';
103
+ if (routeOrganization) {
104
+ routePath += ':organizationId/';
105
+ }
106
+ let initialEntry = '/';
107
+ if (routeOrganization) {
108
+ initialEntry += `${routeOrganization.id}/`;
109
+ }
110
+ if (queryOrganization) {
111
+ initialEntry += `?organization_id=${queryOrganization.id}`;
112
+ }
113
+
114
+ fetchMock.get(
115
+ 'https://joanie.test/api/v1.0/organizations/',
116
+ [...userOrganizationList, routeOrganization, queryOrganization].filter(
117
+ (organization) => organization !== undefined,
118
+ ),
119
+ );
120
+ const { result } = renderHook(useDefaultOrganizationId, {
121
+ wrapper: ({ children }) => (
122
+ <Wrapper routePath={routePath} initialEntry={initialEntry}>
123
+ {children}
124
+ </Wrapper>
125
+ ),
126
+ });
127
+
128
+ // when looking in organization list defaultOrganization will be updated when organizations are fetched.
129
+ await waitFor(() => {
130
+ expect(result.current).toBe(expectedOrganizationId);
131
+ });
132
+ },
133
+ );
134
+ });
@@ -0,0 +1,28 @@
1
+ import { useParams, useSearchParams } from 'react-router-dom';
2
+ import { useOrganizations } from 'hooks/useOrganizations';
3
+ import { Organization } from 'types/Joanie';
4
+
5
+ /**
6
+ * return organization id with this priority:
7
+ * * route param
8
+ * * query param
9
+ * * first organization of user's organizations
10
+ */
11
+ const useDefaultOrganizationId = () => {
12
+ const { organizationId: routeOrganizationId } = useParams<{
13
+ organizationId?: Organization['id'];
14
+ }>();
15
+ const [searchParams] = useSearchParams();
16
+ const queryOrganizationId = searchParams.get('organization_id') || undefined;
17
+ const { items: organizations } = useOrganizations(undefined, {
18
+ enabled: !routeOrganizationId && !queryOrganizationId,
19
+ });
20
+
21
+ return (
22
+ routeOrganizationId ||
23
+ queryOrganizationId ||
24
+ (organizations.length > 0 ? organizations[0].id : undefined)
25
+ );
26
+ };
27
+
28
+ export default useDefaultOrganizationId;
@@ -1,13 +1,17 @@
1
1
  import { useOrganizationContracts } from 'hooks/useContracts';
2
2
  import { PER_PAGE } from 'settings';
3
- import { ContractState, Organization } from 'types/Joanie';
3
+ import { ContractState, CourseProductRelation, Organization } from 'types/Joanie';
4
4
 
5
- const useHasContractToDownload = (organizationId: Organization['id']) => {
5
+ const useHasContractToDownload = (
6
+ organizationId: Organization['id'],
7
+ courseProductRelationId?: CourseProductRelation['id'],
8
+ ) => {
6
9
  const {
7
10
  items: contracts,
8
11
  states: { isFetched },
9
12
  } = useOrganizationContracts({
10
13
  organization_id: organizationId,
14
+ course_product_relation_id: courseProductRelationId,
11
15
  signature_state: ContractState.SIGNED,
12
16
  page: 1,
13
17
  page_size: PER_PAGE.teacherContractList,
@@ -0,0 +1,193 @@
1
+ import fetchMock from 'fetch-mock';
2
+ import { PropsWithChildren } from 'react';
3
+ import { IntlProvider } from 'react-intl';
4
+ import { QueryClientProvider } from '@tanstack/react-query';
5
+ import { renderHook, waitFor } from '@testing-library/react';
6
+ import { MemoryRouter, Route, Routes } from 'react-router-dom';
7
+ import { act } from 'react-dom/test-utils';
8
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
9
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
10
+ import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider';
11
+ import { CourseProductRelationFactory, OrganizationFactory } from 'utils/test/factories/joanie';
12
+ import { ContractState } from 'types/Joanie';
13
+ import useTeacherContractFilters from '.';
14
+
15
+ jest.mock('utils/context', () => ({
16
+ __esModule: true,
17
+ default: mockRichieContextFactory({
18
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.test' },
19
+ joanie_backend: { endpoint: 'https://joanie.test' },
20
+ }).one(),
21
+ }));
22
+
23
+ interface WrapperProps {
24
+ routePath: string;
25
+ initialEntry: string;
26
+ }
27
+
28
+ describe('useTeacherContractFilters', () => {
29
+ const Wrapper = ({ children, routePath, initialEntry }: PropsWithChildren<WrapperProps>) => {
30
+ return (
31
+ <IntlProvider locale="en">
32
+ <QueryClientProvider client={createTestQueryClient({ user: true })}>
33
+ <JoanieSessionProvider>
34
+ <MemoryRouter initialEntries={[initialEntry]}>
35
+ <Routes>
36
+ <Route path={routePath} element={children} />
37
+ </Routes>
38
+ </MemoryRouter>
39
+ </JoanieSessionProvider>
40
+ </QueryClientProvider>
41
+ </IntlProvider>
42
+ );
43
+ };
44
+ beforeEach(() => {
45
+ // Joanie provider's calls
46
+ fetchMock.get('https://joanie.test/api/v1.0/orders/', []);
47
+ fetchMock.get('https://joanie.test/api/v1.0/credit-cards/', []);
48
+ fetchMock.get('https://joanie.test/api/v1.0/addresses/', []);
49
+ });
50
+
51
+ afterEach(() => {
52
+ fetchMock.restore();
53
+ });
54
+
55
+ it('should return default filter when called in a route without parameters', async () => {
56
+ const defaultOrganization = OrganizationFactory({ id: 'default' }).one();
57
+ // fetching user's organizations to initialize default organizationId.
58
+ fetchMock.get('https://joanie.test/api/v1.0/organizations/', [defaultOrganization]);
59
+ const { result } = renderHook(useTeacherContractFilters, {
60
+ wrapper: ({ children }) => (
61
+ <Wrapper routePath="/" initialEntry="/">
62
+ {children}
63
+ </Wrapper>
64
+ ),
65
+ });
66
+
67
+ await waitFor(() => {
68
+ expect(result.current.initialFilters).toStrictEqual({
69
+ contract_ids: [],
70
+ organization_id: defaultOrganization.id,
71
+ course_product_relation_id: undefined,
72
+ signature_state: ContractState.SIGNED,
73
+ });
74
+ expect(result.current.filters).toStrictEqual({
75
+ contract_ids: [],
76
+ organization_id: defaultOrganization.id,
77
+ course_product_relation_id: undefined,
78
+ signature_state: ContractState.SIGNED,
79
+ });
80
+ });
81
+ });
82
+
83
+ it('should use route parameters values when given', async () => {
84
+ const defaultOrganization = OrganizationFactory({ id: 'default' }).one();
85
+ const filteredOrganization = OrganizationFactory({ id: 'filtered' }).one();
86
+ const routeOrganization = OrganizationFactory({ id: 'route' }).one();
87
+ const routeCourseProductRelation = CourseProductRelationFactory().one();
88
+ // fetching user's organizations to initialize default organizationId.
89
+ fetchMock.get('https://joanie.test/api/v1.0/organizations/', [
90
+ defaultOrganization,
91
+ filteredOrganization,
92
+ ]);
93
+ const { result } = renderHook(useTeacherContractFilters, {
94
+ wrapper: ({ children }) => (
95
+ <Wrapper
96
+ routePath="/:organizationId/:courseProductRelationId"
97
+ initialEntry={`/${routeOrganization.id}/${routeCourseProductRelation.id}?organization_id=${filteredOrganization.id}&signature_state=${ContractState?.UNSIGNED}&contract_ids=1&contract_ids=2`}
98
+ >
99
+ {children}
100
+ </Wrapper>
101
+ ),
102
+ });
103
+
104
+ await waitFor(() => {
105
+ expect(result.current.initialFilters).toStrictEqual({
106
+ contract_ids: ['1', '2'],
107
+ organization_id: routeOrganization.id,
108
+ course_product_relation_id: routeCourseProductRelation.id,
109
+ signature_state: ContractState.UNSIGNED,
110
+ });
111
+ expect(result.current.filters).toStrictEqual({
112
+ contract_ids: ['1', '2'],
113
+ organization_id: routeOrganization.id,
114
+ course_product_relation_id: routeCourseProductRelation.id,
115
+ signature_state: ContractState.UNSIGNED,
116
+ });
117
+ });
118
+ });
119
+
120
+ it("should use organizationId from query parameters when it's not in route params", async () => {
121
+ const defaultOrganization = OrganizationFactory({ id: 'default' }).one();
122
+ const filteredOrganization = OrganizationFactory({ id: 'filtered' }).one();
123
+ const routeCourseProductRelation = CourseProductRelationFactory({ id: 'route' }).one();
124
+ // fetching user's organizations to initialize default organizationId.
125
+ fetchMock.get('https://joanie.test/api/v1.0/organizations/', [
126
+ defaultOrganization,
127
+ filteredOrganization,
128
+ ]);
129
+ const { result } = renderHook(useTeacherContractFilters, {
130
+ wrapper: ({ children }) => (
131
+ <Wrapper
132
+ routePath="/:courseProductRelationId"
133
+ initialEntry={`/${routeCourseProductRelation.id}/?organization_id=${filteredOrganization.id}&signature_state=${ContractState?.UNSIGNED}&contract_ids=1&contract_ids=2`}
134
+ >
135
+ {children}
136
+ </Wrapper>
137
+ ),
138
+ });
139
+
140
+ await waitFor(() => {
141
+ expect(result.current.initialFilters).toStrictEqual({
142
+ contract_ids: ['1', '2'],
143
+ organization_id: filteredOrganization.id,
144
+ course_product_relation_id: routeCourseProductRelation.id,
145
+ signature_state: ContractState.UNSIGNED,
146
+ });
147
+ expect(result.current.filters).toStrictEqual({
148
+ contract_ids: ['1', '2'],
149
+ organization_id: filteredOrganization.id,
150
+ course_product_relation_id: routeCourseProductRelation.id,
151
+ signature_state: ContractState.UNSIGNED,
152
+ });
153
+ });
154
+ });
155
+
156
+ it('setFilters should update filter state', async () => {
157
+ const defaultOrganization = OrganizationFactory({ id: 'default' }).one();
158
+ const routeOrganization = OrganizationFactory({ id: 'route' }).one();
159
+ const routeCourseProductRelation = CourseProductRelationFactory().one();
160
+ // fetching user's organizations to initialize default organizationId.
161
+ fetchMock.get('https://joanie.test/api/v1.0/organizations/', [defaultOrganization]);
162
+ const { result } = renderHook(useTeacherContractFilters, {
163
+ wrapper: ({ children }) => (
164
+ <Wrapper routePath="/" initialEntry="/">
165
+ {children}
166
+ </Wrapper>
167
+ ),
168
+ });
169
+
170
+ const expectedInitialFilters = {
171
+ contract_ids: [],
172
+ organization_id: defaultOrganization.id,
173
+ course_product_relation_id: undefined,
174
+ signature_state: ContractState.SIGNED,
175
+ };
176
+ await waitFor(() => {
177
+ expect(result.current.initialFilters).toStrictEqual(expectedInitialFilters);
178
+ });
179
+
180
+ const newFilters = {
181
+ contract_ids: ['1', '2'],
182
+ organization_id: routeOrganization.id,
183
+ course_product_relation_id: routeCourseProductRelation.id,
184
+ signature_state: ContractState.UNSIGNED,
185
+ };
186
+ act(() => {
187
+ result.current.setFilters(newFilters);
188
+ });
189
+
190
+ expect(result.current.filters).toStrictEqual(newFilters);
191
+ expect(result.current.initialFilters).toStrictEqual(expectedInitialFilters);
192
+ });
193
+ });
@@ -0,0 +1,44 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useParams, useSearchParams } from 'react-router-dom';
3
+ import { ContractResourceQuery, ContractState } from 'types/Joanie';
4
+ import useDefaultOrganizationId from '../useDefaultOrganizationId';
5
+
6
+ export type TeacherDashboardContractsParams = {
7
+ organizationId?: string;
8
+ courseProductRelationId?: string;
9
+ };
10
+
11
+ const useTeacherContractFilters = () => {
12
+ const { courseProductRelationId } = useParams<TeacherDashboardContractsParams>();
13
+ const [searchParams] = useSearchParams();
14
+ const searchFilters: ContractResourceQuery = useMemo(() => {
15
+ return {
16
+ organization_id: searchParams.get('organization_id') || undefined,
17
+ course_product_relation_id: searchParams.get('course_product_relation_id') || undefined,
18
+ contract_ids: searchParams.getAll('contract_ids') || undefined,
19
+ signature_state:
20
+ (searchParams.get('signature_state') as ContractState) || ContractState.SIGNED,
21
+ };
22
+ }, Array.from(searchParams.entries()));
23
+
24
+ // default orgnizationId between (ordered by priority): route, query, first user's organization.
25
+ const defaultOrganizationId = useDefaultOrganizationId();
26
+
27
+ const initialFilters = useMemo(() => {
28
+ return {
29
+ ...searchFilters,
30
+ organization_id: defaultOrganizationId,
31
+ course_product_relation_id: courseProductRelationId,
32
+ };
33
+ }, [defaultOrganizationId]);
34
+ const [filters, setFilters] = useState<ContractResourceQuery>(initialFilters);
35
+
36
+ // update current filter with initial value when it's ready
37
+ useEffect(() => {
38
+ setFilters(initialFilters);
39
+ }, [initialFilters]);
40
+
41
+ return { initialFilters, filters, setFilters };
42
+ };
43
+
44
+ export default useTeacherContractFilters;
@@ -31,6 +31,9 @@ export interface RichieContext {
31
31
  release: string;
32
32
  sentry_dsn: Nullable<string>;
33
33
  web_analytics_providers?: Nullable<string[]>;
34
+ site_urls: {
35
+ terms_and_conditions: Nullable<string>;
36
+ };
34
37
  }
35
38
 
36
39
  export interface CommonDataProps {
@@ -78,7 +78,7 @@ describe('utils/search/getSuggestionsSection', () => {
78
78
  expect(mockHandle).toHaveBeenCalledWith(
79
79
  new Error(
80
80
  'Failed to decode JSON in getSuggestionSection FetchError: invalid json response body at ' +
81
- '/api/v1.0/courses/autocomplete/?query=some%20search reason: Unexpected token o in JSON at position 1',
81
+ '/api/v1.0/courses/autocomplete/?query=some%20search reason: Unexpected token \'o\', "not json" is not valid JSON',
82
82
  ),
83
83
  );
84
84
  });
@@ -181,6 +181,9 @@ export const RichieContextFactory = factory<CommonDataProps['context']>(() => ({
181
181
  release: faker.system.semver(),
182
182
  sentry_dsn: null,
183
183
  web_analytics_providers: null,
184
+ site_urls: {
185
+ terms_and_conditions: null,
186
+ },
184
187
  }));
185
188
 
186
189
  export const CourseLightFactory = factory<Course>(() => {
@@ -1,11 +1,14 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import { IntlProvider } from 'react-intl';
3
3
  import { PropsWithChildren } from 'react';
4
- import { EnrollmentFactory } from 'utils/test/factories/joanie';
4
+ import { CredentialOrderFactory, EnrollmentFactory } from 'utils/test/factories/joanie';
5
5
  import { Priority } from 'types';
6
- import { Enrollment } from 'types/Joanie';
7
- import { DATETIME_FORMAT } from 'hooks/useDateFormat';
8
- import { Enrolled } from './DashboardItemCourseEnrolling';
6
+ import { CourseRun, Enrollment } from 'types/Joanie';
7
+ import { DEFAULT_DATE_FORMAT } from 'hooks/useDateFormat';
8
+ import { CourseRunFactoryFromPriority } from 'utils/test/factories/richie';
9
+ import { noop } from 'utils';
10
+ import { computeState } from 'utils/CourseRuns';
11
+ import { DashboardItemCourseEnrollingRun, Enrolled } from './DashboardItemCourseEnrolling';
9
12
 
10
13
  /**
11
14
  * Most of the component of this file are tested from DashboardItemEnrollment.spec.tsx and
@@ -16,49 +19,109 @@ describe('<Enrolled/>', () => {
16
19
  return <IntlProvider locale="en">{children}</IntlProvider>;
17
20
  };
18
21
 
19
- const runTest = async (priority: Priority, expectButton: boolean) => {
20
- const enrollment: Enrollment = EnrollmentFactory().one();
21
- enrollment.course_run.state.priority = priority;
22
- render(<Enrolled enrollment={enrollment} />, { wrapper });
23
- await screen.findByText(
24
- 'You are enrolled for the session from ' +
25
- new Intl.DateTimeFormat('en', DATETIME_FORMAT).format(
26
- new Date(enrollment.course_run.start),
27
- ) +
28
- ' to ' +
29
- new Intl.DateTimeFormat('en', DATETIME_FORMAT).format(new Date(enrollment.course_run.end)),
30
- );
31
- if (expectButton) {
32
- const link = screen.getByRole('link', { name: 'Access course' });
33
- expect(link).toBeEnabled();
34
- expect(link).toHaveAttribute('href', enrollment.course_run.resource_link);
35
- } else {
36
- expect(screen.queryByRole('link', { name: 'Access course' })).toBeNull();
37
- }
38
- };
22
+ it.each([
23
+ {
24
+ buttonTestLabel: 'and access course button',
25
+ priority: Priority.ONGOING_OPEN,
26
+ expectButton: true,
27
+ },
28
+ {
29
+ buttonTestLabel: 'and no access course button',
30
+ priority: Priority.FUTURE_OPEN,
31
+ expectButton: false,
32
+ },
33
+ {
34
+ buttonTestLabel: 'and access course button',
35
+ priority: Priority.ARCHIVED_OPEN,
36
+ expectButton: true,
37
+ },
38
+ {
39
+ buttonTestLabel: 'and no access course button',
40
+ priority: Priority.FUTURE_NOT_YET_OPEN,
41
+ expectButton: false,
42
+ },
43
+ {
44
+ buttonTestLabel: 'and no access course button',
45
+ priority: Priority.FUTURE_CLOSED,
46
+ expectButton: false,
47
+ },
48
+ {
49
+ buttonTestLabel: 'and access course button',
50
+ priority: Priority.ONGOING_CLOSED,
51
+ expectButton: true,
52
+ },
53
+ {
54
+ buttonTestLabel: 'and access course button',
55
+ priority: Priority.ARCHIVED_CLOSED,
56
+ expectButton: true,
57
+ },
58
+ {
59
+ buttonTestLabel: 'and no access course button',
60
+ priority: Priority.TO_BE_SCHEDULED,
61
+ expectButton: false,
62
+ },
63
+ ])(
64
+ 'handles enrollments with priority=$priority $buttonTestLabel',
65
+ async ({ priority, expectButton }) => {
66
+ const enrollment: Enrollment = EnrollmentFactory().one();
67
+ enrollment.course_run.state.priority = priority;
68
+ render(<Enrolled enrollment={enrollment} />, { wrapper });
69
+ await screen.findByText(
70
+ 'You are enrolled for the session from ' +
71
+ new Intl.DateTimeFormat('en', DEFAULT_DATE_FORMAT).format(
72
+ new Date(enrollment.course_run.start),
73
+ ) +
74
+ ' to ' +
75
+ new Intl.DateTimeFormat('en', DEFAULT_DATE_FORMAT).format(
76
+ new Date(enrollment.course_run.end),
77
+ ),
78
+ );
79
+ if (expectButton) {
80
+ const link = screen.getByRole('link', { name: 'Access to course' });
81
+ expect(link).toBeEnabled();
82
+ expect(link).toHaveAttribute('href', enrollment.course_run.resource_link);
83
+ } else {
84
+ expect(screen.queryByRole('link', { name: 'Access to course' })).toBeNull();
85
+ }
86
+ },
87
+ );
88
+ });
89
+
90
+ describe('<DashboardItemCourseEnrollingRun/>', () => {
91
+ it.each([
92
+ [Priority.ONGOING_OPEN, false],
93
+ [Priority.FUTURE_OPEN, false],
94
+ [Priority.ARCHIVED_OPEN, false],
95
+ [Priority.FUTURE_NOT_YET_OPEN, true],
96
+ [Priority.FUTURE_CLOSED, false],
97
+ [Priority.ONGOING_CLOSED, false],
98
+ [Priority.ARCHIVED_CLOSED, false],
99
+ [Priority.TO_BE_SCHEDULED, false],
100
+ ])(
101
+ `handles correctly enrollment_start date displaying with priority=%s`,
102
+ async (priority, expectEnrollmentNotYetOpened) => {
103
+ const order = CredentialOrderFactory().one();
104
+ const courseRun = CourseRunFactoryFromPriority(priority)().one();
105
+ courseRun.state = computeState(courseRun);
106
+ const joanieCourseRun = courseRun as unknown as CourseRun;
107
+ joanieCourseRun.course = order.course;
108
+
109
+ render(
110
+ <IntlProvider locale="en">
111
+ <DashboardItemCourseEnrollingRun
112
+ order={order}
113
+ courseRun={joanieCourseRun}
114
+ selected={false}
115
+ enroll={noop}
116
+ />
117
+ </IntlProvider>,
118
+ );
39
119
 
40
- it('handles enrollments with priority=ONGOING_OPEN', () => {
41
- runTest(Priority.ONGOING_OPEN, true);
42
- });
43
- it('handles enrollments with priority=FUTURE_OPEN', () => {
44
- runTest(Priority.FUTURE_OPEN, false);
45
- });
46
- it('handles enrollments with priority=ARCHIVED_OPEN', () => {
47
- runTest(Priority.ARCHIVED_OPEN, true);
48
- });
49
- it('handles enrollments with priority=FUTURE_NOT_YET_OPEN', () => {
50
- runTest(Priority.FUTURE_NOT_YET_OPEN, false);
51
- });
52
- it('handles enrollments with priority=FUTURE_CLOSED', () => {
53
- runTest(Priority.FUTURE_CLOSED, false);
54
- });
55
- it('handles enrollments with priority=ONGOING_CLOSED', () => {
56
- runTest(Priority.ONGOING_CLOSED, true);
57
- });
58
- it('handles enrollments with priority=ARCHIVED_CLOSED', () => {
59
- runTest(Priority.ARCHIVED_CLOSED, true);
60
- });
61
- it('handles enrollments with priority=TO_BE_SCHEDULED', () => {
62
- runTest(Priority.TO_BE_SCHEDULED, false);
63
- });
120
+ if (expectEnrollmentNotYetOpened) {
121
+ screen.getByText(/Enrollment will open on/);
122
+ } else {
123
+ expect(screen.queryByText(/Enrollment will open on/)).not.toBeInTheDocument();
124
+ }
125
+ },
126
+ );
64
127
  });
@@ -1,15 +1,15 @@
1
- import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
1
+ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
2
2
  import { useMemo } from 'react';
3
3
  import { Button } from '@openfun/cunningham-react';
4
4
  import { CoursesHelper } from 'utils/CoursesHelper';
5
5
  import { Priority } from 'types';
6
6
  import {
7
+ AbstractCourse,
8
+ CertificateOrder,
7
9
  CourseRun,
8
- Enrollment,
9
10
  CredentialOrder,
10
- AbstractCourse,
11
+ Enrollment,
11
12
  Product,
12
- CertificateOrder,
13
13
  } from 'types/Joanie';
14
14
  import { Spinner } from 'components/Spinner';
15
15
  import Banner, { BannerType } from 'components/Banner';
@@ -208,7 +208,7 @@ interface DashboardItemCourseEnrollingRunProps {
208
208
  product?: Product;
209
209
  }
210
210
 
211
- const DashboardItemCourseEnrollingRun = ({
211
+ export const DashboardItemCourseEnrollingRun = ({
212
212
  courseRun,
213
213
  selected,
214
214
  enroll,
@@ -247,7 +247,7 @@ const DashboardItemCourseEnrollingRun = ({
247
247
  }}
248
248
  />
249
249
  </div>
250
- {!isOpenedForEnrollment && (
250
+ {courseRun.state.priority === Priority.FUTURE_NOT_YET_OPEN && (
251
251
  <div className="dashboard-item__course-enrolling__run__not-opened">
252
252
  <FormattedMessage
253
253
  {...messages.enrollmentNotYetOpened}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "2.25.0-b2.dev49",
3
+ "version": "2.25.0-b2.dev62",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {
@@ -147,6 +147,6 @@
147
147
  "workerDirectory": "../richie/static/richie/js"
148
148
  },
149
149
  "volta": {
150
- "node": "18.19.0"
150
+ "node": "20.11.0"
151
151
  }
152
152
  }
@@ -131,18 +131,12 @@
131
131
  }
132
132
 
133
133
  &__logo {
134
- position: relative;
134
+ height: 100%;
135
135
  width: 100%;
136
- padding-bottom: 56.25%; // Aspect ratio 16/9
137
136
 
138
137
  & > img {
139
- position: absolute;
140
- top: 0;
141
- right: 0;
142
- bottom: 0;
143
- left: 0;
144
- width: 100%;
145
138
  height: 100%;
139
+ width: 100%;
146
140
  object-fit: contain;
147
141
  object-position: center;
148
142
  }
@@ -1,28 +0,0 @@
1
- import { useMemo, useState } from 'react';
2
- import { useParams, useSearchParams } from 'react-router-dom';
3
- import { ContractResourceQuery, ContractState } from 'types/Joanie';
4
-
5
- export type TeacherDashboardContractsParams = {
6
- organizationId?: string;
7
- courseProductRelationId?: string;
8
- };
9
-
10
- const useTeacherContractFilters = () => {
11
- const { organizationId, courseProductRelationId } = useParams<TeacherDashboardContractsParams>();
12
- const [searchParams] = useSearchParams();
13
-
14
- const initialFilters = useMemo(
15
- () => ({
16
- signature_state:
17
- (searchParams.get('signature_state') as ContractState) || ContractState.SIGNED,
18
- organization_id: organizationId,
19
- course_product_relation_id: courseProductRelationId,
20
- }),
21
- [],
22
- );
23
- const [filters, setFilters] = useState<ContractResourceQuery>(initialFilters);
24
-
25
- return { initialFilters, filters, setFilters };
26
- };
27
-
28
- export default useTeacherContractFilters;