richie-education 3.2.1-dev9 → 3.2.2-dev26

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.
Files changed (98) hide show
  1. package/i18n/locales/ar-SA.json +29 -1
  2. package/i18n/locales/es-ES.json +29 -1
  3. package/i18n/locales/fa-IR.json +29 -1
  4. package/i18n/locales/fr-CA.json +29 -1
  5. package/i18n/locales/fr-FR.json +29 -1
  6. package/i18n/locales/ko-KR.json +29 -1
  7. package/i18n/locales/pt-PT.json +29 -1
  8. package/i18n/locales/ru-RU.json +29 -1
  9. package/i18n/locales/vi-VN.json +29 -1
  10. package/js/api/joanie.ts +144 -0
  11. package/js/components/PaymentInterfaces/types.ts +7 -0
  12. package/js/components/PaymentScheduleGrid/index.tsx +4 -2
  13. package/js/components/SaleTunnel/AddressSelector/index.spec.tsx +9 -2
  14. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +33 -0
  15. package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup.tsx +253 -0
  16. package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationSingular.tsx +314 -0
  17. package/js/components/SaleTunnel/SaleTunnelInformation/StepContent.tsx +528 -0
  18. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +47 -261
  19. package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +25 -11
  20. package/js/components/SaleTunnel/SubscriptionButton/index.tsx +54 -6
  21. package/js/components/SaleTunnel/_styles.scss +55 -0
  22. package/js/components/SaleTunnel/index.full-process-b2b.spec.tsx +356 -0
  23. package/js/components/SaleTunnel/{index.full-process.spec.tsx → index.full-process-b2c.spec.tsx} +4 -1
  24. package/js/components/SaleTunnel/index.spec.tsx +130 -1
  25. package/js/hooks/useBatchOrder/index.tsx +36 -0
  26. package/js/hooks/useContractArchive/index.ts +2 -0
  27. package/js/hooks/useOfferingOrganizations/index.tsx +38 -0
  28. package/js/hooks/useOrganizationAgreements.tsx/index.tsx +66 -0
  29. package/js/hooks/useOrganizationQuotes/index.tsx +56 -0
  30. package/js/hooks/usePaymentPlan.tsx +2 -1
  31. package/js/hooks/useTeacherPendingAgreementsCount/index.ts +34 -0
  32. package/js/pages/DashboardBatchOrderLayout/_styles.scss +5 -0
  33. package/js/pages/DashboardBatchOrderLayout/index.spec.tsx +78 -0
  34. package/js/pages/DashboardBatchOrderLayout/index.tsx +45 -0
  35. package/js/pages/DashboardBatchOrders/index.spec.tsx +237 -0
  36. package/js/pages/DashboardBatchOrders/index.tsx +84 -0
  37. package/js/pages/TeacherDashboardContractsLayout/TeacherDashboardCourseContractsLayout/index.tsx +0 -1
  38. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/index.tsx +3 -1
  39. package/js/pages/TeacherDashboardOrganizationAgreements/AgreementActionsBar.tsx +49 -0
  40. package/js/pages/TeacherDashboardOrganizationAgreements/BulkAgreementContractButton.tsx +79 -0
  41. package/js/pages/TeacherDashboardOrganizationAgreements/OrganizationAgreementFrame.tsx +71 -0
  42. package/js/pages/TeacherDashboardOrganizationAgreements/SignOrganizationAgreementButton.tsx +60 -0
  43. package/js/pages/TeacherDashboardOrganizationAgreements/hooks/useAgreementsAbilities.tsx +8 -0
  44. package/js/pages/TeacherDashboardOrganizationAgreements/hooks/useHasAgreementToDownload.tsx +27 -0
  45. package/js/pages/TeacherDashboardOrganizationAgreements/hooks/useTeacherAgreementsToSign.tsx +32 -0
  46. package/js/pages/TeacherDashboardOrganizationAgreements/index.spec.tsx +433 -0
  47. package/js/pages/TeacherDashboardOrganizationAgreements/index.tsx +130 -0
  48. package/js/pages/TeacherDashboardOrganizationAgreementsLayout/index.tsx +25 -0
  49. package/js/pages/TeacherDashboardOrganizationCourseLoader/index.spec.tsx +9 -0
  50. package/js/pages/TeacherDashboardOrganizationQuotes/_styles.scss +40 -0
  51. package/js/pages/TeacherDashboardOrganizationQuotes/index.full-process.spec.tsx +194 -0
  52. package/js/pages/TeacherDashboardOrganizationQuotes/index.spec.tsx +144 -0
  53. package/js/pages/TeacherDashboardOrganizationQuotes/index.tsx +521 -0
  54. package/js/pages/TeacherDashboardOrganizationQuotesLayout/index.tsx +26 -0
  55. package/js/translations/ar-SA.json +1 -1
  56. package/js/translations/es-ES.json +1 -1
  57. package/js/translations/fa-IR.json +1 -1
  58. package/js/translations/fr-CA.json +1 -1
  59. package/js/translations/fr-FR.json +1 -1
  60. package/js/translations/ko-KR.json +1 -1
  61. package/js/translations/pt-PT.json +1 -1
  62. package/js/translations/ru-RU.json +1 -1
  63. package/js/translations/vi-VN.json +1 -1
  64. package/js/types/Joanie.ts +216 -1
  65. package/js/utils/AbilitiesHelper/agreementAbilities.ts +14 -0
  66. package/js/utils/AbilitiesHelper/index.ts +7 -0
  67. package/js/utils/AbilitiesHelper/types.ts +12 -3
  68. package/js/utils/ObjectHelper/index.ts +20 -0
  69. package/js/utils/OrderHelper/index.ts +10 -0
  70. package/js/utils/errors/HttpError.ts +1 -0
  71. package/js/utils/test/factories/joanie.ts +156 -1
  72. package/js/widgets/Dashboard/components/DashboardBatchOrderLoader/_styles.scss +14 -0
  73. package/js/widgets/Dashboard/components/DashboardBatchOrderLoader/index.tsx +32 -0
  74. package/js/widgets/Dashboard/components/DashboardCard/index.spec.tsx +18 -0
  75. package/js/widgets/Dashboard/components/DashboardCard/index.stories.tsx +25 -2
  76. package/js/widgets/Dashboard/components/DashboardCard/index.tsx +4 -2
  77. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderPaymentModal/BatchOrderPaymentManager.tsx +88 -0
  78. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderPaymentModal/index.tsx +216 -0
  79. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/DashboardBatchOrderSubItems.tsx +316 -0
  80. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/index.spec.tsx +27 -0
  81. package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/index.tsx +175 -0
  82. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +5 -2
  83. package/js/widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock/index.tsx +4 -1
  84. package/js/widgets/Dashboard/components/DashboardItem/Order/_styles.scss +5 -0
  85. package/js/widgets/Dashboard/components/DashboardItem/_styles.scss +43 -0
  86. package/js/widgets/Dashboard/components/DashboardSidebar/components/AgreementNavLink/index.spec.tsx +214 -0
  87. package/js/widgets/Dashboard/components/DashboardSidebar/components/AgreementNavLink/index.tsx +47 -0
  88. package/js/widgets/Dashboard/components/LearnerDashboardSidebar/index.tsx +1 -0
  89. package/js/widgets/Dashboard/components/TeacherDashboardOrganizationSidebar/index.spec.tsx +21 -3
  90. package/js/widgets/Dashboard/components/TeacherDashboardOrganizationSidebar/index.tsx +9 -0
  91. package/js/widgets/Dashboard/utils/learnerRoutes.tsx +30 -0
  92. package/js/widgets/Dashboard/utils/learnerRoutesPaths.tsx +12 -0
  93. package/js/widgets/Dashboard/utils/teacherDashboardPaths.tsx +12 -0
  94. package/js/widgets/Dashboard/utils/teacherRoutes.tsx +17 -0
  95. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +8 -2
  96. package/package.json +4 -1
  97. package/scss/colors/_theme.scss +1 -1
  98. package/scss/components/_index.scss +1 -0
