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,175 @@
1
+ import { defineMessages, FormattedMessage, FormattedNumber, useIntl } from 'react-intl';
2
+ import { generatePath } from 'react-router';
3
+ import { BatchOrderRead, BatchOrderState } from 'types/Joanie';
4
+ import { PaymentMethod } from 'components/PaymentInterfaces/types';
5
+ import Badge from 'components/Badge';
6
+ import { DashboardItem } from 'widgets/Dashboard/components/DashboardItem/index';
7
+ import { Icon, IconTypeEnum } from 'components/Icon';
8
+ import { RouterButton } from 'widgets/Dashboard/components/RouterButton';
9
+ import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
10
+ import { DashboardBatchOrderSubItems } from './DashboardBatchOrderSubItems';
11
+ import { BatchOrderPaymentManager } from './BatchOrderPaymentModal/BatchOrderPaymentManager';
12
+
13
+ const messages = defineMessages({
14
+ seats: {
15
+ id: 'batchOrder.seats',
16
+ description: 'Text displayed for seats value in batch order',
17
+ defaultMessage: 'Seats',
18
+ },
19
+ [BatchOrderState.DRAFT]: {
20
+ id: 'batchOrder.status.draft',
21
+ description: 'Status label for a draft batch order',
22
+ defaultMessage: 'Draft',
23
+ },
24
+ [BatchOrderState.ASSIGNED]: {
25
+ id: 'batchOrder.status.assigned',
26
+ description: 'Status label for an assigned batch order',
27
+ defaultMessage: 'Assigned',
28
+ },
29
+ [BatchOrderState.QUOTED]: {
30
+ id: 'batchOrder.status.quoted',
31
+ description: 'Status label for a quoted batch order',
32
+ defaultMessage: 'Quoted',
33
+ },
34
+ [BatchOrderState.TO_SIGN]: {
35
+ id: 'batchOrder.status.to_sign',
36
+ description: 'Status label for a batch order awaiting signature',
37
+ defaultMessage: 'To sign',
38
+ },
39
+ [BatchOrderState.SIGNING]: {
40
+ id: 'batchOrder.status.signing',
41
+ description: 'Status label for a batch order in signing process',
42
+ defaultMessage: 'Signing',
43
+ },
44
+ [BatchOrderState.PENDING]: {
45
+ id: 'batchOrder.status.pending',
46
+ description: 'Status label for a pending batch order',
47
+ defaultMessage: 'Pending',
48
+ },
49
+ [BatchOrderState.PROCESS_PAYMENT]: {
50
+ id: 'batchOrder.status.processPayment',
51
+ description: 'Status label for a process payment batch order',
52
+ defaultMessage: 'Process payment',
53
+ },
54
+ [BatchOrderState.FAILED_PAYMENT]: {
55
+ id: 'batchOrder.status.failed_payment',
56
+ description: 'Status label for a batch order with failed payment',
57
+ defaultMessage: 'Failed payment',
58
+ },
59
+ [BatchOrderState.CANCELED]: {
60
+ id: 'batchOrder.status.canceled',
61
+ description: 'Status label for a canceled batch order',
62
+ defaultMessage: 'Canceled',
63
+ },
64
+ [BatchOrderState.COMPLETED]: {
65
+ id: 'batchOrder.status.completed',
66
+ description: 'Status label for a completed batch order',
67
+ defaultMessage: 'Completed',
68
+ },
69
+ [PaymentMethod.BANK_TRANSFER]: {
70
+ id: 'batchOrder.payment.bank',
71
+ description: 'Label for bank transfer payment method',
72
+ defaultMessage: 'Bank transfer',
73
+ },
74
+ [PaymentMethod.CARD_PAYMENT]: {
75
+ id: 'batchOrder.payment.card',
76
+ description: 'Label for card payment method',
77
+ defaultMessage: 'Card payment',
78
+ },
79
+ [PaymentMethod.PURCHASE_ORDER]: {
80
+ id: 'batchOrder.payment.order',
81
+ description: 'Label for purchase order payment method',
82
+ defaultMessage: 'Purchase order',
83
+ },
84
+ paymentNeededButton: {
85
+ id: 'components.ProductCertificateFooter.paymentNeededButton',
86
+ description: 'Button label for the payment needed message',
87
+ defaultMessage: 'Pay {amount}',
88
+ },
89
+ });
90
+
91
+ export const DashboardItemBatchOrder = ({
92
+ batchOrder,
93
+ showDetails = false,
94
+ }: {
95
+ batchOrder: BatchOrderRead;
96
+ showDetails?: boolean;
97
+ }) => {
98
+ const intl = useIntl();
99
+ const needsPayment =
100
+ (batchOrder.state === BatchOrderState.PENDING ||
101
+ batchOrder.state === BatchOrderState.PROCESS_PAYMENT) &&
102
+ batchOrder.payment_method === PaymentMethod.CARD_PAYMENT;
103
+
104
+ return (
105
+ <div className="dashboard-item-order">
106
+ <DashboardItem
107
+ data-testid={`dashboard-item-batch-order-${batchOrder.id}`}
108
+ title={batchOrder.offering?.product.title}
109
+ code={`Ref. ${batchOrder.id}`}
110
+ imageUrl={batchOrder.offering?.course.cover?.src}
111
+ footer={
112
+ <div className="dashboard-item-order__footer">
113
+ <div className="dashboard-item__block__status">
114
+ {batchOrder.state && (
115
+ <Badge color="primary">
116
+ <div className="dashboard-item__block__status__badge">
117
+ <FormattedMessage {...messages[batchOrder.state]} />
118
+ </div>
119
+ </Badge>
120
+ )}
121
+ {batchOrder.nb_seats && (
122
+ <div className="dashboard-item__block__information">
123
+ <Icon name={IconTypeEnum.GROUPS} size="small" />
124
+ <span>{batchOrder.nb_seats}</span>
125
+ <span>{intl.formatMessage(messages.seats)}</span>
126
+ </div>
127
+ )}
128
+ {batchOrder.payment_method && (
129
+ <div className="dashboard-item__block__information">
130
+ <Icon name={IconTypeEnum.MONEY} size="small" />
131
+ <FormattedMessage {...messages[batchOrder.payment_method]} />
132
+ </div>
133
+ )}
134
+ {showDetails && (
135
+ <div className="dashboard-item__block__information">
136
+ <Icon name={IconTypeEnum.OFFER_SUBSCRIPTION} size="small" />
137
+ <span>
138
+ <FormattedNumber
139
+ value={batchOrder.total}
140
+ currency={batchOrder.currency}
141
+ style="currency"
142
+ />
143
+ </span>
144
+ </div>
145
+ )}
146
+ </div>
147
+ <RouterButton
148
+ size="small"
149
+ className="dashboard-item__button"
150
+ href={
151
+ showDetails
152
+ ? generatePath(LearnerDashboardPaths.BATCH_ORDERS, {
153
+ batchOrderId: batchOrder.id!,
154
+ })
155
+ : generatePath(LearnerDashboardPaths.BATCH_ORDER, {
156
+ batchOrderId: batchOrder.id!,
157
+ })
158
+ }
159
+ data-testid="dashboard-item-batch-order__button"
160
+ >
161
+ {intl.formatMessage(
162
+ showDetails
163
+ ? { id: 'batchOrder.viewAll', defaultMessage: 'View all batch orders' }
164
+ : { id: 'batchOrder.viewOne', defaultMessage: 'View details' },
165
+ )}
166
+ </RouterButton>
167
+ </div>
168
+ }
169
+ >
170
+ {needsPayment && <BatchOrderPaymentManager batchOrder={batchOrder} />}
171
+ {showDetails && <DashboardBatchOrderSubItems batchOrder={batchOrder} />}
172
+ </DashboardItem>
173
+ </div>
174
+ );
175
+ };
@@ -78,6 +78,7 @@ export const DashboardItemOrder = ({
78
78
  const isProductPurchasable = ProductHelper.isPurchasable(offering?.product);
79
79
  const isNotResumable = !isActive && !isProductPurchasable;
80
80
  const canEnroll = OrderHelper.allowEnrollment(order);
81
+ const isFreeFromBatchOrder = OrderHelper.isFreeFromBatchOrder(order);
81
82
 
82
83
  if (!product) return null;
83
84
 
@@ -111,7 +112,7 @@ export const DashboardItemOrder = ({
111
112
  </>
112
113
  )}
113
114
  </div>
114
- {!isNotResumable && showDetailsButton && (
115
+ {!isNotResumable && showDetailsButton && !isFreeFromBatchOrder && (
115
116
  <RouterButton
116
117
  size="small"
117
118
  className="dashboard-item__button"
@@ -199,7 +200,9 @@ export const DashboardItemOrder = ({
199
200
  {showCertificate && !!product?.certificate_definition && (
200
201
  <CertificateItem order={order} product={product} />
201
202
  )}
202
- {writable && <OrganizationBlock order={order} product={product} />}
203
+ {writable && !isFreeFromBatchOrder && (
204
+ <OrganizationBlock order={order} product={product} />
205
+ )}
203
206
  </>
204
207
  )}
205
208
  </div>
@@ -2,6 +2,7 @@ import { defineMessages, FormattedMessage } from 'react-intl';
2
2
  import { Button } from '@openfun/cunningham-react';
3
3
  import { CredentialOrder, Product } from 'types/Joanie';
4
4
  import { AddressView } from 'components/Address';
5
+ import { OrderHelper } from 'utils/OrderHelper';
5
6
  import ContractItem from '../ContractItem';
6
7
  import Installment from '../Installment';
7
8
 
@@ -64,6 +65,8 @@ const OrganizationBlock = ({ order, product }: Props) => {
64
65
  return null;
65
66
  }
66
67
 
68
+ const hidePaymentBlock =
69
+ OrderHelper.isFreeWithVoucher(order) || OrderHelper.isFreeFromBatchOrder(order);
67
70
  const showContactsBlock =
68
71
  organization.contact_email || organization.contact_phone || organization.dpo_email;
69
72
 
@@ -141,7 +144,7 @@ const OrganizationBlock = ({ order, product }: Props) => {
141
144
  </div>
142
145
  </div>
143
146
  )}
144
- <Installment order={order} />
147
+ {!hidePaymentBlock && <Installment order={order} />}
145
148
  </div>
146
149
  </div>
147
150
  );
@@ -170,3 +170,8 @@
170
170
  order: 1;
171
171
  }
172
172
  }
173
+
174
+ .sub-item {
175
+ padding: 0.75rem;
176
+ font-size: 0.75rem;
177
+ }
@@ -62,6 +62,34 @@
62
62
  @include media-breakpoint-down(sm) {
63
63
  margin-bottom: 0.5rem;
64
64
  }
65
+
66
+ &__badge {
67
+ padding: 0.25rem;
68
+ display: flex;
69
+ flex-direction: row;
70
+ align-items: center;
71
+ }
72
+
73
+ &__details {
74
+ display: flex;
75
+ flex-direction: column;
76
+ font-size: 1rem;
77
+ &__title {
78
+ font-weight: bold;
79
+ margin-top: 0.5rem;
80
+ }
81
+ }
82
+ }
83
+
84
+ &__information {
85
+ display: flex;
86
+ flex-direction: row;
87
+ gap: 0.25rem;
88
+ margin: 0 0.5rem;
89
+ text-align: center;
90
+ align-items: center;
91
+ align-content: center;
92
+ justify-content: center;
65
93
  }
66
94
  }
