richie-education 2.25.0-b2.dev71 → 2.25.0-b2.dev77

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.
@@ -6,9 +6,14 @@ import { IntlProvider } from 'react-intl';
6
6
  import fetchMock from 'fetch-mock';
7
7
  import { QueryStateFactory } from 'utils/test/factories/reactQuery';
8
8
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
9
- import { ContractFactory, OrganizationFactory } from 'utils/test/factories/joanie';
9
+ import {
10
+ ContractFactory,
11
+ CourseProductRelationFactory,
12
+ OrganizationFactory,
13
+ } from 'utils/test/factories/joanie';
10
14
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
11
15
  import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider';
16
+ import { isCourseProductRelation } from 'types/Joanie';
12
17
  import { Props } from './AbstractContractFrame';
13
18
  import { OrganizationContractFrame } from '.';
14
19
 
@@ -70,132 +75,93 @@ describe('OrganizationContractFrame', () => {
70
75
  fetchMock.restore();
71
76
  });
72
77
 
73
- it('should implement AbstractContractFrame for organization', async () => {
74
- const organization = OrganizationFactory().one();
75
- const contract = ContractFactory().one();
76
- const isOpen = faker.datatype.boolean();
77
-
78
- const expectedUrls = {
79
- getInvitationLink: `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts-signature-link/`,
80
- checkSignature: `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts/?id=${contract.id}`,
81
- };
82
-
83
- fetchMock
84
- .get(expectedUrls.getInvitationLink, {
85
- invitation_link: faker.internet.url(),
86
- contract_ids: [contract.id],
87
- })
88
- .get(expectedUrls.checkSignature, { results: [contract] });
89
-
90
- const handleDone = jest.fn();
91
- const handleClose = jest.fn();
92
-
93
- const client = createTestQueryClient({
94
- user: true,
95
- queriesCallback: (queries) => {
96
- // Push contract and orders queries
97
- queries.push(QueryStateFactory(['user', 'organization_contracts'], { data: [] }));
98
- },
99
- });
100
-
101
- let contractsQueryState = client.getQueryState(['user', 'organization_contracts']);
102
- expect(contractsQueryState?.isInvalidated).toBe(false);
103
-
104
- await act(async () => {
105
- render(
106
- <Wrapper client={client}>
107
- <OrganizationContractFrame
108
- organizationId={organization.id}
109
- isOpen={isOpen}
110
- onDone={handleDone}
111
- onClose={handleClose}
112
- />
113
- </Wrapper>,
114
- );
115
- });
116
-
117
- // isOpen should be passed down to AbstractContractFrame
118
- const contractFrame = await screen.getByTestId('AbstractContractFrame');
119
- expect(contractFrame).toHaveAttribute('data-is-open', String(isOpen));
120
-
121
- // getInvitationLink should post on order/submit-for-signature endpoint
122
- expect(fetchMock.called(expectedUrls.getInvitationLink)).toBe(true);
123
-
124
- // checkSignature should get on order endpoint
125
- expect(fetchMock.called(expectedUrls.checkSignature)).toBe(true);
126
-
127
- // onDone should be tweaked to invalidate the user orders and contracts queries
128
- // and passed down to AbstractContractFrame
129
- contractsQueryState = client.getQueryState(['user', 'organization_contracts']);
130
- expect(contractsQueryState?.isInvalidated).toBe(true);
131
- expect(handleDone).toHaveBeenCalledTimes(1);
132
-
133
- // onClose should be passed down to AbstractContractFrame
134
- expect(handleClose).toHaveBeenCalledTimes(1);
135
- });
136
-
137
- it('should implement AbstractContractFrame for organization with a list of contract ids', async () => {
138
- const organization = OrganizationFactory().one();
139
- const contracts = ContractFactory().many(2);
140
- const isOpen = faker.datatype.boolean();
141
-
142
- const expectedUrls = {
143
- getInvitationLink: `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts-signature-link/?contracts_ids=${contracts[0].id}&contracts_ids=${contracts[1].id}`,
144
- checkSignature: `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts/?id=${contracts[0].id}&id=${contracts[1].id}`,
145
- };
146
-
147
- fetchMock
148
- .get(expectedUrls.getInvitationLink, {
149
- invitation_link: faker.internet.url(),
150
- contract_ids: contracts.map((contract) => contract.id),
151
- })
152
- .get(expectedUrls.checkSignature, { results: [contracts] });
153
-
154
- const handleDone = jest.fn();
155
- const handleClose = jest.fn();
156
-
157
- const client = createTestQueryClient({
158
- user: true,
159
- queriesCallback: (queries) => {
160
- // Push contract and orders queries
161
- queries.push(QueryStateFactory(['user', 'organization_contracts'], { data: [] }));
162
- },
163
- });
164
-
165
- let contractsQueryState = client.getQueryState(['user', 'organization_contracts']);
166
- expect(contractsQueryState?.isInvalidated).toBe(false);
167
-
168
- await act(async () => {
169
- render(
170
- <Wrapper client={client}>
171
- <OrganizationContractFrame
172
- organizationId={organization.id}
173
- contractIds={contracts.map((contract) => contract.id)}
174
- isOpen={isOpen}
175
- onDone={handleDone}
176
- onClose={handleClose}
177
- />
178
- </Wrapper>,
179
- );
180
- });
181
-
182
- // isOpen should be passed down to AbstractContractFrame
183
- const contractFrame = await screen.getByTestId('AbstractContractFrame');
184
- expect(contractFrame).toHaveAttribute('data-is-open', String(isOpen));
185
-
186
- // getInvitationLink should post on order/submit-for-signature endpoint
187
- expect(fetchMock.called(expectedUrls.getInvitationLink)).toBe(true);
188
-
189
- // checkSignature should get on order endpoint
190
- expect(fetchMock.called(expectedUrls.checkSignature)).toBe(true);
191
-
192
- // onDone should be tweaked to invalidate the user orders and contracts queries
193
- // and passed down to AbstractContractFrame
194
- contractsQueryState = client.getQueryState(['user', 'organization_contracts']);
195
- expect(contractsQueryState?.isInvalidated).toBe(true);
196
- expect(handleDone).toHaveBeenCalledTimes(1);
197
-
198
- // onClose should be passed down to AbstractContractFrame
199
- expect(handleClose).toHaveBeenCalledTimes(1);
200
- });
78
+ it.each([
79
+ {
80
+ label: 'contractList: undefined, courseProductRelation: undefined',
81
+ contractList: undefined,
82
+ courseProductRelation: undefined,
83
+ },
84
+ {
85
+ label: 'contractList: 2 Contract, courseProductRelation: undefined',
86
+ contractList: ContractFactory().many(2),
87
+ courseProductRelation: undefined,
88
+ },
89
+ {
90
+ label: 'contractList: undefined, courseProductRelation: one CourseProductRelation',
91
+ contractList: undefined,
92
+ courseProductRelation: CourseProductRelationFactory().one(),
93
+ },
94
+ ])(
95
+ 'should implement AbstractContractFrame for organization and $label',
96
+ async ({ contractList, courseProductRelation }) => {
97
+ const organization = OrganizationFactory().one();
98
+ const contracts = contractList || ContractFactory().many(2);
99
+ const isOpen = faker.datatype.boolean();
100
+
101
+ const invitationLinkQueryString =
102
+ courseProductRelation && isCourseProductRelation(courseProductRelation)
103
+ ? `?course_product_relation_ids=${courseProductRelation.id}`
104
+ : '';
105
+ const expectedUrls = {
106
+ getInvitationLink: `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts-signature-link/${invitationLinkQueryString}`,
107
+ checkSignature: `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts/?id=${contracts[0].id}&id=${contracts[1].id}`,
108
+ };
109
+
110
+ fetchMock
111
+ .get(expectedUrls.getInvitationLink, {
112
+ invitation_link: faker.internet.url(),
113
+ contract_ids: contracts.map((contract) => contract.id),
114
+ })
115
+ .get(expectedUrls.checkSignature, { results: [contracts] });
116
+
117
+ const handleDone = jest.fn();
118
+ const handleClose = jest.fn();
119
+
120
+ const client = createTestQueryClient({
121
+ user: true,
122
+ queriesCallback: (queries) => {
123
+ // Push contract and orders queries
124
+ queries.push(QueryStateFactory(['user', 'organization_contracts'], { data: [] }));
125
+ },
126
+ });
127
+
128
+ let contractsQueryState = client.getQueryState(['user', 'organization_contracts']);
129
+ expect(contractsQueryState?.isInvalidated).toBe(false);
130
+
131
+ await act(async () => {
132
+ render(
133
+ <Wrapper client={client}>
134
+ <OrganizationContractFrame
135
+ organizationId={organization.id}
136
+ courseProductRelationIds={
137
+ courseProductRelation ? [courseProductRelation.id] : undefined
138
+ }
139
+ isOpen={isOpen}
140
+ onDone={handleDone}
141
+ onClose={handleClose}
142
+ />
143
+ </Wrapper>,
144
+ );
145
+ });
146
+
147
+ // isOpen should be passed down to AbstractContractFrame
148
+ const contractFrame = screen.getByTestId('AbstractContractFrame');
149
+ expect(contractFrame).toHaveAttribute('data-is-open', String(isOpen));
150
+
151
+ // getInvitationLink should post on order/submit-for-signature endpoint
152
+ expect(fetchMock.called(expectedUrls.getInvitationLink)).toBe(true);
153
+
154
+ // checkSignature should get on order endpoint
155
+ expect(fetchMock.called(expectedUrls.checkSignature)).toBe(true);
156
+
157
+ // onDone should be tweaked to invalidate the user orders and contracts queries
158
+ // and passed down to AbstractContractFrame
159
+ contractsQueryState = client.getQueryState(['user', 'organization_contracts']);
160
+ expect(contractsQueryState?.isInvalidated).toBe(true);
161
+ expect(handleDone).toHaveBeenCalledTimes(1);
162
+
163
+ // onClose should be passed down to AbstractContractFrame
164
+ expect(handleClose).toHaveBeenCalledTimes(1);
165
+ },
166
+ );
201
167
  });