@@ -0,0 +1,56 @@
1
+ import { defineMessages } from 'react-intl';
2
+ import { useJoanieApi } from 'contexts/JoanieApiContext';
3
+ import { useResource, useResources, UseResourcesProps } from 'hooks/useResources';
4
+ import { OrganizationQuote, OrganizationQuoteQueryFilters } from 'types/Joanie';
5
+
6
+ const messages = defineMessages({
7
+ errorCreate: {
8
+ id: 'hooks.useOrganizationsQuotes.errorGet',
9
+ defaultMessage: 'An error occurred while fetching the batch order.',
10
+ description: 'Error message shown when batch order fetch fails.',
11
+ },
12
+ });
13
+
14
+ export const useOrganizationsQuotes = () => {
15
+ const { organizations } = useJoanieApi();
16
+ const api = organizations.quotes;
17
+ const props: UseResourcesProps<OrganizationQuote, OrganizationQuoteQueryFilters, typeof api> = {
18
+ queryKey: ['organizationQuote'],
19
+ apiInterface: () => api,
20
+ session: true,
21
+ messages,
22
+ };
23
+
24
+ const quotes = useResources<OrganizationQuote, OrganizationQuoteQueryFilters>(props);
25
+ const quote = useResource<OrganizationQuote, OrganizationQuoteQueryFilters>(props);
26
+
27
+ const confirmQuote = async (filters: OrganizationQuoteQueryFilters) => {
28
+ return api.update(filters);
29
+ };
30
+
31
+ const confirmPurchaseOrder = async (filters: OrganizationQuoteQueryFilters) => {
32
+ return api.purchase_order.update(filters);
33
+ };
34
+
35
+ const confirmBankTransfer = async (filters: OrganizationQuoteQueryFilters) => {
36
+ return api.bank_transfer.create(filters);
37
+ };
38
+
39
+ const submitForSignature = async (filters: OrganizationQuoteQueryFilters) => {
40
+ return api.submit_for_signature.create(filters);
41
+ };
42
+
43
+ const downloadQuote = async (filters: OrganizationQuoteQueryFilters) => {
44
+ return api.download_quote.get(filters);
45
+ };
46
+
47
+ return {
48
+ quotes,
49
+ quote,
50
+ confirmQuote,
51
+ confirmPurchaseOrder,
52
+ confirmBankTransfer,
53
+ submitForSignature,
54
+ downloadQuote,
55
+ };
56
+ };
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
2
2
  import { useJoanieApi } from 'contexts/JoanieApiContext';
