From 094893d69e73d9bd7b2da97651688d874da9b48a Mon Sep 17 00:00:00 2001 From: Jad Date: Wed, 13 Dec 2023 11:49:05 -0800 Subject: [PATCH 1/6] Make the search bar an autocomplete --- epictrack-api/src/api/models/project.py | 6 ++ epictrack-api/src/api/models/work.py | 3 +- epictrack-api/src/api/resources/project.py | 22 +++-- epictrack-api/src/api/services/project.py | 9 ++- .../myWorkplans/Filters/NameFilter.tsx | 80 ++++++++++++++----- .../components/myWorkplans/Filters/index.tsx | 2 +- .../src/services/projectService/constants.ts | 3 + .../services/projectService/projectService.ts | 7 +- .../src/utils/MatchingTextHighlight.tsx | 15 ++++ 9 files changed, 115 insertions(+), 32 deletions(-) create mode 100644 epictrack-web/src/services/projectService/constants.ts create mode 100644 epictrack-web/src/utils/MatchingTextHighlight.tsx diff --git a/epictrack-api/src/api/models/project.py b/epictrack-api/src/api/models/project.py index 76c6303fa..2e3de362c 100644 --- a/epictrack-api/src/api/models/project.py +++ b/epictrack-api/src/api/models/project.py @@ -69,6 +69,12 @@ class Project(BaseModelVersioned): proponent = relationship("Proponent", foreign_keys=[proponent_id], lazy="select") region_env = relationship("Region", foreign_keys=[region_id_env], lazy="select") region_flnro = relationship("Region", foreign_keys=[region_id_flnro], lazy="select") + works = relationship('Work', lazy='dynamic') + + @classmethod + def find_all_with_works(cls): + """Return all projects with works.""" + return cls.query.filter(cls.works.any()).filter(cls.is_deleted.is_(False)).all() @classmethod def check_existence(cls, name, project_id=None): diff --git a/epictrack-api/src/api/models/work.py b/epictrack-api/src/api/models/work.py index bcad609d4..10d5a22bb 100644 --- a/epictrack-api/src/api/models/work.py +++ b/epictrack-api/src/api/models/work.py @@ -165,7 +165,8 @@ def _filter_by_staff_id(cls, query, staff_id): @classmethod def _filter_by_search_text(cls, query, search_text): if search_text: - query = query.filter(Work.title.ilike(f'%{search_text}%')) + subquery = exists().where(and_(Work.project_id == Project.id, Project.name == search_text)) + query = query.filter(subquery) return query @classmethod diff --git a/epictrack-api/src/api/resources/project.py b/epictrack-api/src/api/resources/project.py index 8da95db3e..f633203a8 100644 --- a/epictrack-api/src/api/resources/project.py +++ b/epictrack-api/src/api/resources/project.py @@ -39,13 +39,21 @@ class Projects(Resource): @profiletime def get(): """Return all projects.""" - projects = ProjectService.find_all() - return_type = request.args.get("return_type", None) - if return_type == "list_type": - schema = res.ListTypeResponseSchema(many=True) - else: - schema = res.ProjectResponseSchema(many=True) - return jsonify(schema.dump(projects)), HTTPStatus.OK + try: + with_works = request.args.get( + 'with_works', + default=False, + type=lambda v: v.lower() == 'true' + ) + projects = ProjectService.find_all(with_works) + return_type = request.args.get("return_type", None) + if return_type == "list_type": + schema = res.ListTypeResponseSchema(many=True) + else: + schema = res.ProjectResponseSchema(many=True) + return jsonify(schema.dump(projects)), HTTPStatus.OK + except Exception as e: + return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR @staticmethod @cors.crossdomain(origin="*") diff --git a/epictrack-api/src/api/services/project.py b/epictrack-api/src/api/services/project.py index 4b47c6162..d594f8fbd 100644 --- a/epictrack-api/src/api/services/project.py +++ b/epictrack-api/src/api/services/project.py @@ -46,10 +46,17 @@ def find(cls, project_id): return project @classmethod - def find_all(cls): + def find_all(cls, with_works=False): """Find all projects""" + if with_works: + return Project.find_all_with_works() return Project.find_all(default_filters=False) + @classmethod + def find_all_with_works(cls): + """Find all projects""" + return Project.find_all_with_works() + @classmethod def create_project(cls, payload: dict): """Create a new project.""" diff --git a/epictrack-web/src/components/myWorkplans/Filters/NameFilter.tsx b/epictrack-web/src/components/myWorkplans/Filters/NameFilter.tsx index 15a6ea2ff..1f80d14a7 100644 --- a/epictrack-web/src/components/myWorkplans/Filters/NameFilter.tsx +++ b/epictrack-web/src/components/myWorkplans/Filters/NameFilter.tsx @@ -1,15 +1,22 @@ -import React, { useRef } from "react"; -import { TextField } from "@mui/material"; -import debounce from "lodash/debounce"; +import React, { useState, useEffect, useContext } from "react"; +import { Autocomplete, Box, InputAdornment, TextField } from "@mui/material"; import { MyWorkplansContext, WorkPlanSearchOptions, } from "../MyWorkPlanContext"; +import projectService from "../../../services/projectService/projectService"; +import { PROJECT_RETURN_TYPE } from "../../../services/projectService/constants"; +import { ListType } from "../../../models/code"; +import SearchIcon from "../../../assets/images/search.svg"; +import { highlightText } from "../../../utils/MatchingTextHighlight"; +const SEARCH_TEXT_THRESHOLD = 1; export const NameFilter = () => { - const [searchText, setSearchText] = React.useState(""); + const [options, setOptions] = useState([]); + const [searchText, setSearchText] = useState(""); + + const { setSearchOptions } = useContext(MyWorkplansContext); - const { setSearchOptions } = React.useContext(MyWorkplansContext); const handleSearchOptions = (searchText: string) => { setSearchOptions( (prev: WorkPlanSearchOptions) => @@ -20,25 +27,58 @@ export const NameFilter = () => { ); }; - const debouncedSetSearchOptions = useRef( - debounce((searchText: string) => { - handleSearchOptions(searchText); - }, 1000) - ).current; + // Fetch project names from the backend when searchText changes + useEffect(() => { + const fetchProjectNames = async () => { + // Replace this with your actual backend API call + const with_works = true; + const response = (await projectService.getAll( + PROJECT_RETURN_TYPE.LIST_TYPE, + with_works + )) as { data: ListType[] }; - const handleChange = (event: React.ChangeEvent) => { - setSearchText(event.target.value); - debouncedSetSearchOptions(event.target.value); - }; + const projectNames = response.data.map((project) => project.name); + setOptions(projectNames); + }; + + fetchProjectNames(); + }, []); return ( - = SEARCH_TEXT_THRESHOLD ? options : []} + onInputChange={(_, newValue) => { + setSearchText(newValue ?? ""); + }} + onChange={(_, newValue) => { + handleSearchOptions(newValue ?? ""); + }} + renderInput={(params) => ( + + + + ), + }} + /> + )} fullWidth - value={searchText} - onChange={handleChange} + clearOnBlur + noOptionsText="" + renderOption={(props, option, state) => ( +
  • {highlightText(option, state.inputValue)}
  • + )} /> ); }; diff --git a/epictrack-web/src/components/myWorkplans/Filters/index.tsx b/epictrack-web/src/components/myWorkplans/Filters/index.tsx index aaf5b2fd8..61f394997 100644 --- a/epictrack-web/src/components/myWorkplans/Filters/index.tsx +++ b/epictrack-web/src/components/myWorkplans/Filters/index.tsx @@ -15,7 +15,7 @@ const Filters = () => { justifyContent={"space-between"} direction="row" > - + diff --git a/epictrack-web/src/services/projectService/constants.ts b/epictrack-web/src/services/projectService/constants.ts new file mode 100644 index 000000000..fdb86e228 --- /dev/null +++ b/epictrack-web/src/services/projectService/constants.ts @@ -0,0 +1,3 @@ +export const PROJECT_RETURN_TYPE = { + LIST_TYPE: "LIST_TYPE", +}; diff --git a/epictrack-web/src/services/projectService/projectService.ts b/epictrack-web/src/services/projectService/projectService.ts index 45d28a5f6..89461e595 100644 --- a/epictrack-web/src/services/projectService/projectService.ts +++ b/epictrack-web/src/services/projectService/projectService.ts @@ -5,8 +5,11 @@ import { MasterBase } from "../../models/type"; import { ListType } from "../../models/code"; class ProjectService implements ServiceBase { - async getAll(return_type?: string) { - return await http.GetRequest(Endpoints.Projects.PROJECTS, { return_type }); + async getAll(return_type?: string, with_works?: boolean) { + return await http.GetRequest(Endpoints.Projects.PROJECTS, { + return_type, + with_works, + }); } async getById(id: string) { diff --git a/epictrack-web/src/utils/MatchingTextHighlight.tsx b/epictrack-web/src/utils/MatchingTextHighlight.tsx new file mode 100644 index 000000000..aa4863650 --- /dev/null +++ b/epictrack-web/src/utils/MatchingTextHighlight.tsx @@ -0,0 +1,15 @@ +export const highlightText = (text: string, query: string) => { + const index = text.toLowerCase().indexOf(query.toLowerCase()); + if (index !== -1) { + return ( + <> + {text.substring(0, index)} + + {text.substring(index, index + query.length)} + + {text.substring(index + query.length)} + + ); + } + return text; +}; From 50f53ae490627408bebd92cd5167b3afaad7d633 Mon Sep 17 00:00:00 2001 From: Jad Date: Wed, 13 Dec 2023 11:54:46 -0800 Subject: [PATCH 2/6] disable while loading --- .../myWorkplans/Filters/NameFilter.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/epictrack-web/src/components/myWorkplans/Filters/NameFilter.tsx b/epictrack-web/src/components/myWorkplans/Filters/NameFilter.tsx index 1f80d14a7..c5b077fc9 100644 --- a/epictrack-web/src/components/myWorkplans/Filters/NameFilter.tsx +++ b/epictrack-web/src/components/myWorkplans/Filters/NameFilter.tsx @@ -12,6 +12,7 @@ import { highlightText } from "../../../utils/MatchingTextHighlight"; const SEARCH_TEXT_THRESHOLD = 1; export const NameFilter = () => { + const [loading, setLoading] = useState(true); const [options, setOptions] = useState([]); const [searchText, setSearchText] = useState(""); @@ -31,14 +32,19 @@ export const NameFilter = () => { useEffect(() => { const fetchProjectNames = async () => { // Replace this with your actual backend API call - const with_works = true; - const response = (await projectService.getAll( - PROJECT_RETURN_TYPE.LIST_TYPE, - with_works - )) as { data: ListType[] }; + try { + const with_works = true; + const response = (await projectService.getAll( + PROJECT_RETURN_TYPE.LIST_TYPE, + with_works + )) as { data: ListType[] }; - const projectNames = response.data.map((project) => project.name); - setOptions(projectNames); + const projectNames = response.data.map((project) => project.name); + setOptions(projectNames); + setLoading(false); + } catch (error) { + console.log(error); + } }; fetchProjectNames(); @@ -79,6 +85,7 @@ export const NameFilter = () => { renderOption={(props, option, state) => (
  • {highlightText(option, state.inputValue)}
  • )} + disabled={loading} /> ); }; From ac91ef3442d363285a93358b00f6aa37c4f3b4d4 Mon Sep 17 00:00:00 2001 From: Jad Date: Wed, 13 Dec 2023 11:56:55 -0800 Subject: [PATCH 3/6] Remove general try except --- epictrack-api/src/api/resources/project.py | 27 ++++++++++------------ 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/epictrack-api/src/api/resources/project.py b/epictrack-api/src/api/resources/project.py index f633203a8..5315c4323 100644 --- a/epictrack-api/src/api/resources/project.py +++ b/epictrack-api/src/api/resources/project.py @@ -39,21 +39,18 @@ class Projects(Resource): @profiletime def get(): """Return all projects.""" - try: - with_works = request.args.get( - 'with_works', - default=False, - type=lambda v: v.lower() == 'true' - ) - projects = ProjectService.find_all(with_works) - return_type = request.args.get("return_type", None) - if return_type == "list_type": - schema = res.ListTypeResponseSchema(many=True) - else: - schema = res.ProjectResponseSchema(many=True) - return jsonify(schema.dump(projects)), HTTPStatus.OK - except Exception as e: - return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + with_works = request.args.get( + 'with_works', + default=False, + type=lambda v: v.lower() == 'true' + ) + projects = ProjectService.find_all(with_works) + return_type = request.args.get("return_type", None) + if return_type == "list_type": + schema = res.ListTypeResponseSchema(many=True) + else: + schema = res.ProjectResponseSchema(many=True) + return jsonify(schema.dump(projects)), HTTPStatus.OK @staticmethod @cors.crossdomain(origin="*") From b78827037a97f617ceae9620b8bc1530ffc2c877 Mon Sep 17 00:00:00 2001 From: Jad Date: Wed, 13 Dec 2023 14:57:39 -0800 Subject: [PATCH 4/6] Enhancements for filterselect and name filter --- .../shared/filterSelect/FilterSelect.tsx | 43 ++++++------------- epictrack-web/src/styles/theme.tsx | 1 + .../src/utils/MatchingTextHighlight.tsx | 5 ++- 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/epictrack-web/src/components/shared/filterSelect/FilterSelect.tsx b/epictrack-web/src/components/shared/filterSelect/FilterSelect.tsx index 029cdc964..cb429114e 100644 --- a/epictrack-web/src/components/shared/filterSelect/FilterSelect.tsx +++ b/epictrack-web/src/components/shared/filterSelect/FilterSelect.tsx @@ -9,21 +9,8 @@ import SingleValue from "./components/SingleValueContainer"; import DropdownIndicator from "./components/DropDownIndicator"; import { MET_Header_Font_Weight_Regular } from "../../../styles/constants"; -// const useStyle = makeStyles({ -// infoSelect: { -// pointerEvents: "auto", -// borderRadius: "4px", -// "& > div:first-child": { -// paddingRight: 0, -// }, -// "&:hover": { -// backgroundColor: Palette.neutral.bg.main, -// }, -// }, -// }); - +const INPUT_SIZE = "0.875rem"; const FilterSelect = (props: SelectProps) => { - // const classes = useStyle(); const { name, isMulti, defaultValue } = props; const standardDefault = isMulti ? [] : ""; const [options, setOptions] = React.useState([]); @@ -92,7 +79,6 @@ const FilterSelect = (props: SelectProps) => { }; const applyFilters = () => { - // header.column.setFilterValue(selectedOptions); if (props.filterAppliedCallback) { props.filterAppliedCallback(selectedOptions); } @@ -117,7 +103,6 @@ const FilterSelect = (props: SelectProps) => { const clearFilters = () => { setSelectedOptions([]); setSelectValue(isMulti ? [] : ""); - // header.column.setFilterValue(isMulti ? [] : ""); if (props.filterClearedCallback) { props.filterClearedCallback(isMulti ? [] : ""); } @@ -139,6 +124,16 @@ const FilterSelect = (props: SelectProps) => { setOptions(filterOptions); }, [props.options]); + const isSearchable = () => { + if (props.isSearchable !== undefined) return props.isSearchable; + + if (selectValue instanceof Array) { + return selectValue.length === 0; + } + + return !selectValue; + }; + return (