richie-education 3.2.2-dev30 → 3.2.2-dev37

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.
@@ -10,6 +10,7 @@ export enum SubscriptionErrorMessageId {
10
10
  ERROR_FULL_PRODUCT = 'errorFullProduct',
11
11
  ERROR_WITHDRAWAL_RIGHT = 'errorWithdrawalRight',
12
12
  ERROR_BATCH_ORDER_FORM_INVALID = 'batchOrderFormInvalid',
13
+ ERROR_BATCH_ORDER_MAX_ORDERS = 'errorBatchOrderMaxOrders',
13
14
  }
14
15
 
15
16
  export enum PaymentProviders {
@@ -86,6 +86,8 @@ export const SaleTunnelInformationGroup = () => {
86
86
  );
87
87
  };
88
88
 
89
+ const EMAIL_REGEX = /^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$/;
90
+
89
91
  export const validationSchema = Yup.object().shape({
90
92
  offering_id: Yup.string().required(),
91
93
  company_name: Yup.string().required(),
@@ -98,23 +100,25 @@ export const validationSchema = Yup.object().shape({
98
100
  administrative_lastname: Yup.string().required(),
99
101
  administrative_firstname: Yup.string().required(),
100
102
  administrative_profession: Yup.string().required(),
101
- administrative_email: Yup.string().email().required(),
103
+ administrative_email: Yup.string().matches(EMAIL_REGEX).required(),
102
104
  administrative_telephone: Yup.string().required(),
103
105
  signatory_lastname: Yup.string().required(),
104
106
  signatory_firstname: Yup.string().required(),
105
107
  signatory_profession: Yup.string().required(),
106
- signatory_email: Yup.string().email().required(),
108
+ signatory_email: Yup.string().matches(EMAIL_REGEX).required(),
107
109
  signatory_telephone: Yup.string().required(),
108
- billing_address: Yup.object().optional().shape({
109
- company_name: Yup.string().optional(),
110
- identification_number: Yup.string().optional(),
111
- contact_name: Yup.string().optional(),
112
- contact_email: Yup.string().email().optional(),
113
- address: Yup.string().optional(),
114
- postcode: Yup.string().optional(),
115
- city: Yup.string().optional(),
116
- country: Yup.string().optional(),
117
- }),
110
+ billing_address: Yup.object()
111
+ .optional()
112
+ .shape({
113
+ company_name: Yup.string().optional(),
114
+ identification_number: Yup.string().optional(),
115
+ contact_name: Yup.string().optional(),
116
+ contact_email: Yup.string().matches(EMAIL_REGEX).optional(),
117
+ address: Yup.string().optional(),
118
+ postcode: Yup.string().optional(),
119
+ city: Yup.string().optional(),
120
+ country: Yup.string().optional(),
121
+ }),
118
122
  nb_seats: Yup.number().required().min(1),
119
123
  payment_method: Yup.mixed<PaymentMethod>().oneOf(Object.values(PaymentMethod)).required(),
120
124
  funding_entity: Yup.string().optional(),
@@ -198,9 +202,29 @@ const BatchOrderForm = () => {
198
202
  }, [values, batchOrder, setBatchOrder]);
199
203
 
200
204
  useEffect(() => {
201
- const requiredRules = requiredFieldsByStep[activeStep];
202
- const isStepValid = requiredRules.every((field) => !!batchOrder?.[field]);
203
- setIsCurrentStepValid(isStepValid);
205
+ const validateStep = async () => {
206
+ if (activeStep === 0 && batchOrder?.billing_address?.contact_email) {
207
+ const isEmailValid = EMAIL_REGEX.test(batchOrder.billing_address.contact_email);
208
+ if (!isEmailValid) {
209
+ setIsCurrentStepValid(false);
210
+ return;
211
+ }
212
+ }
213
+ const fieldsToValidate = requiredFieldsByStep[activeStep];
214
+ const validationPromises = fieldsToValidate.map(async (field) => {
215
+ try {
216
+ const fieldSchema = Yup.reach(validationSchema, field) as Yup.Schema;
217
+ await fieldSchema.validate(batchOrder?.[field]);
218
+ return true;
219
+ } catch {
220
+ return false;
221
+ }
222
+ });
223
+ const results = await Promise.all(validationPromises);
224
+ const isStepValid = results.every((isValid) => isValid);
225
+ setIsCurrentStepValid(isStepValid);
226
+ };
227
+ validateStep();
204
228
  }, [activeStep, batchOrder]);
205
229
 
206
230
  return (
@@ -208,7 +208,7 @@ export const StepContent = ({
208
208
  const { items: organizations } = useOfferingOrganizations({ id: offering?.id });
209
209
  const orgOptions = organizations.map((organization) => ({
210
210
  label: organization.title,
211
- value: organization.code,
211
+ value: organization.id,
212
212
  }));
213
213
  const [otherBillingAddress, setOtherBillingAddress] = useState(false);
214
214
 
@@ -275,7 +275,19 @@ export const StepContent = ({
275
275
  />
276
276
  <Checkbox
277
277
  label={intl.formatMessage(messages.checkBilling)}
278
- onChange={() => setOtherBillingAddress(!otherBillingAddress)}
278
+ onChange={() => {
279
+ setOtherBillingAddress(!otherBillingAddress);
280
+ form.resetField('billing_address');
281
+ form.resetField('billing_address.contact_name');
282
+ form.resetField('billing_address.contact_email');
283
+ form.resetField('billing_address.company_name');
284
+ form.resetField('billing_address.identification_number');
285
+ form.resetField('billing_address.address');
286
+ form.resetField('billing_address.postcode');
287
+ form.resetField('billing_address.city');
288
+ form.resetField('billing_address.country');
289
+ form.clearErrors();
290
+ }}
279
291
  checked={otherBillingAddress}
280
292
  />
281
293
  </div>
@@ -291,6 +303,13 @@ export const StepContent = ({
291
303
  className="field"
292
304
  {...register('billing_address.contact_email')}
293
305
  label={intl.formatMessage(messages.contactEmail)}
306
+ state={formState.errors.billing_address?.contact_email?.message ? 'error' : 'default'}
307
+ text={
308
+ getLocalizedCunninghamErrorProp(
309
+ intl,
310
+ formState.errors.billing_address?.contact_email?.message,
311
+ ).text
312
+ }
294
313
  />
295
314
  <Input
296
315
  className="field"
@@ -497,21 +516,11 @@ export const StepContent = ({
497
516
  <Input
498
517
  {...register('funding_entity')}
499
518
  label={intl.formatMessage(messages.fundingEntityName)}
500
- required
501
- state={formState.errors.funding_entity?.message ? 'error' : 'default'}
502
- text={
503
- getLocalizedCunninghamErrorProp(intl, formState.errors.funding_entity?.message).text
504
- }
505
519
  />
506
520
  <Input
507
521
  {...register('funding_amount')}
508
522
  type="number"
509
523
  label={intl.formatMessage(messages.fundingEntityAmount)}
510
- required
511
- state={formState.errors.funding_amount?.message ? 'error' : 'default'}
512
- text={
513
- getLocalizedCunninghamErrorProp(intl, formState.errors.funding_amount?.message).text
514
- }
515
524
  />
516
525
  </div>
517
526
  <FormattedMessage {...messages.recommandation} />
@@ -67,6 +67,13 @@ const messages = defineMessages({
67
67
  defaultMessage: 'Some required fields are missing in the form.',
68
68
  description: 'Some required fields are missing in the form.',
69
69
  },
70
+ errorBatchOrderMaxOrders: {
71
+ id: 'components.SubscriptionButton.errorBatchOrderMaxOrders',
72
+ defaultMessage:
73
+ 'Unable to create the order: the maximum number of available seats for this offering has been reached. Please contact support for more information.',
74
+ description:
75
+ 'Error message shown when batch order creation fails because maximum number of orders is reached by an active offering rule.',
76
+ },
70
77
  });
71
78
 
72
79
  enum ComponentStates {
@@ -172,7 +179,11 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
172
179
  return;
173
180
  }
174
181
  batchOrderMethods.create(batchOrder, {
175
- onError: async () => {
182
+ onError: async (createBatchOrderError: HttpError) => {
183
+ if (createBatchOrderError.code === 422) {
184
+ handleError(SubscriptionErrorMessageId.ERROR_BATCH_ORDER_MAX_ORDERS);
185
+ return;
186
+ }
176
187
  handleError();
177
188
  },
178
189
  onSuccess: async (createdBatchOrder: any) => {
@@ -353,4 +353,158 @@ describe('SaleTunnel', () => {
353
353
 
354
354
  screen.getByText('Subscription confirmed!');
355
355
  }, 10000);
356
+
357
+ it('should display the appropriate error message when there are not enough seats available', async () => {
358
+ const course = PacedCourseFactory().one();
359
+ const product = ProductFactory().one();
360
+ const offering = OfferingFactory({ course, product, is_withdrawable: false }).one();
361
+ const paymentPlan = PaymentPlanFactory().one();
362
+ const offeringOrganization = OfferingBatchOrderFactory({
363
+ product: { id: product.id, title: product.title },
364
+ }).one();
365
+
366
+ fetchMock.get(
367
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/`,
368
+ offering,
369
+ );
370
+ fetchMock.get(
371
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-plan/`,
372
+ paymentPlan,
373
+ );
374
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/enrollments/`, []);
375
+ fetchMock.get(
376
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify({
377
+ product_id: product.id,
378
+ course_code: course.code,
379
+ state: NOT_CANCELED_ORDER_STATES,
380
+ })}`,
381
+ [],
382
+ );
383
+ fetchMock.get(
384
+ `https://joanie.endpoint/api/v1.0/offerings/${offering.id}/get-organizations/`,
385
+ offeringOrganization,
386
+ );
387
+
388
+ render(<CourseProductItem productId={product.id} course={course} />, {
389
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
390
+ });
391
+
392
+ // Verify product info
393
+ await screen.findByRole('heading', { level: 3, name: product.title });
394
+ await screen.findByText(formatPrice(product.price_currency, product.price));
395
+ expect(screen.queryByText('Purchased')).not.toBeInTheDocument();
396
+
397
+ const user = userEvent.setup();
398
+ const buyButton = screen.getByRole('button', { name: product.call_to_action });
399
+
400
+ expect(screen.queryByTestId('generic-sale-tunnel-payment-step')).not.toBeInTheDocument();
401
+ await user.click(buyButton);
402
+ await screen.findByTestId('generic-sale-tunnel-payment-step');
403
+
404
+ // Verify learning path
405
+ await screen.findByText('Your learning path');
406
+ const targetCourses = await screen.findAllByTestId('product-target-course');
407
+ expect(targetCourses).toHaveLength(product.target_courses.length);
408
+ targetCourses.forEach((targetCourse, index) => {
409
+ const courseItem = product.target_courses[index];
410
+ const courseDetail = within(targetCourse).getByTestId(
411
+ `target-course-detail-${courseItem.code}`,
412
+ );
413
+ const summary = courseDetail.querySelector('summary')!;
414
+ expect(summary).toHaveTextContent(courseItem.title);
415
+
416
+ const courseRuns = targetCourse.querySelectorAll(
417
+ '.product-detail-row__course-run-dates__item',
418
+ );
419
+ const openedCourseRuns = courseItem.course_runs.filter(
420
+ (cr: CourseRun) => cr.state.priority <= Priority.FUTURE_NOT_YET_OPEN,
421
+ );
422
+ expect(courseRuns).toHaveLength(openedCourseRuns.length);
423
+ });
424
+
425
+ // Select group buy form
426
+ await screen.findByText('Purchase type');
427
+ const formTypeSelect = screen.getByRole('combobox', { name: 'Purchase type' });
428
+ const menu: HTMLDivElement = screen.getByRole('listbox', { name: 'Purchase type' });
429
+ expectMenuToBeClosed(menu);
430
+ await user.click(formTypeSelect);
431
+ expectMenuToBeOpen(menu);
432
+ await user.click(screen.getByText('Group purchase (B2B)'));
433
+
434
+ // Company step
435
+ const $companyName = await screen.findByRole('textbox', { name: 'Company name' });
436
+ const $idNumber = screen.getByRole('textbox', { name: /Identification number/ });
437
+ const $address = screen.getByRole('textbox', { name: 'Address' });
438
+ const $postCode = screen.getByRole('textbox', { name: 'Post code' });
439
+ const $city = screen.getByRole('textbox', { name: 'City' });
440
+ const $country = screen.getByRole('combobox', { name: 'Country' });
441
+
442
+ await user.type($companyName, 'GIP-FUN');
443
+ await user.type($idNumber, '789 242 229 01694');
444
+ await user.type($address, '61 Bis Rue de la Glaciere');
445
+ await user.type($postCode, '75013');
446
+ await user.type($city, 'Paris');
447
+
448
+ const countryMenu: HTMLDivElement = screen.getByRole('listbox', { name: 'Country' });
449
+ await user.click($country);
450
+ expectMenuToBeOpen(countryMenu);
451
+ await user.click(screen.getByText('France'));
452
+
453
+ expect($companyName).toHaveValue('GIP-FUN');
454
+ const visibleValue = $country.querySelector('.c__select__inner__value span');
455
+ expect(visibleValue!.textContent).toBe('France');
456
+
457
+ // Follow-up step
458
+ await user.click(screen.getByRole('button', { name: 'Next' }));
459
+ const $lastName = await screen.findByRole('textbox', { name: 'Last name' });
460
+ const $firstName = screen.getByRole('textbox', { name: 'First name' });
461
+ const $role = screen.getByRole('textbox', { name: 'Role' });
462
+ const $email = screen.getByRole('textbox', { name: 'Email' });
463
+ const $phone = screen.getByRole('textbox', { name: 'Phone' });
464
+
465
+ await user.type($lastName, 'Doe');
466
+ await user.type($firstName, 'John');
467
+ await user.type($role, 'HR');
468
+ await user.type($email, 'john.doe@fun-mooc.com');
469
+ await user.type($phone, '+338203920103');
470
+
471
+ expect($lastName).toHaveValue('Doe');
472
+ expect($email).toHaveValue('john.doe@fun-mooc.com');
473
+
474
+ // Signatory step
475
+ await user.click(screen.getByRole('button', { name: 'Next' }));
476
+ const $signatoryLastName = await screen.findByRole('textbox', { name: 'Last name' });
477
+ const $signatoryFirstName = screen.getByRole('textbox', { name: 'First name' });
478
+ const $signatoryRole = screen.getByRole('textbox', { name: 'Role' });
479
+ const $signatoryEmail = screen.getByRole('textbox', { name: 'Email' });
480
+ const $signatoryPhone = screen.getByRole('textbox', { name: 'Phone' });
481
+
482
+ await user.type($signatoryLastName, 'Doe');
483
+ await user.type($signatoryFirstName, 'John');
484
+ await user.type($signatoryRole, 'CEO');
485
+ await user.type($signatoryEmail, 'john.doe@fun-mooc.com');
486
+ await user.type($signatoryPhone, '+338203920103');
487
+
488
+ // Participants step
489
+ await user.click(screen.getByRole('button', { name: 'Next' }));
490
+ const $nbParticipants = await screen.findByLabelText('How many participants ?');
491
+ await user.type($nbParticipants, '13');
492
+ expect($nbParticipants).toHaveValue(13);
493
+
494
+ fetchMock.post('https://joanie.endpoint/api/v1.0/batch-orders/', {
495
+ status: 422,
496
+ body: {
497
+ __all__: ['Maximum number of orders reached for product Credential Product'],
498
+ },
499
+ });
500
+
501
+ const $subscribeButton = screen.getByRole('button', {
502
+ name: `Subscribe`,
503
+ }) as HTMLButtonElement;
504
+ await user.click($subscribeButton);
505
+
506
+ await screen.findByText(
507
+ 'Unable to create the order: the maximum number of available seats for this offering has been reached. Please contact support for more information.',
508
+ );
509
+ }, 30000);
356
510
  });
@@ -2,7 +2,7 @@ import fetchMock from 'fetch-mock';
2
2
  import { cleanup, screen } from '@testing-library/react';
3
3
  import userEvent, { UserEvent } from '@testing-library/user-event';
4
4
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
5
- import { OrganizationQuoteFactory } from 'utils/test/factories/joanie';
5
+ import { OrganizationFactory, OrganizationQuoteFactory } from 'utils/test/factories/joanie';
6
6
  import { expectNoSpinner } from 'utils/test/expectSpinner';
7
7
  import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
8
8
  import { render } from 'utils/test/render';
@@ -35,6 +35,17 @@ describe('full process for the organization quotes dashboard', () => {
35
35
  it('should works with the full process workflow for any payment methods', async () => {
36
36
  fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
37
37
 
38
+ const organization = OrganizationFactory({
39
+ abilities: {
40
+ can_submit_for_signature_batch_order: true,
41
+ confirm_bank_transfer: true,
42
+ confirm_quote: true,
43
+ download_quote: true,
44
+ sign_contracts: true,
45
+ },
46
+ }).one();
47
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', organization);
48
+
38
49
  const quoteQuoted = OrganizationQuoteFactory({
39
50
  batch_order: {
40
51
  state: BatchOrderState.QUOTED,
@@ -129,7 +140,6 @@ describe('full process for the organization quotes dashboard', () => {
129
140
  await user.click(toggle);
130
141
  expect(card).toHaveClass('dashboard-card--opened');
131
142
 
132
- // Download quote
133
143
  const downloadQuoteButton = await screen.findByRole('button', {
134
144
  name: /Download quote/i,
135
145
  });
@@ -147,7 +157,7 @@ describe('full process for the organization quotes dashboard', () => {
147
157
 
148
158
  // Second step : to sign quote
149
159
  const sendForSignatureButton = await screen.findByRole('button', {
150
- name: /send quote for signature/i,
160
+ name: /send contract for signature/i,
151
161
  });
152
162
  expect(sendForSignatureButton).toBeVisible();
153
163
  await user.click(sendForSignatureButton);
@@ -191,4 +201,133 @@ describe('full process for the organization quotes dashboard', () => {
191
201
  expect(processPaymentButton).toBeVisible();
192
202
  expect(processPaymentButton).toBeDisabled();
193
203
  });
204
+
205
+ it('should work with purchase order payment method workflow', async () => {
206
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
207
+ const organization = OrganizationFactory({
208
+ abilities: {
209
+ can_submit_for_signature_batch_order: true,
210
+ confirm_bank_transfer: true,
211
+ confirm_quote: true,
212
+ download_quote: true,
213
+ sign_contracts: true,
214
+ },
215
+ }).one();
216
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', organization);
217
+
218
+ const quoteQuoted = OrganizationQuoteFactory({
219
+ batch_order: {
220
+ state: BatchOrderState.QUOTED,
221
+ payment_method: PaymentMethod.PURCHASE_ORDER,
222
+ available_actions: { next_action: 'confirm_quote' },
223
+ },
224
+ organization_signed_on: undefined,
225
+ }).one();
226
+
227
+ const quotePurchaseOrder = OrganizationQuoteFactory({
228
+ id: quoteQuoted.id,
229
+ batch_order: {
230
+ state: BatchOrderState.QUOTED,
231
+ payment_method: PaymentMethod.PURCHASE_ORDER,
232
+ available_actions: { next_action: 'confirm_purchase_order' },
233
+ },
234
+ }).one();
235
+
236
+ const quoteSendForSign = OrganizationQuoteFactory({
237
+ id: quoteQuoted.id,
238
+ batch_order: {
239
+ state: BatchOrderState.TO_SIGN,
240
+ payment_method: PaymentMethod.PURCHASE_ORDER,
241
+ contract_submitted: false,
242
+ available_actions: { next_action: 'submit_for_signature' },
243
+ },
244
+ }).one();
245
+
246
+ const quoteCompleted = OrganizationQuoteFactory({
247
+ id: quoteQuoted.id,
248
+ batch_order: {
249
+ state: BatchOrderState.COMPLETED,
250
+ payment_method: PaymentMethod.PURCHASE_ORDER,
251
+ },
252
+ }).one();
253
+
254
+ const quotesResponses = [
255
+ { results: [quoteQuoted], count: 1, previous: null, next: null },
256
+ { results: [quotePurchaseOrder], count: 1, previous: null, next: null },
257
+ { results: [quoteSendForSign], count: 1, previous: null, next: null },
258
+ { results: [quoteCompleted], count: 1, previous: null, next: null },
259
+ ];
260
+
261
+ fetchMock.get(
262
+ `https://joanie.endpoint/api/v1.0/organizations/1/quotes/?page=1&page_size=10`,
263
+ () => quotesResponses.shift(),
264
+ );
265
+
266
+ fetchMock.patch(`https://joanie.endpoint/api/v1.0/organizations/1/confirm-quote/`, 200);
267
+ fetchMock.patch(
268
+ `https://joanie.endpoint/api/v1.0/organizations/1/confirm-purchase-order/`,
269
+ 200,
270
+ );
271
+ fetchMock.post(
272
+ `https://joanie.endpoint/api/v1.0/organizations/1/submit-for-signature-batch-order/`,
273
+ 200,
274
+ );
275
+
276
+ render(<TeacherDashboardOrganizationQuotes />, {
277
+ routerOptions: {
278
+ path: '/organizations/:organizationId/quotes',
279
+ initialEntries: ['/organizations/1/quotes'],
280
+ },
281
+ });
282
+
283
+ await expectNoSpinner();
284
+
285
+ // First step: confirm quote
286
+ const confirmQuoteButton = await screen.findByRole('button', { name: /confirm quote/i });
287
+ expect(confirmQuoteButton).toBeVisible();
288
+ await user.click(confirmQuoteButton);
289
+ await screen.findByText(/total amount/i);
290
+ await user.type(screen.getByLabelText(/total amount/i), '1000');
291
+ await user.click(screen.getByRole('button', { name: /confirm quote/i }));
292
+
293
+ // Second step: confirm purchase order with reference via modal
294
+ const confirmPurchaseOrderButton = await screen.findByRole('button', {
295
+ name: /confirm receipt of purchase order/i,
296
+ });
297
+ expect(confirmPurchaseOrderButton).toBeVisible();
298
+ await user.click(confirmPurchaseOrderButton);
299
+
300
+ // Modal should open with purchase order reference input
301
+ const modalTitle = await screen.findByText(/confirm purchase order/i);
302
+ expect(modalTitle).toBeInTheDocument();
303
+
304
+ const referenceInput = screen.getByLabelText(/purchase order reference/i);
305
+ expect(referenceInput).toBeInTheDocument();
306
+
307
+ // Type reference and confirm
308
+ await user.type(referenceInput, 'this-is-a-reference');
309
+ await user.click(screen.getByRole('button', { name: /confirm receipt of purchase order/i }));
310
+
311
+ // Third step: send for signature
312
+ const sendForSignatureButton = await screen.findByRole('button', {
313
+ name: /send contract for signature/i,
314
+ });
315
+ expect(sendForSignatureButton).toBeVisible();
316
+ await user.click(sendForSignatureButton);
317
+
318
+ // Verify completed state
319
+ cleanup();
320
+ render(<TeacherDashboardOrganizationQuotes />, {
321
+ routerOptions: {
322
+ path: '/organizations/:organizationId/quotes',
323
+ initialEntries: ['/organizations/1/quotes'],
324
+ },
325
+ });
326
+ await expectNoSpinner();
327
+
328
+ // No action button should be visible for completed quote
329
+ expect(
330
+ screen.queryByRole('button', { name: /confirm receipt of purchase order/i }),
331
+ ).not.toBeInTheDocument();
332
+ });
194
333
  });
@@ -2,11 +2,12 @@ import fetchMock from 'fetch-mock';
2
2
  import { screen, waitFor } from '@testing-library/react';
3
3
  import userEvent, { UserEvent } from '@testing-library/user-event';
4
4
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
5
- import { OrganizationQuoteFactory } from 'utils/test/factories/joanie';
5
+ import { OrganizationFactory, OrganizationQuoteFactory } from 'utils/test/factories/joanie';
6
6
  import { expectNoSpinner } from 'utils/test/expectSpinner';
7
7
  import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
8
8
  import { render } from 'utils/test/render';
9
9
  import { expectBannerInfo, expectBannerError } from 'utils/test/expectBanner';
10
+ import { BatchOrderState } from 'types/Joanie';
10
11
  import TeacherDashboardOrganizationQuotes from '.';
11
12
 
12
13
  let user: UserEvent;
@@ -29,6 +30,9 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
29
30
  it('should render a list of quotes for an organization', async () => {
30
31
  const quoteList = OrganizationQuoteFactory().many(1);
31
32
  fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
33
+
34
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', []);
35
+
32
36
  fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/1/quotes/?page=1&page_size=10`, {
33
37
  results: quoteList,
34
38
  count: 0,
@@ -59,6 +63,9 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
59
63
 
60
64
  it('should render an empty list of quotes for an organization', async () => {
61
65
  fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
66
+
67
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', []);
68
+
62
69
  fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/1/quotes/?page=1&page_size=10`, {
63
70
  results: [],
64
71
  count: 0,
@@ -81,6 +88,8 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
81
88
  const quoteList = OrganizationQuoteFactory().many(30);
82
89
  fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
83
90
 
91
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', []);
92
+
84
93
  fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/1/quotes/?page=1&page_size=10`, {
85
94
  results: quoteList.slice(0, 10),
86
95
  count: 30,
@@ -126,6 +135,9 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
126
135
 
127
136
  it('should display an error when API fails', async () => {
128
137
  fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/', []);
138
+
139
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', []);
140
+
129
141
  fetchMock.get(
130
142
  'https://joanie.endpoint/api/v1.0/organizations/1/quotes/?page=1&page_size=10',
131
143
  500,
@@ -141,4 +153,49 @@ describe('pages/TeacherDashboardOrganizationQuotes', () => {
141
153
  await expectNoSpinner();
142
154
  await expectBannerError('An error occurred while fetching resources. Please retry later.');
143
155
  });
156
+
157
+ it('should render disabled buttons when the user is not allowed', async () => {
158
+ const quoteQuoted = OrganizationQuoteFactory({
159
+ batch_order: {
160
+ state: BatchOrderState.QUOTED,
161
+ available_actions: { next_action: 'confirm_quote' },
162
+ },
163
+ }).one();
164
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/`, []);
165
+
166
+ const organization = OrganizationFactory({
167
+ abilities: {
168
+ can_submit_for_signature_batch_order: false,
169
+ confirm_bank_transfer: false,
170
+ confirm_quote: false,
171
+ download_quote: true,
172
+ sign_contracts: false,
173
+ },
174
+ }).one();
175
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/1/', organization);
176
+
177
+ fetchMock.get(`https://joanie.endpoint/api/v1.0/organizations/1/quotes/?page=1&page_size=10`, {
178
+ results: [quoteQuoted],
179
+ count: 1,
180
+ previous: null,
181
+ next: null,
182
+ });
183
+
184
+ render(<TeacherDashboardOrganizationQuotes />, {
185
+ routerOptions: {
186
+ path: '/organizations/:organizationId/quotes',
187
+ initialEntries: ['/organizations/1/quotes'],
188
+ },
189
+ });
190
+
191
+ await expectNoSpinner();
192
+
193
+ const downloadQuoteButton = await screen.findByRole('button', { name: /Download quote/i });
194
+ expect(downloadQuoteButton).toBeVisible();
195
+ expect(downloadQuoteButton).not.toBeDisabled();
196
+
197
+ const confirmQuoteButton = await screen.findByRole('button', { name: /Confirm quote/i });
198
+ expect(confirmQuoteButton).toBeVisible();
199
+ expect(confirmQuoteButton).toBeDisabled();
200
+ });
144
201
  });
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
4
4
  import { useParams } from 'react-router';
5
5
  import Banner, { BannerType } from 'components/Banner';
6
6
  import { useOrganizationsQuotes } from 'hooks/useOrganizationQuotes';
7
+ import { useOrganization } from 'hooks/useOrganizations';
7
8
  import { TeacherDashboardContractsParams } from 'pages/TeacherDashboardContractsLayout/hooks/useTeacherContractFilters';
8
9
  import { BatchOrderState, OrganizationQuote } from 'types/Joanie';
9
10
  import { PaymentMethod } from 'components/PaymentInterfaces/types';
@@ -65,15 +66,25 @@ const messages = defineMessages({
65
66
  id: 'components.OrganizationQuotesTable.confirmPurchaseOrder',
66
67
  description: 'Label for confirming receipt of a purchase order',
67
68
  },
69
+ purchaseOrderModalTitle: {
70
+ defaultMessage: 'Confirm purchase order',
71
+ id: 'components.OrganizationQuotesTable.purchaseOrderModalTitle',
72
+ description: 'Title of the confirm purchase order modal',
73
+ },
74
+ purchaseOrderReferenceLabel: {
75
+ defaultMessage: 'Purchase order reference',
76
+ id: 'components.OrganizationQuotesTable.purchaseOrderReferenceLabel',
77
+ description: 'Label for the purchase order reference input',
78
+ },
68
79
  confirmBank: {
69
80
  defaultMessage: 'Confirm bank transfer',
70
81
  id: 'components.OrganizationQuotesTable.confirmBank',
71
82
  description: 'Label for confirming a bank transfer',
72
83
  },
73
84
  sendForSignature: {
74
- defaultMessage: 'Send quote for signature',
85
+ defaultMessage: 'Send contract for signature',
75
86
  id: 'components.OrganizationQuotesTable.sendForSignature',
76
- description: 'Action label to send a quote for signature',
87
+ description: 'Action label to send a contract for signature',
77
88
  },
78
89
  waitingSignature: {
79
90
  defaultMessage: 'Waiting signature',
@@ -215,6 +226,13 @@ const messages = defineMessages({
215
226
  const TeacherDashboardOrganizationQuotes = () => {
216
227
  const intl = useIntl();
217
228
  const { organizationId: routeOrganizationId } = useParams<TeacherDashboardContractsParams>();
229
+ const {
230
+ item: organization,
231
+ states: { isPending: isOrganizationPending },
232
+ } = useOrganization(routeOrganizationId);
233
+
234
+ const abilities = organization?.abilities;
235
+
218
236
  const pagination = usePagination({ itemsPerPage: 10 });
219
237
 
220
238
  const {
@@ -241,6 +259,11 @@ const TeacherDashboardOrganizationQuotes = () => {
241
259
  const [amount, setAmount] = useState('');
242
260
  const [isModalOpen, setIsModalOpen] = useState(false);
243
261
 
262
+ const [selectedPurchaseOrderQuote, setSelectedPurchaseOrderQuote] =
263
+ useState<OrganizationQuote | null>(null);
264
+ const [purchaseOrderReference, setPurchaseOrderReference] = useState('');
265
+ const [isPurchaseOrderModalOpen, setIsPurchaseOrderModalOpen] = useState(false);
266
+
244
267
  useEffect(() => {
245
268
  if (meta?.pagination?.count) {
246
269
  pagination.setItemsCount(meta.pagination.count);
@@ -249,7 +272,7 @@ const TeacherDashboardOrganizationQuotes = () => {
249
272
 
250
273
  if (error) return <Banner message={error} type={BannerType.ERROR} rounded />;
251
274
 
252
- if (isPending)
275
+ if (isPending || isOrganizationPending)
253
276
  return (
254
277
  <Spinner size="large">
255
278
  <span id="loading-contract-data">
@@ -286,11 +309,27 @@ const TeacherDashboardOrganizationQuotes = () => {
286
309
  await invalidate();
287
310
  };
288
311
 
289
- const handleConfirmPurchaseOrder = async (id: string) => {
312
+ const handleOpenPurchaseOrderModal = (quote: OrganizationQuote) => {
313
+ setSelectedPurchaseOrderQuote(quote);
314
+ setIsPurchaseOrderModalOpen(true);
315
+ };
316
+
317
+ const handleCancelPurchaseOrder = () => {
318
+ setIsPurchaseOrderModalOpen(false);
319
+ setPurchaseOrderReference('');
320
+ setSelectedPurchaseOrderQuote(null);
321
+ };
322
+
323
+ const handleConfirmPurchaseOrder = async () => {
324
+ if (!selectedPurchaseOrderQuote) return;
290
325
  await confirmPurchaseOrder({
291
326
  organization_id: routeOrganizationId,
292
- payload: { quote_id: id },
327
+ payload: {
328
+ quote_id: selectedPurchaseOrderQuote.id,
329
+ purchase_order_reference: purchaseOrderReference,
330
+ },
293
331
  });
332
+ handleCancelPurchaseOrder();
294
333
  await invalidate();
295
334
  };
296
335
 
@@ -336,6 +375,7 @@ const TeacherDashboardOrganizationQuotes = () => {
336
375
  className="mr-2"
337
376
  onClick={() => handleDownloadQuote(quote.id)}
338
377
  icon={<span className="material-icons">download</span>}
378
+ disabled={!abilities?.download_quote}
339
379
  >
340
380
  {intl.formatMessage(messages.downloadQuote)}
341
381
  </Button>
@@ -343,6 +383,7 @@ const TeacherDashboardOrganizationQuotes = () => {
343
383
  size="small"
344
384
  onClick={() => handleOpenConfirm(quote.id)}
345
385
  icon={<span className="material-icons">check_circle</span>}
386
+ disabled={!abilities?.confirm_quote}
346
387
  >
347
388
  {intl.formatMessage(messages.confirmQuote)}
348
389
  </Button>
@@ -353,7 +394,7 @@ const TeacherDashboardOrganizationQuotes = () => {
353
394
  <Button
354
395
  size="small"
355
396
  className="ml-2"
356
- onClick={() => handleConfirmPurchaseOrder(quote.id)}
397
+ onClick={() => handleOpenPurchaseOrderModal(quote)}
357
398
  icon={<span className="material-icons">description</span>}
358
399
  >
359
400
  {intl.formatMessage(messages.confirmPurchaseOrder)}
@@ -365,6 +406,7 @@ const TeacherDashboardOrganizationQuotes = () => {
365
406
  size="small"
366
407
  onClick={() => handleConfirmBankTransfer(quote.batch_order.id)}
367
408
  icon={<span className="material-icons">account_balance</span>}
409
+ disabled={!abilities?.confirm_bank_transfer}
368
410
  >
369
411
  {intl.formatMessage(messages.confirmBank)}
370
412
  </Button>
@@ -373,7 +415,7 @@ const TeacherDashboardOrganizationQuotes = () => {
373
415
  const submitForSignatureButton = (
374
416
  <Button
375
417
  size="small"
376
- disabled={batchOrder.contract_submitted}
418
+ disabled={batchOrder.contract_submitted || !abilities?.can_submit_for_signature_batch_order}
377
419
  onClick={() =>
378
420
  !batchOrder.contract_submitted && handleSubmitForSignature(quote.batch_order.id)
379
421
  }
@@ -514,6 +556,26 @@ const TeacherDashboardOrganizationQuotes = () => {
514
556
  />
515
557
  </div>
516
558
  </Modal>
559
+ <Modal
560
+ isOpen={isPurchaseOrderModalOpen}
561
+ onClose={handleCancelPurchaseOrder}
562
+ title={intl.formatMessage(messages.purchaseOrderModalTitle)}
563
+ size={ModalSize.MEDIUM}
564
+ actions={
565
+ <Button size="small" onClick={handleConfirmPurchaseOrder}>
566
+ {intl.formatMessage(messages.confirmPurchaseOrder)}
567
+ </Button>
568
+ }
569
+ >
570
+ <div className="dashboard__quote__modal">
571
+ <Input
572
+ type="text"
573
+ label={intl.formatMessage(messages.purchaseOrderReferenceLabel)}
574
+ onChange={(e) => setPurchaseOrderReference(e.target.value)}
575
+ value={purchaseOrderReference}
576
+ />
577
+ </div>
578
+ </Modal>
517
579
  </div>
518
580
  );
519
581
  };
@@ -35,6 +35,18 @@ export interface Organization {
35
35
  contact_phone: Nullable<string>;
36
36
  dpo_email: Nullable<string>;
37
37
  address?: Address;
38
+ abilities: {
39
+ can_submit_for_signature_batch_order: boolean;
40
+ confirm_bank_transfer: boolean;
41
+ confirm_quote: boolean;
42
+ delete: boolean;
43
+ download_quote: boolean;
44
+ get: boolean;
45
+ manage_accesses: boolean;
46
+ patch: boolean;
47
+ put: boolean;
48
+ sign_contracts: boolean;
49
+ };
38
50
  }
39
51
 
40
52
  export interface OrganizationResourceQuery extends ResourcesQuery {
@@ -171,6 +171,18 @@ export const OrganizationFactory = factory((): Organization => {
171
171
  dpo_email: faker.internet.email(),
172
172
  contact_phone: faker.phone.number(),
173
173
  address: AddressFactory().one(),
174
+ abilities: {
175
+ can_submit_for_signature_batch_order: faker.datatype.boolean(),
176
+ confirm_bank_transfer: faker.datatype.boolean(),
177
+ confirm_quote: faker.datatype.boolean(),
178
+ delete: faker.datatype.boolean(),
179
+ download_quote: faker.datatype.boolean(),
180
+ get: faker.datatype.boolean(),
181
+ manage_accesses: faker.datatype.boolean(),
182
+ patch: faker.datatype.boolean(),
183
+ put: faker.datatype.boolean(),
184
+ sign_contracts: faker.datatype.boolean(),
185
+ },
174
186
  };
175
187
  });
176
188
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "3.2.2-dev30",
3
+ "version": "3.2.2-dev37",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {