@arquimedes.co/eureka-forms 3.0.50-test → 3.0.51-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/TitleStep/MaterialTitleStep/MaterialTitleStep.js +3 -1
- package/dist/Shared/ErkAutocompleteOption.d.ts +12 -0
- package/dist/Shared/ErkAutocompleteOption.js +37 -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
|
+
});
|
|
@@ -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;
|
|
@@ -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