diff --git a/README.md b/README.md index 1a427d0..2a2af24 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/src/components/Budget/BudgetPage.tsx b/src/components/Budget/BudgetPage.tsx index d91376b..dc1a6b4 100644 --- a/src/components/Budget/BudgetPage.tsx +++ b/src/components/Budget/BudgetPage.tsx @@ -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"; @@ -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"; @@ -43,6 +42,7 @@ function BudgetPage() { const [csvError, setCsvError] = useState([]); const [jsonError, setJsonError] = useState([]); const [show, setShow] = useState(false); + const [focus, setFocus] = useState(""); const [notifications, setNotifications] = useState< { @@ -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, + ]); } } } @@ -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]); } } } diff --git a/src/components/NavBar/NavBar.test.tsx b/src/components/NavBar/NavBar.test.tsx index 506592e..ae664fe 100644 --- a/src/components/NavBar/NavBar.test.tsx +++ b/src/components/NavBar/NavBar.test.tsx @@ -41,6 +41,7 @@ describe("NavBar", () => { it("matches snapshot", () => { expect(comp).toMatchSnapshot(); }); + it("renders initial state", async () => { expect(screen.getByText("2023-03")).toBeInTheDocument(); @@ -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(); }); @@ -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", diff --git a/src/components/NavBar/NavBar.tsx b/src/components/NavBar/NavBar.tsx index 0b19bc8..a4ab824 100644 --- a/src/components/NavBar/NavBar.tsx +++ b/src/components/NavBar/NavBar.tsx @@ -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"; @@ -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; @@ -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({ @@ -67,6 +75,8 @@ function NavBar({ const { currency, handleCurrency } = useConfig(); const { budget, budgetNameList } = useBudget(); + const [options, setOptions] = useState([]); + useHotkeys("pageup", (e) => !e.repeat && handleGoForward(), { preventDefault: true, }); @@ -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(); } @@ -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 ( diff --git a/src/context/BudgetContext.tsx b/src/context/BudgetContext.tsx index 90cb113..69e05ce 100644 --- a/src/context/BudgetContext.tsx +++ b/src/context/BudgetContext.tsx @@ -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 { @@ -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; } @@ -29,7 +28,7 @@ const BudgetContext = createContext({ }, budgetNameList: [], - setBudgetNameList: (value: { id: string; name: string }[] | undefined) => { + setBudgetNameList: (value: SearchOption[] | undefined) => { value; }, @@ -71,7 +70,7 @@ function BudgetProvider({ children }: PropsWithChildren) { ); const [budgetNameList, setBudgetNameList] = useState< - { id: string; name: string }[] | undefined + SearchOption[] | undefined >([]); return ( diff --git a/src/setupTests.ts b/src/setupTests.ts index e217d0d..03e01d4 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -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: { @@ -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: [ @@ -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, }, ]; diff --git a/src/utils.test.ts b/src/utils.test.ts index b201e8c..b74770a 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -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", }, ]; diff --git a/src/utils.ts b/src/utils.ts index 2d97e52..b1f140c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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; @@ -229,13 +230,11 @@ export function focusRef(ref: MutableRefObject) { } } -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 }; }); }