Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature - Implement basic search functionality v2 #159

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
41 changes: 25 additions & 16 deletions src/components/CategoryList.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,42 @@
import { useEffect } from "react";
import { FC } from "react";

import { useAppContext } from "@contexts/AppContext";
import { useCategories } from "@hooks/useCategories";
import { defaultCategory } from "@utils/consts";

const CategoryList = () => {
interface CategoryListItemProps {
name: string;
}

const CategoryListItem: FC<CategoryListItemProps> = ({ name }) => {
const { category, setCategory } = useAppContext();
const { fetchedCategories, loading, error } = useCategories();

useEffect(() => {
setCategory(fetchedCategories[0]);
}, [setCategory, fetchedCategories]);
return (
<li className="category">
<button
className={`category__btn ${
name === category ? "category__btn--active" : ""
}`}
onClick={() => setCategory(name)}
>
{name}
</button>
</li>
);
};

const CategoryList = () => {
const { fetchedCategories, loading, error } = useCategories();

if (loading) return <div>Loading...</div>;

if (error) return <div>Error occurred: {error}</div>;

return (
<ul role="list" className="categories">
<CategoryListItem name={defaultCategory} />
{fetchedCategories.map((name, idx) => (
<li key={idx} className="category">
<button
className={`category__btn ${
name === category ? "category__btn--active" : ""
}`}
onClick={() => setCategory(name)}
>
{name}
</button>
</li>
<CategoryListItem key={idx} name={name} />
))}
</ul>
);
Expand Down
12 changes: 8 additions & 4 deletions src/App.tsx → src/components/Container.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import SnippetList from "@components/SnippetList";
import { FC } from "react";
import { Outlet } from "react-router-dom";

import { useAppContext } from "@contexts/AppContext";
import Banner from "@layouts/Banner";
import Footer from "@layouts/Footer";
import Header from "@layouts/Header";
import Sidebar from "@layouts/Sidebar";

const App = () => {
interface ContainerProps {}

const Container: FC<ContainerProps> = () => {
const { category } = useAppContext();

return (
Expand All @@ -18,12 +22,12 @@ const App = () => {
<h2 className="section-title">
{category ? category : "Select a category"}
</h2>
<SnippetList />
<Outlet />
</section>
</main>
<Footer />
</div>
);
};

export default App;
export default Container;
122 changes: 117 additions & 5 deletions src/components/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,129 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";

import { useAppContext } from "@contexts/AppContext";
import { defaultCategory } from "@utils/consts";

import { SearchIcon } from "./Icons";

const SearchInput = () => {
const [searchParams, setSearchParams] = useSearchParams();

const { searchText, setSearchText, setCategory } = useAppContext();

const inputRef = useRef<HTMLInputElement | null>(null);

const [inputVal, setInputVal] = useState<string>("");

const handleSearchFieldClick = () => {
inputRef.current?.focus();
};

const handleSearchKeyPress = (e: KeyboardEvent) => {
if (e.key === "/") {
e.preventDefault();
inputRef.current?.focus();
}
};

const clearSearch = useCallback(() => {
setInputVal("");
setCategory(defaultCategory);
setSearchText("");
setSearchParams({});
}, [setCategory, setSearchParams, setSearchText]);

const handleEscapePress = useCallback(
(e: KeyboardEvent) => {
if (e.key !== "Escape") {
return;
}
// check if the input is focused
if (document.activeElement !== inputRef.current) {
return;
}

inputRef.current?.blur();

clearSearch();
},
[clearSearch]
);

const handleReturnPress = useCallback(
(e: KeyboardEvent) => {
if (e.key !== "Enter") {
return;
}
// check if the input is focused
if (document.activeElement !== inputRef.current) {
return;
}

const formattedVal = inputVal.trim().toLowerCase();

setCategory(defaultCategory);
setSearchText(formattedVal);
if (!formattedVal) {
setSearchParams({});
} else {
setSearchParams({ search: formattedVal });
}
},
[inputVal, setCategory, setSearchParams, setSearchText]
);

useEffect(() => {
document.addEventListener("keyup", handleSearchKeyPress);
document.addEventListener("keyup", handleEscapePress);
document.addEventListener("keyup", handleReturnPress);

return () => {
document.removeEventListener("keyup", handleSearchKeyPress);
document.removeEventListener("keyup", handleEscapePress);
document.removeEventListener("keyup", handleReturnPress);
};
}, [handleEscapePress, handleReturnPress]);

/**
* Set the input value and search text to the search query from the URL
*/
useEffect(() => {
const search = searchParams.get("search") || "";
setInputVal(search);
setSearchText(search);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<div className="search-field">
<label htmlFor="search">
<SearchIcon />
</label>
<div className="search-field" onClick={handleSearchFieldClick}>
<SearchIcon />
<input
ref={inputRef}
type="search"
id="search"
placeholder="Search here..."
autoComplete="off"
value={inputVal}
onChange={(e) => {
const newValue = e.target.value;
if (!newValue) {
clearSearch();
return;
}
setInputVal(newValue);
}}
onBlur={() => {
// ensure the input value is always in sync with the search text
if (inputVal !== searchText) {
setInputVal(searchText);
}
}}
/>
{!inputVal && !searchText && (
<label htmlFor="search">
Type <kbd>/</kbd> to search
</label>
)}
</div>
);
};
Expand Down
16 changes: 9 additions & 7 deletions src/components/SnippetList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,8 @@ import SnippetModal from "./SnippetModal";
const SnippetList = () => {
const { language, snippet, setSnippet } = useAppContext();
const { fetchedSnippets } = useSnippets();
const [isModalOpen, setIsModalOpen] = useState(false);

if (!fetchedSnippets)
return (
<div>
<LeftAngleArrowIcon />
</div>
);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);

const handleOpenModal = (activeSnippet: SnippetType) => {
setIsModalOpen(true);
Expand All @@ -30,6 +24,14 @@ const SnippetList = () => {
setSnippet(null);
};

if (!fetchedSnippets) {
return (
<div>
<LeftAngleArrowIcon />
</div>
);
}

return (
<>
<motion.ul role="list" className="snippets">
Expand Down
2 changes: 1 addition & 1 deletion src/components/SnippetModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import ReactDOM from "react-dom";

import { useEscapeKey } from "@hooks/useEscapeKey";
import { SnippetType } from "@types";
import { slugify } from "@utils/slugify";
import { slugify } from "@utils/helpers/slugify";

import Button from "./Button";
import CodePreview from "./CodePreview";
Expand Down
16 changes: 8 additions & 8 deletions src/contexts/AppContext.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
import { createContext, FC, useContext, useState } from "react";

import { AppState, LanguageType, SnippetType } from "@types";

// tokens
const defaultLanguage: LanguageType = {
lang: "JAVASCRIPT",
icon: "/icons/javascript.svg",
};
import { defaultCategory, defaultLanguage } from "@utils/consts";

// TODO: add custom loading and error handling
const defaultState: AppState = {
language: defaultLanguage,
setLanguage: () => {},
category: "",
category: defaultCategory,
setCategory: () => {},
snippet: null,
setSnippet: () => {},
searchText: "",
setSearchText: () => {},
};

const AppContext = createContext<AppState>(defaultState);
Expand All @@ -24,8 +21,9 @@ export const AppProvider: FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [language, setLanguage] = useState<LanguageType>(defaultLanguage);
const [category, setCategory] = useState<string>("");
const [category, setCategory] = useState<string>(defaultCategory);
const [snippet, setSnippet] = useState<SnippetType | null>(null);
const [searchText, setSearchText] = useState<string>("");

return (
<AppContext.Provider
Expand All @@ -36,6 +34,8 @@ export const AppProvider: FC<{ children: React.ReactNode }> = ({
setCategory,
snippet,
setSnippet,
searchText,
setSearchText,
}}
>
{children}
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useCategories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useMemo } from "react";

import { useAppContext } from "@contexts/AppContext";
import { SnippetType } from "@types";
import { slugify } from "@utils/slugify";
import { slugify } from "@utils/helpers/slugify";

import { useFetch } from "./useFetch";

Expand Down
35 changes: 30 additions & 5 deletions src/hooks/useSnippets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useMemo } from "react";

import { useAppContext } from "@contexts/AppContext";
import { SnippetType } from "@types";
import { slugify } from "@utils/slugify";
import { defaultCategory } from "@utils/consts";
import { slugify } from "@utils/helpers/slugify";

import { useFetch } from "./useFetch";

Expand All @@ -10,14 +13,36 @@ type CategoryData = {
};

export const useSnippets = () => {
const { language, category } = useAppContext();
const { language, category, searchText } = useAppContext();
const { data, loading, error } = useFetch<CategoryData[]>(
`/consolidated/${slugify(language.lang)}.json`
);

const fetchedSnippets = data
? data.find((item) => item.categoryName === category)?.snippets
: [];
const fetchedSnippets = useMemo(() => {
if (!data) {
return [];
}

if (category === defaultCategory) {
if (searchText) {
return data
.flatMap((item) => item.snippets)
.filter((item) =>
item.title.toLowerCase().includes(searchText.toLowerCase())
);
}
return data.flatMap((item) => item.snippets);
}

if (searchText) {
return data
.find((item) => item.categoryName === category)
?.snippets.filter((item) =>
item.title.toLowerCase().includes(searchText.toLowerCase())
);
}
return data.find((item) => item.categoryName === category)?.snippets;
}, [category, data, searchText]);

return { fetchedSnippets, loading, error };
};
6 changes: 3 additions & 3 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import "@styles/main.css";

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";

import { AppProvider } from "@contexts/AppContext";

import App from "./App";
import { router } from "@router";

createRoot(document.getElementById("root")!).render(
<StrictMode>
<AppProvider>
<App />
<RouterProvider router={router} />
</AppProvider>
</StrictMode>
);
Loading
Loading