diff --git a/package.json b/package.json index a0e041a5..fd673ac5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build-storybook": "build-storybook -s public" }, "dependencies": { + "@reach/combobox": "^0.10.4", "history": "^4.10.1", "lodash": "^4.17.15", "react": "^16.13.1", diff --git a/public/images/arrowDown.svg b/public/images/arrowDown.svg new file mode 100644 index 00000000..94f5058a --- /dev/null +++ b/public/images/arrowDown.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/config/index.tsx b/src/config/index.tsx index bf81f61f..e5851bad 100644 --- a/src/config/index.tsx +++ b/src/config/index.tsx @@ -1,3 +1,4 @@ export * from './routes' export * from './pages' export * from './authKeys' +export * from './procedures' diff --git a/src/config/procedures.tsx b/src/config/procedures.tsx new file mode 100644 index 00000000..6138575d --- /dev/null +++ b/src/config/procedures.tsx @@ -0,0 +1,3 @@ +export const PROCEDURES = { + lst_c_country: 'lst_c_country', +} diff --git a/src/config/routes.tsx b/src/config/routes.tsx index 7cc14d50..708156ee 100644 --- a/src/config/routes.tsx +++ b/src/config/routes.tsx @@ -1,2 +1,2 @@ -export const API_ROOT = '' +export const API_ROOT = 'http://85.10.224.24:8080' export const DATA_URL = `${API_ROOT}/data` diff --git a/src/features/Common/Input/styled.tsx b/src/features/Common/Input/styled.tsx index 708cd621..873080ba 100644 --- a/src/features/Common/Input/styled.tsx +++ b/src/features/Common/Input/styled.tsx @@ -1,11 +1,11 @@ -import styled from 'styled-components/macro' +import styled, { css } from 'styled-components/macro' export type TInputWrapper = { paddingX?: number, wrapperWidth?: number, } -export const InputWrapper = styled.div` +export const wrapperStyles = css` width: ${({ wrapperWidth }) => (wrapperWidth ? `${wrapperWidth}px` : '100%')}; height: 48px; margin: 20px 0; @@ -20,6 +20,10 @@ export const InputWrapper = styled.div` border-radius: 2px; ` +export const InputWrapper = styled.div` + ${wrapperStyles} +` + type TLabel = { labelWidth?: number, } @@ -39,7 +43,7 @@ type TInputStyled = { inputWidth?: number, } -export const InputStyled = styled.input` +export const inputStyles = css` flex-grow: 1; font-weight: bold; font-size: 20px; @@ -68,3 +72,7 @@ export const InputStyled = styled.input` -webkit-text-fill-color: ${({ theme: { colors } }) => colors.text}; } ` + +export const InputStyled = styled.input` + ${inputStyles} +` diff --git a/src/features/CountrySelector/helpers/__tests__/index.tsx b/src/features/CountrySelector/helpers/__tests__/index.tsx new file mode 100644 index 00000000..1b78ecbb --- /dev/null +++ b/src/features/CountrySelector/helpers/__tests__/index.tsx @@ -0,0 +1,69 @@ +import { matchSort } from '..' + +it('does not sort items when query is empty', () => { + const items = [ + { name: 'app' }, + { name: 'apple' }, + ] + const query = '' + const result = matchSort( + items, + 'name', + query, + ) + expect(result).toEqual(items) +}) + +it('non matching words omitted', () => { + const items = [ + { name: 'banana' }, + { name: 'app' }, + { name: 'apple' }, + ] + const query = 'app' + const result = matchSort( + items, + 'name', + query, + ) + expect(result).toEqual([ + items[1], + items[2], + ]) +}) + +it('matching words go first', () => { + const items = [ + { name: 'apple' }, + { name: 'app' }, + ] + const query = 'app' + const result = matchSort( + items, + 'name', + query, + ) + expect(result).toEqual([ + items[1], + items[0], + ]) +}) + +it('words starting with query word go second', () => { + const items = [ + { name: 'apple' }, + { name: 'app' }, + { name: 'buggy app' }, + ] + const query = 'app' + const result = matchSort( + items, + 'name', + query, + ) + expect(result).toEqual([ + items[1], + items[0], + items[2], + ]) +}) diff --git a/src/features/CountrySelector/helpers/index.tsx b/src/features/CountrySelector/helpers/index.tsx new file mode 100644 index 00000000..e891445b --- /dev/null +++ b/src/features/CountrySelector/helpers/index.tsx @@ -0,0 +1,77 @@ +import startsWith from 'lodash/startsWith' +import orderBy from 'lodash/orderBy' +import toLower from 'lodash/toLower' +import filter from 'lodash/filter' +import split from 'lodash/split' +import size from 'lodash/size' +import some from 'lodash/some' + +type Item = { [key: string]: any } + +type Func = ( + items: Array, + key: K, + query: string, +) => Array + +const startsWithIgnore = (word: string, target: string) => ( + startsWith(toLower(word), toLower(target)) +) + +const hasManyWords = (word: string) => size(split(word, ' ')) > 1 + +const atLeastOneWordStartsWith = (word: string, target: string) => { + const words = split(word, ' ') + return some(words, (w) => startsWithIgnore(w, target)) +} + +const filterMatching: Func = ( + items, + key, + query, +) => ( + filter(items, (item) => { + const value = item[key] + if (startsWithIgnore(value, query)) return true + if (hasManyWords(value)) return atLeastOneWordStartsWith(value, query) + return false + }) +) + +const sortMatching: Func = ( + items, + key, + query, +) => ( + orderBy( + items, + (item) => { + if (item[key] === query) return 10 + if (startsWithIgnore(item[key], query)) { + return 5 + } + return 0 + }, + 'desc', + ) +) + +export const matchSort: Func = ( + items, + key, + query, +) => { + if (!query) return items + + const filteredItems = filterMatching( + items, + key, + query, + ) + + return sortMatching( + filteredItems, + key, + query, + ) +} diff --git a/src/features/CountrySelector/hooks.tsx b/src/features/CountrySelector/hooks.tsx new file mode 100644 index 00000000..61a69061 --- /dev/null +++ b/src/features/CountrySelector/hooks.tsx @@ -0,0 +1,88 @@ +import type { ChangeEvent, FocusEvent } from 'react' +import type { Countries } from 'requests' + +import { + useEffect, + useState, + useCallback, +} from 'react' + +import toLower from 'lodash/toLower' +import slice from 'lodash/slice' +import find from 'lodash/find' + +import { getCountries } from 'requests' + +import { matchSort } from './helpers' + +// временно, будем считывать из стора(контекста) лексики +const useCurrentLang = () => 'eng' + +const useCountriesList = () => { + const [countries, setCountries] = useState([]) + + useEffect(() => { + getCountries().then(setCountries) + }, []) + + return countries +} + +const useQuery = () => { + const [query, setQuery] = useState('') + + const onQueryChange = useCallback(({ target }: ChangeEvent) => { + setQuery(target.value) + }, []) + + return { + onQueryChange, + query, + setQuery, + } +} + +const isOptionClicked = (target: HTMLElement) => target?.getAttribute('role') === 'option' + +export const useCountrySelector = () => { + const lang = useCurrentLang() + const countries = useCountriesList() + const { + onQueryChange, + query, + setQuery, + } = useQuery() + + const keyToSortBy = `name_${lang}` as 'name_eng' + + const results = matchSort( + countries, + keyToSortBy, + query, + ) + + const onBlur = (event: FocusEvent) => { + const target = event.relatedTarget as HTMLElement | null + // клик по элементу списка тоже вызывает onBlur + // если кликали элемент списка то событие обрабатывается onCountrySelect + if (target && isOptionClicked(target)) return + + const found = find( + countries, + (country) => toLower(country[keyToSortBy]) === toLower(query), + ) + setQuery(found ? found[keyToSortBy] : '') + } + + return { + countries: slice( + results, + 0, + 20, + ), + onBlur, + onCountrySelect: setQuery, + onQueryChange, + query, + } +} diff --git a/src/features/CountrySelector/index.tsx b/src/features/CountrySelector/index.tsx new file mode 100644 index 00000000..07912710 --- /dev/null +++ b/src/features/CountrySelector/index.tsx @@ -0,0 +1,63 @@ +import React from 'react' + +import isEmpty from 'lodash/isEmpty' +import map from 'lodash/map' +import { + ComboboxPopover, + ComboboxList, + ComboboxOption, +} from '@reach/combobox' +import '@reach/combobox/styles.css' + +import { Label } from 'features/Common/Input/styled' + +import { useCountrySelector } from './hooks' +import { + ComboboxStyled, + ComboboxInputStyled, + Arrow, +} from './styled' + +type Props = { + labelWidth?: number, +} + +export const CountrySelector = ({ labelWidth }: Props) => { + const { + countries, + onBlur, + onCountrySelect, + onQueryChange, + query, + } = useCountrySelector() + + return ( + + + + {!isEmpty(countries) && ( + + + {map(countries, ({ id, name_eng }) => ( + + ))} + + + )} + + ) +} diff --git a/src/features/CountrySelector/styled.tsx b/src/features/CountrySelector/styled.tsx new file mode 100644 index 00000000..bcd76ac8 --- /dev/null +++ b/src/features/CountrySelector/styled.tsx @@ -0,0 +1,27 @@ +import styled from 'styled-components/macro' + +import { Combobox, ComboboxInput } from '@reach/combobox' + +import { wrapperStyles, inputStyles } from 'features/Common/Input/styled' + +export const ComboboxStyled = styled(Combobox)` + ${wrapperStyles} + position: relative; +` + +export const ComboboxInputStyled = styled(ComboboxInput)` + ${inputStyles} + padding-right: 24px; +` + +export const Arrow = styled.div` + position: absolute; + right: 20px; + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 12px; + background-image: url(/images/arrowDown.svg); + background-position: center; + background-repeat: no-repeat; +` diff --git a/src/features/Register/Steps/Registration/index.tsx b/src/features/Register/Steps/Registration/index.tsx index 9e5a5fe8..3eefccde 100644 --- a/src/features/Register/Steps/Registration/index.tsx +++ b/src/features/Register/Steps/Registration/index.tsx @@ -2,6 +2,7 @@ import React from 'react' import { PAGES } from 'config' +import { CountrySelector } from 'features/CountrySelector' import { Input } from 'features/Common' import { BlockTitle, @@ -38,15 +39,15 @@ export const Registration = () => ( label='E-mail' labelWidth={labelWidth} /> - {/* TODO: it should be Dropdown */} + diff --git a/src/helpers/callApi/getResponseData.tsx b/src/helpers/callApi/getResponseData.tsx index 149ace17..2aa97ed1 100644 --- a/src/helpers/callApi/getResponseData.tsx +++ b/src/helpers/callApi/getResponseData.tsx @@ -1,3 +1,3 @@ export const getResponseData = (proc: string) => (response: any) => ( - response?.data?.[0]?.[proc] + response?.[proc] ) diff --git a/src/helpers/index.tsx b/src/helpers/index.tsx index 8842e651..1ba6128d 100644 --- a/src/helpers/index.tsx +++ b/src/helpers/index.tsx @@ -1 +1,2 @@ export * from './callApi' +export * from './callApi/getResponseData' diff --git a/src/requests/getCountries.tsx b/src/requests/getCountries.tsx new file mode 100644 index 00000000..30944b42 --- /dev/null +++ b/src/requests/getCountries.tsx @@ -0,0 +1,28 @@ +import { DATA_URL, PROCEDURES } from 'config' +import { callApi, getResponseData } from 'helpers' + +const proc = PROCEDURES.lst_c_country + +export type Country = { + id: number, + iso_3166_1_alpha_2: string, + iso_3166_1_alpha_3: string, + name_eng: string, + name_rus: string, +} + +export type Countries = Array + +export const getCountries = (): Promise => { + const config = { + body: { + params: null, + proc, + }, + } + + return callApi({ + config, + url: DATA_URL, + }).then(getResponseData(proc)) +} diff --git a/src/requests/index.tsx b/src/requests/index.tsx index d4389750..a803e3e4 100644 --- a/src/requests/index.tsx +++ b/src/requests/index.tsx @@ -1 +1,2 @@ export * from './login' +export * from './getCountries'