diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..a3822f3f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "FastAPI Debugging", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/store/app/main.py", + "console": "integratedTerminal", + "justMyCode": true, + "args": [], + "jinja": true + } + ] +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95b55816..27ff3119 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,7 +117,6 @@ export REACT_APP_BACKEND_URL=http://127.0.0.1:8080 export ROBOLIST_SMTP_HOST=smtp.gmail.com export ROBOLIST_SMTP_SENDER_EMAIL= export ROBOLIST_SMTP_PASSWORD= -export ROBOLIST_SMTP_NAME= export ROBOLIST_SMTP_SENDER_NAME= export ROBOLIST_SMTP_USERNAME= export ROBOLIST_REDIS_HOST= diff --git a/frontend/src/components/ui/Input/Input.module.css b/frontend/src/components/ui/Input/Input.module.css new file mode 100644 index 00000000..29959cba --- /dev/null +++ b/frontend/src/components/ui/Input/Input.module.css @@ -0,0 +1,30 @@ +.input { + display: flex; + height: 3rem; + width: 100%; + border-radius: 0.375rem; + border: 1px solid #e2e8f0; + background-color: #fff; + padding: 0.75rem 0.5rem; + font-size: 0.875rem; + transition: box-shadow 0.2s; +} + +.input[type="file"] { + border: none; + background-color: transparent; +} + +.input:focus { + outline: none; + box-shadow: 0 0 0 2px #3b82f6; +} + +.input:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.input::placeholder { + color: #9ca3af; +} diff --git a/frontend/src/components/ui/Input/Input.tsx b/frontend/src/components/ui/Input/Input.tsx new file mode 100644 index 00000000..6518ee9b --- /dev/null +++ b/frontend/src/components/ui/Input/Input.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import styles from "./Input.module.css"; + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); + +Input.displayName = "Input"; + +export { Input }; diff --git a/frontend/src/components/ui/Search/SearchInput.module.css b/frontend/src/components/ui/Search/SearchInput.module.css new file mode 100644 index 00000000..e1952d6d --- /dev/null +++ b/frontend/src/components/ui/Search/SearchInput.module.css @@ -0,0 +1,29 @@ +.SearchInput { + display: flex; + align-items: center; + border: 1px solid gray; + border-radius: 5px; + width: 50%; +} + +.SearchInput:focus-within { + box-shadow: 0 0 0 2px blue; +} + +.Icon { + margin-left: 0.3em; + filter: opacity(50%); +} + +.SearchInput:focus-within .Icon { + filter: opacity(100%); +} + +.SearchInput .Input { + border: none; + overflow: hidden; +} + +.SearchInput .Input:focus { + box-shadow: none; +} diff --git a/frontend/src/components/ui/Search/SearchInput.tsx b/frontend/src/components/ui/Search/SearchInput.tsx new file mode 100644 index 00000000..2f9fcffc --- /dev/null +++ b/frontend/src/components/ui/Search/SearchInput.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { Search } from "react-bootstrap-icons"; +import { Input } from "../Input/Input"; +import styles from "./SearchInput.module.css"; + +export interface SearchInputProps + extends React.InputHTMLAttributes { + userInput?: string; + onSearch?: (query: string) => void; +} + +const SearchInput = ({ + className, + userInput, + onChange, + onSearch, +}: SearchInputProps) => { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && onSearch && userInput !== undefined) { + onSearch(userInput); // Trigger a new callback onSearch + } + }; + return ( +
+ + +
+ ); +}; + +SearchInput.displayName = "SearchInput"; + +export { SearchInput }; diff --git a/frontend/src/hooks/api.tsx b/frontend/src/hooks/api.tsx index 43b97940..244aef61 100644 --- a/frontend/src/hooks/api.tsx +++ b/frontend/src/hooks/api.tsx @@ -250,9 +250,14 @@ export class api { return response.data.username; } - public async getRobots(page: number): Promise<[Robot[], boolean]> { + public async getRobots( + page: number, + searchQuery?: string, + ): Promise<[Robot[], boolean]> { try { - const response = await this.api.get("/robots/", { params: { page } }); + const response = await this.api.get("/robots/", { + params: { page, ...(searchQuery ? { search_query: searchQuery } : {}) }, + }); return response.data; } catch (error) { if (axios.isAxiosError(error)) { @@ -406,9 +411,14 @@ export class api { } } - public async getParts(page: number): Promise<[Part[], boolean]> { + public async getParts( + page: number, + searchQuery?: string, + ): Promise<[Part[], boolean]> { try { - const response = await this.api.get("/parts/", { params: { page } }); + const response = await this.api.get("/parts/", { + params: { page, ...(searchQuery ? { search_query: searchQuery } : {}) }, + }); return response.data; } catch (error) { if (axios.isAxiosError(error)) { diff --git a/frontend/src/pages/Parts.tsx b/frontend/src/pages/Parts.tsx index 3d8c458d..ebefca74 100644 --- a/frontend/src/pages/Parts.tsx +++ b/frontend/src/pages/Parts.tsx @@ -1,4 +1,5 @@ import ImageComponent from "components/files/ViewImage"; +import { SearchInput } from "components/ui/Search/SearchInput"; import { useAlertQueue } from "hooks/alerts"; import { api, Part } from "hooks/api"; import { useAuthentication } from "hooks/auth"; @@ -20,6 +21,8 @@ const Parts = () => { const [partsData, setParts] = useState(null); const [moreParts, setMoreParts] = useState(false); const [idMap, setIdMap] = useState>(new Map()); + const [searchQuery, setSearchQuery] = useState(""); + const [visibleSearchBarInput, setVisibleSearchBarInput] = useState(""); const { addAlert } = useAlertQueue(); const { page } = useParams(); @@ -33,10 +36,20 @@ const Parts = () => { ); } + function handleSearch() { + const searchQuery = visibleSearchBarInput; + setSearchQuery(searchQuery); + } + + const handleSearchInputEnterKey = (query: string) => { + setVisibleSearchBarInput(query); + handleSearch(); + }; + useEffect(() => { const fetch_robots = async () => { try { - const partsQuery = await auth_api.getParts(pageNumber); + const partsQuery = await auth_api.getParts(pageNumber, searchQuery); setMoreParts(partsQuery[1]); const parts = partsQuery[0]; setParts(parts); @@ -55,8 +68,7 @@ const Parts = () => { } }; fetch_robots(); - }, [pageNumber]); - + }, [pageNumber, searchQuery]); const navigate = useNavigate(); if (!partsData) { @@ -80,6 +92,11 @@ const Parts = () => { navigate("/")}>Home Parts + setVisibleSearchBarInput(e.target.value)} + onSearch={handleSearchInputEnterKey} + /> {partsData.map((part) => ( diff --git a/frontend/src/pages/Robots.tsx b/frontend/src/pages/Robots.tsx index 0b51b173..f6999323 100644 --- a/frontend/src/pages/Robots.tsx +++ b/frontend/src/pages/Robots.tsx @@ -1,4 +1,5 @@ import ImageComponent from "components/files/ViewImage"; +import { SearchInput } from "components/ui/Search/SearchInput"; import { useAlertQueue } from "hooks/alerts"; import { api, Robot } from "hooks/api"; import { useAuthentication } from "hooks/auth"; @@ -20,6 +21,8 @@ const Robots = () => { const [robotsData, setRobot] = useState([]); const [moreRobots, setMoreRobots] = useState(false); const [idMap, setIdMap] = useState>(new Map()); + const [searchQuery, setSearchQuery] = useState(""); + const [visibleSearchBarInput, setVisibleSearchBarInput] = useState(""); const { addAlert } = useAlertQueue(); const { page } = useParams(); @@ -33,10 +36,20 @@ const Robots = () => { ); } + function handleSearch() { + const searchQuery = visibleSearchBarInput; + setSearchQuery(searchQuery); + } + + const handleSearchInputEnterKey = (query: string) => { + setVisibleSearchBarInput(query); + handleSearch(); + }; + useEffect(() => { const fetch_robots = async () => { try { - const robotsQuery = await auth_api.getRobots(pageNumber); + const robotsQuery = await auth_api.getRobots(pageNumber, searchQuery); setMoreRobots(robotsQuery[1]); const robots = robotsQuery[0]; setRobot(robots); @@ -55,7 +68,7 @@ const Robots = () => { } }; fetch_robots(); - }, [pageNumber]); + }, [pageNumber, searchQuery]); const navigate = useNavigate(); if (!robotsData) { @@ -79,6 +92,11 @@ const Robots = () => { navigate("/")}>Home Robots + setVisibleSearchBarInput(e.target.value)} + onSearch={handleSearchInputEnterKey} + /> {robotsData.map((robot) => ( diff --git a/store/app/crud/robots.py b/store/app/crud/robots.py index 4845b831..fc96ea2c 100644 --- a/store/app/crud/robots.py +++ b/store/app/crud/robots.py @@ -70,9 +70,20 @@ async def add_part(self, part: Part) -> None: table = await self.db.Table("Parts") await table.put_item(Item=part.model_dump()) - async def list_robots(self, page: int = 1, items_per_page: int = 12) -> tuple[list[Robot], bool]: + async def list_robots( + self, page: int = 1, items_per_page: int = 12, search_query: Optional[str] = None + ) -> tuple[list[Robot], bool]: table = await self.db.Table("Robots") - response = await table.scan() + if search_query: + response = await table.scan( + FilterExpression="contains(#robot_name, :query) OR contains(description, :query)", + ExpressionAttributeValues={":query": search_query}, + ExpressionAttributeNames={ + "#robot_name": "name" + }, # Define the placeholder since "name" is a dynamodb reserved keyword + ) + else: + response = await table.scan() # This is O(n log n). Look into better ways to architect the schema. sorted_items = sorted(response["Items"], key=get_timestamp, reverse=True) return [ @@ -94,9 +105,20 @@ async def get_robot(self, robot_id: str) -> Robot | None: return None return Robot.model_validate(robot_dict["Item"]) - async def list_parts(self, page: int = 1, items_per_page: int = 12) -> tuple[list[Part], bool]: + async def list_parts( + self, page: int = 1, items_per_page: int = 12, search_query: Optional[str] = None + ) -> tuple[list[Part], bool]: table = await self.db.Table("Parts") - response = await table.scan() + if search_query: + response = await table.scan( + FilterExpression="contains(#part_name, :query) OR contains(description, :query)", + ExpressionAttributeValues={":query": search_query}, + ExpressionAttributeNames={ + "#part_name": "name" + }, # Define the placeholder since "name" is a dynamodb reserved keyword + ) + else: + response = await table.scan() # This is O(n log n). Look into better ways to architect the schema. sorted_items = sorted(response["Items"], key=get_timestamp, reverse=True) return [ diff --git a/store/app/crud/users.py b/store/app/crud/users.py index 9dbfc9f8..92dd401a 100644 --- a/store/app/crud/users.py +++ b/store/app/crud/users.py @@ -45,7 +45,6 @@ async def __aenter__(self) -> Self: ) self.__session_kv, self.__register_kv, self.__reset_password_kv, self.__change_email_kv = sessions - return self async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: # noqa: ANN401 diff --git a/store/app/main.py b/store/app/main.py index 38ea6966..61e0ba81 100644 --- a/store/app/main.py +++ b/store/app/main.py @@ -4,6 +4,7 @@ from contextlib import asynccontextmanager from typing import AsyncGenerator +import uvicorn from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -61,3 +62,7 @@ async def read_root() -> bool: app.include_router(robots_router, prefix="/robots", tags=["robots"]) app.include_router(parts_router, prefix="/parts", tags=["parts"]) app.include_router(image_router, prefix="/image", tags=["image"]) + +# For running with debugger +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8080) diff --git a/store/app/routers/part.py b/store/app/routers/part.py index f5c5de54..75c794fe 100644 --- a/store/app/routers/part.py +++ b/store/app/routers/part.py @@ -22,8 +22,9 @@ async def list_parts( crud: Annotated[Crud, Depends(Crud.get)], page: int = Query(description="Page number for pagination"), + search_query: str = Query(None, description="Search query string"), ) -> tuple[List[Part], bool]: - return await crud.list_parts(page) + return await crud.list_parts(page, search_query=search_query) @parts_router.get("/dump/") diff --git a/store/app/routers/robot.py b/store/app/routers/robot.py index c1f27953..b6626e83 100644 --- a/store/app/routers/robot.py +++ b/store/app/routers/robot.py @@ -22,6 +22,7 @@ async def list_robots( crud: Annotated[Crud, Depends(Crud.get)], page: int = Query(description="Page number for pagination"), + search_query: str = Query(None, description="Search query string"), ) -> tuple[List[Robot], bool]: """Lists the robots in the database. @@ -29,7 +30,7 @@ async def list_robots( Returns the robots on the page and a boolean indicating if there are more pages. """ - return await crud.list_robots(page) + return await crud.list_robots(page, search_query=search_query) @robots_router.get("/your/")