Skip to content

Commit

Permalink
Feature/search-feature (#154)
Browse files Browse the repository at this point in the history
* ignore venv directory

* Create styled Input component

* create search bar

* add search bar to robots

* add debugger entrance point

* use correct ip for debugging

* add robots backend search functionality

* allow SearchInput to take value and onChange

* add robots search to frontend

* add search to parts page

* add more specificity to search input style

* add focus colors for search

* trigger search with Enter key

* fix search on robots and parts page

* remove unused env var

* add description for launch.json addition

* remove json comment

* lint fixes
  • Loading branch information
EtcetFelix authored Jun 25, 2024
1 parent 29754af commit 1276119
Show file tree
Hide file tree
Showing 14 changed files with 225 additions and 17 deletions.
15 changes: 15 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
1 change: 0 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/components/ui/Input/Input.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 22 additions & 0 deletions frontend/src/components/ui/Input/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as React from "react";
import styles from "./Input.module.css";

export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={`${styles.input} ${className}`}
ref={ref}
{...props}
/>
);
},
);

Input.displayName = "Input";

export { Input };
29 changes: 29 additions & 0 deletions frontend/src/components/ui/Search/SearchInput.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
40 changes: 40 additions & 0 deletions frontend/src/components/ui/Search/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement> {
userInput?: string;
onSearch?: (query: string) => void;
}

const SearchInput = ({
className,
userInput,
onChange,
onSearch,
}: SearchInputProps) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onSearch && userInput !== undefined) {
onSearch(userInput); // Trigger a new callback onSearch
}
};
return (
<div className={`${styles.SearchInput} ${className}`}>
<Search className={styles.Icon} />
<Input
type="search"
placeholder="Search..."
className={styles.Input}
value={userInput}
onChange={onChange}
onKeyDown={handleKeyDown}
/>
</div>
);
};

SearchInput.displayName = "SearchInput";

export { SearchInput };
18 changes: 14 additions & 4 deletions frontend/src/hooks/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down
23 changes: 20 additions & 3 deletions frontend/src/pages/Parts.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -20,6 +21,8 @@ const Parts = () => {
const [partsData, setParts] = useState<Part[] | null>(null);
const [moreParts, setMoreParts] = useState<boolean>(false);
const [idMap, setIdMap] = useState<Map<string, string>>(new Map());
const [searchQuery, setSearchQuery] = useState("");
const [visibleSearchBarInput, setVisibleSearchBarInput] = useState("");
const { addAlert } = useAlertQueue();
const { page } = useParams();

Expand All @@ -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);
Expand All @@ -55,8 +68,7 @@ const Parts = () => {
}
};
fetch_robots();
}, [pageNumber]);

}, [pageNumber, searchQuery]);
const navigate = useNavigate();

if (!partsData) {
Expand All @@ -80,6 +92,11 @@ const Parts = () => {
<Breadcrumb.Item onClick={() => navigate("/")}>Home</Breadcrumb.Item>
<Breadcrumb.Item active>Parts</Breadcrumb.Item>
</Breadcrumb>
<SearchInput
userInput={visibleSearchBarInput}
onChange={(e) => setVisibleSearchBarInput(e.target.value)}
onSearch={handleSearchInputEnterKey}
/>

<Row className="mt-5">
{partsData.map((part) => (
Expand Down
22 changes: 20 additions & 2 deletions frontend/src/pages/Robots.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -20,6 +21,8 @@ const Robots = () => {
const [robotsData, setRobot] = useState<Robot[] | null>([]);
const [moreRobots, setMoreRobots] = useState<boolean>(false);
const [idMap, setIdMap] = useState<Map<string, string>>(new Map());
const [searchQuery, setSearchQuery] = useState("");
const [visibleSearchBarInput, setVisibleSearchBarInput] = useState("");
const { addAlert } = useAlertQueue();
const { page } = useParams();

Expand All @@ -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);
Expand All @@ -55,7 +68,7 @@ const Robots = () => {
}
};
fetch_robots();
}, [pageNumber]);
}, [pageNumber, searchQuery]);
const navigate = useNavigate();

if (!robotsData) {
Expand All @@ -79,6 +92,11 @@ const Robots = () => {
<Breadcrumb.Item onClick={() => navigate("/")}>Home</Breadcrumb.Item>
<Breadcrumb.Item active>Robots</Breadcrumb.Item>
</Breadcrumb>
<SearchInput
userInput={visibleSearchBarInput}
onChange={(e) => setVisibleSearchBarInput(e.target.value)}
onSearch={handleSearchInputEnterKey}
/>

<Row className="mt-5">
{robotsData.map((robot) => (
Expand Down
30 changes: 26 additions & 4 deletions store/app/crud/robots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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 [
Expand Down
1 change: 0 additions & 1 deletion store/app/crud/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions store/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
3 changes: 2 additions & 1 deletion store/app/routers/part.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/")
Expand Down
3 changes: 2 additions & 1 deletion store/app/routers/robot.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
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.
The function is paginated. The page size is 12.
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/")
Expand Down

0 comments on commit 1276119

Please sign in to comment.