@arquimedes.co/eureka-forms 3.0.71-test → 3.0.72-test
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.
- package/README +33 -1
- package/dist/@Types/AvailabilityFormStep.d.ts +48 -0
- package/dist/@Types/AvailabilityFormStep.js +24 -0
- package/dist/@Types/BankAccountFormStep.d.ts +67 -0
- package/dist/@Types/BankAccountFormStep.js +14 -0
- package/dist/@Types/BookingFormStep.d.ts +249 -0
- package/dist/@Types/BookingFormStep.js +65 -0
- package/dist/@Types/CalendarFormStep.d.ts +209 -0
- package/dist/@Types/CalendarFormStep.js +55 -0
- package/dist/@Types/CommunicationChannelFormStep.d.ts +51 -0
- package/dist/@Types/CommunicationChannelFormStep.js +15 -0
- package/dist/@Types/ConsentFormStep.d.ts +36 -0
- package/dist/@Types/ConsentFormStep.js +8 -0
- package/dist/@Types/EntityFormFormStep.d.ts +66 -0
- package/dist/@Types/EntityFormFormStep.js +20 -0
- package/dist/@Types/EventFormStep.d.ts +52 -0
- package/dist/@Types/EventFormStep.js +13 -0
- package/dist/@Types/ExternalReferenceFormStep.d.ts +74 -0
- package/dist/@Types/ExternalReferenceFormStep.js +20 -0
- package/dist/@Types/FormStep.d.ts +7 -2
- package/dist/@Types/FormStep.js +2 -1
- package/dist/@Types/LocationFormStep.d.ts +159 -0
- package/dist/@Types/LocationFormStep.js +46 -0
- package/dist/@Types/NumericFormSteps.d.ts +108 -0
- package/dist/@Types/NumericFormSteps.js +20 -0
- package/dist/@Types/PredefinedSelectorFormStep.d.ts +51 -0
- package/dist/@Types/PredefinedSelectorFormStep.js +39 -0
- package/dist/@Types/StepRef.d.ts +135 -0
- package/dist/@Types/StepRef.js +49 -0
- package/dist/App/App.stories.d.ts +10 -0
- package/dist/App/App.stories.js +189 -0
- package/dist/App/App.submitError.test.d.ts +1 -0
- package/dist/App/App.submitError.test.js +53 -0
- package/dist/App/AppHooks.js +8 -0
- package/dist/App/AppHooks.test.js +8 -0
- package/dist/BookingManage/AppointmentText.d.ts +5 -0
- package/dist/BookingManage/AppointmentText.js +20 -0
- package/dist/BookingManage/BookingManagePage.d.ts +17 -0
- package/dist/BookingManage/BookingManagePage.js +177 -0
- package/dist/BookingManage/BookingManagePage.module.css +115 -0
- package/dist/BookingManage/BookingManagePage.test.d.ts +1 -0
- package/dist/BookingManage/BookingManagePage.test.js +149 -0
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.d.ts +14 -5
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.js +8 -1
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.module.css +12 -0
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.test.d.ts +1 -0
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.test.js +21 -0
- package/dist/Form/Form.d.ts +3 -0
- package/dist/Form/Form.js +16 -8
- package/dist/Form/FormFunctions.d.ts +17 -0
- package/dist/Form/FormFunctions.js +22 -0
- package/dist/Form/FormFunctions.test.js +28 -1
- package/dist/Form/FormTypes/ColumnForm/ColumnForm.d.ts +1 -1
- package/dist/Form/FormTypes/ColumnForm/ColumnForm.js +3 -2
- package/dist/Form/FormTypes/StepperForm/StepperForm.d.ts +1 -1
- package/dist/Form/FormTypes/StepperForm/StepperForm.js +3 -2
- package/dist/Form/SubmitError/SubmitError.d.ts +11 -0
- package/dist/Form/SubmitError/SubmitError.js +9 -0
- package/dist/Form/SubmitError/SubmitError.module.css +25 -0
- package/dist/Form/SubmitError/SubmitError.test.d.ts +1 -0
- package/dist/Form/SubmitError/SubmitError.test.js +34 -0
- package/dist/Form/SubmitError/SubmitErrorReason.d.ts +13 -0
- package/dist/Form/SubmitError/SubmitErrorReason.js +20 -0
- package/dist/FormSteps/AvailabilityStep/AvailabilityStep.d.ts +7 -0
- package/dist/FormSteps/AvailabilityStep/AvailabilityStep.js +14 -0
- package/dist/FormSteps/AvailabilityStep/MaterialAvailabilityStep/MaterialAvailabilityStep.d.ts +8 -0
- package/dist/FormSteps/AvailabilityStep/MaterialAvailabilityStep/MaterialAvailabilityStep.js +32 -0
- package/dist/FormSteps/BookingStep/BookingStep.d.ts +17 -0
- package/dist/FormSteps/BookingStep/BookingStep.js +14 -0
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.d.ts +21 -0
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.js +289 -0
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.test.d.ts +1 -0
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.test.js +275 -0
- package/dist/FormSteps/CheckBoxStep/CheckBoxStep.mobileLabel.test.js +20 -12
- package/dist/FormSteps/CheckBoxStep/MaterialCheckBoxStep/MaterialCheckBoxStep.js +6 -13
- package/dist/FormSteps/CheckBoxStep/MaterialCheckBoxStep/MaterialCheckBoxStep.module.css +2 -13
- package/dist/FormSteps/ClassifierSelectorStep/ClassifierSelectorStep.lateClassifiers.test.d.ts +1 -0
- package/dist/FormSteps/ClassifierSelectorStep/ClassifierSelectorStep.lateClassifiers.test.js +70 -0
- package/dist/FormSteps/ClassifierSelectorStep/MaterialClassifierSelectorStep/MaterialClassifierSelectorStep.js +3 -1
- package/dist/FormSteps/EntityValueStep/MaterialEntityValuePickerStep/MaterialEntityValuePickerStep.js +26 -26
- package/dist/FormSteps/EntityValueStep/entityValueDependencyValue.d.ts +12 -0
- package/dist/FormSteps/EntityValueStep/entityValueDependencyValue.js +35 -0
- package/dist/FormSteps/EntityValueStep/entityValueDependencyValue.test.d.ts +1 -0
- package/dist/FormSteps/EntityValueStep/entityValueDependencyValue.test.js +31 -0
- package/dist/FormSteps/EntityValueStep/entityValuePickerWatchedSteps.d.ts +27 -0
- package/dist/FormSteps/EntityValueStep/entityValuePickerWatchedSteps.js +64 -0
- package/dist/FormSteps/EntityValueStep/entityValuePickerWatchedSteps.test.d.ts +1 -0
- package/dist/FormSteps/EntityValueStep/entityValuePickerWatchedSteps.test.js +95 -0
- package/dist/FormSteps/LocationStep/LocationStep.d.ts +10 -0
- package/dist/FormSteps/LocationStep/LocationStep.js +14 -0
- package/dist/FormSteps/LocationStep/MaterialLocationStep/MaterialLocationStep.d.ts +3 -0
- package/dist/FormSteps/LocationStep/MaterialLocationStep/MaterialLocationStep.js +54 -0
- package/dist/FormSteps/LocationStep/MaterialLocationStep/MaterialLocationStep.module.css +18 -0
- package/dist/FormSteps/NumberStep/MaterialNumberStep/MaterialNumberStep.d.ts +3 -0
- package/dist/FormSteps/NumberStep/MaterialNumberStep/MaterialNumberStep.js +170 -0
- package/dist/FormSteps/NumberStep/MaterialNumberStep/MaterialNumberStep.module.css +96 -0
- package/dist/FormSteps/NumberStep/NumberStep.d.ts +10 -0
- package/dist/FormSteps/NumberStep/NumberStep.js +14 -0
- package/dist/FormSteps/Step.js +16 -0
- package/dist/FormSteps/StepFunctions.js +14 -0
- package/dist/FormSteps/StepFunctions.test.js +54 -0
- package/dist/FormSteps/StepHooks.d.ts +21 -0
- package/dist/FormSteps/StepHooks.js +55 -8
- package/dist/FormSteps/StepHooks.test.d.ts +1 -0
- package/dist/FormSteps/StepHooks.test.js +92 -0
- package/dist/FormSteps/isStepHiddenByCondition.test.d.ts +1 -0
- package/dist/FormSteps/isStepHiddenByCondition.test.js +42 -0
- package/dist/Icons/AddIcon.d.ts +3 -0
- package/dist/Icons/AddIcon.js +7 -0
- package/dist/Icons/EditIcon.d.ts +3 -0
- package/dist/Icons/EditIcon.js +7 -0
- package/dist/Icons/RemoveIcon.d.ts +3 -0
- package/dist/Icons/RemoveIcon.js +7 -0
- package/dist/Icons/ViewDayIcon.d.ts +3 -0
- package/dist/Icons/ViewDayIcon.js +7 -0
- package/dist/Icons/ViewWeekIcon.d.ts +3 -0
- package/dist/Icons/ViewWeekIcon.js +7 -0
- package/dist/Services/BookingService.d.ts +226 -0
- package/dist/Services/BookingService.js +264 -0
- package/dist/Services/BookingService.test.d.ts +1 -0
- package/dist/Services/BookingService.test.js +178 -0
- package/dist/Shared/ErkAddressAutocomplete/ErkAddressAutocomplete.d.ts +19 -0
- package/dist/Shared/ErkAddressAutocomplete/ErkAddressAutocomplete.js +80 -0
- package/dist/Shared/ErkAddressAutocomplete/ErkAddressAutocomplete.module.css +88 -0
- package/dist/Shared/ErkAddressAutocomplete/googlePlaces.d.ts +29 -0
- package/dist/Shared/ErkAddressAutocomplete/googlePlaces.js +139 -0
- package/dist/Shared/ErkAvailabilityPicker/ErkAvailabilityPicker.d.ts +25 -0
- package/dist/Shared/ErkAvailabilityPicker/ErkAvailabilityPicker.js +112 -0
- package/dist/Shared/ErkAvailabilityPicker/ErkAvailabilityPicker.module.css +88 -0
- package/dist/Shared/ErkAvailabilityPicker/ErkAvailabilityPicker.test.d.ts +1 -0
- package/dist/Shared/ErkAvailabilityPicker/ErkAvailabilityPicker.test.js +73 -0
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.d.ts +105 -0
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.js +399 -0
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.module.css +646 -0
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.d.ts +36 -0
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.js +177 -0
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.test.d.ts +1 -0
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.test.js +47 -0
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.test.d.ts +1 -0
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.test.js +440 -0
- package/dist/Shared/ErkDatePicker/ErkDatePicker.js +1 -3
- package/dist/Shared/ErkDateRangePicker/ErkDateRangePicker.d.ts +2 -3
- package/dist/Shared/ErkDateRangePicker/ErkDateRangePicker.js +1 -2
- package/dist/Shared/ErkIconButton/ErkIconButton.d.ts +17 -0
- package/dist/Shared/ErkIconButton/ErkIconButton.js +11 -0
- package/dist/Shared/ErkIconButton/ErkIconButton.module.css +41 -0
- package/dist/Shared/ErkLocationField/ErkLocationField.d.ts +24 -0
- package/dist/Shared/ErkLocationField/ErkLocationField.js +128 -0
- package/dist/Shared/ErkLocationField/ErkLocationField.module.css +164 -0
- package/dist/Shared/ErkLocationField/ErkLocationField.test.d.ts +1 -0
- package/dist/Shared/ErkLocationField/ErkLocationField.test.js +82 -0
- package/dist/Shared/ErkLocationField/addressFields.d.ts +12 -0
- package/dist/Shared/ErkLocationField/addressFields.js +34 -0
- package/dist/Shared/ErkLocationField/locationRestrictions.d.ts +6 -0
- package/dist/Shared/ErkLocationField/locationRestrictions.js +51 -0
- package/dist/Shared/ErkLocationInput/ErkLocationInput.d.ts +15 -0
- package/dist/Shared/ErkLocationInput/ErkLocationInput.js +502 -0
- package/dist/Shared/ErkLocationInput/ErkLocationInput.module.css +128 -0
- package/dist/Shared/ErkNumberField/ErkNumberField.d.ts +42 -0
- package/dist/Shared/ErkNumberField/ErkNumberField.js +169 -0
- package/dist/Utils/AxiosWidget.js +9 -1
- package/dist/constants/FormStepTypes.d.ts +5 -1
- package/dist/constants/FormStepTypes.js +4 -0
- package/dist/index.lib.d.ts +4 -2
- package/dist/index.lib.js +3 -1
- package/dist/main.js +4 -1
- package/package.json +2 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import '@testing-library/jest-dom';
|
|
6
|
+
import { addDays, format } from 'date-fns';
|
|
7
|
+
import { es } from 'date-fns/locale';
|
|
8
|
+
import { BookingEdgeError, BookingErrorCodes } from '../Services/BookingService';
|
|
9
|
+
const service = vi.hoisted(() => ({
|
|
10
|
+
fetchBookingAppointment: vi.fn(),
|
|
11
|
+
cancelBookingAppointment: vi.fn(),
|
|
12
|
+
moveBookingAppointment: vi.fn(),
|
|
13
|
+
fetchBookingOffer: vi.fn(),
|
|
14
|
+
fetchBookingSlots: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('../Services/BookingService', async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal()),
|
|
18
|
+
...service,
|
|
19
|
+
}));
|
|
20
|
+
import { appointmentWhen } from './AppointmentText';
|
|
21
|
+
import BookingManagePage from './BookingManagePage';
|
|
22
|
+
const TOMORROW = addDays(new Date(), 1);
|
|
23
|
+
const DAY_KEY = format(TOMORROW, 'yyyy-MM-dd');
|
|
24
|
+
const APPOINTMENT = {
|
|
25
|
+
token: 'link-1',
|
|
26
|
+
status: 'CONFIRMED',
|
|
27
|
+
start: '2030-10-15T14:00:00.000Z',
|
|
28
|
+
end: '2030-10-15T14:30:00.000Z',
|
|
29
|
+
timeZone: 'America/Bogota',
|
|
30
|
+
person: { name: 'Dra. Ana Ruiz', role: 'Odontología' },
|
|
31
|
+
bookingMode: 'IN_PERSON',
|
|
32
|
+
eventTemplateName: 'Valoración',
|
|
33
|
+
reschedule: { apiKey: 'form-key', idStep: 'booking', person: 'person-1', durationMinutes: 30 },
|
|
34
|
+
};
|
|
35
|
+
function renderPage() {
|
|
36
|
+
render(_jsx(BookingManagePage, { token: "link-1", idOrganization: "clinica" }));
|
|
37
|
+
}
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
for (const fn of Object.values(service))
|
|
40
|
+
fn.mockReset();
|
|
41
|
+
service.fetchBookingAppointment.mockResolvedValue(APPOINTMENT);
|
|
42
|
+
});
|
|
43
|
+
describe('BookingManagePage', () => {
|
|
44
|
+
it('shows the appointment in the organization’s clock, with who and how', async () => {
|
|
45
|
+
renderPage();
|
|
46
|
+
expect(await screen.findByText('Martes 15 de octubre')).toBeInTheDocument();
|
|
47
|
+
expect(screen.getByText(/^09:00–09:30 \(hora de Bogotá \(GMT-5\)\)$/)).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText('Dra. Ana Ruiz, Odontología')).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByText('Valoración')).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByText('Presencial')).toBeInTheDocument();
|
|
51
|
+
expect(service.fetchBookingAppointment).toHaveBeenCalledWith('link-1', 'clinica');
|
|
52
|
+
});
|
|
53
|
+
it('says when the link is no good', async () => {
|
|
54
|
+
service.fetchBookingAppointment.mockRejectedValue({
|
|
55
|
+
response: { status: 404, data: { code: 'INVALID_LINK', message: 'Este enlace ya no es válido.' } },
|
|
56
|
+
});
|
|
57
|
+
renderPage();
|
|
58
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Este enlace ya no es válido.');
|
|
59
|
+
});
|
|
60
|
+
it('cancels only after the requester confirms, and then shows it cancelled', async () => {
|
|
61
|
+
const user = userEvent.setup();
|
|
62
|
+
service.cancelBookingAppointment.mockResolvedValue({
|
|
63
|
+
...APPOINTMENT,
|
|
64
|
+
status: 'CANCELLED',
|
|
65
|
+
reschedule: undefined,
|
|
66
|
+
});
|
|
67
|
+
renderPage();
|
|
68
|
+
await user.click(await screen.findByRole('button', { name: 'Cancelar cita' }));
|
|
69
|
+
const dialog = await screen.findByRole('dialog');
|
|
70
|
+
expect(within(dialog).getByText(/Liberaremos este horario y avisaremos a Dra. Ana Ruiz/)).toBeInTheDocument();
|
|
71
|
+
await user.click(within(dialog).getByRole('button', { name: 'Volver' }));
|
|
72
|
+
expect(service.cancelBookingAppointment).not.toHaveBeenCalled();
|
|
73
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
74
|
+
await user.click(screen.getByRole('button', { name: 'Cancelar cita' }));
|
|
75
|
+
await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Sí, cancelar' }));
|
|
76
|
+
expect(service.cancelBookingAppointment).toHaveBeenCalledWith('link-1', 'clinica');
|
|
77
|
+
expect(await screen.findByText('Esta cita está cancelada.')).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByText(/Tu cita quedó cancelada/)).toBeInTheDocument();
|
|
79
|
+
expect(screen.queryByRole('button', { name: 'Cancelar cita' })).not.toBeInTheDocument();
|
|
80
|
+
expect(screen.queryByRole('button', { name: 'Elegir otra hora' })).not.toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
it('keeps the dialog open with the reason when the cancellation is refused', async () => {
|
|
83
|
+
const user = userEvent.setup();
|
|
84
|
+
service.cancelBookingAppointment.mockRejectedValue({
|
|
85
|
+
response: { status: 409, data: { code: 'APPOINTMENT_CLOSED', message: 'Esta cita ya fue cancelada' } },
|
|
86
|
+
});
|
|
87
|
+
renderPage();
|
|
88
|
+
await user.click(await screen.findByRole('button', { name: 'Cancelar cita' }));
|
|
89
|
+
await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Sí, cancelar' }));
|
|
90
|
+
expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent('Esta cita ya fue cancelada');
|
|
91
|
+
});
|
|
92
|
+
it('moves the appointment to another hour of the same person, picked from the step’s offer', async () => {
|
|
93
|
+
const user = userEvent.setup();
|
|
94
|
+
const start = new Date(`${DAY_KEY}T15:00:00.000Z`).toISOString();
|
|
95
|
+
const end = new Date(`${DAY_KEY}T15:30:00.000Z`).toISOString();
|
|
96
|
+
service.fetchBookingOffer.mockResolvedValue({ days: [DAY_KEY] });
|
|
97
|
+
service.fetchBookingSlots.mockResolvedValue([{ start, end, token: 'slot-10' }]);
|
|
98
|
+
const moved = { ...APPOINTMENT, token: 'link-2', start, end };
|
|
99
|
+
service.moveBookingAppointment.mockResolvedValue(moved);
|
|
100
|
+
renderPage();
|
|
101
|
+
await user.click(await screen.findByRole('button', { name: 'Elegir otra hora' }));
|
|
102
|
+
await user.click(await screen.findByRole('button', { name: format(TOMORROW, "EEEE d 'de' MMMM 'de' yyyy", { locale: es }) }));
|
|
103
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
104
|
+
await user.click(await screen.findByRole('button', { name: /^10:00/ }));
|
|
105
|
+
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
|
106
|
+
expect(await screen.findByRole('heading', { name: 'Revisa la nueva hora' })).toBeInTheDocument();
|
|
107
|
+
expect(screen.queryByLabelText(/Nombre/)).not.toBeInTheDocument();
|
|
108
|
+
await user.click(screen.getByRole('button', { name: 'Cambiar la hora' }));
|
|
109
|
+
const route = { apiKey: 'form-key', idStep: 'booking', idOrganization: 'clinica' };
|
|
110
|
+
expect(service.fetchBookingOffer).toHaveBeenCalledWith(route, { person: 'person-1' });
|
|
111
|
+
expect(service.fetchBookingSlots).toHaveBeenCalledWith(route, DAY_KEY, { person: 'person-1' });
|
|
112
|
+
expect(service.moveBookingAppointment).toHaveBeenCalledWith('link-1', 'slot-10', 'clinica');
|
|
113
|
+
expect(await screen.findByText(`Listo. Tu cita quedó para el ${appointmentWhen(moved)}. Te enviamos la confirmación por correo.`)).toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
it('reloads the appointment for a fresh person handle when the offer says it went stale', async () => {
|
|
116
|
+
service.fetchBookingOffer
|
|
117
|
+
.mockRejectedValueOnce(new BookingEdgeError('Esa persona ya no está disponible', BookingErrorCodes.STALE_OFFER, 403))
|
|
118
|
+
.mockResolvedValue({ days: [DAY_KEY] });
|
|
119
|
+
service.fetchBookingAppointment
|
|
120
|
+
.mockResolvedValueOnce(APPOINTMENT)
|
|
121
|
+
.mockResolvedValue({ ...APPOINTMENT, reschedule: { ...APPOINTMENT.reschedule, person: 'person-2' } });
|
|
122
|
+
const user = userEvent.setup();
|
|
123
|
+
renderPage();
|
|
124
|
+
await user.click(await screen.findByRole('button', { name: 'Elegir otra hora' }));
|
|
125
|
+
await waitFor(() => expect(service.fetchBookingOffer).toHaveBeenLastCalledWith(expect.anything(), { person: 'person-2' }));
|
|
126
|
+
});
|
|
127
|
+
it('names the event template with its duration and keeps it when offering another hour', async () => {
|
|
128
|
+
const user = userEvent.setup();
|
|
129
|
+
service.fetchBookingAppointment.mockResolvedValue({
|
|
130
|
+
...APPOINTMENT,
|
|
131
|
+
eventTemplate: { name: 'Valoración', durationMinutes: 45 },
|
|
132
|
+
reschedule: { ...APPOINTMENT.reschedule, durationMinutes: 45, eventTemplate: 'template-1' },
|
|
133
|
+
});
|
|
134
|
+
service.fetchBookingOffer.mockResolvedValue({ days: [DAY_KEY] });
|
|
135
|
+
renderPage();
|
|
136
|
+
expect(await screen.findByText('Valoración (45 min)')).toBeInTheDocument();
|
|
137
|
+
await user.click(screen.getByRole('button', { name: 'Elegir otra hora' }));
|
|
138
|
+
await waitFor(() => expect(service.fetchBookingOffer).toHaveBeenCalledWith(expect.anything(), {
|
|
139
|
+
person: 'person-1',
|
|
140
|
+
eventTemplate: 'template-1',
|
|
141
|
+
}));
|
|
142
|
+
});
|
|
143
|
+
it('offers no other hour when the appointment cannot be moved from here', async () => {
|
|
144
|
+
service.fetchBookingAppointment.mockResolvedValue({ ...APPOINTMENT, reschedule: undefined });
|
|
145
|
+
renderPage();
|
|
146
|
+
expect(await screen.findByRole('button', { name: 'Cancelar cita' })).toBeInTheDocument();
|
|
147
|
+
expect(screen.queryByRole('button', { name: 'Elegir otra hora' })).not.toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { AppProps } from '../../App/App';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
/** Lo que responde el envío del formulario. */
|
|
3
|
+
export interface TicketConfirmation {
|
|
4
|
+
case: string;
|
|
5
|
+
url: string;
|
|
6
|
+
idTicket: string;
|
|
7
|
+
/** Solo llega cuando el formulario tenía una cita reservada: si quedó confirmada al crear el caso. */
|
|
8
|
+
booking?: {
|
|
9
|
+
confirmed: boolean;
|
|
10
|
+
reason?: string;
|
|
7
11
|
};
|
|
12
|
+
}
|
|
13
|
+
/** Lo que se le dice a quien agenda cuando el caso se creó pero la hora reservada no se pudo confirmar. */
|
|
14
|
+
export declare const BOOKING_NOT_CONFIRMED_MESSAGE = "Tu solicitud qued\u00F3 registrada, pero no pudimos reservar la hora elegida. Te contactaremos para acordarla.";
|
|
15
|
+
interface ConfirmationDialogProps {
|
|
16
|
+
confirmation: TicketConfirmation;
|
|
8
17
|
customConfirmation?: AppProps['customConfirmation'];
|
|
9
18
|
onClose: () => void;
|
|
10
19
|
}
|
|
@@ -9,6 +9,8 @@ import { mapDraftEntities } from '../../Utils/DraftFunctions';
|
|
|
9
9
|
import FormContext from '../../Contexts/FormContext';
|
|
10
10
|
import CloseIcon from '../../Icons/CloseIcon';
|
|
11
11
|
import CheckCircleIcon from '../../Icons/CheckCircleIcon';
|
|
12
|
+
/** Lo que se le dice a quien agenda cuando el caso se creó pero la hora reservada no se pudo confirmar. */
|
|
13
|
+
export const BOOKING_NOT_CONFIRMED_MESSAGE = 'Tu solicitud quedó registrada, pero no pudimos reservar la hora elegida. Te contactaremos para acordarla.';
|
|
12
14
|
function ConfirmationDialog({ onClose, confirmation, customConfirmation }) {
|
|
13
15
|
const { formStyle, ...global } = useAppSelector((state) => state.global);
|
|
14
16
|
const form = useContext(FormContext);
|
|
@@ -38,6 +40,11 @@ function ConfirmationDialog({ onClose, confirmation, customConfirmation }) {
|
|
|
38
40
|
padding: widthStats.isMobile ? '5px 0px' : '20px 0px',
|
|
39
41
|
}, children: _jsx(Editor, { editorClassName: 'Erk-Forms-Confirmation', editorState: state, readOnly: true, toolbarHidden: true }) }));
|
|
40
42
|
};
|
|
43
|
+
const renderBookingNotice = () => {
|
|
44
|
+
if (confirmation.booking?.confirmed !== false)
|
|
45
|
+
return null;
|
|
46
|
+
return (_jsx("div", { className: styles.bookingNotice, role: "alert", children: BOOKING_NOT_CONFIRMED_MESSAGE }));
|
|
47
|
+
};
|
|
41
48
|
const renderLink = () => {
|
|
42
49
|
if (!global.confirmation.showLink)
|
|
43
50
|
return null;
|
|
@@ -59,7 +66,7 @@ function ConfirmationDialog({ onClose, confirmation, customConfirmation }) {
|
|
|
59
66
|
padding: widthStats.isMobile ? '0px 20px' : '0px 30px',
|
|
60
67
|
}, children: [_jsx("div", { className: styles.closeIcon, onClick: () => {
|
|
61
68
|
onClose();
|
|
62
|
-
}, children: _jsx(CloseIcon, { style: { fontSize: 'inherit' } }) }), !customConfirmation && (_jsxs("div", { className: styles.container, children: [renderIcon(), renderConfirmation(), renderLink()] })), customConfirmation?.({
|
|
69
|
+
}, children: _jsx(CloseIcon, { style: { fontSize: 'inherit' } }) }), customConfirmation && renderBookingNotice(), !customConfirmation && (_jsxs("div", { className: styles.container, children: [renderIcon(), renderBookingNotice(), renderConfirmation(), renderLink()] })), customConfirmation?.({
|
|
63
70
|
url: confirmation.url,
|
|
64
71
|
caseNumber: confirmation.case,
|
|
65
72
|
idTicket: confirmation.idTicket,
|
|
@@ -69,3 +69,15 @@
|
|
|
69
69
|
position: absolute;
|
|
70
70
|
cursor: pointer;
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
.bookingNotice {
|
|
74
|
+
margin: 10px auto 0;
|
|
75
|
+
max-width: 480px;
|
|
76
|
+
padding: 10px 14px;
|
|
77
|
+
border-radius: 10px;
|
|
78
|
+
background: #fff7ed;
|
|
79
|
+
border: 1px solid #f4d9b0;
|
|
80
|
+
color: #8a5a14;
|
|
81
|
+
font-size: 1rem;
|
|
82
|
+
text-align: center;
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import '@testing-library/jest-dom';
|
|
5
|
+
import ConfirmationDialog, { BOOKING_NOT_CONFIRMED_MESSAGE } from './ConfirmationDialog';
|
|
6
|
+
import { withFormStepProviders } from '../../FormSteps/@StoryDecorators/FormStepDecorator';
|
|
7
|
+
function renderDialog(confirmation) {
|
|
8
|
+
const Story = () => _jsx(ConfirmationDialog, { confirmation: confirmation, onClose: () => undefined });
|
|
9
|
+
render(withFormStepProviders(Story));
|
|
10
|
+
}
|
|
11
|
+
const TICKET = { case: 'ABC123', url: 'https://example.com/ABC123', idTicket: 't1' };
|
|
12
|
+
describe('ConfirmationDialog', () => {
|
|
13
|
+
it('says the hour was not booked when the ticket was created but the appointment was not confirmed', () => {
|
|
14
|
+
renderDialog({ ...TICKET, booking: { confirmed: false, reason: 'HOLD_EXPIRED' } });
|
|
15
|
+
expect(screen.getByRole('alert')).toHaveTextContent(BOOKING_NOT_CONFIRMED_MESSAGE);
|
|
16
|
+
});
|
|
17
|
+
it('adds nothing when the appointment was confirmed or the form had none', () => {
|
|
18
|
+
renderDialog({ ...TICKET, booking: { confirmed: true } });
|
|
19
|
+
expect(screen.queryByText(BOOKING_NOT_CONFIRMED_MESSAGE)).not.toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
});
|
package/dist/Form/Form.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import CBRFormStepTypes from '../constants/CBRFormStepTypes';
|
|
|
7
7
|
import { Form } from '../@Types';
|
|
8
8
|
import { FormStep } from '../@Types/FormStep';
|
|
9
9
|
import { CBRFormStep } from '../@Types/CBRFormStep';
|
|
10
|
+
import { FormSubmitError } from './SubmitError/SubmitErrorReason';
|
|
10
11
|
export interface StepDependency {
|
|
11
12
|
/** The id of the step before it was mapped */
|
|
12
13
|
idOriginal: string;
|
|
@@ -36,6 +37,8 @@ export interface FormComponentProps extends CustomAppProps {
|
|
|
36
37
|
/** Function called when users clicks submit */
|
|
37
38
|
onSubmit: (values: FieldValues) => Promise<any>;
|
|
38
39
|
scrollToTop?: () => void;
|
|
40
|
+
/** The last submission failed: the form shows why and offers to send it again. */
|
|
41
|
+
submitError?: FormSubmitError;
|
|
39
42
|
}
|
|
40
43
|
declare function FormComponent({ form, apiKey, reload, isWidget, branding, setSubmit, scrollToTop, customSteps, containerRef, customSubmit, customSubmitBtns, customConfirmation, }: BaseFormProps): JSX.Element;
|
|
41
44
|
export default FormComponent;
|
package/dist/Form/Form.js
CHANGED
|
@@ -6,7 +6,7 @@ import InternalFormStyle from '../constants/InternalFormStyle';
|
|
|
6
6
|
import ColumnForm from './FormTypes/ColumnForm/ColumnForm';
|
|
7
7
|
import StepperForm from './FormTypes/StepperForm/StepperForm';
|
|
8
8
|
import { FormStyleTypes, FormTypes } from '../constants/FormStepTypes';
|
|
9
|
-
import { calcValue } from './FormFunctions';
|
|
9
|
+
import { calcValue, resolveCaptchaToken } from './FormFunctions';
|
|
10
10
|
import { FormProvider, useForm } from 'react-hook-form';
|
|
11
11
|
import widgetInstance from '../Utils/AxiosWidget';
|
|
12
12
|
import axiosInstance from '../Utils/AxiosAPI';
|
|
@@ -19,6 +19,7 @@ import FormContext, { IdFormContext } from '../Contexts/FormContext';
|
|
|
19
19
|
import { getAppState } from '../Utils/store';
|
|
20
20
|
import LoginPage from '../Login/LoginPage';
|
|
21
21
|
import { focusStep } from '../States/SiteSlice';
|
|
22
|
+
import { toSubmitError } from './SubmitError/SubmitErrorReason';
|
|
22
23
|
function FormComponent({ form, apiKey, reload, isWidget, branding, setSubmit, scrollToTop, customSteps, containerRef, customSubmit, customSubmitBtns, customConfirmation, }) {
|
|
23
24
|
const { idOrganization, internal, postview, user } = useAppSelector((state) => state.global);
|
|
24
25
|
const formMethods = useForm({
|
|
@@ -29,6 +30,7 @@ function FormComponent({ form, apiKey, reload, isWidget, branding, setSubmit, sc
|
|
|
29
30
|
const recaptchaRef = useRef(null);
|
|
30
31
|
const dispatch = useAppDispatch();
|
|
31
32
|
const [showConfirmation, setShowConfirmation] = useState();
|
|
33
|
+
const [submitError, setSubmitError] = useState(undefined);
|
|
32
34
|
const idFocusStep = useAppSelector((state) => state.site.focusStep);
|
|
33
35
|
useEffect(() => {
|
|
34
36
|
if (idFocusStep) {
|
|
@@ -38,15 +40,17 @@ function FormComponent({ form, apiKey, reload, isWidget, branding, setSubmit, sc
|
|
|
38
40
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
41
|
}, [idFocusStep]);
|
|
40
42
|
const onSubmit = useCallback(async (values) => {
|
|
41
|
-
let token = null;
|
|
42
|
-
if (form.hasCaptcha && !internal && !customSubmit) {
|
|
43
|
-
token = recaptchaRef.current?.getValue();
|
|
44
|
-
token ?? (token = await recaptchaRef?.current.executeAsync());
|
|
45
|
-
}
|
|
46
43
|
const ApiKey = internal ? form.apiKey : apiKey;
|
|
47
44
|
if (!ApiKey && !customSubmit && !customSubmitBtns)
|
|
48
45
|
return;
|
|
46
|
+
setSubmitError(undefined);
|
|
49
47
|
try {
|
|
48
|
+
// Resolved inside the try so a captcha that cannot mint a token surfaces through the same
|
|
49
|
+
// error path as any other submit failure, rather than rejecting out of the handler and
|
|
50
|
+
// leaving the submitter with no feedback at all.
|
|
51
|
+
const token = form.hasCaptcha && !internal && !customSubmit
|
|
52
|
+
? await resolveCaptchaToken(recaptchaRef.current)
|
|
53
|
+
: null;
|
|
50
54
|
const state = await dispatch(getAppState({ idForm })).unwrap();
|
|
51
55
|
const newValues = {
|
|
52
56
|
...state.site.values.global,
|
|
@@ -76,7 +80,11 @@ function FormComponent({ form, apiKey, reload, isWidget, branding, setSubmit, sc
|
|
|
76
80
|
setShowConfirmation(resp?.data);
|
|
77
81
|
}
|
|
78
82
|
catch (error) {
|
|
79
|
-
|
|
83
|
+
// A host's own submit reports its failures itself; sending the ticket is ours to report.
|
|
84
|
+
if (customSubmit)
|
|
85
|
+
console.error(error);
|
|
86
|
+
else
|
|
87
|
+
setSubmitError(toSubmitError(error));
|
|
80
88
|
}
|
|
81
89
|
},
|
|
82
90
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -87,7 +95,7 @@ function FormComponent({ form, apiKey, reload, isWidget, branding, setSubmit, sc
|
|
|
87
95
|
return (_jsxs(React.Fragment, { children: [showConfirmation !== undefined && (_jsx(ConfirmationDialog, { confirmation: showConfirmation, onClose: () => {
|
|
88
96
|
reload();
|
|
89
97
|
setShowConfirmation(undefined);
|
|
90
|
-
}, customConfirmation: customConfirmation })), form.hasCaptcha && !internal && !postview && (_jsx(ReCAPTCHA, { ref: recaptchaRef, sitekey: "6LcL22kkAAAAAEotDeAFbRATob-u5vbibbCyWL2p", size: 'invisible', badge: 'bottomright' })), _jsx(FormProvider, { ...formMethods, children: _jsx(FormContext.Provider, { value: form, children: _jsx(FormTypeComponent, { onSubmit: onSubmit, setSubmit: setSubmit, scrollToTop: scrollToTop, customSubmitBtns: customSubmitBtns }) }) })] }));
|
|
98
|
+
}, customConfirmation: customConfirmation })), form.hasCaptcha && !internal && !postview && (_jsx(ReCAPTCHA, { ref: recaptchaRef, sitekey: "6LcL22kkAAAAAEotDeAFbRATob-u5vbibbCyWL2p", size: 'invisible', badge: 'bottomright' })), _jsx(FormProvider, { ...formMethods, children: _jsx(FormContext.Provider, { value: form, children: _jsx(FormTypeComponent, { onSubmit: onSubmit, setSubmit: setSubmit, scrollToTop: scrollToTop, customSubmitBtns: customSubmitBtns, submitError: submitError }) }) })] }));
|
|
91
99
|
};
|
|
92
100
|
if (!idOrganization)
|
|
93
101
|
return _jsx(_Fragment, {});
|
|
@@ -4,6 +4,23 @@ import { CustomStep } from '../FormSteps/CustomStep';
|
|
|
4
4
|
import { CBRFormStep } from '../@Types/CBRFormStep';
|
|
5
5
|
import { MapperElement } from '../@Types/MapperElement';
|
|
6
6
|
import { ValuesStore } from '../States/SiteSlice';
|
|
7
|
+
/** The reCAPTCHA widget surface a submission needs: the response it holds, and the challenge that mints one. */
|
|
8
|
+
export interface CaptchaWidget {
|
|
9
|
+
getValue(): string | null;
|
|
10
|
+
executeAsync(): Promise<string | null>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the reCAPTCHA response to send with a submission, running the invisible challenge when the widget
|
|
14
|
+
* is not holding one yet.
|
|
15
|
+
*
|
|
16
|
+
* An unsolved invisible widget answers `getValue()` with an EMPTY STRING rather than null, so emptiness —
|
|
17
|
+
* not nullishness — is what decides whether the challenge still has to run. A submission that skips the
|
|
18
|
+
* challenge carries no token, and the API rejects every captcha-protected public form with a 403.
|
|
19
|
+
*
|
|
20
|
+
* Yields null when no widget is mounted, leaving the caller to submit tokenless rather than throwing out of
|
|
21
|
+
* the submit handler.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveCaptchaToken(captcha: CaptchaWidget | null | undefined): Promise<string | null>;
|
|
7
24
|
/**
|
|
8
25
|
* Function that cals the value of a step to output on submit
|
|
9
26
|
*/
|
|
@@ -3,6 +3,28 @@ import FormStepTypes, { EntityValueDataTypes } from '../constants/FormStepTypes'
|
|
|
3
3
|
import { calcStepDeps } from '../FormSteps/StepHooks';
|
|
4
4
|
import { calcRecursiveData } from '../Utils/FormStepFunctions';
|
|
5
5
|
import { isValidPhoneNumber } from '../Utils/PhoneFunctions';
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the reCAPTCHA response to send with a submission, running the invisible challenge when the widget
|
|
8
|
+
* is not holding one yet.
|
|
9
|
+
*
|
|
10
|
+
* An unsolved invisible widget answers `getValue()` with an EMPTY STRING rather than null, so emptiness —
|
|
11
|
+
* not nullishness — is what decides whether the challenge still has to run. A submission that skips the
|
|
12
|
+
* challenge carries no token, and the API rejects every captcha-protected public form with a 403.
|
|
13
|
+
*
|
|
14
|
+
* Yields null when no widget is mounted, leaving the caller to submit tokenless rather than throwing out of
|
|
15
|
+
* the submit handler.
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveCaptchaToken(captcha) {
|
|
18
|
+
if (!captcha)
|
|
19
|
+
return null;
|
|
20
|
+
const held = captcha.getValue();
|
|
21
|
+
if (held)
|
|
22
|
+
return held;
|
|
23
|
+
const solved = await captcha.executeAsync();
|
|
24
|
+
if (solved)
|
|
25
|
+
return solved;
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
6
28
|
/**
|
|
7
29
|
* Function that cals the value of a step to output on submit
|
|
8
30
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/require-await */
|
|
2
2
|
import { ContentState, EditorState } from 'draft-js';
|
|
3
3
|
import FormStepTypes, { EntityValueDataTypes } from '../constants/FormStepTypes';
|
|
4
|
-
import { calcDependencies, calcValue } from './FormFunctions';
|
|
4
|
+
import { calcDependencies, calcValue, resolveCaptchaToken } from './FormFunctions';
|
|
5
5
|
import { stringToDraft } from '../Utils/DraftFunctions';
|
|
6
6
|
import { describe, expect, test } from 'vitest';
|
|
7
7
|
describe('FormFunctions', function () {
|
|
@@ -455,4 +455,31 @@ describe('FormFunctions', function () {
|
|
|
455
455
|
expect(dependencyStore.idStep.dependents.length).toBe(1);
|
|
456
456
|
});
|
|
457
457
|
});
|
|
458
|
+
describe('resolveCaptchaToken', function () {
|
|
459
|
+
test('Uses the token the widget already holds without re-running the challenge', async () => {
|
|
460
|
+
let executed = false;
|
|
461
|
+
const token = await resolveCaptchaToken({
|
|
462
|
+
getValue: () => 'already-solved',
|
|
463
|
+
executeAsync: async () => {
|
|
464
|
+
executed = true;
|
|
465
|
+
return 'freshly-solved';
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
expect(token).toBe('already-solved');
|
|
469
|
+
expect(executed).toBe(false);
|
|
470
|
+
});
|
|
471
|
+
test('Runs the challenge when the invisible widget reports an empty response', async () => {
|
|
472
|
+
// An unsolved invisible captcha answers `getValue()` with '' — not null — so emptiness is what
|
|
473
|
+
// must decide whether the challenge still has to run. Treating only nullish as unsolved leaves
|
|
474
|
+
// the submission tokenless and the API answers 403.
|
|
475
|
+
const token = await resolveCaptchaToken({
|
|
476
|
+
getValue: () => '',
|
|
477
|
+
executeAsync: async () => 'freshly-solved',
|
|
478
|
+
});
|
|
479
|
+
expect(token).toBe('freshly-solved');
|
|
480
|
+
});
|
|
481
|
+
test('Yields no token when the widget is not mounted', async () => {
|
|
482
|
+
expect(await resolveCaptchaToken(null)).toBeNull();
|
|
483
|
+
});
|
|
484
|
+
});
|
|
458
485
|
});
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { FormComponentProps } from '../../Form';
|
|
2
|
-
declare function ColumnForm({ onSubmit, customSubmitBtns, ...props }: FormComponentProps): JSX.Element;
|
|
2
|
+
declare function ColumnForm({ onSubmit, customSubmitBtns, submitError, ...props }: FormComponentProps): JSX.Element;
|
|
3
3
|
export default ColumnForm;
|
|
@@ -11,7 +11,8 @@ import CustomBtn from '../../../Shared/CustomBtn/CustomBtn';
|
|
|
11
11
|
import FormContext, { IdFormContext } from '../../../Contexts/FormContext';
|
|
12
12
|
import { addPagedValues } from '../../../States/SiteSlice';
|
|
13
13
|
import { getAppState } from '../../../Utils/store';
|
|
14
|
-
|
|
14
|
+
import SubmitError from '../../SubmitError/SubmitError';
|
|
15
|
+
function ColumnForm({ onSubmit, customSubmitBtns, submitError, ...props }) {
|
|
15
16
|
const [loading, setLoading] = useState(false);
|
|
16
17
|
const { postview, editable, formStyle } = useAppSelector((state) => state.global);
|
|
17
18
|
const { trigger, getValues, handleSubmit } = useFormContext();
|
|
@@ -49,6 +50,6 @@ function ColumnForm({ onSubmit, customSubmitBtns, ...props }) {
|
|
|
49
50
|
return values;
|
|
50
51
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
52
|
}, [form]);
|
|
52
|
-
return (_jsxs("div", { className: styles.container, style: { color: formStyle.textColor }, children: [loading && _jsx("div", { className: styles.curtain }), previous.map((idSection) => (_jsx(Section, { idSection: idSection }, idSection))), next.map((idSection) => (_jsx(Section, { idSection: idSection }, idSection))), _jsx(Terms, {}), !postview && editable && (_jsxs(React.Fragment, { children: [customSubmitBtns === undefined && (_jsx("div", { className: styles.submitBtnContainer, children: _jsx(CustomBtn, { onClick: () => void submit(), width: "calc(100% - 70px)", label: sendLabel ?? 'Enviar', backgroundColor: formStyle.primaryColor, color: formStyle.primaryContrastColor, loading: loading, "data-testid": "form__submit" }) })), customSubmitBtns?.(submit, loading)] }))] }));
|
|
53
|
+
return (_jsxs("div", { className: styles.container, style: { color: formStyle.textColor }, children: [loading && _jsx("div", { className: styles.curtain }), previous.map((idSection) => (_jsx(Section, { idSection: idSection }, idSection))), next.map((idSection) => (_jsx(Section, { idSection: idSection }, idSection))), _jsx(Terms, {}), !postview && editable && (_jsxs(React.Fragment, { children: [customSubmitBtns === undefined && (_jsx("div", { className: styles.submitBtnContainer, children: _jsx(CustomBtn, { onClick: () => void submit(), width: "calc(100% - 70px)", label: sendLabel ?? 'Enviar', backgroundColor: formStyle.primaryColor, color: formStyle.primaryContrastColor, loading: loading, "data-testid": "form__submit" }) })), customSubmitBtns?.(submit, loading), submitError && (_jsx(SubmitError, { error: submitError, disabled: loading, onRetry: () => void submit() }))] }))] }));
|
|
53
54
|
}
|
|
54
55
|
export default ColumnForm;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { FormComponentProps } from '../../Form';
|
|
2
|
-
declare function StepperForm({ onSubmit, scrollToTop }: FormComponentProps): JSX.Element;
|
|
2
|
+
declare function StepperForm({ onSubmit, scrollToTop, submitError }: FormComponentProps): JSX.Element;
|
|
3
3
|
export default StepperForm;
|
|
@@ -11,7 +11,8 @@ import { getAppState } from '../../../Utils/store';
|
|
|
11
11
|
import CustomBtn from '../../../Shared/CustomBtn/CustomBtn';
|
|
12
12
|
import FormContext, { IdFormContext } from '../../../Contexts/FormContext';
|
|
13
13
|
import { addPagedValues } from '../../../States/SiteSlice';
|
|
14
|
-
|
|
14
|
+
import SubmitError from '../../SubmitError/SubmitError';
|
|
15
|
+
function StepperForm({ onSubmit, scrollToTop, submitError }) {
|
|
15
16
|
const [loading, setLoading] = useState(undefined);
|
|
16
17
|
const { postview, editable, formStyle } = useAppSelector((state) => state.global);
|
|
17
18
|
const { trigger, getValues, handleSubmit } = useFormContext();
|
|
@@ -62,6 +63,6 @@ function StepperForm({ onSubmit, scrollToTop }) {
|
|
|
62
63
|
}, [next]);
|
|
63
64
|
if (!idCurrent)
|
|
64
65
|
return _jsx(_Fragment, {});
|
|
65
|
-
return (_jsxs("div", { className: styles.container, style: { color: formStyle.textColor }, children: [loading && _jsx("div", { className: styles.curtain }), _jsx(Section, { idSection: idCurrent }, idCurrent), next.length === 0 && _jsx(Terms, {}), _jsxs("div", { className: styles.btnsContainer, children: [previous.length > 0 && (_jsx(CustomBtn, { label: form.prevSectionLabel ?? 'Volver', "data-testid": "form__back", onClick: handlePrevious, backgroundColor: formStyle.primaryColor, color: formStyle.primaryContrastColor, disabled: !!loading, loading: loading === 'BACK' })), (next.length > 0 || (!postview && editable)) && (_jsx(CustomBtn, { label: next.length > 0 ? (form.nextSectionLabel ?? 'Siguiente') : (sendLabel ?? 'Enviar'), "data-testid": "form__submit", onClick: handleNext, backgroundColor: formStyle.primaryColor, color: formStyle.primaryContrastColor, disabled: !!loading, loading: loading === 'NEXT' }))] })] }));
|
|
66
|
+
return (_jsxs("div", { className: styles.container, style: { color: formStyle.textColor }, children: [loading && _jsx("div", { className: styles.curtain }), _jsx(Section, { idSection: idCurrent }, idCurrent), next.length === 0 && _jsx(Terms, {}), _jsxs("div", { className: styles.btnsContainer, children: [previous.length > 0 && (_jsx(CustomBtn, { label: form.prevSectionLabel ?? 'Volver', "data-testid": "form__back", onClick: handlePrevious, backgroundColor: formStyle.primaryColor, color: formStyle.primaryContrastColor, disabled: !!loading, loading: loading === 'BACK' })), (next.length > 0 || (!postview && editable)) && (_jsx(CustomBtn, { label: next.length > 0 ? (form.nextSectionLabel ?? 'Siguiente') : (sendLabel ?? 'Enviar'), "data-testid": "form__submit", onClick: handleNext, backgroundColor: formStyle.primaryColor, color: formStyle.primaryContrastColor, disabled: !!loading, loading: loading === 'NEXT' }))] }), next.length === 0 && submitError && (_jsx(SubmitError, { error: submitError, disabled: !!loading, onRetry: () => void handleNext() }))] }));
|
|
66
67
|
}
|
|
67
68
|
export default StepperForm;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { FormSubmitError } from './SubmitErrorReason';
|
|
2
|
+
interface SubmitErrorProps {
|
|
3
|
+
error: FormSubmitError;
|
|
4
|
+
/** Sends the form again, through the same validation as the submit button. */
|
|
5
|
+
onRetry: () => void;
|
|
6
|
+
/** While the form is being sent again. */
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** The notice under the submit button after a failed submission, with "Reintentar". */
|
|
10
|
+
declare function SubmitError({ error, onRetry, disabled }: SubmitErrorProps): JSX.Element;
|
|
11
|
+
export default SubmitError;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import ErkButton from '../../Shared/ErkButton/ErkButton';
|
|
3
|
+
import styles from './SubmitError.module.css';
|
|
4
|
+
import { SUBMIT_ERROR_MESSAGE } from './SubmitErrorReason';
|
|
5
|
+
/** The notice under the submit button after a failed submission, with "Reintentar". */
|
|
6
|
+
function SubmitError({ error, onRetry, disabled = false }) {
|
|
7
|
+
return (_jsxs("div", { className: styles.container, role: "alert", children: [_jsxs("div", { className: styles.text, children: [_jsx("span", { children: SUBMIT_ERROR_MESSAGE }), error.detail && _jsx("span", { className: styles.detail, children: error.detail })] }), _jsx(ErkButton, { text: "Reintentar", variant: "outlined", disabled: disabled, onClick: onRetry })] }));
|
|
8
|
+
}
|
|
9
|
+
export default SubmitError;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
flex-wrap: wrap;
|
|
5
|
+
gap: 10px 16px;
|
|
6
|
+
margin: 12px auto 0;
|
|
7
|
+
padding: 12px 16px;
|
|
8
|
+
max-width: calc(100% - 70px);
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
border: 1px solid #f1c2bf;
|
|
11
|
+
border-radius: 10px;
|
|
12
|
+
background: #fdf1f0;
|
|
13
|
+
color: #b3261e;
|
|
14
|
+
font-size: 14px;
|
|
15
|
+
}
|
|
16
|
+
.text {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-direction: column;
|
|
19
|
+
gap: 2px;
|
|
20
|
+
flex: 1 1 220px;
|
|
21
|
+
}
|
|
22
|
+
.detail {
|
|
23
|
+
font-size: 13px;
|
|
24
|
+
color: #7a2a24;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import '@testing-library/jest-dom';
|
|
6
|
+
import SubmitError from './SubmitError';
|
|
7
|
+
import { SUBMIT_ERROR_MESSAGE, toSubmitError } from './SubmitErrorReason';
|
|
8
|
+
import { withFormStepProviders } from '../../FormSteps/@StoryDecorators/FormStepDecorator';
|
|
9
|
+
describe('toSubmitError', () => {
|
|
10
|
+
it('keeps the reason of a 4xx answer, as text or as { message }', () => {
|
|
11
|
+
expect(toSubmitError({ response: { status: 409, data: 'La reserva expiró; elige un horario nuevamente' } })).toEqual({ detail: 'La reserva expiró; elige un horario nuevamente' });
|
|
12
|
+
expect(toSubmitError({ response: { status: 422, data: { message: 'Falta el correo' } } })).toEqual({
|
|
13
|
+
detail: 'Falta el correo',
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
it('says nothing more for a network failure or a server error', () => {
|
|
17
|
+
expect(toSubmitError(new Error('Network Error'))).toEqual({});
|
|
18
|
+
expect(toSubmitError({ response: { status: 500, data: 'Internal Server Error' } })).toEqual({});
|
|
19
|
+
expect(toSubmitError({ response: { status: 409, data: ' ' } })).toEqual({});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('SubmitError', () => {
|
|
23
|
+
it('tells the requester the form was not sent, with the reason, and sends it again on retry', async () => {
|
|
24
|
+
const user = userEvent.setup();
|
|
25
|
+
const onRetry = vi.fn();
|
|
26
|
+
const Story = () => _jsx(SubmitError, { error: { detail: 'La reserva expiró' }, onRetry: onRetry });
|
|
27
|
+
render(withFormStepProviders(Story));
|
|
28
|
+
const alert = screen.getByRole('alert');
|
|
29
|
+
expect(alert).toHaveTextContent(SUBMIT_ERROR_MESSAGE);
|
|
30
|
+
expect(alert).toHaveTextContent('La reserva expiró');
|
|
31
|
+
await user.click(screen.getByRole('button', { name: 'Reintentar' }));
|
|
32
|
+
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** What the requester is told when sending the form fails; what they filled in stays on screen. */
|
|
2
|
+
export declare const SUBMIT_ERROR_MESSAGE = "No pudimos enviar el formulario. Int\u00E9ntalo de nuevo.";
|
|
3
|
+
/** A failed submission, as the form shows it. */
|
|
4
|
+
export interface FormSubmitError {
|
|
5
|
+
/** The server's own reason, when it gave one the requester can act on (e.g. a booking that expired). */
|
|
6
|
+
detail?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Reads a failed submission. A 4xx answer whose body is a message (plain text or `{ message }`) is the
|
|
10
|
+
* server explaining what to fix, so it is kept; anything else (network, 5xx, captcha) is only the
|
|
11
|
+
* generic message.
|
|
12
|
+
*/
|
|
13
|
+
export declare function toSubmitError(error: unknown): FormSubmitError;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** What the requester is told when sending the form fails; what they filled in stays on screen. */
|
|
2
|
+
export const SUBMIT_ERROR_MESSAGE = 'No pudimos enviar el formulario. Inténtalo de nuevo.';
|
|
3
|
+
/**
|
|
4
|
+
* Reads a failed submission. A 4xx answer whose body is a message (plain text or `{ message }`) is the
|
|
5
|
+
* server explaining what to fix, so it is kept; anything else (network, 5xx, captcha) is only the
|
|
6
|
+
* generic message.
|
|
7
|
+
*/
|
|
8
|
+
export function toSubmitError(error) {
|
|
9
|
+
const response = error?.response;
|
|
10
|
+
const status = response?.status;
|
|
11
|
+
if (!status || status < 400 || status >= 500)
|
|
12
|
+
return {};
|
|
13
|
+
const data = response.data;
|
|
14
|
+
const message = typeof data === 'string'
|
|
15
|
+
? data
|
|
16
|
+
: data && typeof data === 'object'
|
|
17
|
+
? data.message
|
|
18
|
+
: undefined;
|
|
19
|
+
return typeof message === 'string' && message.trim() ? { detail: message.trim() } : {};
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { AvailabilityStep } from '../../@Types/CalendarFormStep';
|
|
2
|
+
import { StepProps } from '../Step';
|
|
3
|
+
export interface AvailabilityStepProps extends StepProps {
|
|
4
|
+
step: AvailabilityStep;
|
|
5
|
+
}
|
|
6
|
+
declare function AvailabilityStepComponent(props: AvailabilityStepProps): JSX.Element;
|
|
7
|
+
export default AvailabilityStepComponent;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { FormStyleTypes } from '../../constants/FormStepTypes';
|
|
3
|
+
import MaterialAvailabilityStep from './MaterialAvailabilityStep/MaterialAvailabilityStep';
|
|
4
|
+
import { useAppSelector } from '../../hooks';
|
|
5
|
+
function AvailabilityStepComponent(props) {
|
|
6
|
+
const { formStyle } = useAppSelector((state) => state.global);
|
|
7
|
+
switch (formStyle.type) {
|
|
8
|
+
case FormStyleTypes.MATERIAL:
|
|
9
|
+
default: {
|
|
10
|
+
return _jsx(MaterialAvailabilityStep, { ...props });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export default AvailabilityStepComponent;
|