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;
};