diff --git a/src/config/history.tsx b/src/config/history.tsx new file mode 100644 index 00000000..ea922a80 --- /dev/null +++ b/src/config/history.tsx @@ -0,0 +1,3 @@ +import { createBrowserHistory } from 'history' + +export const history = createBrowserHistory() diff --git a/src/config/index.tsx b/src/config/index.tsx index 4f34c197..6dccc043 100644 --- a/src/config/index.tsx +++ b/src/config/index.tsx @@ -4,3 +4,4 @@ export * from './authKeys' export * from './procedures' export * from './sportTypes' export * from './profileTypes' +export * from './history' diff --git a/src/features/App/index.tsx b/src/features/App/index.tsx index 9363aa75..00190ac8 100644 --- a/src/features/App/index.tsx +++ b/src/features/App/index.tsx @@ -1,6 +1,7 @@ import React from 'react' import { Router } from 'react-router-dom' -import { createBrowserHistory } from 'history' + +import { history } from 'config' import { GlobalStores } from 'features/GlobalStores' import { useAuthStore } from 'features/AuthStore' @@ -11,8 +12,6 @@ import { Theme } from 'features/Theme' import { AuthenticatedApp } from './AuthenticatedApp' import { UnauthenticatedApp } from './UnauthenticatedApp' -const history = createBrowserHistory() - const Main = () => { const { token } = useAuthStore() diff --git a/src/features/HeaderFilters/store/config.tsx b/src/features/HeaderFilters/store/config.tsx new file mode 100644 index 00000000..d2aea213 --- /dev/null +++ b/src/features/HeaderFilters/store/config.tsx @@ -0,0 +1,6 @@ +export const filterKeys = { + DATE: 'date', + MATCH_STATUS: 'status', + SPORT_TYPE: 'sport', + TOURNAMENT_ID: 'tournament', +} diff --git a/src/features/HeaderFilters/store/helpers/isValidDate/__tests__/index.tsx b/src/features/HeaderFilters/store/helpers/isValidDate/__tests__/index.tsx new file mode 100644 index 00000000..9a7ddea7 --- /dev/null +++ b/src/features/HeaderFilters/store/helpers/isValidDate/__tests__/index.tsx @@ -0,0 +1,13 @@ +import { isValidDate } from '..' + +it('returns true for valid dates', () => { + expect(isValidDate(new Date('1970-01-01'))).toBe(true) + expect(isValidDate(new Date('2020-01-01'))).toBe(true) + expect(isValidDate(new Date('2020-08-26T08:54:32.020Z'))).toBe(true) +}) + +it('returns false for invalid dates', () => { + expect(isValidDate(new Date('2020-13-01'))).toBe(false) + expect(isValidDate(new Date('2020-12-32'))).toBe(false) + expect(isValidDate(new Date('2020-08-26T08:99:32.020Z'))).toBe(false) +}) diff --git a/src/features/HeaderFilters/store/helpers/isValidDate/index.tsx b/src/features/HeaderFilters/store/helpers/isValidDate/index.tsx new file mode 100644 index 00000000..d8bed48a --- /dev/null +++ b/src/features/HeaderFilters/store/helpers/isValidDate/index.tsx @@ -0,0 +1,6 @@ +export const isValidDate = (value: Date) => { + if (value instanceof Date) { + return value.toString().toLocaleLowerCase() !== 'invalid date' + } + return false +} diff --git a/src/features/HeaderFilters/store/helpers/isValidMatchStatus/__tests__/index.tsx b/src/features/HeaderFilters/store/helpers/isValidMatchStatus/__tests__/index.tsx new file mode 100644 index 00000000..de53ba35 --- /dev/null +++ b/src/features/HeaderFilters/store/helpers/isValidMatchStatus/__tests__/index.tsx @@ -0,0 +1,15 @@ +import { MatchStatuses } from 'features/HeaderFilters/store/hooks' + +import { isValidMatchStatus } from '..' + +it('returns true for valid match statuses', () => { + expect(isValidMatchStatus(MatchStatuses.Finished)).toBe(true) + expect(isValidMatchStatus(MatchStatuses.Live)).toBe(true) + expect(isValidMatchStatus(MatchStatuses.Soon)).toBe(true) +}) + +it('returns false for invalid match statuses', () => { + expect(isValidMatchStatus(-1)).toBe(false) + expect(isValidMatchStatus(0)).toBe(false) + expect(isValidMatchStatus(4)).toBe(false) +}) diff --git a/src/features/HeaderFilters/store/helpers/isValidMatchStatus/index.tsx b/src/features/HeaderFilters/store/helpers/isValidMatchStatus/index.tsx new file mode 100644 index 00000000..1410ffba --- /dev/null +++ b/src/features/HeaderFilters/store/helpers/isValidMatchStatus/index.tsx @@ -0,0 +1,9 @@ +import isNumber from 'lodash/isNumber' +import includes from 'lodash/includes' +import values from 'lodash/values' + +import { MatchStatuses } from '../../hooks' + +export const isValidMatchStatus = (value: number) => ( + isNumber(value) && includes(values(MatchStatuses), value) +) diff --git a/src/features/HeaderFilters/store/helpers/isValidSportType/__tests__/index.tsx b/src/features/HeaderFilters/store/helpers/isValidSportType/__tests__/index.tsx new file mode 100644 index 00000000..e1e99439 --- /dev/null +++ b/src/features/HeaderFilters/store/helpers/isValidSportType/__tests__/index.tsx @@ -0,0 +1,15 @@ +import { SportTypes } from 'config' + +import { isValidSportType } from '..' + +it('returns true for valid sport types', () => { + expect(isValidSportType(SportTypes.BASKETBALL)).toBe(true) + expect(isValidSportType(SportTypes.FOOTBALL)).toBe(true) + expect(isValidSportType(SportTypes.HOCKEY)).toBe(true) +}) + +it('returns false for invalid sport types', () => { + expect(isValidSportType(-1)).toBe(false) + expect(isValidSportType(0)).toBe(false) + expect(isValidSportType(4)).toBe(false) +}) diff --git a/src/features/HeaderFilters/store/helpers/isValidSportType/index.tsx b/src/features/HeaderFilters/store/helpers/isValidSportType/index.tsx new file mode 100644 index 00000000..876cd9e9 --- /dev/null +++ b/src/features/HeaderFilters/store/helpers/isValidSportType/index.tsx @@ -0,0 +1,9 @@ +import isNumber from 'lodash/isNumber' +import includes from 'lodash/includes' +import values from 'lodash/values' + +import { SportTypes } from 'config' + +export const isValidSportType = (value: number | null) => ( + isNumber(value) && includes(values(SportTypes), value) +) diff --git a/src/features/HeaderFilters/store/hooks/index.tsx b/src/features/HeaderFilters/store/hooks/index.tsx index 0caa7a59..d0cc0184 100644 --- a/src/features/HeaderFilters/store/hooks/index.tsx +++ b/src/features/HeaderFilters/store/hooks/index.tsx @@ -10,15 +10,23 @@ import flatten from 'lodash/flatten' import pipe from 'lodash/fp/pipe' import fpMap from 'lodash/fp/map' import fpOrderBy from 'lodash/fp/orderBy' +import isNumber from 'lodash/isNumber' import format from 'date-fns/format' import startOfDay from 'date-fns/startOfDay' import type { Matches, Content } from 'requests' -import { SportTypes, SPORT_NAMES } from 'config' import { getMatches } from 'requests' -import { useLexicsStore } from 'features/LexicsStore' +import { SportTypes, SPORT_NAMES } from 'config' import { getProfileLogo } from 'helpers' +import { useQueryParamStore } from 'hooks' +import { useLexicsStore } from 'features/LexicsStore' + +import { filterKeys } from '../config' +import { isValidDate } from '../helpers/isValidDate' +import { isValidSportType } from '../helpers/isValidSportType' +import { isValidMatchStatus } from '../helpers/isValidMatchStatus' + type Name = 'name_rus' | 'name_eng' export type Match = { @@ -49,10 +57,39 @@ export const useFilters = () => { const { suffix, } = useLexicsStore() - const [selectedDate, setSelectedDate] = useState(new Date()) - const [selectedMatchStatus, setSelectedMatchStatus] = useState(MatchStatuses.Live) - const [selectedSportTypeId, setSelectedSportTypeId] = useState(null) - const [selectedTournamentId, setSelectedTournamentId] = useState(null) + const [selectedDate, setSelectedDate] = useQueryParamStore({ + defaultValue: new Date(), + key: filterKeys.DATE, + validator: isValidDate, + }) + + const [ + selectedMatchStatus, + setSelectedMatchStatus, + ] = useQueryParamStore({ + defaultValue: MatchStatuses.Live, + key: filterKeys.MATCH_STATUS, + validator: isValidMatchStatus, + }) + + const [ + selectedSportTypeId, + setSelectedSportTypeId, + ] = useQueryParamStore({ + defaultValue: null, + key: filterKeys.SPORT_TYPE, + validator: isValidSportType, + }) + + const [ + selectedTournamentId, + setSelectedTournamentId, + ] = useQueryParamStore({ + defaultValue: null, + key: filterKeys.TOURNAMENT_ID, + validator: isNumber, + }) + const [teamId, setTeamId] = useState(null) const [matches, setMatches] = useState({ broadcast: [], @@ -118,15 +155,13 @@ export const useFilters = () => { fpOrderBy((match: Match) => Number(new Date(match.date)), 'desc'), )(content) as Array, [suffix]) - const preparedMatches = { - broadcast: prepareMatches(matches.broadcast), - features: prepareMatches(matches.features), - highlights: prepareMatches(matches.highlights), - isVideoSections: matches.isVideoSections, - } - const store = useMemo(() => ({ - matches: preparedMatches, + matches: { + broadcast: prepareMatches(matches.broadcast), + features: prepareMatches(matches.features), + highlights: prepareMatches(matches.highlights), + isVideoSections: matches.isVideoSections, + }, selectedDate, selectedMatchStatus, selectedSportTypeId, @@ -141,9 +176,12 @@ export const useFilters = () => { selectedMatchStatus, selectedSportTypeId, selectedTournamentId, + setSelectedDate, + setSelectedMatchStatus, + setSelectedSportTypeId, setSelectedTournamentId, - setTeamId, - preparedMatches, + matches, + prepareMatches, ]) return store diff --git a/src/features/QueryParamsStorage/index.tsx b/src/features/QueryParamsStorage/index.tsx new file mode 100644 index 00000000..a5acc5f0 --- /dev/null +++ b/src/features/QueryParamsStorage/index.tsx @@ -0,0 +1,72 @@ +import forEach from 'lodash/forEach' +import size from 'lodash/size' + +import type { History } from 'history' + +import { history } from 'config/history' + +/** + * Не явно наследует от Storage (https://developer.mozilla.org/ru/docs/Web/API/Storage) + * прямое наследование выдает ошибку: TypeError: Illegal constructor + */ +class QueryParamStorage { + /** + * через history обновляем url строку + */ + history: History + + /** + * хранит все состояние query param + */ + urlParams: URLSearchParams + + constructor(historyArg: History) { + this.history = historyArg + this.urlParams = new URLSearchParams(this.history.location.search) + } + + get entries() { + const entries = this.urlParams.entries() + return Array.from(entries) + } + + updateHistory() { + history.push(`?${this.urlParams.toString()}`) + } + + clear() { + forEach(this.entries, ([key]) => { + this.urlParams.delete(key) + }) + this.updateHistory() + } + + getItem(key: string) { + return this.urlParams.get(key) + } + + key(index: number) { + const keys = this.urlParams.keys() + return Array.from(keys)[index] + } + + removeItem(key: string) { + this.urlParams.delete(key) + this.updateHistory() + } + + setItem(key: string, value: string) { + if (JSON.parse(value)) { + this.urlParams.set(key, value) + this.updateHistory() + } else { + this.removeItem(key) + } + } + + get length() { + return size(this.entries) + } +} + +export const queryParamStorage = new QueryParamStorage(history) diff --git a/src/features/ToggleScore/store.tsx b/src/features/ToggleScore/store.tsx index 8715bcf1..c5a5c5a3 100644 --- a/src/features/ToggleScore/store.tsx +++ b/src/features/ToggleScore/store.tsx @@ -4,7 +4,11 @@ import React, { useContext, } from 'react' -import { useToggle } from 'hooks' +import isBoolean from 'lodash/isBoolean' + +import { useLocalStore } from 'hooks' + +const SCORE_KEY = 'hide_score' type ScoreStore = { isVisible: boolean, @@ -16,7 +20,12 @@ type Props = { children: ReactNode } const ScoreContext = createContext({} as ScoreStore) export const ScoreStore = ({ children }: Props) => { - const { isOpen, toggle } = useToggle() + const [isOpen, setIsOpen] = useLocalStore({ + defaultValue: false, + key: SCORE_KEY, + validator: isBoolean, + }) + const toggle = () => setIsOpen(!isOpen) return ( diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index 95d70492..ab9755cb 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -2,3 +2,4 @@ export * from './usePageId' export * from './useToggle' export * from './useRequest' export * from './useSportNameParam' +export * from './useStorage' diff --git a/src/hooks/useStorage/helpers.tsx b/src/hooks/useStorage/helpers.tsx new file mode 100644 index 00000000..0ca2e99b --- /dev/null +++ b/src/hooks/useStorage/helpers.tsx @@ -0,0 +1,45 @@ +import isString from 'lodash/isString' + +const dateFormat = /^\d{4}-\d{2}-\d{2}/ + +/** + * Проверяет имеет ли строка формат yyyy-MM-dd + */ +const isDateString = (value: string) => ( + isString(value) && dateFormat.test(value) +) + +/** + * Преобразовывает строку дату в объект Date + * 2020-08-26T08:17:01.604Z => 2020-08-26 + */ +const dateReviver = (key: string, value: string) => { + if (isDateString(value)) { + return new Date(value) + } + + return value +} + +/** + * Убирает время из строки даты + * 2020-08-26T08:17:01.604Z => 2020-08-26 + */ +export const dateReplacer = (key: string, value: string) => { + if (isDateString(value)) { + return value.slice(0, 10) + } + + return value +} + +/** + * Считывает значение из стора по key + */ +export const readStorageInitialValue = (storage: Storage, key: string) => { + const rawValue = storage.getItem(key) + if (rawValue) { + return JSON.parse(rawValue, dateReviver) + } + return null +} diff --git a/src/hooks/useStorage/index.tsx b/src/hooks/useStorage/index.tsx new file mode 100644 index 00000000..e28262c5 --- /dev/null +++ b/src/hooks/useStorage/index.tsx @@ -0,0 +1,60 @@ +import { + useState, + useCallback, + useEffect, +} from 'react' + +import { queryParamStorage } from 'features/QueryParamsStorage' + +import { + dateReplacer, + readStorageInitialValue, +} from './helpers' + +type Args = { + defaultValue: T, + key: string, + + /** + * функция для валидации полученного значения из стора + * если значение не валидно то используется defaultValue + */ + validator?: (value: T) => boolean, +} + +/** + * Хук высшего порядка, получает сторедж + * и возвращает хук работающий с данным стореджем + * Хук считывает значение из стора и использует как начальное значение стейта, + * при вызове сеттера обновляется сторедж и реакт стейет + */ +const createHook = (storage: Storage) => ( + ({ + defaultValue, + key, + validator, + }: Args) => { + const storeValue = readStorageInitialValue(storage, key) + const isValid = validator && validator(storeValue) + const initialState = isValid ? storeValue : defaultValue + + const [state, setState] = useState(initialState) + + const setStateAndSave = useCallback((value: T) => { + storage.setItem(key, JSON.stringify(value, dateReplacer)) + setState(value) + }, [key]) + + useEffect(() => { + if (!isValid) { + storage.removeItem(key) + } + }, [isValid, key]) + + return [state, setStateAndSave] as const + } +) + +export const useLocalStore = createHook(localStorage) +export const useSessionStore = createHook(sessionStorage) +export const useQueryParamStore = createHook(queryParamStorage) diff --git a/src/hooks/useToggle.tsx b/src/hooks/useToggle.tsx index f05f3d84..4ec6e58b 100644 --- a/src/hooks/useToggle.tsx +++ b/src/hooks/useToggle.tsx @@ -1,7 +1,7 @@ import { useState, useCallback } from 'react' -export const useToggle = () => { - const [isOpen, setIsOpen] = useState(false) +export const useToggle = (initialState = false) => { + const [isOpen, setIsOpen] = useState(initialState) const open = useCallback(() => setIsOpen(true), []) const close = useCallback(() => setIsOpen(false), []) const toggle = useCallback(() => setIsOpen((state) => !state), [])