feat: support items filtering by tags on UI

This commit is contained in:
Onikiienko Bogdan
2022-11-14 23:13:00 +02:00
committed by Bastian
parent 8d1ddfc4e3
commit f54d49372e
16 changed files with 457 additions and 15 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.vscode
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*

View File

@@ -20,7 +20,7 @@ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
return to.concat(ar || Array.prototype.slice.call(from)); return to.concat(ar || Array.prototype.slice.call(from));
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.getFirstLetter = exports.groupByFirstLetter = exports.groupByQuadrants = exports.nonFeaturedOnly = exports.featuredOnly = exports.FlagType = exports.HomepageOption = void 0; exports.getTags = exports.filteredOnly = exports.getFirstLetter = exports.groupByFirstLetter = exports.groupByQuadrants = exports.nonFeaturedOnly = exports.featuredOnly = exports.FlagType = exports.HomepageOption = void 0;
var HomepageOption; var HomepageOption;
(function (HomepageOption) { (function (HomepageOption) {
HomepageOption["chart"] = "chart"; HomepageOption["chart"] = "chart";
@@ -78,3 +78,23 @@ var getFirstLetter = function (item) {
return item.title.substr(0, 1).toUpperCase(); return item.title.substr(0, 1).toUpperCase();
}; };
exports.getFirstLetter = getFirstLetter; exports.getFirstLetter = getFirstLetter;
var filteredOnly = function (items, tags) {
return items.filter(function (item) {
var itemTags = item.tags;
if (typeof itemTags === "undefined") {
return false;
}
if (Array.isArray(tags)) {
return tags.every(function (tag) { return itemTags.includes(tag); });
}
return itemTags.includes(tags);
});
};
exports.filteredOnly = filteredOnly;
var getTags = function (items) {
var tags = items.reduce(function (acc, item) {
return !item.tags ? acc : acc.concat(item.tags);
}, []).sort();
return Array.from(new Set(tags));
};
exports.getTags = getTags;

83
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"@types/jest": "29.1.2", "@types/jest": "29.1.2",
"@types/react": "18.0.21", "@types/react": "18.0.21",
"@types/react-dom": "18.0.6", "@types/react-dom": "18.0.6",
"@types/react-modal": "3.13.1",
"@types/sanitize-html": "2.6.2", "@types/sanitize-html": "2.6.2",
"@types/walk": "2.3.1", "@types/walk": "2.3.1",
"classnames": "2.3.2", "classnames": "2.3.2",
@@ -33,6 +34,7 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-icons": "4.4.0", "react-icons": "4.4.0",
"react-modal": "3.16.1",
"react-router-dom": "6.4.2", "react-router-dom": "6.4.2",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-tooltip": "4.3.0", "react-tooltip": "4.3.0",
@@ -4213,6 +4215,14 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/react-modal": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/@types/react-modal/-/react-modal-3.13.1.tgz",
"integrity": "sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.17.1", "version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@@ -8025,6 +8035,11 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1" "url": "https://github.com/sindresorhus/execa?sponsor=1"
} }
}, },
"node_modules/exenv": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz",
"integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw=="
},
"node_modules/exit": { "node_modules/exit": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@@ -13496,6 +13511,29 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
}, },
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"node_modules/react-modal": {
"version": "3.16.1",
"resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz",
"integrity": "sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==",
"dependencies": {
"exenv": "^1.2.0",
"prop-types": "^15.7.2",
"react-lifecycles-compat": "^3.0.0",
"warning": "^4.0.3"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18",
"react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18"
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
@@ -15371,6 +15409,14 @@
"makeerror": "1.0.12" "makeerror": "1.0.12"
} }
}, },
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/watchpack": { "node_modules/watchpack": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz",
@@ -19075,6 +19121,14 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"@types/react-modal": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/@types/react-modal/-/react-modal-3.13.1.tgz",
"integrity": "sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg==",
"requires": {
"@types/react": "*"
}
},
"@types/resolve": { "@types/resolve": {
"version": "1.17.1", "version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@@ -21826,6 +21880,11 @@
"strip-final-newline": "^2.0.0" "strip-final-newline": "^2.0.0"
} }
}, },
"exenv": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz",
"integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw=="
},
"exit": { "exit": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@@ -25662,6 +25721,22 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
}, },
"react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"react-modal": {
"version": "3.16.1",
"resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz",
"integrity": "sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==",
"requires": {
"exenv": "^1.2.0",
"prop-types": "^15.7.2",
"react-lifecycles-compat": "^3.0.0",
"warning": "^4.0.3"
}
},
"react-refresh": { "react-refresh": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
@@ -27035,6 +27110,14 @@
"makeerror": "1.0.12" "makeerror": "1.0.12"
} }
}, },
"warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"requires": {
"loose-envify": "^1.0.0"
}
},
"watchpack": { "watchpack": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz",

View File

@@ -32,6 +32,7 @@
"@types/jest": "29.1.2", "@types/jest": "29.1.2",
"@types/react": "18.0.21", "@types/react": "18.0.21",
"@types/react-dom": "18.0.6", "@types/react-dom": "18.0.6",
"@types/react-modal": "3.13.1",
"@types/sanitize-html": "2.6.2", "@types/sanitize-html": "2.6.2",
"@types/walk": "2.3.1", "@types/walk": "2.3.1",
"classnames": "2.3.2", "classnames": "2.3.2",
@@ -47,6 +48,7 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-icons": "4.4.0", "react-icons": "4.4.0",
"react-modal": "3.16.1",
"react-router-dom": "6.4.2", "react-router-dom": "6.4.2",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-tooltip": "4.3.0", "react-tooltip": "4.3.0",

View File

@@ -5,13 +5,13 @@ import {
Navigate, Navigate,
Route, Route,
Routes, Routes,
useLocation,
useParams, useParams,
} from "react-router-dom"; } from "react-router-dom";
import { ConfigData } from "../config"; import { ConfigData } from "../config";
import { Messages, MessagesProvider } from "../context/MessagesContext"; import { Messages, MessagesProvider } from "../context/MessagesContext";
import { Item } from "../model"; import { useSearchParamState } from "../hooks/use-search-param-state";
import { Item, filteredOnly, getTags } from "../model";
import Footer from "./Footer/Footer"; import Footer from "./Footer/Footer";
import Header from "./Header/Header"; import Header from "./Header/Header";
import Router from "./Router"; import Router from "./Router";
@@ -33,12 +33,17 @@ const useFetch = <D extends unknown>(url: string): D | undefined => {
return data; return data;
}; };
const useQuery = () => new URLSearchParams(useLocation().search);
const usePage = (params: Record<string, string | undefined>) => { const usePage = (params: Record<string, string | undefined>) => {
return (params["*"] || "").replace(".html", ""); return (params["*"] || "").replace(".html", "");
}; };
const useFilteredItems = ({ items }: { items: Item[] }) => {
const [searchParamState] = useSearchParamState();
const { tags } = searchParamState;
return tags ? filteredOnly(items, tags) : items;
};
const RouterWithPageParam = ({ const RouterWithPageParam = ({
items, items,
releases, releases,
@@ -49,29 +54,33 @@ const RouterWithPageParam = ({
config: ConfigData; config: ConfigData;
}) => { }) => {
const page = usePage(useParams()); const page = usePage(useParams());
const query = useQuery(); const [searchParamState] = useSearchParamState();
const { search } = searchParamState;
const filteredItems = useFilteredItems({ items });
return ( return (
<Router <Router
pageName={page || ""} pageName={page || ""}
search={query.get("search") || ""} search={search || ""}
items={items} items={filteredItems}
releases={releases} releases={releases}
config={config} config={config}
/> />
); );
}; };
const HeaderWithPageParam = () => { const HeaderWithPageParam = ({ items }: { items: Item[] }) => {
const page = usePage(useParams()); const page = usePage(useParams());
const tags = getTags(items);
return <Header pageName={page || ""} />; return <Header pageName={page || ""} tags={tags} />;
}; };
const FooterWithPageParam = ({ items }: { items: Item[] }) => { const FooterWithPageParam = ({ items }: { items: Item[] }) => {
const page = usePage(useParams()); const page = usePage(useParams());
const filteredItems = useFilteredItems({ items });
return <Footer pageName={page || ""} items={items} />; return <Footer pageName={page || ""} items={filteredItems} />;
}; };
interface Data { interface Data {
@@ -102,7 +111,7 @@ export default function App() {
<div> <div>
<div className="page"> <div className="page">
<div className="page__header"> <div className="page__header">
<HeaderWithPageParam /> <HeaderWithPageParam items={items} />
</div> </div>
<div className={classNames("page__content")}> <div className={classNames("page__content")}>
<RouterWithPageParam <RouterWithPageParam

View File

@@ -5,17 +5,27 @@ import { useNavigate } from "react-router-dom";
import { radarNameShort } from "../../config"; import { radarNameShort } from "../../config";
import { useMessages } from "../../context/MessagesContext"; import { useMessages } from "../../context/MessagesContext";
import { useSearchParamState } from "../../hooks/use-search-param-state";
import { Tag } from "../../model";
import Branding from "../Branding/Branding"; import Branding from "../Branding/Branding";
import Link from "../Link/Link"; import Link from "../Link/Link";
import LogoLink from "../LogoLink/LogoLink"; import LogoLink from "../LogoLink/LogoLink";
import Search from "../Search/Search"; import Search from "../Search/Search";
import TagsModal from "../TagsModal/TagsModal";
export default function Header({ pageName }: { pageName: string }) { export default function Header({
pageName,
tags,
}: {
pageName: string;
tags: Tag[];
}) {
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { searchLabel, pageHelp, pageOverview } = useMessages(); const { searchLabel, pageHelp, pageOverview } = useMessages();
const navigate = useNavigate(); const navigate = useNavigate();
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const [searchParamState, setSearchParamsState] = useSearchParamState();
const openSearch = () => { const openSearch = () => {
setSearchOpen(true); setSearchOpen(true);
@@ -30,9 +40,12 @@ export default function Header({ pageName }: { pageName: string }) {
}; };
const handleSearchSubmit = () => { const handleSearchSubmit = () => {
let { tags } = searchParamState;
tags = Array.isArray(tags) ? tags.join("|") : tags;
navigate({ navigate({
pathname: "/overview.html", pathname: "/overview.html",
search: qs.stringify({ search: search }), search: qs.stringify({ search: search, tags }),
}); });
setSearchOpen(false); setSearchOpen(false);
@@ -47,6 +60,31 @@ export default function Header({ pageName }: { pageName: string }) {
}, 0); }, 0);
}; };
const handleTagChange = (tag: Tag) => {
const { search, tags = [] } = searchParamState;
let newTags;
// Toggle changed item in tags searchParam depends on type Array or String
if (Array.isArray(tags)) {
newTags = tags.includes(tag)
? tags.filter((item: string) => item !== tag)
: [...tags, tag];
} else {
newTags = tags !== tag ? [tags, tag] : [];
}
setSearchParamsState({
tags: newTags,
search,
});
};
const [modalIsOpen, setModalIsOpen] = useState(false);
const toggleModal = function () {
setModalIsOpen(!modalIsOpen);
};
const smallLogo = pageName !== "index"; const smallLogo = pageName !== "index";
return ( return (
@@ -60,6 +98,14 @@ export default function Header({ pageName }: { pageName: string }) {
</Link> </Link>
</div> </div>
)} )}
{Boolean(tags.length) && (
<div className="nav__item">
<button className="icon-link" onClick={toggleModal}>
<span className="icon icon--filter icon-link__icon" />
Filter
</button>
</div>
)}
<div className="nav__item"> <div className="nav__item">
<Link pageName="overview" className="icon-link"> <Link pageName="overview" className="icon-link">
<span className="icon icon--overview icon-link__icon" /> <span className="icon icon--overview icon-link__icon" />
@@ -81,6 +127,14 @@ export default function Header({ pageName }: { pageName: string }) {
ref={searchRef} ref={searchRef}
/> />
</div> </div>
{Boolean(tags.length) && (
<TagsModal
tags={tags}
isOpen={modalIsOpen}
closeModal={toggleModal}
handleTagChange={handleTagChange}
/>
)}
</div> </div>
</div> </div>
</Branding> </Branding>

View File

@@ -0,0 +1,17 @@
import { Tag } from "../../model";
export default function ItemTags({ tags }: { tags?: Tag[] }) {
if (!tags) {
return null;
}
return (
<div className="markdown">
{"Tags: "}
{tags.map((tag, id) => [
id !== 0 && ", ",
<span key={tag}>"{tag}"</span>,
])}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { Link as RLink } from "react-router-dom"; import { Link as RLink } from "react-router-dom";
import { useSearchParamState } from "../../hooks/use-search-param-state";
import "./link.scss"; import "./link.scss";
type LinkProps = { type LinkProps = {
@@ -15,8 +16,17 @@ function Link({
className, className,
style = {}, style = {},
}: React.PropsWithChildren<LinkProps>) { }: React.PropsWithChildren<LinkProps>) {
const [searchParamState] = useSearchParamState(undefined, {
parseOptions: { decode: false },
});
const { tags } = searchParamState;
return ( return (
<RLink to={`/${pageName}.html`} style={style} {...{ className }}> <RLink
to={tags ? `/${pageName}.html?tags=${tags}` : `/${pageName}.html`}
style={style}
{...{ className }}
>
{children} {children}
</RLink> </RLink>
); );

View File

@@ -8,6 +8,7 @@ import EditButton from "../EditButton/EditButton";
import FooterEnd from "../FooterEnd/FooterEnd"; import FooterEnd from "../FooterEnd/FooterEnd";
import ItemList from "../ItemList/ItemList"; import ItemList from "../ItemList/ItemList";
import ItemRevisions from "../ItemRevisions/ItemRevisions"; import ItemRevisions from "../ItemRevisions/ItemRevisions";
import ItemTags from "../ItemTags/ItemTags";
import Link from "../Link/Link"; import Link from "../Link/Link";
import SetTitle from "../SetTitle"; import SetTitle from "../SetTitle";
import "./item-page.scss"; import "./item-page.scss";
@@ -128,6 +129,7 @@ const PageItem: React.FC<Props> = ({
className="markdown" className="markdown"
dangerouslySetInnerHTML={{ __html: item.body }} dangerouslySetInnerHTML={{ __html: item.body }}
/> />
<ItemTags tags={item.tags} />
{item.revisions.length > 1 && ( {item.revisions.length > 1 && (
<ItemRevisions <ItemRevisions
revisions={item.revisions.slice(1)} revisions={item.revisions.slice(1)}

View File

@@ -5,6 +5,7 @@ import EditButton from "../EditButton/EditButton";
import Fadeable from "../Fadeable/Fadeable"; import Fadeable from "../Fadeable/Fadeable";
import ItemList from "../ItemList/ItemList"; import ItemList from "../ItemList/ItemList";
import ItemRevisions from "../ItemRevisions/ItemRevisions"; import ItemRevisions from "../ItemRevisions/ItemRevisions";
import ItemTags from "../ItemTags/ItemTags";
import Link from "../Link/Link"; import Link from "../Link/Link";
import SetTitle from "../SetTitle"; import SetTitle from "../SetTitle";
@@ -74,6 +75,7 @@ export default function PageItemMobile({
className="markdown" className="markdown"
dangerouslySetInnerHTML={{ __html: item.body }} dangerouslySetInnerHTML={{ __html: item.body }}
/> />
<ItemTags tags={item.tags} />
{item.revisions.length > 1 && ( {item.revisions.length > 1 && (
<ItemRevisions revisions={item.revisions.slice(1)} /> <ItemRevisions revisions={item.revisions.slice(1)} />
)} )}

View File

@@ -0,0 +1,90 @@
import { ChangeEvent } from "react";
import ReactModal from "react-modal";
import { useSearchParamState } from "../../hooks/use-search-param-state";
import { Tag } from "../../model";
import "./tags-modal.scss";
const customStyles = {
content: {
top: "50%",
left: "50%",
right: "auto",
bottom: "auto",
marginRight: "-50%",
transform: "translate(-50%, -50%)",
backgroundColor: "var(--color-gray-dark)",
color: "var(--color-white)",
padding: 0,
borderRadius: "20px",
},
overlay: {
zIndex: 999,
backgroundColor: "rgba(71, 81, 87, 0.6)",
},
};
ReactModal.setAppElement("#root");
type TagsModalProps = {
tags: Tag[];
isOpen: boolean;
closeModal: () => void;
handleTagChange: (tag: Tag) => void;
};
export default function TagsModal({
tags,
isOpen,
closeModal,
handleTagChange,
}: TagsModalProps) {
const [searchParamState] = useSearchParamState();
let { tags: tagsFromURL } = searchParamState;
tagsFromURL = Array.isArray(tagsFromURL) ? tagsFromURL : [tagsFromURL];
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
handleTagChange(value);
};
return (
<ReactModal
isOpen={isOpen}
onRequestClose={closeModal}
style={customStyles}
contentLabel="Filters Modal"
closeTimeoutMS={300}
>
<div className="tags-modal">
<button
onClick={closeModal}
className="tags-modal__close-button link-button"
>
<span className="icon icon--close" />
</button>
<h4 className="headline tags-modal__title">Choose your filters</h4>
<ul
className="tags-modal__list"
style={{ columns: Math.ceil(tags.length / 8) }}
>
{tags.map((tag, index) => (
<li key={index} className="tags-modal__list-item">
<input
type="checkbox"
id={`tag-checkbox-${index}`}
className="tags-modal__list-item-checkbox"
name={tag}
value={tag}
checked={tagsFromURL.includes(tag)}
onChange={handleChange}
/>
<label htmlFor={`tag-checkbox-${index}`}>{tag}</label>
</li>
))}
</ul>
</div>
</ReactModal>
);
}

View File

@@ -0,0 +1,44 @@
.tags-modal {
font-size: 16px;
position: relative;
padding: 50px;
&__close-button {
position: absolute;
top: 10px;
right: 30px;
transform: translateX(20px);
}
&__title {
width: 100%;
}
&__list {
padding: 15px 0 0;
list-style-type: none;
margin: 0;
}
&__list-item {
margin-bottom: 5px;
display: flex;
}
&__list-item-checkbox {
margin-right: 10px;
}
}
.ReactModal__Overlay {
opacity: 0;
transition: opacity 300ms ease-in-out;
}
.ReactModal__Overlay--after-open{
opacity: 1;
}
.ReactModal__Overlay--before-close{
opacity: 0;
}

View File

@@ -0,0 +1,71 @@
/* @Libs */
import {
type ParseOptions,
type StringifyOptions,
parse,
stringify,
} from "query-string";
/* @Hooks */
import { type SetStateAction, useMemo, useRef } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
const initialParseOptions: ParseOptions = {
arrayFormat: "separator",
arrayFormatSeparator: "|",
parseBooleans: true,
parseNumbers: true,
};
const initialStringifyOptions: StringifyOptions = {
arrayFormat: "separator",
arrayFormatSeparator: "|",
skipEmptyString: true,
skipNull: true,
};
type Options = {
replace?: boolean;
parseOptions?: ParseOptions;
stringifyOptions?: StringifyOptions;
};
type SearchParamState = Record<string, any>;
export function useSearchParamState<
T extends SearchParamState = SearchParamState
>(initialState?: T | (() => T), options?: Options) {
type State = Partial<T>;
const { replace = true, parseOptions, stringifyOptions } = options || {};
const location = useLocation();
const [, setSearchParams] = useSearchParams();
const initialStateRef = useRef<State>(
typeof initialState === "function"
? (initialState as () => T)()
: initialState || {}
);
const state = useMemo(
() => ({
...initialStateRef.current,
...(parse(location.search, {
...initialStringifyOptions,
...parseOptions,
}) as State),
}),
[location.search, parseOptions]
);
function setSearchParamsState(s: SetStateAction<State>) {
const newState = typeof s === "function" ? s(state) : s;
const stringifyState = stringify(
{ ...state, ...newState },
{ ...initialParseOptions, ...stringifyOptions }
);
setSearchParams(stringifyState, { replace });
}
return [state, setSearchParamsState] as const;
}

7
src/icons/filter.svg Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve">
<metadata> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>
<g><path d="M981.4,50.7c-15.5-29.2-39.5-43-72.2-43c-135.8,0-273.4,0-409.2,0c-51.6,0-103.2,0-154.7,0c-86,0-171.9,0-257.9,0c-43,0-72.2,25.8-77.4,67.1c0,3.4,1.7,10.3,3.4,12C121.8,210.6,230.1,331,340.1,453c5.2,5.2,6.9,10.3,6.9,18.9c0,113.5,0,225.2,0,338.7c0,49.9,24.1,87.7,68.8,110c30.9,15.5,63.6,25.8,94.6,39.5c25.8,10.3,49.9,20.6,75.6,29.2c25.8,8.6,49.9-3.4,58.5-25.8c3.4-8.6,5.2-20.6,5.2-30.9c0-153,0-306,0-459.1c0-6.9,1.7-12,6.9-17.2c39.5-44.7,80.8-89.4,120.4-134.1c68.8-77.4,139.3-154.7,208-232.1c1.7-1.7,5.2-6.9,5.2-10.3C986.6,69.6,986.6,59.3,981.4,50.7z M878.2,95.4C792.3,190,708,284.6,622.1,379.1c-1.7,1.7-22.4,22.4-48.1,46.4c0,144.4,0,340.4,0,476.2c-3.4-1.7-6.9-1.7-8.6-3.4c-36.1-13.8-72.2-29.2-108.3-43c-22.4-8.6-32.7-25.8-32.7-49.9c0-110,0-220.1,0-331.8c0-1.7,0-25.8,0-48.1c-24.1-24.1-44.7-43-46.4-44.7c-89.4-96.3-175.4-194.3-263.1-290.6c-1.7-1.7-3.4-3.4-5.2-6.9c259.6,0,517.5,0,778.8,0C883.4,88.6,881.7,92,878.2,95.4z" fill="#7E8991"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -111,3 +111,29 @@ const addItemToRing = (ring: Item[] = [], item: Item) => [...ring, item];
export const getFirstLetter = (item: Item) => export const getFirstLetter = (item: Item) =>
item.title.substr(0, 1).toUpperCase(); item.title.substr(0, 1).toUpperCase();
export type Tag = string;
export const filteredOnly = (items: Item[], tags: Tag[] | string) => {
return items.filter((item: Item) => {
const { tags: itemTags } = item;
if (typeof itemTags === "undefined") {
return false;
}
if (Array.isArray(tags)) {
return tags.every(tag => itemTags.includes(tag));
}
return itemTags.includes(tags);
});
}
export const getTags = (items: Item[]): Tag[] => {
const tags: Tag[] = items.reduce((acc: Tag[], item: Item) => {
return !item.tags ? acc : acc.concat(item.tags);
}, []).sort();
return Array.from(new Set(tags));
};

View File

@@ -51,4 +51,8 @@
height: 18px; height: 18px;
background-size: 18px; background-size: 18px;
} }
&--filter {
background-image: url("../../icons/filter.svg");
}
} }