Skip to content

Commit aa57b18

Browse files
committed
👽️(frontend) show custom error message in b2b sale tunnel
The API now returns a 422 error code when the batch order was created with more seats than the maximum available.
1 parent 4643d45 commit aa57b18

File tree

5 files changed

+170
-3
lines changed

5 files changed

+170
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ Versioning](https://semver.org/spec/v2.0.0.html).
1111
### Added
1212

1313
- Add keycloak as an authentication backend
14-
- Disable quote actions for member users
14+
- Disable quote actions for member users
15+
- Add custom error message for batch orders when there are no available seats
1516

1617
### Fixed
1718

src/frontend/js/components/PaymentInterfaces/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export enum SubscriptionErrorMessageId {
1010
ERROR_FULL_PRODUCT = 'errorFullProduct',
1111
ERROR_WITHDRAWAL_RIGHT = 'errorWithdrawalRight',
1212
ERROR_BATCH_ORDER_FORM_INVALID = 'batchOrderFormInvalid',
13+
ERROR_BATCH_ORDER_MAX_ORDERS = 'errorBatchOrderMaxOrders',
1314
}
1415

1516
export enum PaymentProviders {

src/frontend/js/components/SaleTunnel/SubscriptionButton/index.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ const messages = defineMessages({
6767
defaultMessage: 'Some required fields are missing in the form.',
6868
description: 'Some required fields are missing in the form.',
6969
},
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+
},
7077
});
7178

7279
enum ComponentStates {
@@ -172,7 +179,11 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
172179
return;
173180
}
174181
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+
}
176187
handleError();
177188
},
178189
onSuccess: async (createdBatchOrder: any) => {

src/frontend/js/components/SaleTunnel/index.full-process-b2b.spec.tsx

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,4 +353,158 @@ describe('SaleTunnel', () => {
353353

354354
screen.getByText('Subscription confirmed!');
355355
}, 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, '[email protected]');
469+
await user.type($phone, '+338203920103');
470+
471+
expect($lastName).toHaveValue('Doe');
472+
expect($email).toHaveValue('[email protected]');
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, '[email protected]');
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);
356510
});

src/frontend/js/pages/TeacherDashboardOrganizationQuotes/index.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { expectNoSpinner } from 'utils/test/expectSpinner';
77
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
88
import { render } from 'utils/test/render';
99
import { expectBannerInfo, expectBannerError } from 'utils/test/expectBanner';
10-
import TeacherDashboardOrganizationQuotes from '.';
1110
import { BatchOrderState } from 'types/Joanie';
11+
import TeacherDashboardOrganizationQuotes from '.';
1212

1313
let user: UserEvent;
1414

0 commit comments

Comments
 (0)