Unverified Commit 6b75a0f6 authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Feature/species search match (#10)

- Add lodash (because we are loading it as a sub-dependency anyway, and it will make our life easier)
- Add the SpeciesSearchMatch element whose responsibility it is to markup the matched substrings in the search results
- Add tests for SpeciesSearchMatch
parent d69cb76c
Pipeline #14619 failed with stage
in 3 minutes
......@@ -48,6 +48,7 @@
"classnames": "2.2.6",
"dotenv": "6.2.0",
"foundation-sites": "6.5.3",
"lodash": "4.17.11",
"react": "16.8.1",
"react-cookie": "^3.0.8",
"react-dom": "16.8.1",
......@@ -80,6 +81,7 @@
"@types/enzyme": "3.1.17",
"@types/enzyme-adapter-react-16": "1.0.3",
"@types/jest": "23.3.14",
"@types/lodash": "4.14.122",
"@types/node": "10.12.24",
"@types/prettier": "1.16.0",
"@types/react": "16.8.2",
......
......@@ -4,15 +4,24 @@ import SpeciesSearchMatch from '../species-search-match/SpeciesSearchMatch';
import styles from './SpeciesAutosuggestionPanel.scss';
import { SearchMatches } from 'src/content/app/species-selector/types/species-search';
import { SearchMatch } from 'src/content/app/species-selector/types/species-search';
type Props = {
matches: SearchMatches;
matches: SearchMatch[];
onMatchSelected: (match: SearchMatch) => void;
};
const SpeciesAutosuggestionPanel = (props: Props) => {
const onMatchSelected = (match: SearchMatch) => () => {
props.onMatchSelected(match);
};
const matches = props.matches.map((match) => (
<SpeciesSearchMatch match={match} key={match.description} />
<SpeciesSearchMatch
match={match}
onClick={onMatchSelected(match)}
key={match.description}
/>
));
return <div className={styles.speciesAutosuggestionPanel}>{matches}</div>;
......
@import 'src/styles/common';
.speciesSearchMatch {
font-weight: bold;
color: $ens-black;
padding: 2px 24px;
margin: 0 -23px;
cursor: pointer;
&:hover {
background-color: $ens-light-blue;
}
}
.speciesSearchMatchMatched {
font-weight: $dark;
}
.speciesSearchMatchScientificName {
font-style: italic;
font-weight: $light;
margin-left: 1rem;
}
import React from 'react';
import { mount, render } from 'enzyme';
import SpeciesSearchMatch from './SpeciesSearchMatch';
import styles from './SpeciesSearchMatch.scss';
const onClick = jest.fn();
const matchTemplate = {
description: 'Human GRCh38.p12',
scientific_name: 'Homo sapiens',
matched_substrings: [
{
length: 3,
offset: 0,
match: 'description' as 'description'
}
],
genome: 'GRCh38_demo'
};
describe('<SpeciesSearchMatch />', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('highlights a single match in the description field', () => {
const renderedMatch = render(
<SpeciesSearchMatch match={matchTemplate} onClick={onClick} />
);
const highlightedFragments = renderedMatch.find(
`.${styles.speciesSearchMatchMatched}`
);
expect(highlightedFragments.length).toBe(1);
// highlighting Hum in Human
expect(highlightedFragments.first().text()).toBe('Hum');
});
test('highlights a single match in the scientific_name field', () => {
const match = {
...matchTemplate,
matched_substrings: [
{
length: 3,
offset: 0,
match: 'scientific_name' as 'scientific_name'
}
]
};
const renderedMatch = render(
<SpeciesSearchMatch match={match} onClick={onClick} />
);
const highlightedFragments = renderedMatch.find(
`.${styles.speciesSearchMatchScientificName}
.${styles.speciesSearchMatchMatched}`
);
expect(highlightedFragments.length).toBe(1);
// highlighting Hom in Homo sapiens
expect(highlightedFragments.first().text()).toBe('Hom');
});
test('highlights multiple matches in the description field', () => {
const match = {
...matchTemplate,
description: 'Bacillus subtilis',
matched_substrings: [
{
length: 3,
offset: 0,
match: 'description' as 'description'
},
{
length: 3,
offset: 9,
match: 'description' as 'description'
}
]
};
delete match.scientific_name;
const renderedMatch = render(
<SpeciesSearchMatch match={match} onClick={onClick} />
);
const highlightedFragments = renderedMatch.find(
`.${styles.speciesSearchMatchMatched}`
);
expect(highlightedFragments.length).toBe(2);
// highlighting Bac in Bacillus and sub in subtilis
expect(highlightedFragments.first().text()).toBe('Bac');
expect(highlightedFragments.last().text()).toBe('sub');
});
test('calls click handler when clicked', () => {
const renderedMatch = mount(
<SpeciesSearchMatch match={matchTemplate} onClick={onClick} />
);
renderedMatch.simulate('click');
expect(onClick).toHaveBeenCalled();
});
});
import React from 'react';
import classNames from 'classnames';
import zip from 'lodash/zip';
import sortBy from 'lodash/sortBy';
import { SearchMatch } from 'src/content/app/species-selector/types/species-search';
import {
SearchMatch,
MatchedSubstring
} from 'src/content/app/species-selector/types/species-search';
import styles from './SpeciesSearchMatch.scss';
type Props = {
match: SearchMatch;
onClick: () => void;
};
const SpeciesSearchMatch = ({ match }: Props) => {
return <div className={styles.speciesSearchMatch}>{match.description}</div>;
type SplitterProps = {
string: string;
matchedSubsctrings: MatchedSubstring[];
};
type FormatStringProps = {
string: string;
substrings: SplitSubstring[];
};
type SplitSubstring = {
start: number;
end: number;
isMatch: boolean;
};
type NumberTuple = [number, number];
const SpeciesSearchMatch = ({ match, onClick }: Props) => {
return (
<div className={styles.speciesSearchMatch} onClick={onClick}>
<CommonName match={match} />
<ScientificName match={match} />
</div>
);
};
const CommonName = ({ match }: { match: SearchMatch }) => {
const { description, matched_substrings } = match;
const descriptionMatches = matched_substrings.filter(
({ match }) => match === 'description'
);
const substrings = sortBy(
splitMatch({ string: description, matchedSubsctrings: descriptionMatches }),
({ start }) => start
);
return <span>{formatString({ string: description, substrings })}</span>;
};
const ScientificName = ({ match }: { match: SearchMatch }) => {
const { scientific_name, matched_substrings } = match;
if (!scientific_name) return null;
const scientificNameMatches = matched_substrings.filter(
({ match }) => match === 'scientific_name'
);
const substrings = sortBy(
splitMatch({
string: scientific_name,
matchedSubsctrings: scientificNameMatches
}),
({ start }) => start
);
return (
<span className={styles.speciesSearchMatchScientificName}>
{formatString({ string: scientific_name, substrings })}
</span>
);
};
const formatString = ({ string, substrings }: FormatStringProps) =>
substrings.length
? substrings.map(({ start, end, isMatch }) => (
<span
className={classNames({
[styles.speciesSearchMatchMatched]: isMatch
})}
key={`${start}-${end}`}
>
{string.substring(start, end)}
</span>
))
: string;
const splitMatch = ({ string, matchedSubsctrings }: SplitterProps) => {
const matchStartIndices = matchedSubsctrings.map(({ offset }) => offset);
const matchEndIndices = matchedSubsctrings.map(
({ offset, length }) => offset + length
);
const matchIndices = zip(matchStartIndices, matchEndIndices) as NumberTuple[];
const accumulator: SplitSubstring[] = [];
return matchIndices.reduce((result, current, index, array) => {
const [currentStartIndex, currentEndIndex] = current; // notice, currentEndIndex is exclusive
const nextStartIndex =
index < array.length - 1 ? array[index + 1][0] : null;
if (index === 0 && current[0] > 0) {
// if there is an unmatched part of the string before the first match,
// add it as the first item in the list of substrings
result = [
{
start: 0,
end: currentStartIndex,
isMatch: false
}
];
}
// add the matched substring
result = [
...result,
{
start: currentStartIndex,
end: currentEndIndex,
isMatch: true
}
];
if (nextStartIndex) {
// if there is another match in the same string, add the unmatched portion between
// the current and the next match
result = [
...result,
{
start: currentEndIndex,
end: nextStartIndex,
isMatch: false
}
];
} else if (
index === array.length - 1 &&
currentEndIndex < string.length - 1
) {
// if there is unmatched trailing portion of the string, add it to the list of substrings
result = [
...result,
{
start: currentEndIndex,
end: string.length,
isMatch: false
}
];
}
return result;
}, accumulator);
};
export default SpeciesSearchMatch;
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment