Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatch select values #216

Merged
merged 8 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ Common date-time formats can be viewed [here](https://docs.sheetjs.com/docs/csf/
maxFileSize?: number
// Automatically map imported headers to specified fields if possible. Default: true
autoMapHeaders?: boolean
// When field type is "select", automatically match values if possible. Default: true
autoMapSelectValues?: boolean
// Headers matching accuracy: 1 for strict and up for more flexible matching. Default: 2
autoMapDistance?: number
```
Expand Down
1 change: 1 addition & 0 deletions src/ReactSpreadsheetImport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const defaultTheme = themeOverrides

export const defaultRSIProps: Partial<RsiProps<any>> = {
autoMapHeaders: true,
autoMapSelectValues: false,
allowInvalidSubmit: true,
autoMapDistance: 2,
translations: translations,
Expand Down
18 changes: 11 additions & 7 deletions src/steps/MatchColumnsStep/MatchColumnsStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export type Columns<T extends string> = Column<T>[]
export const MatchColumnsStep = <T extends string>({ data, headerValues, onContinue }: MatchColumnsProps<T>) => {
const toast = useToast()
const dataExample = data.slice(0, 2)
const { fields, autoMapHeaders, autoMapDistance, translations } = useRsi<T>()
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>()
const [isLoading, setIsLoading] = useState(false)
const [columns, setColumns] = useState<Columns<T>>(
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
Expand All @@ -81,7 +81,7 @@ export const MatchColumnsStep = <T extends string>({ data, headerValues, onConti
columns.map<Column<T>>((column, index) => {
columnIndex === index ? setColumn(column, field, data) : column
if (columnIndex === index) {
return setColumn(column, field, data)
return setColumn(column, field, data, autoMapSelectValues)
} else if (index === existingFieldIndex) {
toast({
status: "warning",
Expand All @@ -99,6 +99,7 @@ export const MatchColumnsStep = <T extends string>({ data, headerValues, onConti
)
},
[
autoMapSelectValues,
columns,
data,
fields,
Expand Down Expand Up @@ -151,12 +152,15 @@ export const MatchColumnsStep = <T extends string>({ data, headerValues, onConti
setIsLoading(false)
}, [onContinue, columns, data, fields])

useEffect(() => {
if (autoMapHeaders) {
setColumns(getMatchedColumns(columns, fields, data, autoMapDistance))
}
useEffect(
() => {
if (autoMapHeaders) {
setColumns(getMatchedColumns(columns, fields, data, autoMapDistance, autoMapSelectValues))
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
[],
)

return (
<>
Expand Down
2 changes: 1 addition & 1 deletion src/steps/MatchColumnsStep/components/TemplateColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type { Styles } from "./ColumnGrid"
const getAccordionTitle = <T extends string>(fields: Fields<T>, column: Column<T>, translations: Translations) => {
const fieldLabel = fields.find((field) => "value" in column && field.key === column.value)!.label
return `${translations.matchColumnsStep.matchDropdownTitle} ${fieldLabel} (${
"matchedOptions" in column && column.matchedOptions.length
"matchedOptions" in column && column.matchedOptions.filter((option) => !option.value).length
} ${translations.matchColumnsStep.unmatched})`
}

Expand Down
186 changes: 186 additions & 0 deletions src/steps/MatchColumnsStep/tests/MatchColumnsStep.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,192 @@ describe("Match Columns automatic matching", () => {
expect(onContinue.mock.calls[0][0]).toEqual(result)
})

test("AutoMatches select values on mount", async () => {
const header = ["first name", "count", "Email"]
const OPTION_RESULT_ONE = "John"
const OPTION_RESULT_ONE_VALUE = "1"
const OPTION_RESULT_TWO = "Dane"
const OPTION_RESULT_TWO_VALUE = "2"
const OPTION_RESULT_THREE = "Kane"
const data = [
// match by option label
[OPTION_RESULT_ONE, "123", "[email protected]"],
// match by option value
[OPTION_RESULT_TWO_VALUE, "333", "[email protected]"],
// do not match
[OPTION_RESULT_THREE, "534", "[email protected]"],
]
const options = [
{ label: OPTION_RESULT_ONE, value: OPTION_RESULT_ONE_VALUE },
{ label: OPTION_RESULT_TWO, value: OPTION_RESULT_TWO_VALUE },
]
// finds only names with automatic matching
const result = [{ name: OPTION_RESULT_ONE_VALUE }, { name: OPTION_RESULT_TWO_VALUE }, { name: undefined }]

const alternativeFields = [
{
label: "Name",
key: "name",
alternateMatches: ["first name"],
fieldType: {
type: "select",
options,
},
example: "Stephanie",
},
] as const

const onContinue = jest.fn()
render(
<Providers
theme={defaultTheme}
rsiValues={{ ...mockRsiValues, fields: alternativeFields, autoMapSelectValues: true }}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
</ModalWrapper>
</Providers>,
)

expect(screen.getByText(/1 Unmatched/)).toBeInTheDocument()

const nextButton = screen.getByRole("button", {
name: "Next",
})

await userEvent.click(nextButton)

await waitFor(() => {
expect(onContinue).toBeCalled()
})
expect(onContinue.mock.calls[0][0]).toEqual(result)
})

test("Does not auto match select values when autoMapSelectValues:false", async () => {
const header = ["first name", "count", "Email"]
const OPTION_RESULT_ONE = "John"
const OPTION_RESULT_ONE_VALUE = "1"
const OPTION_RESULT_TWO = "Dane"
const OPTION_RESULT_TWO_VALUE = "2"
const OPTION_RESULT_THREE = "Kane"
const data = [
// match by option label
[OPTION_RESULT_ONE, "123", "[email protected]"],
// match by option value
[OPTION_RESULT_TWO_VALUE, "333", "[email protected]"],
// do not match
[OPTION_RESULT_THREE, "534", "[email protected]"],
]
const options = [
{ label: OPTION_RESULT_ONE, value: OPTION_RESULT_ONE_VALUE },
{ label: OPTION_RESULT_TWO, value: OPTION_RESULT_TWO_VALUE },
]
const result = [{ name: undefined }, { name: undefined }, { name: undefined }]

const alternativeFields = [
{
label: "Name",
key: "name",
alternateMatches: ["first name"],
fieldType: {
type: "select",
options,
},
example: "Stephanie",
},
] as const

const onContinue = jest.fn()
render(
<Providers
theme={defaultTheme}
rsiValues={{ ...mockRsiValues, fields: alternativeFields, autoMapSelectValues: false }}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
</ModalWrapper>
</Providers>,
)

expect(screen.getByText(/3 Unmatched/)).toBeInTheDocument()

const nextButton = screen.getByRole("button", {
name: "Next",
})

await userEvent.click(nextButton)

await waitFor(() => {
expect(onContinue).toBeCalled()
})
expect(onContinue.mock.calls[0][0]).toEqual(result)
})

test("AutoMatches select values on select", async () => {
const header = ["first name", "count", "Email"]
const OPTION_RESULT_ONE = "John"
const OPTION_RESULT_ONE_VALUE = "1"
const OPTION_RESULT_TWO = "Dane"
const OPTION_RESULT_TWO_VALUE = "2"
const OPTION_RESULT_THREE = "Kane"
const data = [
// match by option label
[OPTION_RESULT_ONE, "123", "[email protected]"],
// match by option value
[OPTION_RESULT_TWO_VALUE, "333", "[email protected]"],
// do not match
[OPTION_RESULT_THREE, "534", "[email protected]"],
]
const options = [
{ label: OPTION_RESULT_ONE, value: OPTION_RESULT_ONE_VALUE },
{ label: OPTION_RESULT_TWO, value: OPTION_RESULT_TWO_VALUE },
]
// finds only names with automatic matching
const result = [{ name: OPTION_RESULT_ONE_VALUE }, { name: OPTION_RESULT_TWO_VALUE }, { name: undefined }]

const alternativeFields = [
{
label: "Name",
key: "name",
fieldType: {
type: "select",
options,
},
example: "Stephanie",
},
] as const

const onContinue = jest.fn()
render(
<Providers
theme={defaultTheme}
rsiValues={{ ...mockRsiValues, fields: alternativeFields, autoMapSelectValues: true }}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
<div id={SELECT_DROPDOWN_ID} />
</ModalWrapper>
</Providers>,
)

await selectEvent.select(screen.getByLabelText(header[0]), alternativeFields[0].label, {
container: document.getElementById(SELECT_DROPDOWN_ID)!,
})

expect(screen.getByText(/1 Unmatched/)).toBeInTheDocument()

const nextButton = screen.getByRole("button", {
name: "Next",
})

await userEvent.click(nextButton)

await waitFor(() => {
expect(onContinue).toBeCalled()
})
expect(onContinue.mock.calls[0][0]).toEqual(result)
})

test("Boolean-like values are returned as Booleans", async () => {
const header = ["namezz", "is_cool", "Email"]
const data = [
Expand Down
7 changes: 4 additions & 3 deletions src/steps/MatchColumnsStep/utils/getMatchedColumns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const getMatchedColumns = <T extends string>(
fields: Fields<T>,
data: MatchColumnsProps<T>["data"],
autoMapDistance: number,
autoMapSelectValues?: boolean,
) =>
columns.reduce<Column<T>[]>((arr, column) => {
const autoMatch = findMatch(column.header, fields, autoMapDistance)
Expand All @@ -21,18 +22,18 @@ export const getMatchedColumns = <T extends string>(
return lavenstein(duplicate.value, duplicate.header) < lavenstein(autoMatch, column.header)
? [
...arr.slice(0, duplicateIndex),
setColumn(arr[duplicateIndex], field, data),
setColumn(arr[duplicateIndex], field, data, autoMapSelectValues),
...arr.slice(duplicateIndex + 1),
setColumn(column),
]
: [
...arr.slice(0, duplicateIndex),
setColumn(arr[duplicateIndex]),
...arr.slice(duplicateIndex + 1),
setColumn(column, field, data),
setColumn(column, field, data, autoMapSelectValues),
]
} else {
return [...arr, setColumn(column, field, data)]
return [...arr, setColumn(column, field, data, autoMapSelectValues)]
}
} else {
return [...arr, column]
Expand Down
19 changes: 16 additions & 3 deletions src/steps/MatchColumnsStep/utils/setColumn.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
import type { Field } from "../../../types"
import { Column, ColumnType, MatchColumnsProps } from "../MatchColumnsStep"
import { Column, ColumnType, MatchColumnsProps, MatchedOptions } from "../MatchColumnsStep"
import { uniqueEntries } from "./uniqueEntries"

export const setColumn = <T extends string>(
oldColumn: Column<T>,
field?: Field<T>,
data?: MatchColumnsProps<T>["data"],
autoMapSelectValues?: boolean,
): Column<T> => {
switch (field?.fieldType.type) {
case "select":
const fieldOptions = field.fieldType.options
const uniqueData = uniqueEntries(data || [], oldColumn.index) as MatchedOptions<T>[]
const matchedOptions = autoMapSelectValues
? uniqueData.map((record) => {
const value = fieldOptions.find(
(fieldOption) => fieldOption.value === record.entry || fieldOption.label === record.entry,
)?.value
return value ? ({ ...record, value } as MatchedOptions<T>) : (record as MatchedOptions<T>)
})
: uniqueData
const allMatched = matchedOptions.filter((o) => o.value).length == uniqueData?.length

return {
...oldColumn,
type: ColumnType.matchedSelect,
type: allMatched ? ColumnType.matchedSelectOptions : ColumnType.matchedSelect,
value: field.key,
matchedOptions: uniqueEntries(data || [], oldColumn.index),
matchedOptions,
}
case "checkbox":
return { index: oldColumn.index, type: ColumnType.matchedCheckbox, value: field.key, header: oldColumn.header }
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export type RsiProps<T extends string> = {
maxFileSize?: number
// Automatically map imported headers to specified fields if possible. Default: true
autoMapHeaders?: boolean
// When field type is "select", automatically match values if possible. Default: false
autoMapSelectValues?: boolean
// Headers matching accuracy: 1 for strict and up for more flexible matching
autoMapDistance?: number
// Initial Step state to be rendered on load
Expand Down
Loading