@@ -4,14 +4,21 @@ import { useJoanieApi } from 'contexts/JoanieApiContext';
4
4
  import AbstractContractFrame, {
5
5
  AbstractProps,
6
6
  } from 'components/ContractFrame/AbstractContractFrame';
7
- import { Contract } from 'types/Joanie';
7
+ import { Contract, CourseProductRelation } from 'types/Joanie';
8
8
 
9
9
  interface Props extends AbstractProps {
10
10
  contractIds?: Contract['id'][];
11
11
  organizationId: string;
12
+ courseProductRelationIds?: CourseProductRelation['id'][];
12
13
  }
13
14
 
14
- const OrganizationContractFrame = ({ organizationId, contractIds, onDone, ...props }: Props) => {
15
+ const OrganizationContractFrame = ({
16
+ organizationId,
17
+ courseProductRelationIds = [],
18
+ contractIds,
19
+ onDone,
20
+ ...props
21
+ }: Props) => {
15
22
  const api = useJoanieApi();
16
23
  const queryClient = useQueryClient();
17
24
  const [contractIdsToCheck, setContractIdsToCheck] = useState(contractIds);
@@ -22,6 +29,7 @@ const OrganizationContractFrame = ({ organizationId, contractIds, onDone, ...pro
22
29
  be signed. We need to keep track of these ids to check if all contracts have been signed.
23
30
  */
24
31
  const response = await api.organizations.contracts.getSignatureLinks({
32
+ course_product_relation_ids: courseProductRelationIds,
25
33
  organization_id: organizationId,
26
34
  contracts_ids: contractIds,
27
35
  });
@@ -76,18 +76,19 @@ describe('pages/TeacherDashboardContracts', () => {
76
76
  student_signed_on: Date.toString(),
77
77
  organization_signed_on: Date.toString(),
78
78
  }).many(3);
79
- const organization = OrganizationFactory().one();
79
+ const organizations = OrganizationFactory().many(2);
80
+ const defaultOrganization = organizations[0];
80
81
 
81
82
  // OrganizationContractFilter request all organizations forwho the user have access
82
- fetchMock.get(`https://joanie.test/api/v1.0/organizations/`, [organization]);
83
+ fetchMock.get(`https://joanie.test/api/v1.0/organizations/`, organizations);
83
84
  // TeacherDashboardContracts request a paginated list of contracts to display
84
85
  fetchMock.get(
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
+ `https://joanie.test/api/v1.0/organizations/${defaultOrganization.id}/contracts/?course_product_relation_id=2&signature_state=signed&page=1&page_size=25`,
86
87
  { results: contracts, count: 0, previous: null, next: null },
87
88
  );
88
89
  // useTeacherContractsToSign request all contract to sign, without pagination
89
90
  fetchMock.get(
90
- `https://joanie.test/api/v1.0/organizations/${organization.id}/contracts/?course_product_relation_id=2&signature_state=half_signed`,
91
+ `https://joanie.test/api/v1.0/organizations/${defaultOrganization.id}/contracts/?signature_state=half_signed&course_product_relation_id=2`,
91
92
  { results: [], count: 0, previous: null, next: null },
92
93
  );
93
94
 
@@ -101,10 +102,10 @@ describe('pages/TeacherDashboardContracts', () => {
101
102
  await expectNoSpinner();
102
103
 
103
104
  // Organization filter should have been rendered
104
- const organizationFilter: HTMLInputElement = screen.getByRole('combobox', {
105
+ const organizationFilter: HTMLInputElement = await screen.findByRole('combobox', {
105
106
  name: 'Organization',
106
107
  });
107
- expect(organizationFilter).toHaveAttribute('value', organization.title);
108
+ expect(organizationFilter).toHaveAttribute('value', defaultOrganization.title);
108
109
 
109
110
  // Signature state filter should have been rendered
110
111
  const signatureStateFilter: HTMLInputElement = screen.getByRole('combobox', {
@@ -330,4 +331,34 @@ describe('pages/TeacherDashboardContracts', () => {
330
331
  await expectNoSpinner();
331
332
  await expectBannerError('An error occurred while fetching contracts. Please retry later.');
332
333
  });
334
+
335
+ it('should hide organization filter when user only have one organization', async () => {
336
+ const defaultOrganization = OrganizationFactory().one();
337
+ fetchMock.get('https://joanie.test/api/v1.0/organizations/', [defaultOrganization]);
338
+
339
+ const contracts = ContractFactory({
340
+ student_signed_on: Date.toString(),
341
+ abilities: { sign: true },
342
+ }).many(3);
343
+ fetchMock.get(
344
+ `https://joanie.test/api/v1.0/organizations/${defaultOrganization.id}/contracts/?signature_state=signed&page=1&page_size=25`,
345
+ { results: [], count: 0, previous: null, next: null },
346
+ );
347
+ fetchMock.get(
348
+ `https://joanie.test/api/v1.0/organizations/${defaultOrganization.id}/contracts/?signature_state=half_signed`,
349
+ { results: contracts, count: 3, previous: null, next: null },
350
+ );
351
+ render(<Wrapper path="/" initialEntry="" />);
352
+ await expectNoSpinner();
353
+
354
+ // Signature state filter should have been rendered
355
+ screen.getByRole('combobox', {
356
+ name: 'Signature state',
357
+ hidden: true,
358
+ });
359
+
360
+ // Organization filter should not have been rendered
361
+ const organizationFilter = screen.queryByRole('combobox', { name: 'Organization' });
362
+ expect(organizationFilter).not.toBeInTheDocument();
363
+ });
333
364
  });
@@ -9,6 +9,7 @@ import Banner, { BannerType } from 'components/Banner';
9
9
  import { PER_PAGE } from 'settings';
10
10
  import { ContractResourceQuery } from 'types/Joanie';
11
11
 
12
+ import { useOrganizations } from 'hooks/useOrganizations';
12
13
  import ContractFiltersBar from '../components/ContractFiltersBar';
13
14
  import useTeacherContractFilters, {
14
15
  TeacherDashboardContractsParams,
@@ -41,7 +42,15 @@ const TeacherDashboardContracts = () => {
41
42
  defaultPage: page ? parseInt(page, 10) : 1,
42
43
  pageSize: PER_PAGE.teacherContractList,
43
44
  });
44
- const { organizationId } = useParams<TeacherDashboardContractsParams>();
45
+ const { organizationId: routeOrganizationId } = useParams<TeacherDashboardContractsParams>();
46
+ // organization list is used to show/hide organization filter.
47
+ // when organizationId is in route's params this filter is always hidden.
48
+ // therefore we don't need to enable this query.
49
+ const {
50
+ items: organizationList,
51
+ states: { isFetched: isOrganizationListFetched },
52
+ } = useOrganizations(undefined, { enabled: !!routeOrganizationId });
53
+ const hasMultipleOrganizations = isOrganizationListFetched && organizationList.length > 1;
45
54
  const { initialFilters, filters, setFilters } = useTeacherContractFilters();
46
55
  const {
47
56
  items: contracts,
@@ -88,7 +97,7 @@ const TeacherDashboardContracts = () => {
88
97
  <ContractFiltersBar
89
98
  defaultValues={initialFilters}
90
99
  onFiltersChange={handleFiltersChange}
91
- hideFilterOrganization={!!organizationId}
100
+ hideFilterOrganization={!!(routeOrganizationId || !hasMultipleOrganizations)}
92
101
  />
93
102
  </div>
94
103
  <DataGrid
@@ -19,6 +19,7 @@ const ContractActionsBar = ({ organizationId, courseProductRelationId }: Contrac
19
19
 
20
20
  const canDownloadContracts = hasContractToDownload && !courseProductRelationId;
21
21
  const nbAvailableActions = [canSignContracts, canDownloadContracts].filter((val) => val).length;
22
+ const courseProductRelationIds = courseProductRelationId ? [courseProductRelationId] : undefined;
22
23
  return (
23
24
  nbAvailableActions > 0 && (
24
25
  <div
@@ -31,6 +32,7 @@ const ContractActionsBar = ({ organizationId, courseProductRelationId }: Contrac
31
32
  {canSignContracts && (
32
33
  <div>
33
34
  <SignOrganizationContractButton
35
+ courseProductRelationIds={courseProductRelationIds}
34
36
  organizationId={organizationId}
35
37
  contractToSignCount={contractsToSignCount}
36
38
  />
@@ -1,12 +1,13 @@
1
1
  import { faker } from '@faker-js/faker';
2
2
  import { render, screen } from '@testing-library/react';
3
- import { IntlProvider } from 'react-intl';
4
3
  import { PropsWithChildren } from 'react';
4
+ import { IntlProvider } from 'react-intl';
5
5
  import { QueryClientProvider } from '@tanstack/react-query';
6
+ import fetchMock from 'fetch-mock';
7
+ import userEvent from '@testing-library/user-event';
6
8
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
7
9
  import JoanieApiProvider from 'contexts/JoanieApiContext';
8
10
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
9
-
10
11
  import SignOrganizationContractButton from '.';
11
12
 
12
13
  jest.mock('utils/context', () => ({
@@ -35,26 +36,27 @@ describe('TeacherDashboardContractsLayout/SignOrganizationContractButton', () =>
35
36
  });
36
37
 
37
38
  afterEach(() => {
38
- jest.resetAllMocks();
39
+ fetchMock.restore();
39
40
  });
40
41
 
41
- it("shouldn't render sign button and <OrganizationContractFrame/> when contractToSignCount > 0", () => {
42
+ it('should display sign button user have some contract to sign', () => {
42
43
  render(
43
44
  <Wrapper>
44
45
  <SignOrganizationContractButton
45
46
  organizationId={faker.string.uuid()}
46
- contractToSignCount={1}
47
+ contractToSignCount={12}
47
48
  />
48
49
  </Wrapper>,
49
50
  );
50
51
 
51
- expect(screen.getByRole('button', { name: /Sign all pending contracts/ })).toBeInTheDocument();
52
-
52
+ expect(
53
+ screen.getByRole('button', { name: 'Sign all pending contracts (12)' }),
54
+ ).toBeInTheDocument();
53
55
  const DashboardContractFramePortal = document.getElementsByClassName('ReactModalPortal');
54
56
  expect(DashboardContractFramePortal).toHaveLength(1);
55
57
  });
56
58
 
57
- it("shouldn't only render <OrganizationContractFrame/> when contractToSignCount is 0", () => {
59
+ it("shouldn't display sign button user don't have some contract to sign", () => {
58
60
  render(
59
61
  <Wrapper>
60
62
  <SignOrganizationContractButton
@@ -67,8 +69,47 @@ describe('TeacherDashboardContractsLayout/SignOrganizationContractButton', () =>
67
69
  expect(
68
70
  screen.queryByRole('button', { name: /Sign all pending contracts/ }),
69
71
  ).not.toBeInTheDocument();
70
-
71
72
  const DashboardContractFramePortal = document.getElementsByClassName('ReactModalPortal');
72
73
  expect(DashboardContractFramePortal).toHaveLength(1);
73
74
  });
75
+
76
+ it.each([
77
+ {
78
+ label: "organization's contracts",
79
+ organizationId: faker.string.uuid(),
80
+ courseProductRelationIds: undefined,
81
+ },
82
+ {
83
+ label: "organization's training contracts",
84
+ organizationId: faker.string.uuid(),
85
+ courseProductRelationIds: [faker.string.uuid()],
86
+ },
87
+ ])('should open $label frame on click', async ({ organizationId, courseProductRelationIds }) => {
88
+ render(
89
+ <Wrapper>
90
+ <SignOrganizationContractButton
91
+ organizationId={organizationId}
92
+ courseProductRelationIds={courseProductRelationIds}
93
+ contractToSignCount={12}
94
+ />
95
+ </Wrapper>,
96
+ );
97
+
98
+ const $button = screen.getByRole('button', { name: /Sign all pending contracts/ });
99
+ const user = userEvent.setup();
100
+
101
+ let getInvitationLinkUrl = `https://joanie.test/api/v1.0/organizations/${organizationId}/contracts-signature-link/`;
102
+ if (courseProductRelationIds) {
103
+ getInvitationLinkUrl += `?course_product_relation_ids=${courseProductRelationIds[0]}`;
104
+ }
105
+
106
+ fetchMock.get(getInvitationLinkUrl, {
107
+ invitation_link: 'https://dummysignaturebackend.fr',
108
+ contract_ids: [],
109
+ });
110
+ await user.click($button);
111
+
112
+ expect(screen.getByTestId('dashboard-contract-frame')).toBeInTheDocument();
113
+ expect(fetchMock.called(getInvitationLinkUrl)).toBe(true);
114
+ });
74
115
  });
@@ -3,7 +3,7 @@ import { defineMessages, FormattedMessage } from 'react-intl';
3
3
  import { Button } from '@openfun/cunningham-react';
4
4
  import { OrganizationContractFrame } from 'components/ContractFrame';
5
5
 
6
- import { Organization } from 'types/Joanie';
6
+ import { CourseProductRelation, Organization } from 'types/Joanie';
7
7
 
8
8
  const messages = defineMessages({
9
9
  signAllPendingContracts: {
@@ -14,11 +14,16 @@ const messages = defineMessages({
14
14
  });
15
15
 
16
16
  interface Props {
17
+ courseProductRelationIds?: CourseProductRelation['id'][];
17
18
  organizationId: Organization['id'];
18
19
  contractToSignCount: number;
19
20
  }
20
21
 
21
- const SignOrganizationContractButton = ({ organizationId, contractToSignCount }: Props) => {
22
+ const SignOrganizationContractButton = ({
23
+ organizationId,
24
+ contractToSignCount,
25
+ courseProductRelationIds = [],
26
+ }: Props) => {
22
27
  const [contractFrameOpened, setContractFrameOpened] = useState(false);
23
28
  const hasContractToSign = contractToSignCount > 0;
24
29
 
@@ -43,6 +48,7 @@ const SignOrganizationContractButton = ({ organizationId, contractToSignCount }:
43
48
  </Button>
44
49
  )}
45
50
  <OrganizationContractFrame
51
+ courseProductRelationIds={courseProductRelationIds}
46
52
  organizationId={organizationId}
47
53
  isOpen={contractFrameOpened}
48
54
  onClose={() => setContractFrameOpened(false)}
@@ -452,6 +452,7 @@ export interface ContractResourceQuery extends PaginatedResourceQuery {
452
452
  export interface OrganizationContractSignatureLinksFilters {
453
453
  contracts_ids?: string[];
454
454
  organization_id: Organization['id'];
455
+ course_product_relation_ids?: CourseProductRelation['id'][];
455
456
  }
456
457
 
457
458
  export interface ContractInvitationLinkResponse {
@@ -5,6 +5,7 @@ import { ContractState, CourseProductRelation, Organization } from 'types/Joanie
5
5
  import useTeacherPendingContractsCount from 'hooks/useTeacherPendingContractsCount';
6
6
  import { ContractActions } from 'utils/AbilitiesHelper/types';
7
7
  import useContractAbilities from 'hooks/useContractAbilities';
8
+ import useDefaultOrganizationId from 'pages/TeacherDashboardContractsLayout/hooks/useDefaultOrganizationId';
8
9
  import MenuNavLink from '../MenuNavLink';
9
10
 
10
11
  interface ContractNavLinkProps {
@@ -18,8 +19,9 @@ const ContractNavLink = ({
18
19
  organizationId,
19
20
  courseProductRelationId,
20
21
  }: ContractNavLinkProps) => {
22
+ const defaultOrganizationId = useDefaultOrganizationId();
21
23
  const { contracts: pendingContracts, pendingContractCount } = useTeacherPendingContractsCount({
22
- organizationId,
24
+ organizationId: organizationId || defaultOrganizationId,
23
25
  courseProductRelationId,
24
26
  });
25
27
  const contractAbilities = useContractAbilities(pendingContracts);
@@ -4,7 +4,8 @@ import { render, screen } from '@testing-library/react';
4
4
  import { IntlProvider, createIntl } from 'react-intl';
5
5
  import { QueryClientProvider } from '@tanstack/react-query';
6
6
  import { CunninghamProvider } from '@openfun/cunningham-react';
7
- import { CourseListItem } from 'types/Joanie';
7
+ import { PropsWithChildren } from 'react';
8
+ import { CourseListItem, CourseProductRelation, Organization } from 'types/Joanie';
8
9
  import {
9
10
  RichieContextFactory as mockRichieContextFactory,
10
11
  UserFactory,
@@ -16,7 +17,11 @@ import {
16
17
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
17
18
  import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider';
18
19
 
19
- import { CourseFactory } from 'utils/test/factories/joanie';
20
+ import {
21
+ CourseFactory,
22
+ CourseProductRelationFactory,
23
+ OrganizationFactory,
24
+ } from 'utils/test/factories/joanie';
20
25
  import { expectNoSpinner } from 'utils/test/expectSpinner';
21
26
  import { TeacherDashboardCourseSidebar, messages } from '.';
22
27
 
@@ -37,26 +42,44 @@ jest.mock('utils/indirection/window', () => ({
37
42
  const intl = createIntl({ locale: 'en' });
38
43
 
39
44
  interface RenderTeacherDashboardCourseSidebarProps {
40
- courseId: string;
45
+ courseId: CourseListItem['id'];
46
+ organizationId?: Organization['id'];
47
+ courseProductRelationId?: CourseProductRelation['id'];
41
48
  }
42
- const renderTeacherDashboardCourseSidebar = ({
49
+
50
+ const Wrapper = ({
51
+ children,
43
52
  courseId,
44
- }: RenderTeacherDashboardCourseSidebarProps) =>
45
- render(
53
+ organizationId,
54
+ courseProductRelationId,
55
+ }: PropsWithChildren<RenderTeacherDashboardCourseSidebarProps>) => {
56
+ let routePath = '/:courseId';
57
+ let initialEntry = `/${courseId}`;
58
+
59
+ if (courseProductRelationId) {
60
+ routePath += '/:courseProductRelationId';
61
+ initialEntry += `/${courseProductRelationId}`;
62
+ }
63
+ if (organizationId) {
64
+ routePath = '/:organizationId' + routePath;
65
+ initialEntry = `/${organizationId}` + initialEntry;
66
+ }
67
+ return (
46
68
  <IntlProvider locale="en">
47
69
  <QueryClientProvider client={createTestQueryClient({ user: UserFactory().one() })}>
48
70
  <JoanieSessionProvider>
49
71
  <CunninghamProvider>
50
- <MemoryRouter initialEntries={[`/${courseId}`]}>
72
+ <MemoryRouter initialEntries={[initialEntry]}>
51
73
  <Routes>
52
- <Route path="/:courseId" element={<TeacherDashboardCourseSidebar />} />
74
+ <Route path={routePath} element={children} />
53
75
  </Routes>
54
76
  </MemoryRouter>
55
77
  </CunninghamProvider>
56
78
  </JoanieSessionProvider>
57
79
  </QueryClientProvider>
58
- </IntlProvider>,
80
+ </IntlProvider>
59
81
  );
82
+ };
60
83
 
61
84
  describe('<TeacherDashboardCourseSidebar/>', () => {
62
85
  let nbApiRequest: number;
@@ -77,7 +100,12 @@ describe('<TeacherDashboardCourseSidebar/>', () => {
77
100
  fetchMock.get(`https://joanie.endpoint/api/v1.0/courses/${course.id}/`, course);
78
101
  nbApiRequest += 1; // call to course
79
102
 
80
- renderTeacherDashboardCourseSidebar({ courseId: course.id });
103
+ render(
104
+ <Wrapper courseId={course.id}>
105
+ <TeacherDashboardCourseSidebar />
106
+ </Wrapper>,
107
+ );
108
+
81
109
  await expectNoSpinner('Loading course...');
82
110
  const link = screen.getByRole('link', {
83
111
  name: intl.formatMessage(messages.syllabusLinkLabel),
@@ -85,25 +113,103 @@ describe('<TeacherDashboardCourseSidebar/>', () => {
85
113
  expect(link).toHaveAttribute('href', `/redirects/courses/${course.code}`);
86
114
  });
87
115
 
88
- it('should display menu items', async () => {
89
- const course: CourseListItem = CourseFactory().one();
90
- fetchMock.get(`https://joanie.endpoint/api/v1.0/courses/${course.id}/`, course);
91
- nbApiRequest += 1; // call to course
116
+ it.each([
117
+ {
118
+ label: 'course',
119
+ course: CourseFactory().one(),
120
+ organization: undefined,
121
+ courseProductRelation: undefined,
122
+ expectedRoutes: [TeacherDashboardPaths.COURSE_GENERAL_INFORMATION],
123
+ },
124
+ {
125
+ label: 'training',
126
+ course: CourseFactory().one(),
127
+ organization: undefined,
128
+ courseProductRelation: CourseProductRelationFactory().one(),
129
+ expectedRoutes: [
130
+ TeacherDashboardPaths.COURSE_PRODUCT,
131
+ TeacherDashboardPaths.COURSE_CONTRACTS,
132
+ ],
133
+ },
134
+ {
135
+ label: "organization's course",
136
+ course: CourseFactory().one(),
137
+ organization: OrganizationFactory().one(),
138
+ courseProductRelation: undefined,
139
+ expectedRoutes: [TeacherDashboardPaths.ORGANIZATION_COURSE_GENERAL_INFORMATION],
140
+ },
141
+ {
142
+ label: "organization's training",
143
+ course: CourseFactory().one(),
144
+ organization: OrganizationFactory().one(),
145
+ courseProductRelation: CourseProductRelationFactory().one(),
146
+ expectedRoutes: [
147
+ TeacherDashboardPaths.ORGANIZATION_PRODUCT,
148
+ TeacherDashboardPaths.ORGANIZATION_PRODUCT_CONTRACTS,
149
+ ],
150
+ },
151
+ ])(
152
+ 'should display menu items for "$label" route',
153
+ async ({ course, organization, courseProductRelation, expectedRoutes }) => {
154
+ // mock api for organization's training
155
+ if (organization && courseProductRelation) {
156
+ // fetching training's contracts
157
+ nbApiRequest += 1;
158
+ fetchMock.get(
159
+ `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts/?course_product_relation_id=${courseProductRelation.id}&signature_state=half_signed&page=1&page_size=25`,
160
+ [],
161
+ );
162
+ // fetching organization's training
163
+ nbApiRequest += 1;
164
+ fetchMock.get(
165
+ `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/course-product-relations/${courseProductRelation.id}/`,
166
+ courseProductRelation,
167
+ );
168
+ } else if (organization) {
169
+ // fetching organization's course
170
+ nbApiRequest += 1;
171
+ fetchMock.get(
172
+ `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/courses/${course.id}/`,
173
+ course,
174
+ );
175
+ } else if (courseProductRelation) {
176
+ // fetching training
177
+ nbApiRequest += 1;
178
+ fetchMock.get(
179
+ `https://joanie.endpoint/api/v1.0/course-product-relations/${courseProductRelation.id}/`,
180
+ courseProductRelation,
181
+ );
182
+ } else {
183
+ // mock api for course
184
+ nbApiRequest += 1;
185
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/courses/${course.id}/`, course);
186
+ }
92
187
 
93
- renderTeacherDashboardCourseSidebar({ courseId: course.id });
94
- await expectNoSpinner('Loading course...');
95
- expect(
96
- screen.getByRole('link', {
97
- name: intl.formatMessage(
98
- TEACHER_DASHBOARD_ROUTE_LABELS[TeacherDashboardPaths.COURSE_GENERAL_INFORMATION],
99
- ),
100
- }),
101
- ).toBeInTheDocument();
102
-
103
- expect(screen.queryByTestId('organization-links')).not.toBeInTheDocument();
104
- // general informations
105
- // go to syllabus
106
- expect(screen.getAllByRole('link')).toHaveLength(2);
107
- expect(fetchMock.calls()).toHaveLength(nbApiRequest);
108
- });
188
+ render(
189
+ <Wrapper
190
+ courseId={course.id}
191
+ courseProductRelationId={courseProductRelation ? courseProductRelation.id : undefined}
192
+ organizationId={organization ? organization.id : undefined}
193
+ >
194
+ <TeacherDashboardCourseSidebar />
195
+ </Wrapper>,
196
+ );
197
+
198
+ await expectNoSpinner('Loading course...');
199
+ expectedRoutes.forEach((expectedRoute) => {
200
+ expect(
201
+ screen.getByRole('link', {
202
+ name: intl.formatMessage(TEACHER_DASHBOARD_ROUTE_LABELS[expectedRoute]),
203
+ }),
204
+ ).toBeInTheDocument();
205
+ });
206
+
207
+ expect(screen.queryByTestId('organization-links')).not.toBeInTheDocument();
208
+
209
+ let nbExpectedLinks = expectedRoutes.length;
210
+ nbExpectedLinks += 1; // link to syllabus
211
+ expect(screen.getAllByRole('link')).toHaveLength(nbExpectedLinks);
212
+ expect(fetchMock.calls()).toHaveLength(nbApiRequest);
213
+ },
214
+ );
109
215
  });
@@ -2,7 +2,7 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
2
2
  import { useParams } from 'react-router-dom';
3
3
  import { useMemo } from 'react';
4
4
  import { capitalize } from 'lodash-es';
5
- import { DashboardSidebar } from 'widgets/Dashboard/components/DashboardSidebar';
5
+ import { DashboardSidebar, MenuLink } from 'widgets/Dashboard/components/DashboardSidebar';
6
6
  import {
7
7
  getDashboardRouteLabel,
8
8
  getDashboardRoutePath,
@@ -11,6 +11,8 @@ import { useCourse } from 'hooks/useCourses';
11
11
  import { Spinner } from 'components/Spinner';
12
12
  import { Icon, IconTypeEnum } from 'components/Icon';
13
13
  import { useCourseProductRelation } from 'hooks/useCourseProductRelation';
14
+ import { TeacherDashboardPaths } from 'widgets/Dashboard/utils/teacherRouteMessages';
15
+ import ContractNavLink from '../DashboardSidebar/components/ContractNavLink';
14
16
  import { getMenuRoutes } from './utils';
15
17
 
16
18
  export const messages = defineMessages({
@@ -41,23 +43,35 @@ export const TeacherDashboardCourseSidebar = () => {
41
43
  const getRoutePath = getDashboardRoutePath(intl);
42
44
  const getRouteLabel = getDashboardRouteLabel(intl);
43
45
  const {
44
- courseId,
45
- courseProductRelationId = '',
46
- organizationId,
46
+ organizationId: routeOrganizationId,
47
+ courseId: routeCourseId,
48
+ courseProductRelationId: routeCourseProductRelationId = '',
47
49
  } = useParams<{
50
+ organizationId?: string;
48
51
  courseId: string;
49
52
  courseProductRelationId: string;
50
- organizationId?: string;
51
53
  }>();
52
54
 
53
55
  const {
54
56
  item: singleCourse,
55
57
  states: { fetching: courseFetching },
56
- } = useCourse(courseId, { organization_id: organizationId });
58
+ } = useCourse(
59
+ routeCourseId,
60
+ { organization_id: routeOrganizationId },
61
+ { enabled: !routeCourseProductRelationId },
62
+ );
63
+
57
64
  const {
58
65
  item: courseProductRelation,
59
66
  states: { fetching: courseProductRelationFetching },
60
- } = useCourseProductRelation(courseProductRelationId, { organization_id: organizationId });
67
+ } = useCourseProductRelation(
68
+ routeCourseProductRelationId,
69
+ {
70
+ organization_id: routeOrganizationId,
71
+ },
72
+ { enabled: !!routeCourseProductRelationId },
73
+ );
74
+
61
75
  const fetching = useMemo(
62
76
  () => courseFetching || courseProductRelationFetching,
63
77
  [courseFetching, courseProductRelationFetching],
@@ -71,14 +85,48 @@ export const TeacherDashboardCourseSidebar = () => {
71
85
  [courseProductRelation, singleCourse],
72
86
  );
73
87
 
74
- const menuLinks = getMenuRoutes({ courseProductRelationId, organizationId }).map((path) => ({
75
- to: getRoutePath(path, { courseId, courseProductRelationId, organizationId }),
76
- label: getRouteLabel(path),
77
- }));
88
+ const getMenuLinkFromPath = (basePath: TeacherDashboardPaths) => {
89
+ const path = getRoutePath(basePath, {
90
+ organizationId: routeOrganizationId,
91
+ courseId: routeCourseId,
92
+ courseProductRelationId: routeCourseProductRelationId,
93
+ });
94
+
95
+ const menuLink: MenuLink = {
96
+ to: path,
97
+ label: getRouteLabel(basePath),
98
+ };
99
+
100
+ if (
101
+ [
102
+ TeacherDashboardPaths.ORGANIZATION_PRODUCT_CONTRACTS,
103
+ TeacherDashboardPaths.COURSE_PRODUCT_CONTRACTS,
104
+ ].includes(basePath)
105
+ ) {
106
+ menuLink.component = (
107
+ <ContractNavLink
108
+ link={menuLink}
109
+ organizationId={routeOrganizationId}
110
+ courseProductRelationId={routeCourseProductRelationId}
111
+ />
112
+ );
113
+ }
114
+
115
+ return menuLink;
116
+ };
117
+
118
+ const menuLinkList = useMemo(
119
+ () =>
120
+ getMenuRoutes({
121
+ courseProductRelationId: routeCourseProductRelationId,
122
+ organizationId: routeOrganizationId,
123
+ }).map(getMenuLinkFromPath),
124
+ [routeOrganizationId, routeCourseProductRelationId],
125
+ );
78
126
 
79
127
  return (
80
128
  <DashboardSidebar
81
- menuLinks={menuLinks}
129
+ menuLinks={menuLinkList}
82
130
  header={
83
131
  course === undefined
84
132
  ? ''
@@ -4,16 +4,20 @@ interface GetMenuRoutesArgs {
4
4
  courseProductRelationId?: string;
5
5
  organizationId?: string;
6
6
  }
7
+
7
8
  export const getMenuRoutes = ({ courseProductRelationId, organizationId }: GetMenuRoutesArgs) => {
8
9
  if (organizationId) {
9
10
  if (courseProductRelationId) {
10
- return [TeacherDashboardPaths.ORGANIZATION_PRODUCT];
11
+ return [
12
+ TeacherDashboardPaths.ORGANIZATION_PRODUCT,
13
+ TeacherDashboardPaths.ORGANIZATION_PRODUCT_CONTRACTS,
14
+ ];
11
15
  }
12
16
  return [TeacherDashboardPaths.ORGANIZATION_COURSE_GENERAL_INFORMATION];
13
17
  }
14
18
 
15
19
  if (courseProductRelationId) {
16
- return [TeacherDashboardPaths.COURSE_PRODUCT];
20
+ return [TeacherDashboardPaths.COURSE_PRODUCT, TeacherDashboardPaths.COURSE_PRODUCT_CONTRACTS];
17
21
  }
18
22
  return [TeacherDashboardPaths.COURSE_GENERAL_INFORMATION];
19
23
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "2.25.0-b2.dev71",
3
+ "version": "2.25.0-b2.dev77",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {