@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.
- package/components/Suggester/SuggesterType.d.ts +3 -1
- package/components/shared/Combobox/ComboboxType.d.ts +2 -0
- package/components/shared/MDLabel/MDLabel.js +2 -1
- package/components/shared/MDLabel/MDLabel.js.map +1 -1
- package/components/shared/MDLabel/MDLabel.spec.js +25 -31
- package/components/shared/MDLabel/MDLabel.spec.js.map +1 -1
- package/esm/components/Suggester/SuggesterType.d.ts +3 -1
- package/esm/components/shared/Combobox/ComboboxType.d.ts +2 -0
- package/esm/components/shared/MDLabel/MDLabel.js +2 -1
- package/esm/components/shared/MDLabel/MDLabel.js.map +1 -1
- package/esm/components/shared/MDLabel/MDLabel.spec.js +25 -31
- package/esm/components/shared/MDLabel/MDLabel.spec.js.map +1 -1
- package/esm/use-lunatic/reducer/reducerInitializer.js +1 -1
- package/esm/use-lunatic/reducer/reducerInitializer.js.map +1 -1
- package/esm/utils/search/SearchInterface.d.ts +9 -1
- package/esm/utils/search/melauto.d.ts +4 -1
- package/esm/utils/search/melauto.js +17 -17
- package/esm/utils/search/melauto.js.map +1 -1
- package/esm/utils/search/melauto.spec.js +9 -13
- package/esm/utils/search/melauto.spec.js.map +1 -1
- package/package.json +4 -2
- package/src/components/Suggester/SuggesterType.ts +3 -1
- package/src/components/shared/Combobox/ComboboxType.ts +2 -0
- package/src/components/shared/MDLabel/MDLabel.spec.tsx +25 -31
- package/src/components/shared/MDLabel/MDLabel.tsx +2 -2
- package/src/stories/behaviour/cleaning/cleaning.stories.tsx +7 -0
- package/src/stories/behaviour/cleaning/source-sequence.json +444 -0
- package/src/stories/behaviour/search/search.stories.tsx +258 -0
- package/src/use-lunatic/reducer/reducerInitializer.tsx +1 -1
- package/src/use-lunatic/use-lunatic.test.ts +6 -2
- package/src/utils/search/SearchInterface.ts +10 -1
- package/src/utils/search/melauto.spec.ts +10 -13
- package/src/utils/search/melauto.ts +20 -13
- package/tsconfig.build.tsbuildinfo +1 -1
- package/use-lunatic/reducer/reducerInitializer.js +1 -1
- package/use-lunatic/reducer/reducerInitializer.js.map +1 -1
- package/utils/search/SearchInterface.d.ts +9 -1
- package/utils/search/melauto.d.ts +4 -1
- package/utils/search/melauto.js +11 -14
- package/utils/search/melauto.js.map +1 -1
- package/utils/search/melauto.spec.js +9 -13
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
/**
|