diff --git a/README.md b/README.md index f34a77f..1b5caa4 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ type SkillGroupData = { skillCount: SkillCount; selectedSkillCount: SkillCount; resetSkills: () => void; + handleFilter: (query: string) => void; }; type SkillCount = { @@ -180,6 +181,18 @@ type SavedDataType = { ## Features +### Filtering + +The `` component exposes the `handleFilter()` method which can be used to close any trees that don't contain skills that match the query. This can be used in conjunction with your own input component like so: + +```tsx + handleFilter(e.target.value)} + placeholder="Filter through trees..." +/> +``` + ### Custom Themes It's likely that you're application won't look to hot with a dark blue/rainbow themed skill tree. Fortunately, a custom theme can be supplied to the `SkillTreeGroup` component. The styles passed through will override the defaults to allow your skill tree to fit nicely into your application. The theme object's type is exported in the package as `SkillThemeType`. I don't perform any object merging between the default styles and the user-defined object, so you'll need to fill out the whole object. diff --git a/example/components/FIlterInput.tsx b/example/components/FIlterInput.tsx new file mode 100644 index 0000000..9c5d6c4 --- /dev/null +++ b/example/components/FIlterInput.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; + +interface Props { + handleFilter: (query: string) => void; +} + +function FilterInput(props: Props) { + const { handleFilter } = props; + return ( + handleFilter(e.target.value)} + placeholder="Filter through trees..." + /> + ); +} + +export default FilterInput; diff --git a/example/index.tsx b/example/index.tsx index 700da83..ed03173 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -18,6 +18,7 @@ import { import './index.css'; import { legsPushData, legsPullData, hpSavedData } from './mockData'; import { ContextStorage } from '../src/models'; +import FilterInput from './components/FIlterInput'; function handleSave( storage: ContextStorage, @@ -35,6 +36,7 @@ const App = () => { skillCount, selectedSkillCount, resetSkills, + handleFilter, }: SkillGroupDataType) => { const totalSkillCount = skillCount.optional + skillCount.required; const totalSelectedCount = @@ -60,6 +62,7 @@ const App = () => { Reset + { const totalSkillCount = skillCount.required + skillCount.optional; const totalSelectedSkillCount = @@ -167,6 +168,10 @@ function renderComponent( + handleFilter(target.value)} + /> { expect(htmlNode).toHaveStyleRule('background', 'grey'); }); }); + + describe('filtering', () => { + it('should display the tree when no query has been made', () => { + const { getByPlaceholderText, getAllByTestId } = renderComponent([]); + + const filterInput = getByPlaceholderText('filter'); + const [visibilityContainer] = getAllByTestId('visibility-container'); + + expect(visibilityContainer).toHaveStyle('opacity: 1'); + + fireEvent.change(filterInput, { + target: { + value: 'ppppa', + }, + }); + + expect(visibilityContainer).toHaveStyle('opacity: 0'); + }); + + it('should display the tree if the query contains a matching skillid', () => { + const { getByPlaceholderText, getAllByTestId } = renderComponent([]); + + const filterInput = getByPlaceholderText('filter'); + const [visibilityContainer] = getAllByTestId('visibility-container'); + + expect(visibilityContainer).toHaveStyle('opacity: 1'); + + fireEvent.change(filterInput, { + target: { + value: 'java', + }, + }); + + expect(visibilityContainer).toHaveStyle('opacity: 1'); + }); + + it('should display the tree if no valid query is present', () => { + const { getByPlaceholderText, getAllByTestId } = renderComponent([]); + + const filterInput = getByPlaceholderText('filter'); + const [visibilityContainer] = getAllByTestId('visibility-container'); + + expect(visibilityContainer).toHaveStyle('opacity: 1'); + + fireEvent.change(filterInput, { + target: { + value: null, + }, + }); + + expect(visibilityContainer).toHaveStyle('opacity: 1'); + }); + + it('should not display the tree if the query does not contain a matching skillid', () => { + const { getByPlaceholderText, getAllByTestId } = renderComponent([]); + + const filterInput = getByPlaceholderText('filter'); + const [visibilityContainer] = getAllByTestId('visibility-container'); + + expect(visibilityContainer).toHaveStyle('opacity: 1'); + + fireEvent.change(filterInput, { + target: { + value: 'javascrooo', + }, + }); + + expect(visibilityContainer).toHaveStyle('opacity: 0'); + }); + + it('should cause a tree to hide/show when the filter query changes', () => { + const { getByPlaceholderText, getAllByTestId } = renderComponent([]); + + const filterInput = getByPlaceholderText('filter'); + const [visibilityContainer] = getAllByTestId('visibility-container'); + + expect(visibilityContainer).toHaveStyle('opacity: 1'); + + fireEvent.change(filterInput, { + target: { + value: 'javascrooo', + }, + }); + + expect(visibilityContainer).toHaveStyle('opacity: 0'); + + fireEvent.change(filterInput, { + target: { + value: 'javascroo', + }, + }); + + expect(visibilityContainer).toHaveStyle('opacity: 0'); + + fireEvent.change(filterInput, { + target: { + value: 'java', + }, + }); + + expect(visibilityContainer).toHaveStyle('opacity: 1'); + + fireEvent.change(filterInput, { + target: { + value: 'javajava', + }, + }); + + expect(visibilityContainer).toHaveStyle('opacity: 0'); + + fireEvent.change(filterInput, { + target: { + value: null, + }, + }); + + expect(visibilityContainer).toHaveStyle('opacity: 1'); + }); + + it('should only display the trees that contain skillIds that match the filter query', () => { + const { getByPlaceholderText, getAllByTestId } = renderComponent( + complexData + ); + + const filterInput = getByPlaceholderText('filter'); + const [visibilityContainerOne, visibilityContainerTwo] = getAllByTestId( + 'visibility-container' + ); + + expect(visibilityContainerOne).toHaveStyle('opacity: 1'); + expect(visibilityContainerTwo).toHaveStyle('opacity: 1'); + + fireEvent.change(filterInput, { + target: { + value: 'css', + }, + }); + + expect(visibilityContainerOne).toHaveStyle('opacity: 1'); + expect(visibilityContainerTwo).toHaveStyle('opacity: 0'); + }); + + it('should display the trees when the filter query contains only spaces', () => { + const { getByPlaceholderText, getAllByTestId } = renderComponent([]); + + const filterInput = getByPlaceholderText('filter'); + const [visibilityContainerOne] = getAllByTestId('visibility-container'); + + expect(visibilityContainerOne).toHaveStyle('opacity: 1'); + + fireEvent.change(filterInput, { + target: { + value: ' ', + }, + }); + + expect(visibilityContainerOne).toHaveStyle('opacity: 1'); + }); + }); }); diff --git a/src/components/SkillNode.tsx b/src/components/SkillNode.tsx index c52334a..094e711 100644 --- a/src/components/SkillNode.tsx +++ b/src/components/SkillNode.tsx @@ -113,7 +113,7 @@ function SkillNode({ return ( + {children} + + ); +} + +export default SkillProvider; diff --git a/src/components/SkillTree.tsx b/src/components/SkillTree.tsx index 4fb19f2..1b9a993 100644 --- a/src/components/SkillTree.tsx +++ b/src/components/SkillTree.tsx @@ -1,13 +1,15 @@ -import React, { useContext, useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { Skill, SavedDataType, ContextStorage } from '../models'; import SkillTreeSegment from './SkillTreeSegment'; import HSeparator from './ui/HSeparator'; import CalculateNodeCount from './CalculateNodeCount'; import { SkillTreeProvider } from '../context/SkillContext'; import styled, { BaseThemedCssFunction } from 'styled-components'; -import MobileContext from '../context/MobileContext'; import { SkillTheme } from '../theme'; import SkillTreeHeader from './SkillTreeHeader'; +import AddToFilterIndex from './filter/AddToFilterIndex'; +import FilterListener from './filter/FilterListener'; +import useMobile from '../hooks/useMobile'; const css: BaseThemedCssFunction = require('styled-components').css; @@ -42,56 +44,67 @@ function SkillTree({ handleSave, collapsible = false, }: Props) { - const { isMobile } = useContext(MobileContext); + const isMobile = useMobile(); const [isVisible, setVisibility] = useState(true); - function toggleVisibility() { - if (!collapsible) return; + const memoizedToggleVisibility = useCallback( + function toggleVisibility() { + if (!collapsible) return; - return setVisibility(!isVisible); - } + return setVisibility(!isVisible); + }, + [isVisible] + ); return ( - - - - - - - {data.map((skill, i) => { - const displaySeparator = data.length - 1 !== i && isMobile; - - return ( - - - - - ); - })} - - - - + + + + + + + + + + {data.map((skill, i) => { + const displaySeparator = data.length - 1 !== i && isMobile; + + return ( + + + + + ); + })} + + + + + ); } diff --git a/src/components/SkillTreeGroup.tsx b/src/components/SkillTreeGroup.tsx index 0c3a252..2b70acc 100644 --- a/src/components/SkillTreeGroup.tsx +++ b/src/components/SkillTreeGroup.tsx @@ -2,9 +2,9 @@ import * as React from 'react'; import AppContext from '../context/AppContext'; import styled, { ThemeProvider } from 'styled-components'; import defaultTheme from '../theme/index'; -import { DeepPartial } from 'models/utils'; +import { DeepPartial } from '../models/utils'; import { SkillGroupData } from '../models'; -import { MobileProvider } from '../context/MobileContext'; +import FilterContext from '../context/FilterContext'; type Props = { children: (treeData: SkillGroupData) => React.ReactNode; @@ -19,19 +19,21 @@ function SkillTreeGroup({ theme, children }: Props) { AppContext ); - const skillTreeTheme = { ...defaultTheme, ...theme }; + const { handleFilter } = React.useContext(FilterContext); + const skillTreeTheme = React.useMemo(() => ({ ...defaultTheme, ...theme }), [ + theme, + ]); - const treeData = { + const treeData: SkillGroupData = { skillCount, selectedSkillCount, resetSkills, + handleFilter, }; return ( - - {children(treeData)} - + {children(treeData)} ); } diff --git a/src/components/SkillTreeHeader.tsx b/src/components/SkillTreeHeader.tsx index dde097a..b8a04e4 100644 --- a/src/components/SkillTreeHeader.tsx +++ b/src/components/SkillTreeHeader.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import styled, { BaseThemedCssFunction } from 'styled-components'; import SkillCountSubtitle from './SkillCountSubtitle'; import { SkillTheme } from '../theme'; @@ -26,14 +26,20 @@ interface CollapsibleContainerProps { function SkillTreeHeader(props: Props) { const { handleClick, collapsible, isVisible, id, title, description } = props; + + const memoizedHandleKeyDown = useCallback( + function handleKeyDown(e: React.KeyboardEvent) { + if (e.keyCode === 13) { + handleClick(); + } + }, + [handleClick] + ); + return ( ) => { - if (e.keyCode === 13) { - handleClick(); - } - }} + onKeyDown={memoizedHandleKeyDown} onClick={handleClick} isCollapsible={collapsible} > diff --git a/src/components/__tests__/SkillTree.test.tsx b/src/components/__tests__/SkillTree.test.tsx index 2d6cf63..3b502bb 100644 --- a/src/components/__tests__/SkillTree.test.tsx +++ b/src/components/__tests__/SkillTree.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, fireEvent, act } from '@testing-library/react'; import SkillTree, { Props } from '../SkillTree'; import MockLocalStorage from '../../__mocks__/mockLocalStorage'; -import { SkillProvider } from '../../context/AppContext'; +import SkillProvider from '../../components/SkillProvider'; import SkillTreeGroup from '../../components/SkillTreeGroup'; import { Skill, SkillCount } from '../../models/index'; import { SavedDataType } from '../../'; diff --git a/src/components/filter/AddToFilterIndex.tsx b/src/components/filter/AddToFilterIndex.tsx new file mode 100644 index 0000000..1e396c3 --- /dev/null +++ b/src/components/filter/AddToFilterIndex.tsx @@ -0,0 +1,40 @@ +import { useContext, useEffect } from 'react'; +import { Skill, SkillMap } from '../../models'; +import FilterContext from '../../context/FilterContext'; + +interface Props { + treeId: string; + skills: Skill[]; +} + +function createSkillsTreeMap(treeId: string, skills: Skill[]) { + const skillsTreeMap: SkillMap = {}; + + function addSkillToMap(currentSkill: Skill[]) { + currentSkill.forEach(skill => { + if (skill.children.length > 0) { + addSkillToMap(skill.children); + } + + skillsTreeMap[skill.title.toLowerCase()] = treeId; + }); + } + + addSkillToMap(skills); + + return skillsTreeMap; +} + +function AddToFilterIndex(props: Props) { + const { skills, treeId } = props; + const { addToSkillMap } = useContext(FilterContext); + + useEffect(() => { + const skillsTreeMap = createSkillsTreeMap(treeId, skills); + addToSkillMap(skillsTreeMap); + }, []); + + return null; +} + +export default AddToFilterIndex; diff --git a/src/components/filter/FilterListener.tsx b/src/components/filter/FilterListener.tsx new file mode 100644 index 0000000..ef275eb --- /dev/null +++ b/src/components/filter/FilterListener.tsx @@ -0,0 +1,32 @@ +import { useEffect, useContext } from 'react'; +import FilterContext from '../../context/FilterContext'; + +interface Props { + setVisibility: (isVisible: boolean) => void; + isVisible: boolean; + treeId: string; +} + +function FilterListener({ setVisibility, isVisible, treeId }: Props) { + const { filtersMatches } = useContext(FilterContext); + + useEffect(() => { + if (!filtersMatches) { + if (isVisible === true) return; + + return setVisibility(true); + } + + if (!filtersMatches.has(treeId)) { + if (isVisible === false) return; + return setVisibility(false); + } + + if (isVisible === true) return; + return setVisibility(true); + }, [filtersMatches]); + + return null; +} + +export default FilterListener; diff --git a/src/components/tooltip/Tooltip.tsx b/src/components/tooltip/Tooltip.tsx index 7698a77..7179e04 100644 --- a/src/components/tooltip/Tooltip.tsx +++ b/src/components/tooltip/Tooltip.tsx @@ -3,7 +3,7 @@ import TooltipContent from './TooltipContent'; import styled from 'styled-components'; import Tippy from '@tippy.js/react'; import { Tooltip } from '../../models'; -import MobileContext from '../../context/MobileContext'; +import useMobile from '../../hooks/useMobile'; import 'tippy.js/dist/tippy.css'; import 'tippy.js/animations/shift-away.css'; @@ -17,18 +17,27 @@ interface Props { function Tooltip(props: Props) { const { children, tooltip, title } = props; const { direction = 'top', content } = tooltip; - const { isMobile } = React.useContext(MobileContext); + const isMobile = useMobile(); + + const placement = React.useMemo(() => (isMobile ? 'top' : direction), [ + isMobile, + direction, + ]); + + const memoizedContent = React.useMemo(() => { + return ; + }, [content, title]); return ( } + content={memoizedContent} > {children} diff --git a/src/components/ui/Node.tsx b/src/components/ui/Node.tsx index c74dede..6016f6d 100644 --- a/src/components/ui/Node.tsx +++ b/src/components/ui/Node.tsx @@ -25,42 +25,48 @@ interface StyledNodeProps { isIOS: boolean; } -const Node = React.forwardRef( - (props: Props, ref: React.Ref) => { +const isIOS = isIOSDevice(); + +const Node = React.forwardRef(function Node( + props: Props, + ref: React.Ref +) { + const { handleClick, id, currentState, skill } = props; + + const memoizedHandleKeyDown = React.useCallback( function handleKeyDown(e: React.KeyboardEvent) { if (e.keyCode === 13) { handleClick(); } - } - - const { handleClick, id, currentState, skill } = props; - - return ( - - {'icon' in skill ? ( - - - - ) : ( - - {skill.title} - - )} - - ); - } -); + }, + [handleClick] + ); + + return ( + + {'icon' in skill ? ( + + + + ) : ( + + {skill.title} + + )} + + ); +}); export default Node; diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index a858a6b..bcc2053 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -38,7 +38,7 @@ export const initialState = { }, }; -export function SkillProvider({ children }: Props) { +export function AppProvider({ children }: Props) { const [resetId, setResetId] = React.useState(''); const [skillCount, setSkillCount] = React.useState({ required: 0, diff --git a/src/context/FilterContext.tsx b/src/context/FilterContext.tsx new file mode 100644 index 0000000..f79ba40 --- /dev/null +++ b/src/context/FilterContext.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { SkillMap } from '../models'; + +interface Props { + children: React.ReactNode; +} + +interface IFilterContext { + filtersMatches: Set | null; + handleFilter: (query: string) => void; + addToSkillMap: (skillMap: SkillMap) => void; +} + +const FilterContext = React.createContext({ + filtersMatches: null, + handleFilter: () => null, + addToSkillMap: () => null, +}); + +export function FilterProvider(props: Props) { + const [skillMap, setSkillMap] = React.useState({}); + const [filtersMatches, setMatches] = React.useState | null>(null); + // keep the map, also keep track of the sorted keys. (if performance becomes a concern). + + function handleFilter(query: string) { + if (query.trim() === '') { + return setMatches(null); + } + + const sanitizedQuery = query.toLowerCase(); + const skills = Object.keys(skillMap); + const filteredSkills = skills.filter(key => key.includes(sanitizedQuery)); + const treeIds = new Set(filteredSkills.map(skill => skillMap[skill])); + + return setMatches(treeIds); + } + + function addToSkillMap(skillMap: SkillMap) { + return setSkillMap(prev => ({ + ...prev, + ...skillMap, + })); + } + + return ( + + {props.children} + + ); +} + +export default FilterContext; diff --git a/src/context/MobileContext.tsx b/src/context/MobileContext.tsx deleted file mode 100644 index 9653515..0000000 --- a/src/context/MobileContext.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { createContext, useState, useEffect } from 'react'; -import { throttle } from 'lodash'; - -type Props = { - children: React.ReactNode; -}; - -interface IMobileContext { - isMobile: boolean; -} - -const MobileContext = createContext({ - isMobile: true, -}); - -export function MobileProvider({ children }: Props) { - const [isMobile, setMobileState] = useState(false); - - function handleResize() { - setMobileState(window.innerWidth < 1200); - } - - useEffect(() => { - setMobileState(window.innerWidth < 1200); - }, []); - - useEffect(() => { - const throttleHandleResize = throttle(handleResize, 500); - - window.addEventListener('resize', throttleHandleResize); - - return function cleanup() { - window.removeEventListener('resize', throttleHandleResize); - }; - }); - - return ( - - {children} - - ); -} - -export default MobileContext; diff --git a/src/hooks/useMobile.tsx b/src/hooks/useMobile.tsx new file mode 100644 index 0000000..b7f0922 --- /dev/null +++ b/src/hooks/useMobile.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; +import { throttle } from 'lodash'; + +const useMobile = () => { + const [width, setWidth] = useState(window.innerWidth); + + useEffect(() => { + function handler() { + setWidth(window.innerWidth); + } + + const throttledHandler = throttle(handler, 500); + + window.addEventListener('resize', throttledHandler); + + return () => { + window.removeEventListener('resize', throttledHandler); + }; + }, []); + + return width < 1200; +}; + +export default useMobile; diff --git a/src/index.tsx b/src/index.tsx index 18167af..f38848e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,6 @@ export { default as SkillTreeGroup } from './components/SkillTreeGroup'; export { default as SkillTree } from './components/SkillTree'; -export { SkillProvider } from './context/AppContext'; +export { default as SkillProvider } from './components/SkillProvider'; export { Skill as SkillType } from './models/index'; export { SavedDataType } from './models/index'; export { SkillTheme as SkillThemeType } from './theme/index'; diff --git a/src/models/index.ts b/src/models/index.ts index 27ce880..aaa6841 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -6,10 +6,13 @@ export type TooltipDirection = 'right' | 'left' | 'top' | 'bottom'; export type NodeState = 'locked' | 'unlocked' | 'selected'; +export type SkillMap = Record; + export interface SkillGroupData { skillCount: SkillCount; selectedSkillCount: SkillCount; resetSkills: VoidFunction; + handleFilter: (query: string) => void; } export type SavedDataType = Dictionary; diff --git a/src/models/utils.ts b/src/models/utils.ts index c9cfded..f24e2aa 100644 --- a/src/models/utils.ts +++ b/src/models/utils.ts @@ -5,5 +5,5 @@ export type Dictionary = { export type Nullable = T | null; export type DeepPartial = { - [P in keyof T]: DeepPartial; + [P in keyof T]?: DeepPartial; };