Skip to content

Commit

Permalink
feat: improve search by matching item names
Browse files Browse the repository at this point in the history
Signed-off-by: rare-magma <[email protected]>
  • Loading branch information
rare-magma committed Sep 24, 2023
1 parent 7951007 commit 7162974
Show file tree
Hide file tree
Showing 8 changed files with 105 additions and 55 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ Keyboard shortcuts can be triggered when no input field is selected.
| Go to current month's budget | Home |
| Go to newer budget | PageUp |
| Rename current budget | R |
| Search budget list | F or / |
| Search | F or / |
| Focus savings goal | G |
| Focus reserves | E |
| Change currency | T |
Expand All @@ -173,6 +173,7 @@ Keyboard shortcuts can be triggered when no input field is selected.
- The currency is initially selected according to your browser's preferred languages.
- Example: English (United States) browser language displays values in US Dollars. English (India) uses Indian Rupees.
- It's possible to override it by selecting a different currency code based on [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217).
- Querying the search bar finds matching budget/expense/income names, opens the budget that contains the match and highlights it.

## Known issues

Expand Down
33 changes: 23 additions & 10 deletions src/components/Budget/BudgetPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Papa from "papaparse";
import { Suspense, lazy, useEffect, useRef, useState } from "react";
import { Col, Container, Row, ToastContainer } from "react-bootstrap";
import { Option } from "react-bootstrap-typeahead/types/types";
import { useHotkeys } from "react-hotkeys-hook";
import { useParams } from "react-router-dom";
import { useBudget } from "../../context/BudgetContext";
Expand All @@ -22,7 +21,7 @@ import {
import ErrorModal, { CsvError, JsonError } from "../ErrorModal/ErrorModal";
import LandingPage from "../LandingPage/LandingPage";
import Loading from "../Loading/Loading";
import NavBar from "../NavBar/NavBar";
import NavBar, { SearchOption } from "../NavBar/NavBar";
import Notification from "../Notification/Notification";
import { Stat } from "../StatCard/Stat";
import StatCard from "../StatCard/StatCard";
Expand All @@ -43,6 +42,7 @@ function BudgetPage() {
const [csvError, setCsvError] = useState<CsvError[]>([]);
const [jsonError, setJsonError] = useState<JsonError[]>([]);
const [show, setShow] = useState(false);
const [focus, setFocus] = useState("");

const [notifications, setNotifications] = useState<
{
Expand Down Expand Up @@ -285,22 +285,35 @@ function BudgetPage() {
});
}

function handleSelect(o: Option[] | undefined) {
if (o) {
const selectedBudget = o as unknown as Budget[];
const filteredList =
selectedBudget &&
budgetList?.filter((item: Budget) => item.id === selectedBudget[0].id);
function handleSelect(selectedBudget: SearchOption[] | undefined) {
if (selectedBudget && budgetList) {
const filteredList = budgetList.filter(
(item: Budget) => item.id === selectedBudget[0].id,
);

filteredList && setBudget(filteredList[0]);

if (selectedBudget[0].item && selectedBudget[0].item.length > 0) {
setFocus(selectedBudget[0].item);
}
}
}

useEffect(() => {
const element = document.querySelector(`input[value="${focus}"]`);
if (element !== null) {
(element as HTMLElement).focus();
}
}, [focus]);

function handleGo(step: number, limit: number) {
const sortedList = budgetList?.sort((a, b) => a.name.localeCompare(b.name));
if (budget) {
const index = sortedList?.findIndex((b) => b.name.includes(budget.name));
if (index !== limit && sortedList) {
handleSelect([sortedList[(index ?? 0) + step] as unknown as Option[]]);
handleSelect([
sortedList[(index ?? 0) + step] as unknown as SearchOption,
]);
}
}
}
Expand All @@ -312,7 +325,7 @@ function BudgetPage() {
b.name.includes(name.slice(0, 7)),
);
if (index !== -1 && budgetList && index) {
handleSelect([budgetList[index] as unknown as Option[]]);
handleSelect([budgetList[index] as unknown as SearchOption]);
}
}
}
Expand Down
20 changes: 2 additions & 18 deletions src/components/NavBar/NavBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe("NavBar", () => {
it("matches snapshot", () => {
expect(comp).toMatchSnapshot();
});

it("renders initial state", async () => {
expect(screen.getByText("2023-03")).toBeInTheDocument();

Expand All @@ -49,9 +50,7 @@ describe("NavBar", () => {
await userEvent.click(newButton[0]);
await userEvent.click(newButton[0]);

expect(
screen.getByPlaceholderText("Search list of budgets..."),
).toBeInTheDocument();
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
expect(screen.getByLabelText("go to older budget")).toBeInTheDocument();
expect(screen.getByLabelText("go to newer budget")).toBeInTheDocument();
});
Expand Down Expand Up @@ -123,21 +122,6 @@ describe("NavBar", () => {
expect(onRemove).toBeCalledWith("035c2de4-00a4-403c-8f0e-f81339be9a4e");
});

it("triggers onSelect when user selects budget", async () => {
await userEvent.type(
screen.getByPlaceholderText("Search list of budgets..."),
"2023-04",
);
await userEvent.click(screen.getByText("2023-04"));

expect(onSelect).toBeCalledWith([
{
id: "135b2ce4-00a4-403c-8f0e-f81339be9a4e",
name: "2023-04",
},
]);
});

it("opens instructions in new tab", async () => {
const instructionsButton = screen.getByLabelText(
"open instructions in new tab",
Expand Down
76 changes: 63 additions & 13 deletions src/components/NavBar/NavBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { Offcanvas, OverlayTrigger, Tooltip } from "react-bootstrap";
import { Typeahead } from "react-bootstrap-typeahead";
import { AsyncTypeahead, Typeahead } from "react-bootstrap-typeahead";
import TypeaheadRef from "react-bootstrap-typeahead/types/core/Typeahead";
import { Option } from "react-bootstrap-typeahead/types/types";
import Button from "react-bootstrap/Button";
Expand All @@ -20,12 +20,20 @@ import {
import { FaRegClone } from "react-icons/fa";
import { useBudget } from "../../context/BudgetContext";
import { useConfig } from "../../context/ConfigContext";
import { budgetsDB } from "../../context/db";
import { currenciesList } from "../../lists/currenciesList";
import { focusRef } from "../../utils";
import { Budget } from "../Budget/Budget";
import { NavBarDelete } from "./NavBarDelete";
import { NavBarExport } from "./NavBarExport";
import { NavBarItem } from "./NavBarItem";

export interface SearchOption {
id: string;
item: string;
name: string;
}

interface NavBarProps {
onClone: () => void;
onExport: (t: string) => void;
Expand All @@ -36,7 +44,7 @@ interface NavBarProps {
onNew: () => void;
onRemove: (name: string) => void;
onRename: (name?: string | null) => void;
onSelect: (budget: Option[]) => void;
onSelect: (option: SearchOption[]) => void;
}

function NavBar({
Expand Down Expand Up @@ -67,6 +75,8 @@ function NavBar({
const { currency, handleCurrency } = useConfig();
const { budget, budgetNameList } = useBudget();

const [options, setOptions] = useState<Option[]>([]);

useHotkeys("pageup", (e) => !e.repeat && handleGoForward(), {
preventDefault: true,
});
Expand Down Expand Up @@ -144,9 +154,9 @@ function NavBar({
}
}

function handleSelect(budget: Option[]) {
function handleSelect(option: Option[]) {
setExpanded(false);
onSelect(budget);
onSelect(option as SearchOption[]);
if (typeRef.current) {
typeRef.current.clear();
}
Expand All @@ -171,6 +181,46 @@ function NavBar({
}
}

function handleSearch() {
let options: SearchOption[] = [];

budgetsDB
.iterate((value) => {
options = options.concat(
(value as Budget).incomes.items.map((i) => {
return {
id: (value as Budget).id,
name: (value as Budget).name,
item: i.name,
};
}),
(value as Budget).expenses.items.map((i) => {
return {
id: (value as Budget).id,
name: (value as Budget).name,
item: i.name,
};
}),
);
})
.then(() => {
if (budgetNameList) {
options = options.concat(budgetNameList);
}
setOptions(
options.sort((a, b) => a.name.localeCompare(b.name)).reverse(),
);
})
.catch((e) => {
throw new Error(e as string);
});
}

function getLabelKey(option: unknown): string {
const label = option as SearchOption;
return label.item ? `${label.name} ${label.item}` : `${label.name}`;
}

return (
<Navbar
variant={theme}
Expand Down Expand Up @@ -284,20 +334,20 @@ function NavBar({
<Nav>
<Nav className="m-2">
{budgetNameList && budgetNameList.length > 1 && (
<Typeahead
<AsyncTypeahead
id="search-budget-list"
filterBy={["name"]}
labelKey="name"
filterBy={["name", "item"]}
labelKey={getLabelKey}
ref={typeRef}
style={expanded ? {} : { minWidth: "10ch" }}
onChange={(budget: Option[]) => {
handleSelect(budget);
onChange={(option: Option[]) => {
handleSelect(option);
}}
className="w-100"
options={budgetNameList
.sort((a, b) => a.name.localeCompare(b.name))
.reverse()}
placeholder="Search list of budgets..."
options={options}
placeholder="Search..."
isLoading={false}
onSearch={handleSearch}
/>
)}
</Nav>
Expand Down
11 changes: 5 additions & 6 deletions src/context/BudgetContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PropsWithChildren, createContext, useContext, useState } from "react";
import { Budget } from "../components/Budget/Budget";
import { SearchOption } from "../components/NavBar/NavBar";
import { calcPercentage } from "../utils";

interface BudgetContextInterface {
Expand All @@ -9,10 +10,8 @@ interface BudgetContextInterface {
budgetList: Budget[] | undefined;
setBudgetList: (value: Budget[] | undefined) => void;

budgetNameList: { id: string; name: string }[] | undefined;
setBudgetNameList: (
value: { id: string; name: string }[] | undefined,
) => void;
budgetNameList: SearchOption[] | undefined;
setBudgetNameList: (value: SearchOption[] | undefined) => void;

revenuePercentage: number;
}
Expand All @@ -29,7 +28,7 @@ const BudgetContext = createContext<BudgetContextInterface>({
},

budgetNameList: [],
setBudgetNameList: (value: { id: string; name: string }[] | undefined) => {
setBudgetNameList: (value: SearchOption[] | undefined) => {
value;
},

Expand Down Expand Up @@ -71,7 +70,7 @@ function BudgetProvider({ children }: PropsWithChildren) {
);

const [budgetNameList, setBudgetNameList] = useState<
{ id: string; name: string }[] | undefined
SearchOption[] | undefined
>([]);

return (
Expand Down
6 changes: 4 additions & 2 deletions src/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const testBudget2 = new Budget({
total: 50,
},
incomes: {
items: [{ id: 2, name: "name", value: 200 }],
items: [{ id: 2, name: "name2", value: 200 }],
total: 200,
},
stats: {
Expand All @@ -90,7 +90,7 @@ export const testBudget2 = new Budget({
});

export const testBigBudget = new Budget({
id: "035c2de4-00a4-403c-8f0e-f81339be9a4e",
id: "225c2de5-00a4-403c-8f0e-f81339be9a4e",
name: "2023-03",
expenses: {
items: [
Expand Down Expand Up @@ -211,10 +211,12 @@ export const testBudgetList = [testBudget, testBudget2, testBigBudget];
export const testBudgetNameList = [
{
id: testBudget.id,
item: "",
name: testBudget.name,
},
{
id: testBudget2.id,
item: "",
name: testBudget2.name,
},
];
Expand Down
2 changes: 2 additions & 0 deletions src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,12 @@ test("createBudgetNameList", () => {
const expectedResult = [
{
id: "035c2de4-00a4-403c-8f0e-f81339be9a4e",
item: "",
name: "2023-03",
},
{
id: "135b2ce4-00a4-403c-8f0e-f81339be9a4e",
item: "",
name: "2023-04",
},
];
Expand Down
9 changes: 4 additions & 5 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { MutableRefObject } from "react";
import { Budget } from "./components/Budget/Budget";
import { CsvItem } from "./components/Budget/CsvItem";
import { ItemForm } from "./components/ItemForm/ItemForm";
import { SearchOption } from "./components/NavBar/NavBar";
import { currenciesMap } from "./lists/currenciesMap";

export const userLang = navigator.language;
Expand Down Expand Up @@ -229,13 +230,11 @@ export function focusRef(ref: MutableRefObject<HTMLInputElement | undefined>) {
}
}

export function createBudgetNameList(
list: Budget[],
): { id: string; name: string }[] {
export function createBudgetNameList(list: Budget[]): SearchOption[] {
return list
.filter((b: Budget) => b?.id !== undefined && b.name !== undefined)
.filter((b: Budget) => b.id !== undefined && b.name !== undefined)
.map((b: Budget) => {
return { id: b.id, name: b.name };
return { id: b.id, item: "", name: b.name };
});
}

Expand Down

0 comments on commit 7162974

Please sign in to comment.