@inseefr/lunatic 3.13.6-rc.1 → 3.14.0

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.
Files changed (42) hide show
  1. package/components/Suggester/SuggesterType.d.ts +3 -1
  2. package/components/shared/Combobox/ComboboxType.d.ts +2 -0
  3. package/components/shared/MDLabel/MDLabel.js +2 -1
  4. package/components/shared/MDLabel/MDLabel.js.map +1 -1
  5. package/components/shared/MDLabel/MDLabel.spec.js +25 -31
  6. package/components/shared/MDLabel/MDLabel.spec.js.map +1 -1
  7. package/esm/components/Suggester/SuggesterType.d.ts +3 -1
  8. package/esm/components/shared/Combobox/ComboboxType.d.ts +2 -0
  9. package/esm/components/shared/MDLabel/MDLabel.js +2 -1
  10. package/esm/components/shared/MDLabel/MDLabel.js.map +1 -1
  11. package/esm/components/shared/MDLabel/MDLabel.spec.js +25 -31
  12. package/esm/components/shared/MDLabel/MDLabel.spec.js.map +1 -1
  13. package/esm/use-lunatic/reducer/reducerInitializer.js +1 -1
  14. package/esm/use-lunatic/reducer/reducerInitializer.js.map +1 -1
  15. package/esm/utils/search/SearchInterface.d.ts +9 -1
  16. package/esm/utils/search/melauto.d.ts +4 -1
  17. package/esm/utils/search/melauto.js +17 -17
  18. package/esm/utils/search/melauto.js.map +1 -1
  19. package/esm/utils/search/melauto.spec.js +9 -13
  20. package/esm/utils/search/melauto.spec.js.map +1 -1
  21. package/package.json +4 -2
  22. package/src/components/Suggester/SuggesterType.ts +3 -1
  23. package/src/components/shared/Combobox/ComboboxType.ts +2 -0
  24. package/src/components/shared/MDLabel/MDLabel.spec.tsx +25 -31
  25. package/src/components/shared/MDLabel/MDLabel.tsx +2 -2
  26. package/src/stories/behaviour/cleaning/cleaning.stories.tsx +7 -0
  27. package/src/stories/behaviour/cleaning/source-sequence.json +444 -0
  28. package/src/stories/behaviour/search/search.stories.tsx +258 -0
  29. package/src/use-lunatic/reducer/reducerInitializer.tsx +1 -1
  30. package/src/use-lunatic/use-lunatic.test.ts +6 -2
  31. package/src/utils/search/SearchInterface.ts +10 -1
  32. package/src/utils/search/melauto.spec.ts +10 -13
  33. package/src/utils/search/melauto.ts +20 -13
  34. package/tsconfig.build.tsbuildinfo +1 -1
  35. package/use-lunatic/reducer/reducerInitializer.js +1 -1
  36. package/use-lunatic/reducer/reducerInitializer.js.map +1 -1
  37. package/utils/search/SearchInterface.d.ts +9 -1
  38. package/utils/search/melauto.d.ts +4 -1
  39. package/utils/search/melauto.js +11 -14
  40. package/utils/search/melauto.js.map +1 -1
  41. package/utils/search/melauto.spec.js +9 -13
  42. package/utils/search/melauto.spec.js.map +1 -1
@@ -0,0 +1,258 @@
1
+ import classNames from 'classnames';
2
+ import {
3
+ Orchestrator,
4
+ OrchestratorMeta,
5
+ type OrchestratorStory,
6
+ } from '../../utils/Orchestrator';
7
+ import D from '../../../i18n';
8
+
9
+ import { ComboboxOptionType } from '../../../components/shared/Combobox/ComboboxType';
10
+
11
+ import { Meta } from '@storybook/react';
12
+ import {
13
+ type ComponentProps,
14
+ type FormEventHandler,
15
+ useCallback,
16
+ useMemo,
17
+ useState,
18
+ } from 'react';
19
+ import { LunaticSource } from '../../../type.source';
20
+ import { useLocalStorage } from '../../../hooks/useLocalStorage';
21
+
22
+ type Props = {
23
+ option: ComboboxOptionType;
24
+ shouldDisplayOptionId?: boolean;
25
+ selected?: boolean;
26
+ };
27
+ const CustomComboboxOption = ({
28
+ option,
29
+ shouldDisplayOptionId = true,
30
+ selected,
31
+ }: Props) => {
32
+ const { id, value, label, score, melautoScore } = option;
33
+
34
+ const scoreValues = (
35
+ <span>{` [ Score: ${score} | MelautoScore: ${melautoScore} ] `}</span>
36
+ );
37
+
38
+ if (value === 'OTHER') {
39
+ return (
40
+ <div className={classNames('lunatic-combo-box-option', { selected })}>
41
+ <span className="label">
42
+ {`${D.SUGGESTER_NO_RESULT} : ${D.SUGGESTER_ARBITRARY} "${label}"`}
43
+ </span>
44
+ </div>
45
+ );
46
+ }
47
+ if (label) {
48
+ return (
49
+ <div className={classNames('lunatic-combo-box-option', { selected })}>
50
+ {shouldDisplayOptionId && (
51
+ <>
52
+ <span className="id">{id || value}</span>
53
+ <span> - </span>
54
+ </>
55
+ )}
56
+ <span className="label">{label}</span>
57
+ {scoreValues}
58
+ </div>
59
+ );
60
+ }
61
+ return (
62
+ <div className={classNames('lunatic-combo-box-option', { selected })}>
63
+ <span className="id">{id || value}</span>
64
+ {scoreValues}
65
+ </div>
66
+ );
67
+ };
68
+
69
+ const meta: Meta<typeof Orchestrator> = {
70
+ title: 'Behaviour/SearchScore',
71
+ ...OrchestratorMeta,
72
+ };
73
+
74
+ export default meta;
75
+
76
+ export const Default: OrchestratorStory = {
77
+ args: {
78
+ slots: {
79
+ ComboboxOption: CustomComboboxOption,
80
+ },
81
+ },
82
+ render(args) {
83
+ return <PasteOrchestrator {...args} />;
84
+ },
85
+ };
86
+
87
+ type Nomenclature = { name: string; uri: string };
88
+
89
+ type GetReferentiel = NonNullable<
90
+ ComponentProps<typeof Orchestrator>['getReferentiel']
91
+ >;
92
+
93
+ function PasteOrchestrator(
94
+ props: Readonly<ComponentProps<typeof Orchestrator>>
95
+ ) {
96
+ const [source, setSource, sourceAsString] =
97
+ useLocalStorage<LunaticSource | null>('story-source-search');
98
+ const [nomenclatures, setNomenclatures] = useLocalStorage<
99
+ Nomenclature[] | null
100
+ >('story-nomenclatures-search');
101
+
102
+ const getReferentiel = useCallback<GetReferentiel>(
103
+ async (name: string) => {
104
+ const nomenclature = (nomenclatures ?? []).find((n) => n.name === name);
105
+ if (!nomenclature) {
106
+ throw new Error(`Nomenclature "${name}" introuvable`);
107
+ }
108
+ const response = await fetch(nomenclature.uri);
109
+ if (!response.ok) {
110
+ throw new Error(
111
+ `Impossible de charger "${name}" (${response.status} ${response.statusText})`
112
+ );
113
+ }
114
+ return response.json();
115
+ },
116
+ [nomenclatures]
117
+ );
118
+
119
+ const extraTabs = useMemo(() => {
120
+ return [
121
+ {
122
+ label: 'Source & nomenclatures',
123
+ children: (
124
+ <FormForSourceAndNomenclatures
125
+ source={sourceAsString}
126
+ nomenclatures={nomenclatures ?? []}
127
+ onChange={(source: string, nomenclatures: Nomenclature[]) => {
128
+ setNomenclatures(JSON.stringify(nomenclatures));
129
+ setSource(source);
130
+ }}
131
+ />
132
+ ),
133
+ },
134
+ ];
135
+ }, [sourceAsString, nomenclatures, setSource, setNomenclatures]);
136
+
137
+ if (!source) {
138
+ return <>{extraTabs[0].children}</>;
139
+ }
140
+
141
+ const key = `${sourceAsString}-${JSON.stringify(nomenclatures)}`;
142
+
143
+ return (
144
+ <div key={key}>
145
+ <Orchestrator
146
+ {...props}
147
+ slots={{
148
+ ComboboxOption: CustomComboboxOption,
149
+ }}
150
+ source={source}
151
+ getReferentiel={getReferentiel}
152
+ extraTabs={extraTabs}
153
+ />
154
+ </div>
155
+ );
156
+ }
157
+
158
+ function FormForSourceAndNomenclatures(
159
+ props: Readonly<{
160
+ source: string;
161
+ nomenclatures: Nomenclature[];
162
+ onChange: (source: string, nomenclatures: Nomenclature[]) => void;
163
+ }>
164
+ ) {
165
+ const [items, setItems] = useState<Nomenclature[]>(props.nomenclatures);
166
+
167
+ const add = () => setItems((prev) => [...prev, { name: '', uri: '' }]);
168
+
169
+ const remove = (index: number) =>
170
+ setItems((prev) => prev.filter((_, i) => i !== index));
171
+
172
+ const update = (index: number, field: keyof Nomenclature, value: string) =>
173
+ setItems((prev) =>
174
+ prev.map((item, i) => (i === index ? { ...item, [field]: value } : item))
175
+ );
176
+
177
+ const onSubmit: FormEventHandler<HTMLFormElement> = (e) => {
178
+ e.preventDefault();
179
+ const data = new FormData(e.currentTarget);
180
+ if (data.has('source')) {
181
+ props.onChange(
182
+ data.get('source') as string,
183
+ items.filter((item) => item.name.trim() && item.uri.trim())
184
+ );
185
+ }
186
+ };
187
+
188
+ return (
189
+ <form onSubmit={onSubmit}>
190
+ <fieldset className="fieldset">
191
+ <legend className="fieldset-legend">Paste the source.json</legend>
192
+ <textarea
193
+ required
194
+ name="source"
195
+ defaultValue={props.source}
196
+ className="textarea w-full h-24"
197
+ placeholder="source.json content"
198
+ />
199
+ <div className="label">
200
+ The content of this textarea will be saved in your local storage to
201
+ persist between refreshes
202
+ </div>
203
+ </fieldset>
204
+ <fieldset className="fieldset">
205
+ <legend className="fieldset-legend">Nomenclatures</legend>
206
+ <div className="label">
207
+ Associate the nomenclature name (the one used in the source) with the
208
+ URL that returns a JSON array. The list is saved in local storage.
209
+ </div>
210
+
211
+ <ul className="flex flex-col gap-2 mt-2">
212
+ {items.map((item, index) => (
213
+ // biome-ignore lint/suspicious/noArrayIndexKey: liste éditable sans id stable
214
+ <li key={index} className="flex flex-row items-end gap-2">
215
+ <label className="flex flex-col flex-1">
216
+ <span className="label">Name</span>
217
+ <input
218
+ required
219
+ type="text"
220
+ className="input w-full"
221
+ placeholder="ex: L_COMMUNES"
222
+ value={item.name}
223
+ onChange={(e) => update(index, 'name', e.target.value)}
224
+ />
225
+ </label>
226
+ <label className="flex flex-col flex-[2]">
227
+ <span className="label">URI</span>
228
+ <input
229
+ required
230
+ type="url"
231
+ className="input w-full"
232
+ placeholder="https://.../nomenclature.json"
233
+ value={item.uri}
234
+ onChange={(e) => update(index, 'uri', e.target.value)}
235
+ />
236
+ </label>
237
+ <button
238
+ type="button"
239
+ className="btn btn-ghost"
240
+ title="Delete this nomenclature"
241
+ onClick={() => remove(index)}
242
+ >
243
+ ✕
244
+ </button>
245
+ </li>
246
+ ))}
247
+ </ul>
248
+
249
+ <button type="button" className="btn btn-secondary w-fit" onClick={add}>
250
+ Add a nomenclature
251
+ </button>
252
+ </fieldset>
253
+ <button className="btn btn-primary mt-2" type="submit">
254
+ Load
255
+ </button>
256
+ </form>
257
+ );
258
+ }
@@ -66,7 +66,7 @@ export function reducerInitializer({
66
66
  logger?: LunaticLogger;
67
67
  }): LunaticReducerState {
68
68
  const variables = LunaticVariablesStore.makeFromSource(source, data, {
69
- autoCommit: source.pagination !== 'question',
69
+ autoCommit: false,
70
70
  changeHandler: onVariableChange,
71
71
  disableCleaning: disableFilters,
72
72
  });
@@ -303,12 +303,16 @@ describe('use-lunatic()', () => {
303
303
  name: 'PRENOM',
304
304
  value: ['John', 'Doe', 'Marc'],
305
305
  },
306
- { name: 'AGE', value: [18, 18, 18] },
307
306
  ]);
308
- // Go in the first iteration
307
+ // Go in the first iteration (commit the resizing)
309
308
  result.current.goNextPage();
310
309
  result.current.goNextPage();
311
310
  });
311
+ act(() => {
312
+ result.current.handleChanges(
313
+ [0, 1, 2].map((i) => ({ name: 'AGE', value: 18, iteration: [i] }))
314
+ );
315
+ });
312
316
  const expectCollectedAgeToEqual = (expectation: unknown[]) => {
313
317
  expect(result.current.getData(false).COLLECTED?.AGE.COLLECTED).toEqual(
314
318
  expectation
@@ -5,7 +5,16 @@ export type SearchInfo = LunaticSuggester;
5
5
  export type IndexEntry = {
6
6
  id: string;
7
7
  label: string;
8
- [k: string]: string;
8
+ /**
9
+ * Score computed by MiniSearch algo
10
+ */
11
+ score: number;
12
+
13
+ /**
14
+ * Score compute by melauto algo
15
+ */
16
+ melautoScore: number;
17
+ [k: string]: any;
9
18
  };
10
19
 
11
20
  export interface SearchInterface<T extends IndexEntry> {
@@ -22,12 +22,9 @@ describe('applyMelauto', () => {
22
22
  { id: 'hello-w' },
23
23
  ];
24
24
  const sortedData = applyMelauto('hello', noLabelData);
25
- const expectedSortedData = [
26
- { id: 'b', label: 'Hello' },
27
- { id: 'hello-w' },
28
- { id: 'a' },
29
- ];
30
- expect(sortedData).toStrictEqual(expectedSortedData);
25
+
26
+ // check only order with the id
27
+ expect(sortedData.map((item) => item.id)).toEqual(['b', 'hello-w', 'a']);
31
28
  });
32
29
  });