3
3
  import { PaymentPlan } from 'types/Joanie';
4
4
  import { Nullable } from 'types/utils';
5
+ import { HttpError } from 'utils/errors/HttpError';
5
6
 
6
7
  type PaymentPlanFilters = {
7
8
  course_code: string;
@@ -13,7 +14,7 @@ export const usePaymentPlan = (filters: PaymentPlanFilters) => {
13
14
  const queryKey = ['courses-products', ...Object.values(filters), 'payment-plan'];
14
15
 
15
16
  const api = useJoanieApi();
16
- return useQuery<Nullable<PaymentPlan>, Error>({
17
+ return useQuery<Nullable<PaymentPlan>, HttpError>({
17
18
  queryKey,
18
19
  queryFn: () =>
19
20
  api.courses.products.paymentPlan.get({
@@ -0,0 +1,34 @@
1
+ import { useOrganizationAgreements } from 'hooks/useOrganizationAgreements.tsx';
2
+ import { PER_PAGE } from 'settings';
3
+ import { ContractState, Offering, Organization } from 'types/Joanie';
4
+
5
+ interface UseTeacherPendingContractsCountProps {
6
+ organizationId?: Organization['id'];
7
+ offeringId?: Offering['id'];
8
+ }
9
+
10
+ const useTeacherPendingAgreementsCount = ({
11
+ organizationId,
12
+ offeringId,
13
+ }: UseTeacherPendingContractsCountProps) => {
14
+ const { items: agreements, meta } = useOrganizationAgreements({
15
+ organization_id: organizationId,
16
+ offering_id: offeringId,
17
+ signature_state: ContractState.LEARNER_SIGNED,
18
+ page: 1,
19
+ page_size: PER_PAGE.teacherContractList,
20
+ });
21
+
22
+ if (organizationId) {
23
+ return {
24
+ agreements,
25
+ pendingAgreementCount: meta?.pagination?.count ?? 0,
26
+ };
27
+ }
28
+ return {
29
+ agreements: [],
30
+ pendingAgreementCount: 0,
31
+ };
32
+ };
33
+
34
+ export default useTeacherPendingAgreementsCount;
@@ -0,0 +1,5 @@
1
+ .dashboard-order-layout {
2
+ .dashboard__sidebar {
3
+ display: none;
4
+ }
5
+ }
@@ -0,0 +1,78 @@
1
+ import { findByRole, render, screen, waitFor } from '@testing-library/react';
2
+ import { generatePath } from 'react-router';
3
+ import fetchMock from 'fetch-mock';
4
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
5
+ import { BatchOrderReadFactory } from 'utils/test/factories/joanie';
6
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
7
+ import { DashboardTest } from 'widgets/Dashboard/components/DashboardTest';
8
+ import { expectUrlMatchLocationDisplayed } from 'utils/test/expectUrlMatchLocationDisplayed';
9
+ import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
10
+ import { BaseJoanieAppWrapper } from 'utils/test/wrappers/BaseJoanieAppWrapper';
11
+
12
+ jest.mock('utils/context', () => ({
13
+ __esModule: true,
14
+ default: mockRichieContextFactory({
15
+ authentication: { backend: 'fonzie', endpoint: 'https://demo.endpoint' },
16
+ joanie_backend: { endpoint: 'https://joanie.endpoint' },
17
+ }).one(),
18
+ }));
19
+
20
+ jest.mock('settings', () => ({
21
+ __esModule: true,
22
+ ...jest.requireActual('settings'),
23
+ PER_PAGE: { useBatchOrders: 10 },
24
+ }));
25
+
26
+ describe('<DashboardBatchOrderLayout />', () => {
27
+ const WrapperWithDashboard = (route: string) => {
28
+ const client = createTestQueryClient({ user: true });
29
+ return (
30
+ <BaseJoanieAppWrapper queryOptions={{ client }}>
31
+ <DashboardTest initialRoute={route} />
32
+ </BaseJoanieAppWrapper>
33
+ );
34
+ };
35
+
36
+ beforeEach(() => {
37
+ fetchMock.get('https://joanie.endpoint/api/v1.0/addresses/', []);
38
+ fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', []);
39
+ fetchMock.get('https://joanie.endpoint/api/v1.0/orders/', []);
40
+ fetchMock.get('https://joanie.endpoint/api/v1.0/enrollments/', []);
41
+ fetchMock.get('https://joanie.endpoint/api/v1.0/batch-orders/', []);
42
+ });
43
+
44
+ afterEach(() => {
45
+ jest.restoreAllMocks();
46
+ jest.clearAllMocks();
47
+ fetchMock.restore();
48
+ });
49
+
50
+ it('renders sidebar', async () => {
51
+ const batchOrder = BatchOrderReadFactory().one();
52
+
53
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/batch-orders/${batchOrder.id}/`, batchOrder);
54
+
55
+ render(
56
+ WrapperWithDashboard(
57
+ generatePath(LearnerDashboardPaths.BATCH_ORDER, {
58
+ batchOrderId: batchOrder.id,
59
+ }),
60
+ ),
61
+ );
62
+
63
+ await waitFor(() =>
64
+ expectUrlMatchLocationDisplayed(
65
+ generatePath(LearnerDashboardPaths.BATCH_ORDER, {
66
+ batchOrderId: batchOrder.id,
67
+ }),
68
+ ),
69
+ );
70
+
71
+ const sidebar = screen.getByTestId('dashboard__sidebar');
72
+ await findByRole(sidebar, 'heading');
73
+
74
+ screen.getByRole('link', {
75
+ name: 'General information',
76
+ });
77
+ });
78
+ });
@@ -0,0 +1,45 @@
1
+ import { generatePath, Outlet, useParams } from 'react-router';
2
+ import { useIntl } from 'react-intl';
3
+ import { useMemo } from 'react';
4
+ import { getDashboardRouteLabel } from 'widgets/Dashboard/utils/dashboardRoutes';
5
+ import { DashboardLayout } from 'widgets/Dashboard/components/DashboardLayout';
6
+ import { useBreadcrumbsPlaceholders } from 'hooks/useBreadcrumbsPlaceholders';
7
+ import { BatchOrderRead } from 'types/Joanie';
8
+
9
+ import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
10
+ import { useBatchOrder } from 'hooks/useBatchOrder/index';
11
+ import { LearnerDashboardSidebar } from 'widgets/Dashboard/components/LearnerDashboardSidebar';
12
+
13
+ export const DashboardBatchOrderLayout = () => {
14
+ const intl = useIntl();
15
+ const getRouteLabel = getDashboardRouteLabel(intl);
16
+ const params = useParams<{ batchOrderId: string }>();
17
+ const { item: batchOrder } = useBatchOrder(params.batchOrderId);
18
+
19
+ const links = useMemo(
20
+ () => [
21
+ {
22
+ to: generatePath(LearnerDashboardPaths.BATCH_ORDER, { batchOrderId: params.batchOrderId! }),
23
+ label: getRouteLabel(LearnerDashboardPaths.ORDER_RUNS),
24
+ },
25
+ ],
26
+ [],
27
+ );
28
+
29
+ return (
30
+ <DashboardLayout
31
+ className="dashboard-order-layout"
32
+ sidebar={<LearnerDashboardSidebar menuLinks={links} />}
33
+ >
34
+ <DashboardBatchOrderLayoutContent batchOrder={batchOrder} />
35
+ </DashboardLayout>
36
+ );
37
+ };
38
+
39
+ const DashboardBatchOrderLayoutContent = ({ batchOrder }: { batchOrder?: BatchOrderRead }) => {
40
+ useBreadcrumbsPlaceholders({
41
+ orderTitle: batchOrder?.id ?? '',
42
+ });
43
+
44
+ return <Outlet />;
45
+ };
@@ -0,0 +1,237 @@
1
+ import { act, screen, waitFor, within } from '@testing-library/react';
2
+ import fetchMock from 'fetch-mock';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
5
+ import { DashboardTest } from 'widgets/Dashboard/components/DashboardTest';
6
+ import { BatchOrderReadFactory } from 'utils/test/factories/joanie';
7
+ import { expectSpinner, expectNoSpinner } from 'utils/test/expectSpinner';
8
+ import { expectBannerError, expectBannerInfo, expectNoBannerInfo } from 'utils/test/expectBanner';
9
+ import { Deferred } from 'utils/test/deferred';
10
+ import { HttpStatusCode } from 'utils/errors/HttpError';
11
+ import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
12
+ import { render } from 'utils/test/render';
13
+ import { BaseJoanieAppWrapper } from 'utils/test/wrappers/BaseJoanieAppWrapper';
14
+ import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
15
+ import { BatchOrderState } from 'types/Joanie';
16
+ import { PaymentMethod } from 'components/PaymentInterfaces/types';
17
+
18
+ jest.mock('utils/context', () => ({
19
+ __esModule: true,
20
+ default: mockRichieContextFactory({
21
+ authentication: { backend: 'fonzie', endpoint: 'https://demo.endpoint' },
22
+ joanie_backend: { endpoint: 'https://joanie.endpoint' },
23
+ }).one(),
24
+ }));
25
+
26
+ jest.mock('settings', () => ({
27
+ __esModule: true,
28
+ ...jest.requireActual('settings'),
29
+ PER_PAGE: { useBatchOrders: 10 },
30
+ PAYMENT_SETTINGS: { pollInterval: 20, pollLimit: 5 },
31
+ }));
32
+
33
+ const mockMessageModal = jest.fn();
34
+ jest.mock('@openfun/cunningham-react', () => ({
35
+ ...jest.requireActual('@openfun/cunningham-react'),
36
+ useModals: () => ({
37
+ messageModal: mockMessageModal,
38
+ }),
39
+ }));
40
+
41
+ jest.mock('components/PaymentInterfaces');
42
+
43
+ describe('<DashboardBatchOrders/>', () => {
44
+ setupJoanieSession();
45
+ const perPage = 10;
46
+
47
+ afterEach(() => {
48
+ fetchMock.restore();
49
+ });
50
+
51
+ it('renders an empty placeholder', async () => {
52
+ const deferred = new Deferred();
53
+ fetchMock.get(
54
+ `https://joanie.endpoint/api/v1.0/batch-orders/?page=1&page_size=${perPage}`,
55
+ deferred.promise,
56
+ );
57
+
58
+ render(<DashboardTest initialRoute={LearnerDashboardPaths.BATCH_ORDERS} />, {
59
+ wrapper: BaseJoanieAppWrapper,
60
+ });
61
+
62
+ await expectSpinner('Loading batch orders...');
63
+ await expectNoBannerInfo('You have no batch orders yet.');
64
+
65
+ act(() => {
66
+ deferred.resolve({ results: [], count: 0, next: null, previous: null });
67
+ });
68
+
69
+ await expectNoSpinner('Loading batch orders...');
70
+ await expectBannerInfo('You have no batch orders yet.');
71
+ });
72
+
73
+ it('renders a list of batch orders and excludes canceled ones', async () => {
74
+ const batchOrders = [
75
+ ...BatchOrderReadFactory({ payment_method: PaymentMethod.BANK_TRANSFER }).many(3),
76
+ BatchOrderReadFactory({ state: BatchOrderState.CANCELED }).one(),
77
+ ];
78
+
79
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/batch-orders/?page=1&page_size=${perPage}`, {
80
+ results: batchOrders,
81
+ count: batchOrders.length,
82
+ next: null,
83
+ previous: null,
84
+ });
85
+
86
+ render(<DashboardTest initialRoute={LearnerDashboardPaths.BATCH_ORDERS} />, {
87
+ wrapper: BaseJoanieAppWrapper,
88
+ });
89
+
90
+ await expectNoSpinner('Loading batch orders...');
91
+ await expectNoBannerInfo('You have no batch orders yet.');
92
+
93
+ const items = screen.getAllByTestId('batch-order-enrollment-list-item');
94
+ expect(items).toHaveLength(3);
95
+ });
96
+
97
+ it('renders pagination and loads next page', async () => {
98
+ const total = perPage + 3;
99
+ const batchOrders = BatchOrderReadFactory({ payment_method: PaymentMethod.BANK_TRANSFER }).many(
100
+ total,
101
+ );
102
+
103
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/batch-orders/?page=1&page_size=${perPage}`, {
104
+ results: batchOrders.slice(0, perPage),
105
+ count: total,
106
+ next: `https://joanie.endpoint/api/v1.0/batch-orders/?page=2&page_size=${perPage}`,
107
+ previous: null,
108
+ });
109
+
110
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/batch-orders/?page=2&page_size=${perPage}`, {
111
+ results: batchOrders.slice(perPage, total),
112
+ count: total,
113
+ next: null,
114
+ previous: `https://joanie.endpoint/api/v1.0/batch-orders/?page=1&page_size=${perPage}`,
115
+ });
116
+
117
+ render(<DashboardTest initialRoute={LearnerDashboardPaths.BATCH_ORDERS} />, {
118
+ wrapper: BaseJoanieAppWrapper,
119
+ });
120
+
121
+ await expectNoSpinner('Loading batch orders...');
122
+ let items = screen.getAllByTestId('batch-order-enrollment-list-item');
123
+ expect(items).toHaveLength(perPage);
124
+
125
+ const nextPageLink = screen.getByRole('link', { name: 'Last page 2' });
126
+ await userEvent.click(nextPageLink);
127
+
128
+ await waitFor(() => {
129
+ items = screen.getAllByTestId('batch-order-enrollment-list-item');
130
+ expect(items).toHaveLength(total - perPage);
131
+ });
132
+ });
133
+
134
+ it('shows an error', async () => {
135
+ const deferred = new Deferred();
136
+ fetchMock.get(
137
+ `https://joanie.endpoint/api/v1.0/batch-orders/?page=1&page_size=${perPage}`,
138
+ deferred.promise,
139
+ );
140
+
141
+ render(<DashboardTest initialRoute={LearnerDashboardPaths.BATCH_ORDERS} />, {
142
+ wrapper: BaseJoanieAppWrapper,
143
+ });
144
+
145
+ deferred.resolve({
146
+ status: HttpStatusCode.INTERNAL_SERVER_ERROR,
147
+ body: 'Internal Server Error',
148
+ });
149
+
150
+ await expectNoSpinner('Loading orders and enrollments...');
151
+ await expectBannerError('An error occurred while fetching resources. Please retry later.');
152
+ });
153
+
154
+ it('renders a batch order which needs payment and use payment process', async () => {
155
+ const batchOrder = BatchOrderReadFactory({
156
+ payment_method: PaymentMethod.CARD_PAYMENT,
157
+ state: BatchOrderState.PENDING,
158
+ total: 200,
159
+ currency: 'EUR',
160
+ }).one();
161
+
162
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/batch-orders/?page=1&page_size=${perPage}`, {
163
+ results: [batchOrder],
164
+ count: 1,
165
+ next: null,
166
+ previous: null,
167
+ });
168
+
169
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/batch-orders/`, [batchOrder]);
170
+
171
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/batch-orders/${batchOrder.id}/`, batchOrder);
172
+
173
+ render(<DashboardTest initialRoute={LearnerDashboardPaths.BATCH_ORDERS} />, {
174
+ wrapper: BaseJoanieAppWrapper,
175
+ });
176
+
177
+ await expectNoSpinner('Loading batch orders...');
178
+ await expectNoBannerInfo('You have no batch orders yet.');
179
+
180
+ const items = screen.getAllByTestId('batch-order-enrollment-list-item');
181
+ expect(items).toHaveLength(1);
182
+
183
+ await screen.findByText('Payment required');
184
+
185
+ await userEvent.click(
186
+ await screen.findByRole('button', {
187
+ name: 'Pay €200.00',
188
+ }),
189
+ );
190
+
191
+ fetchMock.post(
192
+ `https://joanie.endpoint/api/v1.0/batch-orders/${batchOrder.id}/submit-for-payment/`,
193
+ {
194
+ payment_id: 'payment_id',
195
+ provider: 'payment_provider',
196
+ url: 'payment_url',
197
+ },
198
+ );
199
+
200
+ const modal = await screen.findByRole('dialog');
201
+ const payButton = await within(modal).findByRole('button', { name: 'Pay €200.00' });
202
+ await userEvent.click(payButton);
203
+
204
+ await screen.findByText('Payment interface component');
205
+ await screen.findByTestId('payment-success');
206
+
207
+ fetchMock.get(
208
+ `https://joanie.endpoint/api/v1.0/batch-orders/?page=1&page_size=${perPage}`,
209
+ {
210
+ results: [{ ...batchOrder, state: BatchOrderState.COMPLETED }],
211
+ count: 1,
212
+ next: null,
213
+ previous: null,
214
+ },
215
+ { overwriteRoutes: true },
216
+ );
217
+ fetchMock.get(
218
+ `https://joanie.endpoint/api/v1.0/batch-orders/${batchOrder.id}/`,
219
+ { ...batchOrder, state: BatchOrderState.COMPLETED },
220
+ { overwriteRoutes: true },
221
+ );
222
+
223
+ await userEvent.click(screen.getByTestId('payment-success'));
224
+
225
+ await waitFor(() => {
226
+ expect(mockMessageModal).toHaveBeenCalledWith(
227
+ expect.objectContaining({
228
+ title: 'Payment successful',
229
+ }),
230
+ );
231
+ });
232
+
233
+ expect(screen.queryByRole('button', { name: /Pay/ })).not.toBeInTheDocument();
234
+
235
+ await screen.findByText('Completed');
236
+ });
237
+ });
@@ -0,0 +1,84 @@
1
+ import { useEffect } from 'react';
2
+ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
3
+ import classNames from 'classnames';
4
+ import { Spinner } from 'components/Spinner';
5
+ import Banner, { BannerType } from 'components/Banner';
6
+ import { usePagination, Pagination } from 'components/Pagination';
7
+ import { BatchOrderState } from 'types/Joanie';
8
+ import { useBatchOrders } from 'hooks/useBatchOrder';
9
+ import { DashboardItemBatchOrder } from 'widgets/Dashboard/components/DashboardItem/BatchOrder';
10
+
11
+ const messages = defineMessages({
12
+ loading: {
13
+ defaultMessage: 'Loading batch orders...',
14
+ description: 'Message displayed while loading orders and enrollments',
15
+ id: 'components.DashboardBatchOrders.loading',
16
+ },
17
+ emptyList: {
18
+ id: 'components.DashboardBatchOrders.emptyList',
19
+ description: "Empty placeholder of the dashboard's list of orders and enrollments",
20
+ defaultMessage: 'You have no batch orders yet.',
21
+ },
22
+ });
23
+
24
+ export const DashboardBatchOrders = () => {
25
+ const intl = useIntl();
26
+ const pagination = usePagination({ itemsPerPage: 10 });
27
+
28
+ const { items, meta, states } = useBatchOrders({
29
+ page: pagination.currentPage,
30
+ page_size: pagination.itemsPerPage,
31
+ });
32
+
33
+ useEffect(() => {
34
+ if (meta?.pagination?.count) {
35
+ pagination.setItemsCount(meta.pagination.count);
36
+ }
37
+ }, [meta?.pagination?.count]);
38
+
39
+ if (states.error) {
40
+ return <Banner message={states.error} type={BannerType.ERROR} />;
41
+ }
42
+
43
+ if (items?.length === 0 && states?.isPending) {
44
+ return (
45
+ <Spinner aria-labelledby="loading-courses-data">
46
+ <span id="loading-courses-data">
47
+ <FormattedMessage {...messages.loading} />
48
+ </span>
49
+ </Spinner>
50
+ );
51
+ }
52
+
53
+ return (
54
+ <div className="dashboard__courses">
55
+ {items?.length === 0 && (
56
+ <div className="dashboard__courses__empty">
57
+ <Banner message={intl.formatMessage(messages.emptyList)} />
58
+ </div>
59
+ )}
60
+ {items?.length > 0 && (
61
+ <>
62
+ <div
63
+ className={classNames('dashboard__courses__list', {
64
+ 'dashboard__list--loading': states?.isPending,
65
+ })}
66
+ >
67
+ {items
68
+ ?.filter((batchOrder) => batchOrder.state !== BatchOrderState.CANCELED)
69
+ .map((batchOrder) => (
70
+ <div
71
+ key={batchOrder.id}
72
+ className="dashboard__courses__list__item"
73
+ data-testid="batch-order-enrollment-list-item"
74
+ >
75
+ <DashboardItemBatchOrder batchOrder={batchOrder} />
76
+ </div>
77
+ ))}
78
+ </div>
79
+ <Pagination {...pagination} />
80
+ </>
81
+ )}
82
+ </div>
83
+ );
84
+ };
@@ -1,5 +1,4 @@
1
1
  import { FormattedMessage, defineMessages } from 'react-intl';
2
-
3
2
  import { TeacherDashboardCourseSidebar } from 'widgets/Dashboard/components/TeacherDashboardCourseSidebar';
4
3
  import { DashboardLayout } from 'widgets/Dashboard/components/DashboardLayout';
5
4
  import TeacherDashboardContracts from '../TeacherDashboardContracts';
@@ -20,11 +20,13 @@ export enum ContractDownloadStatus {
20
20
  interface UseTeacherContractsBulkDownloadProps {
21
21
  organizationId?: Organization['id'];
22
22
  offeringId?: Offering['id'];
23
+ fromBatchOrder?: boolean;
23
24
  }
24
25
 
25
26
  const useDownloadContractArchive = ({
26
27
  organizationId,
27
28
  offeringId,
29
+ fromBatchOrder,
28
30
  }: UseTeacherContractsBulkDownloadProps) => {
29
31
  const localstorageArchiveFilters = {
30
32
  organizationId,
@@ -116,7 +118,7 @@ const useDownloadContractArchive = ({
116
118
  const createContractArchive = async () => {
117
119
  let newContractArchiveId;
118
120
  if (contractArchiveId === null) {
119
- newContractArchiveId = await createArchive(organizationId, offeringId);
121
+ newContractArchiveId = await createArchive(organizationId, offeringId, fromBatchOrder);
120
122
  setContractArchiveId(newContractArchiveId);
121
123
  storeContractArchiveId({
122
124
  ...localstorageArchiveFilters,
@@ -0,0 +1,49 @@
1
+ import classNames from 'classnames';
2
+ import { Organization, Offering } from 'types/Joanie';
3
+ import useTeacherAgreementsToSign from './hooks/useTeacherAgreementsToSign';
4
+ import SignOrganizationAgreementButton from './SignOrganizationAgreementButton';
5
+ import BulkDownloadAgreementButton from './BulkAgreementContractButton';
6
+ import useHasAgreementToDownload from './hooks/useHasAgreementToDownload';
7
+
8
+ interface AgreementActionsProps {
9
+ organizationId: Organization['id'];
10
+ offeringId?: Offering['id'];
11
+ }
12
+
13
+ const AgreementActionsBar = ({ organizationId, offeringId }: AgreementActionsProps) => {
14
+ const { canSignAgreements, agreementToSignCount } = useTeacherAgreementsToSign({
15
+ organizationId,
16
+ offeringId,
17
+ });
18
+ const hasContractToDownload = useHasAgreementToDownload(organizationId, offeringId);
19
+
20
+ const nbAvailableActions = [canSignAgreements, hasContractToDownload].filter((val) => val).length;
21
+ const offeringIds = offeringId ? [offeringId] : undefined;
22
+
23
+ return (
24
+ nbAvailableActions > 0 && (
25
+ <div
26
+ className={classNames('dashboard__page__actions-row', {
27
+ 'dashboard__page__actions-row--space-between': nbAvailableActions > 1,
28
+ 'dashboard__page__actions-row--end': nbAvailableActions === 1,
29
+ })}
30
+ data-testid="teacher-contracts-list-actionsBar"
31
+ >
32
+ {canSignAgreements && (
33
+ <div>
34
+ <SignOrganizationAgreementButton
35
+ offeringIds={offeringIds}
36
+ organizationId={organizationId}
37
+ agreementToSignCount={agreementToSignCount}
38
+ />
39
+ </div>
40
+ )}
41
+ {hasContractToDownload && (
42
+ <BulkDownloadAgreementButton organizationId={organizationId} offeringId={offeringId} />
43
+ )}
44
+ </div>
45
+ )
46
+ );
47
+ };
48
+
49
+ export default AgreementActionsBar;