@arquimedes.co/eureka-forms 3.0.71 → 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/dist/@Types/BookingFormStep.d.ts +70 -18
- package/dist/@Types/BookingFormStep.js +21 -0
- package/dist/App/App.submitError.test.d.ts +1 -0
- package/dist/App/App.submitError.test.js +53 -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 +9 -2
- 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/BookingStep/MaterialBookingStep/MaterialBookingStep.d.ts +8 -1
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.js +197 -41
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.test.d.ts +1 -0
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.test.js +275 -0
- package/dist/Services/BookingService.d.ts +159 -12
- package/dist/Services/BookingService.js +210 -12
- package/dist/Services/BookingService.test.js +130 -9
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.d.ts +63 -12
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.js +141 -36
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.module.css +41 -3
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.d.ts +11 -3
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.js +69 -9
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.test.js +13 -15
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.test.js +331 -62
- package/dist/main.js +4 -1
- package/package.json +1 -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
|
@@ -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) {
|
|
@@ -41,6 +43,7 @@ function FormComponent({ form, apiKey, reload, isWidget, branding, setSubmit, sc
|
|
|
41
43
|
const ApiKey = internal ? form.apiKey : apiKey;
|
|
42
44
|
if (!ApiKey && !customSubmit && !customSubmitBtns)
|
|
43
45
|
return;
|
|
46
|
+
setSubmitError(undefined);
|
|
44
47
|
try {
|
|
45
48
|
// Resolved inside the try so a captcha that cannot mint a token surfaces through the same
|
|
46
49
|
// error path as any other submit failure, rather than rejecting out of the handler and
|
|
@@ -77,7 +80,11 @@ function FormComponent({ form, apiKey, reload, isWidget, branding, setSubmit, sc
|
|
|
77
80
|
setShowConfirmation(resp?.data);
|
|
78
81
|
}
|
|
79
82
|
catch (error) {
|
|
80
|
-
|
|
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));
|
|
81
88
|
}
|
|
82
89
|
},
|
|
83
90
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -88,7 +95,7 @@ function FormComponent({ form, apiKey, reload, isWidget, branding, setSubmit, sc
|
|
|
88
95
|
return (_jsxs(React.Fragment, { children: [showConfirmation !== undefined && (_jsx(ConfirmationDialog, { confirmation: showConfirmation, onClose: () => {
|
|
89
96
|
reload();
|
|
90
97
|
setShowConfirmation(undefined);
|
|
91
|
-
}, 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 }) }) })] }));
|
|
92
99
|
};
|
|
93
100
|
if (!idOrganization)
|
|
94
101
|
return _jsx(_Fragment, {});
|
|
@@ -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
|
+
}
|
|
@@ -5,9 +5,16 @@ import { BookingStepProps } from '../BookingStep';
|
|
|
5
5
|
*
|
|
6
6
|
* Todo lo que el picker muestra sale de la oferta que publica el backend, no del paso guardado: las
|
|
7
7
|
* personas llegan como handles firmados (nunca ids de agente), los días son los que tienen cupo, y
|
|
8
|
-
* las horas se leen en la zona horaria de la organización.
|
|
8
|
+
* las horas se leen en la zona horaria de la organización. "Reservar" en el picker bloquea el slot;
|
|
9
9
|
* enviar el formulario es lo que lo convierte en cita, y "Cambiar" lo devuelve a la oferta.
|
|
10
10
|
*
|
|
11
|
+
* El contacto de la reserva: el nombre sale de un paso de nombre ya diligenciado o lo pide el picker;
|
|
12
|
+
* el correo sale del paso que nombra `contactEmail` (`STEP`) o lo pide el picker (`FIELD`, o si ese
|
|
13
|
+
* paso quedó vacío).
|
|
14
|
+
*
|
|
15
|
+
* Con varios tipos de atención y `requesterPicksType`, quien agenda elige primero el tipo: los días,
|
|
16
|
+
* las horas y la reserva se piden con su handle (`eventTemplate`) y la duración es la del tipo.
|
|
17
|
+
*
|
|
11
18
|
* Un host que pase `fetchSlots` sustituye al borde por completo (historias, previsualizaciones).
|
|
12
19
|
*/
|
|
13
20
|
declare function MaterialBookingStep({ step, editable, fetchSlots: customFetchSlots }: BookingStepProps): JSX.Element;
|