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

View File

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

View File

@@ -5,17 +5,27 @@ import { useNavigate } from "react-router-dom";
import { radarNameShort } from "../../config";
import { useMessages } from "../../context/MessagesContext";
import { useSearchParamState } from "../../hooks/use-search-param-state";
import { Tag } from "../../model";
import Branding from "../Branding/Branding";
import Link from "../Link/Link";
import LogoLink from "../LogoLink/LogoLink";
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 [search, setSearch] = useState("");
const { searchLabel, pageHelp, pageOverview } = useMessages();
const navigate = useNavigate();
const searchRef = useRef<HTMLInputElement>(null);
const [searchParamState, setSearchParamsState] = useSearchParamState();
const openSearch = () => {
setSearchOpen(true);
@@ -30,9 +40,12 @@ export default function Header({ pageName }: { pageName: string }) {
};
const handleSearchSubmit = () => {
let { tags } = searchParamState;
tags = Array.isArray(tags) ? tags.join("|") : tags;
navigate({
pathname: "/overview.html",
search: qs.stringify({ search: search }),
search: qs.stringify({ search: search, tags }),
});
setSearchOpen(false);
@@ -47,6 +60,31 @@ export default function Header({ pageName }: { pageName: string }) {
}, 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";
return (
@@ -60,6 +98,14 @@ export default function Header({ pageName }: { pageName: string }) {
</Link>
</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">
<Link pageName="overview" className="icon-link">
<span className="icon icon--overview icon-link__icon" />
@@ -81,6 +127,14 @@ export default function Header({ pageName }: { pageName: string }) {
ref={searchRef}
/>
</div>
{Boolean(tags.length) && (
<TagsModal
tags={tags}
isOpen={modalIsOpen}
closeModal={toggleModal}
handleTagChange={handleTagChange}
/>
)}
</div>
</div>
</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 { Link as RLink } from "react-router-dom";
import { useSearchParamState } from "../../hooks/use-search-param-state";
import "./link.scss";
type LinkProps = {
@@ -15,8 +16,17 @@ function Link({
className,
style = {},
}: React.PropsWithChildren<LinkProps>) {
const [searchParamState] = useSearchParamState(undefined, {
parseOptions: { decode: false },
});
const { tags } = searchParamState;
return (
<RLink to={`/${pageName}.html`} style={style} {...{ className }}>
<RLink
to={tags ? `/${pageName}.html?tags=${tags}` : `/${pageName}.html`}
style={style}
{...{ className }}
>
{children}
</RLink>
);

View File

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

View File

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