67
95
 
@@ -69,6 +97,11 @@
69
97
  justify-content: center;
70
98
  min-width: rem-calc(140px);
71
99
  }
100
+
101
+ &__label {
102
+ font-weight: bold;
103
+ margin-right: 0.25rem;
104
+ }
72
105
  }
73
106
 
74
107
  .dashboard-sub-item-list {
@@ -123,6 +156,16 @@
123
156
  overflow: hidden;
124
157
  }
125
158
  }
159
+
160
+ &__footer {
161
+ .content {
162
+ padding: 0.5rem 1rem;
163
+ font-size: 0.8rem;
164
+ display: flex;
165
+ gap: 1rem;
166
+ align-items: center;
167
+ }
168
+ }
126
169
  }
127
170
 
128
171
  .dashboard-item__course-enrolling {
@@ -0,0 +1,214 @@
1
+ import { screen } from '@testing-library/react';
2
+ import fetchMock from 'fetch-mock';
3
+ import { faker } from '@faker-js/faker';
4
+ import queryString from 'query-string';
5
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
6
+ import { PER_PAGE } from 'settings';
7
+ import { ContractResourceQuery, ContractState } from 'types/Joanie';
8
+ import { AgreementFactory } from 'utils/test/factories/joanie';
9
+ import { AgreementActions } from 'utils/AbilitiesHelper/types';
10
+ import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
11
+ import { render } from 'utils/test/render';
12
+ import { MenuLink } from '../..';
13
+ import AgreementNavLink from '.';
14
+
15
+ jest.mock('utils/context', () => ({
16
+ __esModule: true,
17
+ default: mockRichieContextFactory({
18
+ authentication: { backend: 'fonzie', endpoint: 'https://demo.endpoint' },
19
+ joanie_backend: { endpoint: 'https://joanie.endpoint' },
20
+ }).one(),
21
+ }));
22
+
23
+ describe('<AgreementNavLink />', () => {
24
+ setupJoanieSession();
25
+
26
+ beforeEach(() => {
27
+ // useDefaultOrganization hook request organization list
28
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/', []);
29
+ });
30
+
31
+ it('should render a AgreementNavLink with route and label when neither organizationId and offeringId are given', () => {
32
+ const link: MenuLink = {
33
+ to: '/dummy/url/',
34
+ label: 'My agreement navigation link',
35
+ };
36
+
37
+ render(<AgreementNavLink link={link} />);
38
+
39
+ expect(screen.getByRole('link', { name: 'My agreement navigation link' })).toBeInTheDocument();
40
+ expect(screen.queryByTestId('badge')).not.toBeInTheDocument();
41
+ });
42
+
43
+ describe('without sign ability', () => {
44
+ const agreementAbilities = { [AgreementActions.SIGN]: false };
45
+ it.each([
46
+ {
47
+ organizationId: faker.string.uuid(),
48
+ offeringId: undefined,
49
+ },
50
+ {
51
+ organizationId: faker.string.uuid(),
52
+ offeringId: faker.string.uuid(),
53
+ },
54
+ {
55
+ organizationId: undefined,
56
+ offeringId: faker.string.uuid(),
57
+ },
58
+ {
59
+ organizationId: undefined,
60
+ offeringId: undefined,
61
+ },
62
+ ])(
63
+ 'should never render Badge for organizationId: $organizationId and offeringId: $offeringId',
64
+ async ({ organizationId, offeringId }) => {
65
+ let agreementQueryParams: ContractResourceQuery = {
66
+ signature_state: ContractState.LEARNER_SIGNED,
67
+ page: 1,
68
+ page_size: PER_PAGE.teacherContractList,
69
+ };
70
+ if (offeringId) {
71
+ agreementQueryParams = {
72
+ offering_id: offeringId,
73
+ ...agreementQueryParams,
74
+ };
75
+ }
76
+
77
+ fetchMock.get(
78
+ `https://joanie.endpoint/api/v1.0/organizations/${organizationId}/agreements/?${queryString.stringify(
79
+ agreementQueryParams,
80
+ { sort: false },
81
+ )}`,
82
+ {
83
+ count: 1,
84
+ next: null,
85
+ previous: null,
86
+ results: [AgreementFactory({ abilities: agreementAbilities }).one()],
87
+ },
88
+ );
89
+
90
+ render(
91
+ <AgreementNavLink
92
+ link={{
93
+ to: '/dummy/url/',
94
+ label: 'My agreement navigation link',
95
+ }}
96
+ organizationId={organizationId}
97
+ offeringId={offeringId}
98
+ />,
99
+ );
100
+
101
+ expect(
102
+ screen.getByRole('link', { name: 'My agreement navigation link' }),
103
+ ).toBeInTheDocument();
104
+ expect(screen.queryByTestId('badge')).not.toBeInTheDocument();
105
+ },
106
+ );
107
+ });
108
+
109
+ describe('with sign ability', () => {
110
+ const agreementAbilities = { [AgreementActions.SIGN]: true };
111
+ it.each([
112
+ // with 1 agreements to sign
113
+ {
114
+ organizationId: faker.string.uuid(),
115
+ offeringId: undefined,
116
+ nbAgreementsToSign: 1,
117
+ expectedBadgeCount: 1,
118
+ },
119
+ {
120
+ organizationId: faker.string.uuid(),
121
+ offeringId: faker.string.uuid(),
122
+ nbAgreementsToSign: 1,
123
+ expectedBadgeCount: 1,
124
+ },
125
+ {
126
+ organizationId: undefined,
127
+ offeringId: faker.string.uuid(),
128
+ nbAgreementsToSign: 1,
129
+ expectedBadgeCount: undefined,
130
+ },
131
+ {
132
+ organizationId: undefined,
133
+ offeringId: undefined,
134
+ nbAgreementsToSign: 1,
135
+ expectedBadgeCount: undefined,
136
+ },
137
+
138
+ // with 0 agreements to sign
139
+ {
140
+ organizationId: faker.string.uuid(),
141
+ offeringId: undefined,
142
+ nbAgreementsToSign: 0,
143
+ expectedBadgeCount: undefined,
144
+ },
145
+ {
146
+ organizationId: faker.string.uuid(),
147
+ offeringId: faker.string.uuid(),
148
+ nbAgreementsToSign: 0,
149
+ expectedBadgeCount: undefined,
150
+ },
151
+ {
152
+ organizationId: undefined,
153
+ offeringId: faker.string.uuid(),
154
+ nbAgreementsToSign: 0,
155
+ expectedBadgeCount: undefined,
156
+ },
157
+ {
158
+ organizationId: undefined,
159
+ offeringId: undefined,
160
+ nbAgreementsToSign: 0,
161
+ expectedBadgeCount: undefined,
162
+ },
163
+ ])(
164
+ 'should render Badge (count: $expectedBadgeCount) for nb agreements to sign: $nbAgreementsToSign, organizationId: $organizationId and offeringId: $offeringId',
165
+ async ({ nbAgreementsToSign, organizationId, offeringId, expectedBadgeCount }) => {
166
+ let agreementQueryParams: ContractResourceQuery = {
167
+ signature_state: ContractState.LEARNER_SIGNED,
168
+ page: 1,
169
+ page_size: PER_PAGE.teacherContractList,
170
+ };
171
+ if (offeringId) {
172
+ agreementQueryParams = {
173
+ offering_id: offeringId,
174
+ ...agreementQueryParams,
175
+ };
176
+ }
177
+
178
+ fetchMock.get(
179
+ `https://joanie.endpoint/api/v1.0/organizations/${organizationId}/agreements/?${queryString.stringify(
180
+ agreementQueryParams,
181
+ { sort: false },
182
+ )}`,
183
+ {
184
+ count: nbAgreementsToSign,
185
+ next: null,
186
+ previous: null,
187
+ results: [AgreementFactory({ abilities: agreementAbilities }).one()],
188
+ },
189
+ );
190
+ render(
191
+ <AgreementNavLink
192
+ link={{
193
+ to: '/dummy/url/',
194
+ label: 'My agreement navigation link',
195
+ }}
196
+ organizationId={organizationId}
197
+ offeringId={offeringId}
198
+ />,
199
+ );
200
+
201
+ expect(
202
+ screen.getByRole('link', { name: 'My agreement navigation link' }),
203
+ ).toBeInTheDocument();
204
+ if (expectedBadgeCount === undefined) {
205
+ expect(screen.queryByTestId('badge')).not.toBeInTheDocument();
206
+ } else {
207
+ const $badge = await screen.findByTestId('badge');
208
+ expect($badge).toBeInTheDocument();
209
+ expect($badge).toHaveTextContent(`${expectedBadgeCount}`);
210
+ }
211
+ },
212
+ );
213
+ });
214
+ });
@@ -0,0 +1,47 @@
1
+ import { createSearchParams } from 'react-router';
2
+ import { useMemo } from 'react';
3
+ import { MenuLink } from 'widgets/Dashboard/components/DashboardSidebar';
4
+ import { ContractState, Offering, Organization } from 'types/Joanie';
5
+ import { AgreementActions } from 'utils/AbilitiesHelper/types';
6
+ import useDefaultOrganizationId from 'hooks/useDefaultOrganizationId';
7
+ import useAgreementAbilities from 'pages/TeacherDashboardOrganizationAgreements/hooks/useAgreementsAbilities';
8
+ import useTeacherPendingAgreementsCount from 'hooks/useTeacherPendingAgreementsCount';
9
+ import MenuNavLink from '../MenuNavLink';
10
+
11
+ interface AgreementNavLinkProps {
12
+ link: MenuLink;
13
+ organizationId?: Organization['id'];
14
+ offeringId?: Offering['id'];
15
+ }
16
+
17
+ const AgreementNavLink = ({ link, organizationId, offeringId }: AgreementNavLinkProps) => {
18
+ const defaultOrganizationId = useDefaultOrganizationId();
19
+ const { agreements: pendingAgreements, pendingAgreementCount } = useTeacherPendingAgreementsCount(
20
+ {
21
+ organizationId: organizationId || defaultOrganizationId,
22
+ offeringId,
23
+ },
24
+ );
25
+ const agreementAbilities = useAgreementAbilities(pendingAgreements);
26
+ const canSignAgreements = agreementAbilities.can(AgreementActions.SIGN);
27
+ const hasAgreementsToSign = useMemo(
28
+ () => canSignAgreements && pendingAgreementCount > 0,
29
+ [canSignAgreements, pendingAgreementCount],
30
+ );
31
+ const searchParams = useMemo(() => {
32
+ if (hasAgreementsToSign) {
33
+ return createSearchParams({ signature_state: ContractState.LEARNER_SIGNED });
34
+ }
35
+
36
+ return createSearchParams({ signature_state: ContractState.SIGNED });
37
+ }, [hasAgreementsToSign]);
38
+
39
+ return (
40
+ <MenuNavLink
41
+ link={{ ...link, to: `${link.to}?${searchParams.toString()}` }}
42
+ badgeCount={hasAgreementsToSign ? pendingAgreementCount : undefined}
43
+ />
44
+ );
45
+ };
46
+
47
+ export default AgreementNavLink;
@@ -37,6 +37,7 @@ export const LearnerDashboardSidebar = (props: Partial<DashboardSidebarProps>) =
37
37
  LearnerDashboardPaths.CERTIFICATES,
38
38
  LearnerDashboardPaths.CONTRACTS,
39
39
  LearnerDashboardPaths.PREFERENCES,
40
+ LearnerDashboardPaths.BATCH_ORDERS,
40
41
  ].map((path) => ({
41
42
  to: generatePath(path),
42
43
  label: getRouteLabel(path),
@@ -34,6 +34,12 @@ describe('<TeacherDashboardOrganizationSidebar />', () => {
34
34
  `/contracts/?signature_state=half_signed&page=1&page_size=${PER_PAGE.teacherContractList}`,
35
35
  { results: [], count: 0, previous: null, next: null },
36
36
  );
37
+ fetchMock.get(
38
+ 'https://joanie.endpoint/api/v1.0/organizations/' +
39
+ organization.id +
40
+ `/agreements/?signature_state=half_signed&page=1&page_size=${PER_PAGE.teacherContractList}`,
41
+ { results: [], count: 0, previous: null, next: null },
42
+ );
37
43
 
38
44
  render(<TeacherDashboardOrganizationSidebar />, {
39
45
  routerOptions: { path: '/:organizationId', initialEntries: [`/${organization.id}`] },
@@ -50,9 +56,11 @@ describe('<TeacherDashboardOrganizationSidebar />', () => {
50
56
 
51
57
  // It should display menu links
52
58
  const links = screen.getAllByRole('link');
53
- expect(links).toHaveLength(2);
59
+ expect(links).toHaveLength(4);
54
60
  expect(links[0]).toHaveTextContent('Courses');
55
61
  expect(links[1]).toHaveTextContent('Contracts');
62
+ expect(links[2]).toHaveTextContent('Quotes');
63
+ expect(links[3]).toHaveTextContent('Agreements');
56
64
  // No badge should be displayed next to contract link
57
65
  expect(links[1].nextSibling).toBeNull();
58
66
  });
@@ -76,6 +84,12 @@ describe('<TeacherDashboardOrganizationSidebar />', () => {
76
84
  next: null,
77
85
  },
78
86
  );
87
+ fetchMock.get(
88
+ 'https://joanie.endpoint/api/v1.0/organizations/' +
89
+ organization.id +
90
+ `/agreements/?signature_state=half_signed&page=1&page_size=${PER_PAGE.teacherContractList}`,
91
+ { results: [], count: 0, previous: null, next: null },
92
+ );
79
93
 
80
94
  render(<TeacherDashboardOrganizationSidebar />, {
81
95
  routerOptions: { path: '/:organizationId', initialEntries: [`/${organization.id}`] },
@@ -108,10 +122,14 @@ describe('<TeacherDashboardOrganizationSidebar />', () => {
108
122
  {
109
123
  results: ContractFactory({ abilities: { sign: false } }).many(contractToSignCount),
110
124
  count: contractToSignCount,
111
- previous: null,
112
- next: null,
113
125
  },
114
126
  );
127
+ fetchMock.get(
128
+ 'https://joanie.endpoint/api/v1.0/organizations/' +
129
+ organization.id +
130
+ `/agreements/?signature_state=half_signed&page=1&page_size=${PER_PAGE.teacherContractList}`,
131
+ { results: [], count: 0, previous: null, next: null },
132
+ );
115
133
 
116
134
  render(<TeacherDashboardOrganizationSidebar />, {
117
135
  routerOptions: { path: '/:organizationId', initialEntries: [`/${organization.id}`] },
@@ -7,6 +7,7 @@ import { getDashboardRouteLabel } from 'widgets/Dashboard/utils/dashboardRoutes'
7
7
  import { TeacherDashboardPaths } from 'widgets/Dashboard/utils/teacherDashboardPaths';
8
8
  import { DashboardAvatar, DashboardAvatarVariantEnum } from '../DashboardAvatar';
9
9
  import ContractNavLink from '../DashboardSidebar/components/ContractNavLink';
10
+ import AgreementNavLink from '../DashboardSidebar/components/AgreementNavLink';
10
11
 
11
12
  const messages = defineMessages({
12
13
  subHeader: {
@@ -47,12 +48,20 @@ export const TeacherDashboardOrganizationSidebar = () => {
47
48
  );
48
49
  }
49
50
 
51
+ if (basePath === TeacherDashboardPaths.ORGANIZATION_AGREEMENTS) {
52
+ menuLink.component = (
53
+ <AgreementNavLink link={menuLink} organizationId={organizationId} offeringId={offeringId} />
54
+ );
55
+ }
56
+
50
57
  return menuLink;
51
58
  };
52
59
 
53
60
  const links = [
54
61
  TeacherDashboardPaths.ORGANIZATION_COURSES,
55
62
  TeacherDashboardPaths.ORGANIZATION_CONTRACTS,
63
+ TeacherDashboardPaths.ORGANIZATION_QUOTES,
64
+ TeacherDashboardPaths.ORGANIZATION_AGREEMENTS,
56
65
  ].map(getMenuLinkFromPath);
57
66
 
58
67
  if (fetching) {