Skip to content

Commit f401985

Browse files
authored
Merge pull request #216 from UgnisSoftware/UGN-413
Automatch select values
2 parents a192098 + dedf404 commit f401985

File tree

8 files changed

+223
-14
lines changed

8 files changed

+223
-14
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ Common date-time formats can be viewed [here](https://docs.sheetjs.com/docs/csf/
203203
maxFileSize?: number
204204
// Automatically map imported headers to specified fields if possible. Default: true
205205
autoMapHeaders?: boolean
206+
// When field type is "select", automatically match values if possible. Default: true
207+
autoMapSelectValues?: boolean
206208
// Headers matching accuracy: 1 for strict and up for more flexible matching. Default: 2
207209
autoMapDistance?: number
208210
```

src/ReactSpreadsheetImport.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const defaultTheme = themeOverrides
1111

1212
export const defaultRSIProps: Partial<RsiProps<any>> = {
1313
autoMapHeaders: true,
14+
autoMapSelectValues: false,
1415
allowInvalidSubmit: true,
1516
autoMapDistance: 2,
1617
translations: translations,

src/steps/MatchColumnsStep/MatchColumnsStep.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export type Columns<T extends string> = Column<T>[]
6565
export const MatchColumnsStep = <T extends string>({ data, headerValues, onContinue }: MatchColumnsProps<T>) => {
6666
const toast = useToast()
6767
const dataExample = data.slice(0, 2)
68-
const { fields, autoMapHeaders, autoMapDistance, translations } = useRsi<T>()
68+
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>()
6969
const [isLoading, setIsLoading] = useState(false)
7070
const [columns, setColumns] = useState<Columns<T>>(
7171
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
@@ -81,7 +81,7 @@ export const MatchColumnsStep = <T extends string>({ data, headerValues, onConti
8181
columns.map<Column<T>>((column, index) => {
8282
columnIndex === index ? setColumn(column, field, data) : column
8383
if (columnIndex === index) {
84-
return setColumn(column, field, data)
84+
return setColumn(column, field, data, autoMapSelectValues)
8585
} else if (index === existingFieldIndex) {
8686
toast({
8787
status: "warning",
@@ -99,6 +99,7 @@ export const MatchColumnsStep = <T extends string>({ data, headerValues, onConti
9999
)
100100
},
101101
[
102+
autoMapSelectValues,
102103
columns,
103104
data,
104105
fields,
@@ -151,12 +152,15 @@ export const MatchColumnsStep = <T extends string>({ data, headerValues, onConti
151152
setIsLoading(false)
152153
}, [onContinue, columns, data, fields])
153154

154-
useEffect(() => {
155-
if (autoMapHeaders) {
156-
setColumns(getMatchedColumns(columns, fields, data, autoMapDistance))
157-
}
155+
useEffect(
156+
() => {
157+
if (autoMapHeaders) {
158+
setColumns(getMatchedColumns(columns, fields, data, autoMapDistance, autoMapSelectValues))
159+
}
160+
},
158161
// eslint-disable-next-line react-hooks/exhaustive-deps
159-
}, [])
162+
[],
163+
)
160164

161165
return (
162166
<>

src/steps/MatchColumnsStep/components/TemplateColumn.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { Styles } from "./ColumnGrid"
2222
const getAccordionTitle = <T extends string>(fields: Fields<T>, column: Column<T>, translations: Translations) => {
2323
const fieldLabel = fields.find((field) => "value" in column && field.key === column.value)!.label
2424
return `${translations.matchColumnsStep.matchDropdownTitle} ${fieldLabel} (${
25-
"matchedOptions" in column && column.matchedOptions.length
25+
"matchedOptions" in column && column.matchedOptions.filter((option) => !option.value).length
2626
} ${translations.matchColumnsStep.unmatched})`
2727
}
2828

src/steps/MatchColumnsStep/tests/MatchColumnsStep.test.tsx

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,192 @@ describe("Match Columns automatic matching", () => {
180180
expect(onContinue.mock.calls[0][0]).toEqual(result)
181181
})
182182

183+
test("AutoMatches select values on mount", async () => {
184+
const header = ["first name", "count", "Email"]
185+
const OPTION_RESULT_ONE = "John"
186+
const OPTION_RESULT_ONE_VALUE = "1"
187+
const OPTION_RESULT_TWO = "Dane"
188+
const OPTION_RESULT_TWO_VALUE = "2"
189+
const OPTION_RESULT_THREE = "Kane"
190+
const data = [
191+
// match by option label
192+
[OPTION_RESULT_ONE, "123", "[email protected]"],
193+
// match by option value
194+
[OPTION_RESULT_TWO_VALUE, "333", "[email protected]"],
195+
// do not match
196+
[OPTION_RESULT_THREE, "534", "[email protected]"],
197+
]
198+
const options = [
199+
{ label: OPTION_RESULT_ONE, value: OPTION_RESULT_ONE_VALUE },
200+
{ label: OPTION_RESULT_TWO, value: OPTION_RESULT_TWO_VALUE },
201+
]
202+
// finds only names with automatic matching
203+
const result = [{ name: OPTION_RESULT_ONE_VALUE }, { name: OPTION_RESULT_TWO_VALUE }, { name: undefined }]
204+
205+
const alternativeFields = [
206+
{
207+
label: "Name",
208+
key: "name",
209+
alternateMatches: ["first name"],
210+
fieldType: {
211+
type: "select",
212+
options,
213+
},
214+
example: "Stephanie",
215+
},
216+
] as const
217+
218+
const onContinue = jest.fn()
219+
render(
220+
<Providers
221+
theme={defaultTheme}
222+
rsiValues={{ ...mockRsiValues, fields: alternativeFields, autoMapSelectValues: true }}
223+
>
224+
<ModalWrapper isOpen={true} onClose={() => {}}>
225+
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
226+
</ModalWrapper>
227+
</Providers>,
228+
)
229+
230+
expect(screen.getByText(/1 Unmatched/)).toBeInTheDocument()
231+
232+
const nextButton = screen.getByRole("button", {
233+
name: "Next",
234+
})
235+
236+
await userEvent.click(nextButton)
237+
238+
await waitFor(() => {
239+
expect(onContinue).toBeCalled()
240+
})
241+
expect(onContinue.mock.calls[0][0]).toEqual(result)
242+
})
243+
244+
test("Does not auto match select values when autoMapSelectValues:false", async () => {
245+
const header = ["first name", "count", "Email"]
246+
const OPTION_RESULT_ONE = "John"
247+
const OPTION_RESULT_ONE_VALUE = "1"
248+
const OPTION_RESULT_TWO = "Dane"
249+
const OPTION_RESULT_TWO_VALUE = "2"
250+
const OPTION_RESULT_THREE = "Kane"
251+
const data = [
252+
// match by option label
253+
[OPTION_RESULT_ONE, "123", "[email protected]"],
254+
// match by option value
255+
[OPTION_RESULT_TWO_VALUE, "333", "[email protected]"],
256+
// do not match
257+
[OPTION_RESULT_THREE, "534", "[email protected]"],
258+
]
259+
const options = [
260+
{ label: OPTION_RESULT_ONE, value: OPTION_RESULT_ONE_VALUE },
261+
{ label: OPTION_RESULT_TWO, value: OPTION_RESULT_TWO_VALUE },
262+
]
263+
const result = [{ name: undefined }, { name: undefined }, { name: undefined }]
264+
265+
const alternativeFields = [
266+
{
267+
label: "Name",
268+
key: "name",
269+
alternateMatches: ["first name"],
270+
fieldType: {
271+
type: "select",
272+
options,
273+
},
274+
example: "Stephanie",
275+
},
276+
] as const
277+
278+
const onContinue = jest.fn()
279+
render(
280+
<Providers
281+
theme={defaultTheme}
282+
rsiValues={{ ...mockRsiValues, fields: alternativeFields, autoMapSelectValues: false }}
283+
>
284+
<ModalWrapper isOpen={true} onClose={() => {}}>
285+
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
286+
</ModalWrapper>
287+
</Providers>,
288+
)
289+
290+
expect(screen.getByText(/3 Unmatched/)).toBeInTheDocument()
291+
292+
const nextButton = screen.getByRole("button", {
293+
name: "Next",
294+
})
295+
296+
await userEvent.click(nextButton)
297+
298+
await waitFor(() => {
299+
expect(onContinue).toBeCalled()
300+
})
301+
expect(onContinue.mock.calls[0][0]).toEqual(result)
302+
})
303+
304+
test("AutoMatches select values on select", async () => {
305+
const header = ["first name", "count", "Email"]
306+
const OPTION_RESULT_ONE = "John"
307+
const OPTION_RESULT_ONE_VALUE = "1"
308+
const OPTION_RESULT_TWO = "Dane"
309+
const OPTION_RESULT_TWO_VALUE = "2"
310+
const OPTION_RESULT_THREE = "Kane"
311+
const data = [
312+
// match by option label
313+
[OPTION_RESULT_ONE, "123", "[email protected]"],
314+
// match by option value
315+
[OPTION_RESULT_TWO_VALUE, "333", "[email protected]"],
316+
// do not match
317+
[OPTION_RESULT_THREE, "534", "[email protected]"],
318+
]
319+
const options = [
320+
{ label: OPTION_RESULT_ONE, value: OPTION_RESULT_ONE_VALUE },
321+
{ label: OPTION_RESULT_TWO, value: OPTION_RESULT_TWO_VALUE },
322+
]
323+
// finds only names with automatic matching
324+
const result = [{ name: OPTION_RESULT_ONE_VALUE }, { name: OPTION_RESULT_TWO_VALUE }, { name: undefined }]
325+
326+
const alternativeFields = [
327+
{
328+
label: "Name",
329+
key: "name",
330+
fieldType: {
331+
type: "select",
332+
options,
333+
},
334+
example: "Stephanie",
335+
},
336+
] as const
337+
338+
const onContinue = jest.fn()
339+
render(
340+
<Providers
341+
theme={defaultTheme}
342+
rsiValues={{ ...mockRsiValues, fields: alternativeFields, autoMapSelectValues: true }}
343+
>
344+
<ModalWrapper isOpen={true} onClose={() => {}}>
345+
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
346+
<div id={SELECT_DROPDOWN_ID} />
347+
</ModalWrapper>
348+
</Providers>,
349+
)
350+
351+
await selectEvent.select(screen.getByLabelText(header[0]), alternativeFields[0].label, {
352+
container: document.getElementById(SELECT_DROPDOWN_ID)!,
353+
})
354+
355+
expect(screen.getByText(/1 Unmatched/)).toBeInTheDocument()
356+
357+
const nextButton = screen.getByRole("button", {
358+
name: "Next",
359+
})
360+
361+
await userEvent.click(nextButton)
362+
363+
await waitFor(() => {
364+
expect(onContinue).toBeCalled()
365+
})
366+
expect(onContinue.mock.calls[0][0]).toEqual(result)
367+
})
368+
183369
test("Boolean-like values are returned as Booleans", async () => {
184370
const header = ["namezz", "is_cool", "Email"]
185371
const data = [

src/steps/MatchColumnsStep/utils/getMatchedColumns.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const getMatchedColumns = <T extends string>(
1010
fields: Fields<T>,
1111
data: MatchColumnsProps<T>["data"],
1212
autoMapDistance: number,
13+
autoMapSelectValues?: boolean,
1314
) =>
1415
columns.reduce<Column<T>[]>((arr, column) => {
1516
const autoMatch = findMatch(column.header, fields, autoMapDistance)
@@ -21,18 +22,18 @@ export const getMatchedColumns = <T extends string>(
2122
return lavenstein(duplicate.value, duplicate.header) < lavenstein(autoMatch, column.header)
2223
? [
2324
...arr.slice(0, duplicateIndex),
24-
setColumn(arr[duplicateIndex], field, data),
25+
setColumn(arr[duplicateIndex], field, data, autoMapSelectValues),
2526
...arr.slice(duplicateIndex + 1),
2627
setColumn(column),
2728
]
2829
: [
2930
...arr.slice(0, duplicateIndex),
3031
setColumn(arr[duplicateIndex]),
3132
...arr.slice(duplicateIndex + 1),
32-
setColumn(column, field, data),
33+
setColumn(column, field, data, autoMapSelectValues),
3334
]
3435
} else {
35-
return [...arr, setColumn(column, field, data)]
36+
return [...arr, setColumn(column, field, data, autoMapSelectValues)]
3637
}
3738
} else {
3839
return [...arr, column]

src/steps/MatchColumnsStep/utils/setColumn.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
11
import type { Field } from "../../../types"
2-
import { Column, ColumnType, MatchColumnsProps } from "../MatchColumnsStep"
2+
import { Column, ColumnType, MatchColumnsProps, MatchedOptions } from "../MatchColumnsStep"
33
import { uniqueEntries } from "./uniqueEntries"
44

55
export const setColumn = <T extends string>(
66
oldColumn: Column<T>,
77
field?: Field<T>,
88
data?: MatchColumnsProps<T>["data"],
9+
autoMapSelectValues?: boolean,
910
): Column<T> => {
1011
switch (field?.fieldType.type) {
1112
case "select":
13+
const fieldOptions = field.fieldType.options
14+
const uniqueData = uniqueEntries(data || [], oldColumn.index) as MatchedOptions<T>[]
15+
const matchedOptions = autoMapSelectValues
16+
? uniqueData.map((record) => {
17+
const value = fieldOptions.find(
18+
(fieldOption) => fieldOption.value === record.entry || fieldOption.label === record.entry,
19+
)?.value
20+
return value ? ({ ...record, value } as MatchedOptions<T>) : (record as MatchedOptions<T>)
21+
})
22+
: uniqueData
23+
const allMatched = matchedOptions.filter((o) => o.value).length == uniqueData?.length
24+
1225
return {
1326
...oldColumn,
14-
type: ColumnType.matchedSelect,
27+
type: allMatched ? ColumnType.matchedSelectOptions : ColumnType.matchedSelect,
1528
value: field.key,
16-
matchedOptions: uniqueEntries(data || [], oldColumn.index),
29+
matchedOptions,
1730
}
1831
case "checkbox":
1932
return { index: oldColumn.index, type: ColumnType.matchedCheckbox, value: field.key, header: oldColumn.header }

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export type RsiProps<T extends string> = {
3535
maxFileSize?: number
3636
// Automatically map imported headers to specified fields if possible. Default: true
3737
autoMapHeaders?: boolean
38+
// When field type is "select", automatically match values if possible. Default: false
39+
autoMapSelectValues?: boolean
3840
// Headers matching accuracy: 1 for strict and up for more flexible matching
3941
autoMapDistance?: number
4042
// Initial Step state to be rendered on load

0 commit comments

Comments
 (0)