@arquimedes.co/eureka-forms 3.0.50-test → 3.0.52-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/App/App.stories.d.ts +30 -0
- package/dist/App/App.stories.js +298 -0
- package/dist/App/App.stories.test.d.ts +1 -0
- package/dist/App/App.stories.test.js +56 -0
- package/dist/FormSteps/DatePickerStep/DatePickerStep.required.test.d.ts +1 -0
- package/dist/FormSteps/DatePickerStep/DatePickerStep.required.test.js +94 -0
- package/dist/FormSteps/TitleStep/MaterialTitleStep/MaterialTitleStep.js +3 -1
- package/dist/Shared/ErkAutocompleteOption.d.ts +12 -0
- package/dist/Shared/ErkAutocompleteOption.js +37 -0
- package/dist/Shared/ErkDatePicker/ErkDatePicker.js +2 -7
- package/dist/Shared/ErkDatePicker/ErkDatePicker.required.test.d.ts +1 -0
- package/dist/Shared/ErkDatePicker/ErkDatePicker.required.test.js +30 -0
- package/dist/Shared/ErkSmartSelect/ErkSmartSelect.js +2 -1
- package/dist/Shared/ErkSmartSelect/ErkSmartSelect.test.d.ts +1 -0
- package/dist/Shared/ErkSmartSelect/ErkSmartSelect.test.js +33 -0
- package/dist/Shared/SmartDraftRenderer/SmartDraftRenderer.d.ts +7 -1
- package/dist/Shared/SmartDraftRenderer/SmartDraftRenderer.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import App from './App';
|
|
4
|
+
declare function FullHeight(Story: React.ComponentType): JSX.Element;
|
|
5
|
+
declare const meta: {
|
|
6
|
+
title: string;
|
|
7
|
+
component: typeof App;
|
|
8
|
+
decorators: (typeof FullHeight)[];
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: string;
|
|
11
|
+
};
|
|
12
|
+
args: {
|
|
13
|
+
isWidget: false;
|
|
14
|
+
idOrganization: string;
|
|
15
|
+
valuesData: {};
|
|
16
|
+
customSubmit: (values: Record<string, unknown>) => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
/** Parsed form, as the widget receives it from the API. */
|
|
22
|
+
export declare const Parseado: Story;
|
|
23
|
+
/** TITLE with an INTEGRATION entity depending on a TEXTINPUT step. */
|
|
24
|
+
export declare const IntegracionConStepDependencia: Story;
|
|
25
|
+
/** Parsed form pre-filled with values. */
|
|
26
|
+
export declare const ParseadoConValores: Story;
|
|
27
|
+
/** Raw builder form: EUREKA entities resolved locally via mapDraftEntities. */
|
|
28
|
+
export declare const PreParseado: Story;
|
|
29
|
+
/** Final view (postview): what the user submitted, rendered read-only. */
|
|
30
|
+
export declare const Postview: Story;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import App from './App';
|
|
3
|
+
/**
|
|
4
|
+
* Stories to test full forms from a `{ form, values }` payload:
|
|
5
|
+
*
|
|
6
|
+
* - `Parseado`: a parsed form (what the widget receives, drafts are plain text).
|
|
7
|
+
* - `PreParseado`: a raw builder form (EUREKA/ENTITYVALUES entities still in the
|
|
8
|
+
* drafts, `rootSteps` instead of section steps). Entities are resolved locally
|
|
9
|
+
* with a stubbed `mapDraftEntities`, so no backend is needed.
|
|
10
|
+
*
|
|
11
|
+
* Paste any form JSON into `formData`/`valuesData` from the Controls panel.
|
|
12
|
+
*/
|
|
13
|
+
/** Raw builder form (pre-parsed): title draft still has EUREKA entities. */
|
|
14
|
+
const preParsedForm = {
|
|
15
|
+
steps: {
|
|
16
|
+
'TITLE-NkSFXOcH-z': {
|
|
17
|
+
id: 'TITLE-NkSFXOcH-z',
|
|
18
|
+
type: 'TITLE',
|
|
19
|
+
title: {
|
|
20
|
+
blocks: [
|
|
21
|
+
{
|
|
22
|
+
key: '2ri0w',
|
|
23
|
+
type: 'unstyled',
|
|
24
|
+
text: 'Razón social (Proveedor) ',
|
|
25
|
+
depth: 0,
|
|
26
|
+
inlineStyleRanges: [{ offset: 0, length: 24, style: 'BOLD' }],
|
|
27
|
+
entityRanges: [{ offset: 0, length: 24, key: 0 }],
|
|
28
|
+
data: {},
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
entityMap: {
|
|
32
|
+
'0': {
|
|
33
|
+
type: 'EUREKA',
|
|
34
|
+
mutability: 'IMMUTABLE',
|
|
35
|
+
data: {
|
|
36
|
+
type: 'ENTITYVALUES',
|
|
37
|
+
idEntity: '6400adf810f16b8b68c24e72',
|
|
38
|
+
renderType: 'INLINE',
|
|
39
|
+
block: {
|
|
40
|
+
key: '2522g',
|
|
41
|
+
type: 'unstyled',
|
|
42
|
+
text: 'Razón social (Proveedor)',
|
|
43
|
+
depth: 0,
|
|
44
|
+
inlineStyleRanges: [{ offset: 0, length: 24, style: 'BOLD' }],
|
|
45
|
+
entityRanges: [{ offset: 0, length: 24, key: 0 }],
|
|
46
|
+
data: {},
|
|
47
|
+
},
|
|
48
|
+
entityMap: {
|
|
49
|
+
'0': {
|
|
50
|
+
type: 'EUREKA',
|
|
51
|
+
mutability: 'IMMUTABLE',
|
|
52
|
+
data: {
|
|
53
|
+
type: 'ENTITYVALUE_MAPPING',
|
|
54
|
+
idEntity: '6400adf810f16b8b68c24e72',
|
|
55
|
+
idProperty: 'NombreCompleto',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
'TEXTINPUT-lCmhOv5hYA': {
|
|
65
|
+
id: 'TEXTINPUT-lCmhOv5hYA',
|
|
66
|
+
type: 'TEXTINPUT',
|
|
67
|
+
label: 'name',
|
|
68
|
+
description: '',
|
|
69
|
+
required: false,
|
|
70
|
+
size: 2,
|
|
71
|
+
isSubject: false,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
size: {
|
|
75
|
+
blockNum: 4,
|
|
76
|
+
blockSize: 210,
|
|
77
|
+
spacingSize: 20,
|
|
78
|
+
},
|
|
79
|
+
rootSteps: ['TITLE-NkSFXOcH-z', 'TEXTINPUT-lCmhOv5hYA'],
|
|
80
|
+
label: 'Enviar',
|
|
81
|
+
firstSection: 'FIRST',
|
|
82
|
+
sections: {
|
|
83
|
+
FIRST: {
|
|
84
|
+
id: 'FIRST',
|
|
85
|
+
steps: ['TITLE-NkSFXOcH-z', 'TEXTINPUT-lCmhOv5hYA'],
|
|
86
|
+
name: '',
|
|
87
|
+
nextSection: null,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
/** The same form already parsed: drafts are plain text, no pending entities. */
|
|
92
|
+
const parsedForm = {
|
|
93
|
+
...preParsedForm,
|
|
94
|
+
steps: {
|
|
95
|
+
...preParsedForm.steps,
|
|
96
|
+
'TITLE-NkSFXOcH-z': {
|
|
97
|
+
id: 'TITLE-NkSFXOcH-z',
|
|
98
|
+
type: 'TITLE',
|
|
99
|
+
title: {
|
|
100
|
+
blocks: [
|
|
101
|
+
{
|
|
102
|
+
key: '2ri0w',
|
|
103
|
+
type: 'unstyled',
|
|
104
|
+
text: 'Razón social (Proveedor)',
|
|
105
|
+
depth: 0,
|
|
106
|
+
inlineStyleRanges: [{ offset: 0, length: 24, style: 'BOLD' }],
|
|
107
|
+
entityRanges: [],
|
|
108
|
+
data: {},
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
entityMap: {},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const exampleValues = {
|
|
117
|
+
'TEXTINPUT-lCmhOv5hYA': 'Capta S.A.S.',
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Local replacement for the backend `/draft/mapFormEntities` call: resolves a
|
|
121
|
+
* draft by dropping its EUREKA entities and keeping the literal text, so
|
|
122
|
+
* pre-parsed forms render in Storybook without a server.
|
|
123
|
+
*/
|
|
124
|
+
function makeStoryMapDraftEntities(form) {
|
|
125
|
+
return (property) => {
|
|
126
|
+
const path = property.split('.');
|
|
127
|
+
const stepProperty = path.pop();
|
|
128
|
+
const idStep = path[0];
|
|
129
|
+
const step = form.steps[idStep];
|
|
130
|
+
const draft = stepProperty ? step?.[stepProperty] : undefined;
|
|
131
|
+
if (!draft)
|
|
132
|
+
return Promise.reject(new Error(`Storybook mapDraftEntities: draft not found for "${property}"`));
|
|
133
|
+
return Promise.resolve({
|
|
134
|
+
blocks: draft.blocks.map((block) => ({ ...block, entityRanges: [] })),
|
|
135
|
+
entityMap: {},
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function FullHeight(Story) {
|
|
140
|
+
return (_jsx("div", { style: { height: '100vh', display: 'flex', flexDirection: 'column' }, children: _jsx(Story, {}) }));
|
|
141
|
+
}
|
|
142
|
+
const meta = {
|
|
143
|
+
title: 'Form/FormRenderer',
|
|
144
|
+
component: App,
|
|
145
|
+
decorators: [FullHeight],
|
|
146
|
+
parameters: { layout: 'fullscreen' },
|
|
147
|
+
args: {
|
|
148
|
+
isWidget: false,
|
|
149
|
+
idOrganization: 'storybook',
|
|
150
|
+
valuesData: {},
|
|
151
|
+
customSubmit: (values) => {
|
|
152
|
+
// eslint-disable-next-line no-console
|
|
153
|
+
console.log('Storybook submit:', values);
|
|
154
|
+
return Promise.resolve();
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
export default meta;
|
|
159
|
+
/** Parsed form, as the widget receives it from the API. */
|
|
160
|
+
export const Parseado = {
|
|
161
|
+
args: {
|
|
162
|
+
formData: parsedForm,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Regression: TITLE whose title draft has an INTEGRATION entity parameterized by a
|
|
167
|
+
* TEXTINPUT step (`dependencies: [idStep]`). Mimics the backend contract of
|
|
168
|
+
* `/draft/mapFormEntities` (`keepNotMapped`): with the dependency unset the entity
|
|
169
|
+
* can't resolve and comes back untouched; with a value the text is resolved and the
|
|
170
|
+
* entity dropped. Production crashed with React #185 (max update depth) on render.
|
|
171
|
+
*/
|
|
172
|
+
const integrationTitleForm = {
|
|
173
|
+
steps: {
|
|
174
|
+
'TITLE-w4yCyiB1LM': {
|
|
175
|
+
id: 'TITLE-w4yCyiB1LM',
|
|
176
|
+
type: 'TITLE',
|
|
177
|
+
dependencies: ['TEXTINPUT-bwKwg_jKzD'],
|
|
178
|
+
description: {
|
|
179
|
+
blocks: [
|
|
180
|
+
{
|
|
181
|
+
depth: 0,
|
|
182
|
+
data: {},
|
|
183
|
+
inlineStyleRanges: [],
|
|
184
|
+
text: '',
|
|
185
|
+
type: 'unstyled',
|
|
186
|
+
key: '3hfq7',
|
|
187
|
+
entityRanges: [],
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
entityMap: {},
|
|
191
|
+
},
|
|
192
|
+
title: {
|
|
193
|
+
blocks: [
|
|
194
|
+
{
|
|
195
|
+
depth: 0,
|
|
196
|
+
data: {},
|
|
197
|
+
inlineStyleRanges: [{ offset: 7, length: 31, style: 'BOLD' }],
|
|
198
|
+
text: 'Título Test Draft Integration Con Step ',
|
|
199
|
+
type: 'unstyled',
|
|
200
|
+
key: '0',
|
|
201
|
+
entityRanges: [{ offset: 7, length: 31, key: 0 }],
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
entityMap: {
|
|
205
|
+
'0': {
|
|
206
|
+
type: 'EUREKA',
|
|
207
|
+
mutability: 'IMMUTABLE',
|
|
208
|
+
data: {
|
|
209
|
+
type: 'INTEGRATION',
|
|
210
|
+
idIntegration: '6960368e749b73499854bcac',
|
|
211
|
+
values: { param: { type: 'STEP', idStep: 'TEXTINPUT-bwKwg_jKzD' } },
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
'TEXTINPUT-bwKwg_jKzD': {
|
|
218
|
+
id: 'TEXTINPUT-bwKwg_jKzD',
|
|
219
|
+
type: 'TEXTINPUT',
|
|
220
|
+
label: 'Campo',
|
|
221
|
+
description: '',
|
|
222
|
+
required: false,
|
|
223
|
+
size: 2,
|
|
224
|
+
isSubject: false,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
size: { blockNum: 4, blockSize: 210, spacingSize: 20 },
|
|
228
|
+
rootSteps: ['TITLE-w4yCyiB1LM', 'TEXTINPUT-bwKwg_jKzD'],
|
|
229
|
+
label: 'Enviar',
|
|
230
|
+
firstSection: 'FIRST',
|
|
231
|
+
sections: {
|
|
232
|
+
FIRST: {
|
|
233
|
+
id: 'FIRST',
|
|
234
|
+
steps: ['TITLE-w4yCyiB1LM', 'TEXTINPUT-bwKwg_jKzD'],
|
|
235
|
+
name: '',
|
|
236
|
+
nextSection: null,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
function makeIntegrationMapDraftEntities(form) {
|
|
241
|
+
return (property, dependencies) => {
|
|
242
|
+
const path = property.split('.');
|
|
243
|
+
const stepProperty = path.pop();
|
|
244
|
+
const idStep = path[0];
|
|
245
|
+
const step = form.steps[idStep];
|
|
246
|
+
const draft = stepProperty ? step?.[stepProperty] : undefined;
|
|
247
|
+
if (!draft)
|
|
248
|
+
return Promise.reject(new Error(`Storybook mapDraftEntities: draft not found for "${property}"`));
|
|
249
|
+
const depValue = dependencies['TEXTINPUT-bwKwg_jKzD'];
|
|
250
|
+
// Unresolvable integration (dependency null): keepNotMapped — return the draft as-is.
|
|
251
|
+
if (depValue == null || depValue === '')
|
|
252
|
+
return Promise.resolve(JSON.parse(JSON.stringify(draft)));
|
|
253
|
+
// Resolved: replace each entity range with the integration result, drop the entities.
|
|
254
|
+
return Promise.resolve({
|
|
255
|
+
blocks: draft.blocks.map((block) => {
|
|
256
|
+
let text = block.text;
|
|
257
|
+
for (const range of [...block.entityRanges].sort((a, b) => b.offset - a.offset)) {
|
|
258
|
+
const chars = [...text];
|
|
259
|
+
chars.splice(range.offset, range.length, ...`[${depValue}]`);
|
|
260
|
+
text = chars.join('');
|
|
261
|
+
}
|
|
262
|
+
return { ...block, text, entityRanges: [], inlineStyleRanges: [] };
|
|
263
|
+
}),
|
|
264
|
+
entityMap: {},
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** TITLE with an INTEGRATION entity depending on a TEXTINPUT step. */
|
|
269
|
+
export const IntegracionConStepDependencia = {
|
|
270
|
+
args: {
|
|
271
|
+
formData: integrationTitleForm,
|
|
272
|
+
mapDraftEntities: makeIntegrationMapDraftEntities(integrationTitleForm),
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
/** Parsed form pre-filled with values. */
|
|
276
|
+
export const ParseadoConValores = {
|
|
277
|
+
args: {
|
|
278
|
+
formData: parsedForm,
|
|
279
|
+
valuesData: exampleValues,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
/** Raw builder form: EUREKA entities resolved locally via mapDraftEntities. */
|
|
283
|
+
export const PreParseado = {
|
|
284
|
+
args: {
|
|
285
|
+
formData: preParsedForm,
|
|
286
|
+
mapDraftEntities: makeStoryMapDraftEntities(preParsedForm),
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
/** Final view (postview): what the user submitted, rendered read-only. */
|
|
290
|
+
export const Postview = {
|
|
291
|
+
args: {
|
|
292
|
+
formData: parsedForm,
|
|
293
|
+
valuesData: exampleValues,
|
|
294
|
+
postview: true,
|
|
295
|
+
internal: true,
|
|
296
|
+
editable: false,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { composeStories } from '@storybook/react-vite';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import '@testing-library/jest-dom';
|
|
7
|
+
import * as stories from './App.stories';
|
|
8
|
+
const { Parseado, ParseadoConValores, PreParseado, Postview, IntegracionConStepDependencia } = composeStories(stories);
|
|
9
|
+
describe('FormRenderer stories', () => {
|
|
10
|
+
it('renders the parsed form with its title and input', async () => {
|
|
11
|
+
const { container } = render(_jsx(Parseado, {}));
|
|
12
|
+
await waitFor(() => {
|
|
13
|
+
expect(container.textContent).toContain('Razón social');
|
|
14
|
+
});
|
|
15
|
+
expect(screen.getAllByText('name').length).toBeGreaterThan(0);
|
|
16
|
+
});
|
|
17
|
+
it('renders the parsed form pre-filled with values', async () => {
|
|
18
|
+
render(_jsx(ParseadoConValores, {}));
|
|
19
|
+
await waitFor(() => {
|
|
20
|
+
expect(screen.getByDisplayValue('Capta S.A.S.')).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
it('renders the pre-parsed form resolving EUREKA entities locally', async () => {
|
|
24
|
+
const { container } = render(_jsx(PreParseado, {}));
|
|
25
|
+
await waitFor(() => {
|
|
26
|
+
expect(container.textContent).toContain('Razón social');
|
|
27
|
+
});
|
|
28
|
+
expect(screen.getAllByText('name').length).toBeGreaterThan(0);
|
|
29
|
+
});
|
|
30
|
+
it('renders the postview with the submitted value', async () => {
|
|
31
|
+
render(_jsx(Postview, {}));
|
|
32
|
+
await waitFor(() => {
|
|
33
|
+
expect(screen.getByDisplayValue('Capta S.A.S.')).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
it('renders a title with an INTEGRATION entity depending on a step and reacts to typing', async () => {
|
|
37
|
+
const { container } = render(_jsx(IntegracionConStepDependencia, {}));
|
|
38
|
+
await waitFor(() => {
|
|
39
|
+
expect(container.textContent).toContain('Título');
|
|
40
|
+
});
|
|
41
|
+
const input = await screen.findByRole('textbox');
|
|
42
|
+
await userEvent.type(input, 'hola');
|
|
43
|
+
// The dependency dispatch is debounced (1s), so give the refetch room to land.
|
|
44
|
+
await waitFor(() => {
|
|
45
|
+
expect(container.textContent).toContain('[hola]');
|
|
46
|
+
}, { timeout: 5000 });
|
|
47
|
+
// Changing the dependency AFTER the first resolution must re-map the title:
|
|
48
|
+
// the stored value is a mapped copy with its entities stripped, so mapping
|
|
49
|
+
// has to keep keying off the original step draft.
|
|
50
|
+
await userEvent.clear(input);
|
|
51
|
+
await userEvent.type(input, 'chao');
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
expect(container.textContent).toContain('[chao]');
|
|
54
|
+
}, { timeout: 5000 });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import '@testing-library/jest-dom';
|
|
7
|
+
import { Provider } from 'react-redux';
|
|
8
|
+
import { useForm, FormProvider } from 'react-hook-form';
|
|
9
|
+
import { configureStore } from '@reduxjs/toolkit';
|
|
10
|
+
import { EurekaFormsReducer, defaultRootState } from '../../Utils/store';
|
|
11
|
+
import { RootApi } from '../../Utils/_api';
|
|
12
|
+
import FormStepTypes from '../../constants/FormStepTypes';
|
|
13
|
+
import { StoreContext } from '../../Utils/StoreContext';
|
|
14
|
+
import { IdFormContext } from '../../Contexts/FormContext';
|
|
15
|
+
import FormContext from '../../Contexts/FormContext';
|
|
16
|
+
import SectionContext from '../../Contexts/SectionContext';
|
|
17
|
+
import MaterialProviders from '../../Utils/MaterialProviders';
|
|
18
|
+
import InternalFormStyle from '../../constants/InternalFormStyle';
|
|
19
|
+
import DatePickerStep from './DatePickerStep';
|
|
20
|
+
const STORY_FORM_ID = 'STORY_FORM';
|
|
21
|
+
const requiredDateStep = {
|
|
22
|
+
id: 'datepicker-step',
|
|
23
|
+
idSection: 'SECTION_1',
|
|
24
|
+
stepPath: ['datepicker-step'],
|
|
25
|
+
type: FormStepTypes.DATEPICKER,
|
|
26
|
+
label: 'Fecha de nacimiento',
|
|
27
|
+
description: null,
|
|
28
|
+
required: true,
|
|
29
|
+
pickTime: false,
|
|
30
|
+
size: 4,
|
|
31
|
+
editable: true,
|
|
32
|
+
};
|
|
33
|
+
const form = {
|
|
34
|
+
firstSection: 'SECTION_1',
|
|
35
|
+
sections: {
|
|
36
|
+
SECTION_1: { id: 'SECTION_1', name: 'Sección 1', steps: [], nextSection: null },
|
|
37
|
+
},
|
|
38
|
+
steps: { 'datepicker-step': { ...requiredDateStep } },
|
|
39
|
+
confirmationMessage: { blocks: [], entityMap: {} },
|
|
40
|
+
showLink: false,
|
|
41
|
+
size: { blockSize: 200, blockNum: 4, spacingSize: 10 },
|
|
42
|
+
};
|
|
43
|
+
function makeStore() {
|
|
44
|
+
return configureStore({
|
|
45
|
+
reducer: { forms: EurekaFormsReducer, [RootApi.reducerPath]: RootApi.reducer },
|
|
46
|
+
preloadedState: { forms: { [STORY_FORM_ID]: defaultRootState } },
|
|
47
|
+
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(RootApi.middleware),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function Harness({ onValid, pickTime, required = true, onReady, }) {
|
|
51
|
+
const methods = useForm({ mode: 'onTouched' });
|
|
52
|
+
const step = { ...requiredDateStep, required, pickTime: !!pickTime };
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
onReady?.(methods);
|
|
55
|
+
}, [methods, onReady]);
|
|
56
|
+
return (_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(Provider, { store: makeStore(), context: StoreContext, children: _jsx(IdFormContext.Provider, { value: STORY_FORM_ID, children: _jsx(FormContext.Provider, { value: form, children: _jsx(SectionContext.Provider, { value: "SECTION_1", children: _jsx(FormProvider, { ...methods, children: _jsxs("form", { onSubmit: (e) => void methods.handleSubmit(onValid)(e), children: [_jsx(DatePickerStep, { step: step, editable: true }), _jsx("button", { type: "submit", children: "Enviar" })] }) }) }) }) }) }) }));
|
|
57
|
+
}
|
|
58
|
+
describe('DatePickerStep required validation', () => {
|
|
59
|
+
it('does not leak a defaultValue Date into the form value on mount', () => {
|
|
60
|
+
let methods;
|
|
61
|
+
render(_jsx(Harness, { onValid: vi.fn(), onReady: (m) => (methods = m) }));
|
|
62
|
+
expect(methods?.getValues('datepicker-step') ?? null).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
it('blocks submit and shows the obligatorio error when a required date is left empty (handleSubmit)', async () => {
|
|
65
|
+
const onValid = vi.fn();
|
|
66
|
+
render(_jsx(Harness, { onValid: onValid }));
|
|
67
|
+
await userEvent.click(screen.getByRole('button', { name: 'Enviar' }));
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(screen.getByText('Este campo es obligatorio')).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
expect(onValid).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
it('reports invalid via trigger() like the real ColumnForm submit path', async () => {
|
|
74
|
+
let methods;
|
|
75
|
+
render(_jsx(Harness, { onValid: vi.fn(), onReady: (m) => (methods = m) }));
|
|
76
|
+
const valid = await methods.trigger(undefined, { shouldFocus: true });
|
|
77
|
+
expect(valid).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
it('pickTime required: trigger() reports invalid when empty', async () => {
|
|
80
|
+
let methods;
|
|
81
|
+
render(_jsx(Harness, { onValid: vi.fn(), pickTime: true, onReady: (m) => (methods = m) }));
|
|
82
|
+
const valid = await methods.trigger(undefined, { shouldFocus: true });
|
|
83
|
+
expect(valid).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
it('control: a NON-required empty date submits (proves the required flag is the deciding factor)', async () => {
|
|
86
|
+
const onValid = vi.fn();
|
|
87
|
+
render(_jsx(Harness, { onValid: onValid, required: false }));
|
|
88
|
+
await userEvent.click(screen.getByRole('button', { name: 'Enviar' }));
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
expect(onValid).toHaveBeenCalledTimes(1);
|
|
91
|
+
});
|
|
92
|
+
expect(screen.queryByText('Este campo es obligatorio')).not.toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -19,8 +19,10 @@ function TitleStep({ step }) {
|
|
|
19
19
|
const size = step.size ?? form.size.blockNum;
|
|
20
20
|
const title = useMemo(() => calcBaseDraft(value?.title ?? step.title), //Usa por defecto lo que venga en el value, pero no escucha cambios
|
|
21
21
|
[step.title, value?.title]);
|
|
22
|
+
const sourceTitle = useMemo(() => calcBaseDraft(step.title), [step.title]);
|
|
22
23
|
const description = useMemo(() => calcBaseDraft(value?.description ?? step.description), //Usa por defecto lo que venga en el value, pero no escucha cambios
|
|
23
24
|
[step.description, value?.description]);
|
|
25
|
+
const sourceDescription = useMemo(() => calcBaseDraft(step.description), [step.description]);
|
|
24
26
|
const dependencies = useAppSelector((state) => selectDependencies(state, step.dependencies));
|
|
25
27
|
const stableDepsValues = useAppSelector((state) => (step.dependencies ?? []).map((id) => state.site.dependencies[id]?.value), shallowEqual);
|
|
26
28
|
const values = useMemo(() => {
|
|
@@ -37,7 +39,7 @@ function TitleStep({ step }) {
|
|
|
37
39
|
width: widthStats.currentBreakPoint <= size ? '100%' : calcStepWidth(size, form.size),
|
|
38
40
|
}, children: [_jsx("div", { className: styles.titleLbl, style: {
|
|
39
41
|
textAlign: widthStats.isMobile && widthStats.currentBreakPoint <= size ? 'center' : 'start',
|
|
40
|
-
}, children: _jsx(SmartDraftRenderer, { draft: title, dependencies: values, property: `${path}.title`, onChange: (title) => onChange({ ...value, title }) }) }), _jsx("div", { className: styles.descriptionPar, children: _jsx(SmartDraftRenderer, { draft: description, dependencies: values, margin: title ? '10px 0px' : '0px 0px 5px 0px', property: `${path}.description`, onChange: (description) => onChange({ ...value, description }) }) })] }));
|
|
42
|
+
}, children: _jsx(SmartDraftRenderer, { draft: title, sourceDraft: sourceTitle, dependencies: values, property: `${path}.title`, onChange: (title) => onChange({ ...value, title }) }) }), _jsx("div", { className: styles.descriptionPar, children: _jsx(SmartDraftRenderer, { draft: description, sourceDraft: sourceDescription, dependencies: values, margin: title ? '10px 0px' : '0px 0px 5px 0px', property: `${path}.description`, onChange: (description) => onChange({ ...value, description }) }) })] }));
|
|
41
43
|
}
|
|
42
44
|
export default TitleStep;
|
|
43
45
|
function calcBaseDraft(value) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Option element for the searchable `ErkSmartSelect` (MUI `Autocomplete`). Replaces MUI `MenuItem`
|
|
3
|
+
* in `renderOption`: v9's `MenuItem` requires a surrounding `MenuList` context (it throws without
|
|
4
|
+
* one), but an Autocomplete's listbox is a plain `<ul>`, so a styled `<li>` is used instead.
|
|
5
|
+
* Hover/focus styling comes from MUI's own `.Erk-MuiAutocomplete-option` rules (the option props
|
|
6
|
+
* carry that class); this just ports the `ErkMenuItem` look (padding, wrapping, leading-icon layout)
|
|
7
|
+
* and the selected background.
|
|
8
|
+
*/
|
|
9
|
+
declare const ErkAutocompleteOption: import("@emotion/styled").StyledComponent<import("@mui/system").MUIStyledCommonProps<import("@mui/material").Theme> & {
|
|
10
|
+
selected?: boolean;
|
|
11
|
+
}, import("react").DetailedHTMLProps<import("react").LiHTMLAttributes<HTMLLIElement>, HTMLLIElement>, {}>;
|
|
12
|
+
export default ErkAutocompleteOption;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { styled } from '@mui/material';
|
|
2
|
+
/**
|
|
3
|
+
* Option element for the searchable `ErkSmartSelect` (MUI `Autocomplete`). Replaces MUI `MenuItem`
|
|
4
|
+
* in `renderOption`: v9's `MenuItem` requires a surrounding `MenuList` context (it throws without
|
|
5
|
+
* one), but an Autocomplete's listbox is a plain `<ul>`, so a styled `<li>` is used instead.
|
|
6
|
+
* Hover/focus styling comes from MUI's own `.Erk-MuiAutocomplete-option` rules (the option props
|
|
7
|
+
* carry that class); this just ports the `ErkMenuItem` look (padding, wrapping, leading-icon layout)
|
|
8
|
+
* and the selected background.
|
|
9
|
+
*/
|
|
10
|
+
const ErkAutocompleteOption = styled('li', {
|
|
11
|
+
// `ownerState` is injected by MUI v9 into the option props (spread here via Autocomplete's
|
|
12
|
+
// renderOption); a custom shouldForwardProp drops MUI's default exclusion, so filter it too or it
|
|
13
|
+
// leaks onto the <li> (React "unrecognized prop" warning).
|
|
14
|
+
shouldForwardProp: (prop) => prop !== 'selected' && prop !== 'ownerState',
|
|
15
|
+
})(({ theme, selected }) => ({
|
|
16
|
+
display: 'flex',
|
|
17
|
+
alignItems: 'center',
|
|
18
|
+
position: 'relative',
|
|
19
|
+
listStyle: 'none',
|
|
20
|
+
padding: '6px 12px',
|
|
21
|
+
whiteSpace: 'normal',
|
|
22
|
+
cursor: 'pointer',
|
|
23
|
+
backgroundColor: selected ? theme.palette.action.selected : undefined,
|
|
24
|
+
'&:has(.ErkIcon)': {
|
|
25
|
+
paddingLeft: '40px',
|
|
26
|
+
'& .ErkIcon': {
|
|
27
|
+
left: 12,
|
|
28
|
+
top: '50%',
|
|
29
|
+
position: 'absolute',
|
|
30
|
+
transform: 'translateY(-50%)',
|
|
31
|
+
fill: theme.palette.primary.main,
|
|
32
|
+
width: '20px',
|
|
33
|
+
height: '20px',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
export default ErkAutocompleteOption;
|
|
@@ -170,13 +170,8 @@ function CustomPickerField({ error, required, helperText, size = 'small', labelM
|
|
|
170
170
|
function CustomDatePicker({ error, required, disabled, readOnly, helperText, size = 'small', labelMargin = 5, pickTime = false, inputRef, label,
|
|
171
171
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
172
172
|
orientation: _orientation, ...others }) {
|
|
173
|
-
// MUI X v9 no longer forwards `required` to the field-slot label when the field is a Material
|
|
174
|
-
// TextField (the legacy `slots.field` shape used here), so the required asterisk never renders.
|
|
175
|
-
// The picker's `label` does propagate to the field via the picker context, so bake the marker
|
|
176
|
-
// into the label to keep the required indicator visible.
|
|
177
|
-
const displayLabel = required && typeof label === 'string' ? `${label} *` : label;
|
|
178
173
|
if (pickTime) {
|
|
179
|
-
return (_jsx(StyledDateTimePicker, { ampm: true, size: size, reduceAnimations: true, ...others, label:
|
|
174
|
+
return (_jsx(StyledDateTimePicker, { ampm: true, size: size, reduceAnimations: true, ...others, label: label, disabled: disabled, readOnly: readOnly, showDaysOutsideCurrentMonth: true, slotProps: {
|
|
180
175
|
actionBar: {
|
|
181
176
|
actions: ['clear', 'accept'],
|
|
182
177
|
},
|
|
@@ -236,7 +231,7 @@ orientation: _orientation, ...others }) {
|
|
|
236
231
|
} }));
|
|
237
232
|
}
|
|
238
233
|
else {
|
|
239
|
-
return (_jsx(StyledDatePicker, { ...others, label:
|
|
234
|
+
return (_jsx(StyledDatePicker, { ...others, label: label, size: size, reduceAnimations: true, readOnly: readOnly, disabled: disabled, showDaysOutsideCurrentMonth: true, views: ['year', 'month', 'day'], slotProps: {
|
|
240
235
|
actionBar: {
|
|
241
236
|
actions: ['clear', 'accept'],
|
|
242
237
|
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import '@testing-library/jest-dom';
|
|
5
|
+
import MaterialProviders from '../../Utils/MaterialProviders';
|
|
6
|
+
import InternalFormStyle from '../../constants/InternalFormStyle';
|
|
7
|
+
import ErkDatePicker from './ErkDatePicker';
|
|
8
|
+
/**
|
|
9
|
+
* Guards the MUI X v9 regression fix: a `required` date picker must never inject a
|
|
10
|
+
* default value (today's date) into the field. Doing so silently satisfies the
|
|
11
|
+
* required constraint, so an untouched "required" date behaves as if it were filled.
|
|
12
|
+
*/
|
|
13
|
+
describe('ErkDatePicker required behaviour', () => {
|
|
14
|
+
it('does not pre-fill today into a required field (no value provided)', () => {
|
|
15
|
+
render(_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(ErkDatePicker, { required: true, label: "Fecha", onChange: vi.fn() }) }));
|
|
16
|
+
const input = document.querySelector('input');
|
|
17
|
+
expect(input?.value).toBe('');
|
|
18
|
+
});
|
|
19
|
+
it('does not pre-fill today into a required date-time field', () => {
|
|
20
|
+
render(_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(ErkDatePicker, { required: true, pickTime: true, label: "Fecha y hora", onChange: vi.fn() }) }));
|
|
21
|
+
const input = document.querySelector('input');
|
|
22
|
+
expect(input?.value).toBe('');
|
|
23
|
+
});
|
|
24
|
+
it('renders exactly one required asterisk (no duplicate from a manual marker)', () => {
|
|
25
|
+
render(_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(ErkDatePicker, { required: true, label: "Fecha", value: null, onChange: vi.fn() }) }));
|
|
26
|
+
const label = document.querySelector('label')?.textContent ?? '';
|
|
27
|
+
const asterisks = (label.match(/\*/g) ?? []).length;
|
|
28
|
+
expect(asterisks).toBe(1);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -3,6 +3,7 @@ import { createFilterOptions, CircularProgress, Autocomplete as MuiAutocomplete,
|
|
|
3
3
|
import ErkSelect from '../ErkSelect/ErkSelect';
|
|
4
4
|
import { StyledTextField } from '../ErkTextField/ErkTextField';
|
|
5
5
|
import ErkMenuItem from '../ErkMenuItem';
|
|
6
|
+
import ErkAutocompleteOption from '../ErkAutocompleteOption';
|
|
6
7
|
const filter = createFilterOptions();
|
|
7
8
|
const StyledAutoComplete = styled(MuiAutocomplete)(({ theme, readOnly, ...props }) => ({
|
|
8
9
|
'& .Erk-MuiInputBase-root': {
|
|
@@ -34,7 +35,7 @@ export default function ErkSmartSelect({ options, value = '', onChange, label, g
|
|
|
34
35
|
else {
|
|
35
36
|
return filter(options, params);
|
|
36
37
|
}
|
|
37
|
-
}, renderOption: ({ key, ...props }, option) => (_jsx(
|
|
38
|
+
}, renderOption: ({ key, ...props }, option, state) => (_jsx(ErkAutocompleteOption, { ...props, selected: state.selected, children: option.label }, key)), getOptionKey: (option) => getValueString(option), getOptionLabel: (option) => {
|
|
38
39
|
// Value selected with enter, right from the input
|
|
39
40
|
if (typeof option === 'string') {
|
|
40
41
|
return option;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
@@ -0,0 +1,33 @@
|
|
|
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 ErkSmartSelect from './ErkSmartSelect';
|
|
7
|
+
import MaterialProviders from '../../Utils/MaterialProviders';
|
|
8
|
+
import InternalFormStyle from '../../constants/InternalFormStyle';
|
|
9
|
+
const options = [
|
|
10
|
+
{ label: 'Uno', value: '1' },
|
|
11
|
+
{ label: 'Dos', value: '2' },
|
|
12
|
+
];
|
|
13
|
+
describe('ErkSmartSelect (searchable)', () => {
|
|
14
|
+
it('renders the options as listbox <li> items when opened, with no prop leaking to the DOM', async () => {
|
|
15
|
+
const user = userEvent.setup();
|
|
16
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
17
|
+
render(_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(ErkSmartSelect, { searchable: true, label: "Selector", options: options, value: "", onChange: () => { } }) }));
|
|
18
|
+
await user.click(screen.getByRole('combobox'));
|
|
19
|
+
const option = await screen.findByRole('option', { name: 'Uno' });
|
|
20
|
+
expect(option.tagName).toBe('LI');
|
|
21
|
+
expect(screen.getByRole('option', { name: 'Dos' })).toBeInTheDocument();
|
|
22
|
+
// `ownerState`/`selected` must not reach the <li> (styled shouldForwardProp filters them).
|
|
23
|
+
expect(option).not.toHaveAttribute('ownerState');
|
|
24
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
25
|
+
consoleError.mockRestore();
|
|
26
|
+
});
|
|
27
|
+
it('marks the selected option', async () => {
|
|
28
|
+
const user = userEvent.setup();
|
|
29
|
+
render(_jsx(MaterialProviders, { formStyle: InternalFormStyle, children: _jsx(ErkSmartSelect, { searchable: true, label: "Selector", options: options, value: options[0], onChange: () => { } }) }));
|
|
30
|
+
await user.click(screen.getByRole('combobox'));
|
|
31
|
+
expect(await screen.findByRole('option', { name: 'Uno' })).toHaveAttribute('aria-selected', 'true');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -3,9 +3,15 @@ import { EurekaDraft } from '../../@Types/Draft/Draft';
|
|
|
3
3
|
interface SmartDraftRendererProps {
|
|
4
4
|
property: string;
|
|
5
5
|
draft: EurekaDraft;
|
|
6
|
+
/**
|
|
7
|
+
* The unmapped draft from the form definition. `draft` may be a previously mapped copy
|
|
8
|
+
* (entities already resolved and stripped), so whether mapping runs — and keeps re-running
|
|
9
|
+
* when `dependencies` change — must be decided from the original.
|
|
10
|
+
*/
|
|
11
|
+
sourceDraft?: EurekaDraft;
|
|
6
12
|
margin?: React.CSSProperties['margin'];
|
|
7
13
|
dependencies: Record<string, any>;
|
|
8
14
|
onChange?: (value: EurekaDraft) => void;
|
|
9
15
|
}
|
|
10
|
-
declare function SmartDraftRenderer({ draft, margin, onChange, property, dependencies }: SmartDraftRendererProps): JSX.Element;
|
|
16
|
+
declare function SmartDraftRenderer({ draft, sourceDraft, margin, onChange, property, dependencies, }: SmartDraftRendererProps): JSX.Element;
|
|
11
17
|
export default SmartDraftRenderer;
|
|
@@ -11,11 +11,11 @@ import { skipToken } from '@reduxjs/toolkit/query';
|
|
|
11
11
|
import { useAppSelector } from '../../hooks';
|
|
12
12
|
import { IdFormContext } from '../../Contexts/FormContext';
|
|
13
13
|
import { DraftLoadingContext } from './DraftLoadingContext';
|
|
14
|
-
function SmartDraftRenderer({ draft, margin, onChange, property, dependencies }) {
|
|
14
|
+
function SmartDraftRenderer({ draft, sourceDraft, margin, onChange, property, dependencies, }) {
|
|
15
15
|
const idForm = useContext(IdFormContext);
|
|
16
16
|
const mapDraftEntities = useContext(CustomContext).mapDraftEntities;
|
|
17
17
|
const postview = useAppSelector((state) => state.global.postview);
|
|
18
|
-
const hasEntities = useMemo(() => Object.keys(draft.entityMap).length > 0, [draft]);
|
|
18
|
+
const hasEntities = useMemo(() => Object.keys((sourceDraft ?? draft).entityMap).length > 0, [sourceDraft, draft]);
|
|
19
19
|
const { data: mapped, isFetching } = DraftApi.useMapDraftQuery(!hasEntities || postview
|
|
20
20
|
? skipToken
|
|
21
21
|
: {
|
package/package.json
CHANGED