33
30
 
@@ -70,13 +67,13 @@ describe('melautoScore', () => {
70
67
  { id: '0112B', label: '0112B' },
71
68
  ];
72
69
  const sortedData = applyMelauto('011', numericData);
73
- const expectedSortedData = [
74
- { id: '0111Z', label: '0111Z' },
75
- { id: '0112B', label: '0112B' },
76
- { id: '0115Z', label: '0115Z' },
77
- { id: '0120A', label: '0120A' },
78
- ];
79
- expect(sortedData).toStrictEqual(expectedSortedData);
70
+ // check only order with the id
71
+ expect(sortedData.map((item) => item.id)).toEqual([
72
+ '0111Z',
73
+ '0112B',
74
+ '0115Z',
75
+ '0120A',
76
+ ]);
80
77
  });
81
78
 
82
79
  it('should apply alphanumeric sort with numeric awareness', () => {
@@ -7,24 +7,31 @@
7
7
  * (e.g., "0111Z" will appear before "0115Z" when both match "011")
8
8
  * Numeric awareness means "item2" will come before "item10" (not "item10" before "item2")
9
9
  */
10
+ export type WithMelautoScore<T> = T & { melautoScore?: number };
11
+
10
12
  export function applyMelauto<T extends { id: string; label?: string }>(
11
13
  query: string,
12
14
  data: T[]
13
- ): T[] {
14
- return data.sort((a, b) => {
15
- const sa = melautoScore(a.label ?? a.id, query);
16
- const sb = melautoScore(b.label ?? b.id, query);
17
- const diff = sb - sa;
18
- if (diff !== 0) return diff;
15
+ ): WithMelautoScore<T>[] {
16
+ // no query -> no sort
17
+ if (query === undefined || query === '') return data;
18
+ return data
19
+ .map((item) => ({
20
+ ...item,
21
+ melautoScore: melautoScore(item.label ?? item.id, query),
22
+ }))
23
+ .sort((a, b) => {
24
+ const diff = b.melautoScore - a.melautoScore;
25
+ if (diff !== 0) return diff;
19
26
 
20
- // If scores are equals: alphanumeric sort with numeric awareness
21
- const ta = (a.label ?? a.id).toString();
22
- const tb = (b.label ?? b.id).toString();
23
- return ta.localeCompare(tb, undefined, {
24
- numeric: true,
25
- sensitivity: 'base',
27
+ // if equal -> sort alphabetically with numeric awareness
28
+ const ta = (a.label ?? a.id).toString();
29
+ const tb = (b.label ?? b.id).toString();
30
+ return ta.localeCompare(tb, undefined, {
31
+ numeric: true,
32
+ sensitivity: 'base',
33
+ });
26
34
  });
27
- });
28
35
  }
29
36
 
30
37
  /**