Skip to content

Commit

Permalink
Merge pull request #116 from aodn/feature/5743-improve-searchbar
Browse files Browse the repository at this point in the history
Feature/5743 improve searchbar
  • Loading branch information
vietnguyengit authored Aug 8, 2024
2 parents af10c74 + 4fec2d3 commit dcc63e5
Show file tree
Hide file tree
Showing 16 changed files with 208 additions and 99 deletions.
2 changes: 1 addition & 1 deletion src/components/common/dropdown/CommonSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
Theme,
} from "@mui/material";
import { FC, useState } from "react";
import { capitalizeFirstLetter } from "../../../utils/capitalizeFirstLetter";
import { capitalizeFirstLetter } from "../../../utils/StringUtils";

interface CommonSelectProps {
items: string[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe("Component Reducer Function Test", () => {
};

const answer1: string = formatToUrlParam(sample1);
expect(answer1).toEqual("isImosOnlyDataset=false&searchText=");
expect(answer1).toEqual("isImosOnlyDataset=false");

const sample2: ParameterState = {
isImosOnlyDataset: false,
Expand Down
9 changes: 6 additions & 3 deletions src/components/common/store/componentParamReducer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,12 @@ const formatToUrlParam = (param: ParameterState) => {
const parts = [];
for (const key in result) {
if (Object.prototype.hasOwnProperty.call(result, key)) {
parts.push(
`${encodeURIComponent(key)}=${encodeURIComponent(result[key])}`
);
// Check if the value is not an empty string before adding to parts
if (result[key] !== "") {
parts.push(
`${encodeURIComponent(key)}=${encodeURIComponent(result[key])}`
);
}
}
}
return parts.join("&");
Expand Down
26 changes: 20 additions & 6 deletions src/components/search/ComplexTextSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const ComplexTextSearch = () => {
const navigate = useNavigate();
const [showFilters, setShowFilters] = useState<boolean>(false);

// set the default value to false to allow user do search without typing anything
const [pendingSearch, setPendingSearch] = useState<boolean>(false);

const redirectSearch = useCallback(() => {
const componentParam: ParameterState = getComponentState(store.getState());
navigate(pageDefault.search + "?" + formatToUrlParam(componentParam), {
Expand All @@ -37,14 +40,22 @@ const ComplexTextSearch = () => {
event: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>,
isSuggesterOpen: boolean
) => {
if (event.key === "Enter" && !isSuggesterOpen) {
// TODO: a more user-friendly way to execute 'enter' press function is to delay the search to wait for pendingSearch turn to true
// instead of prevent user doing search if pendingSearch is false
// considering the debounce (300ms) and fetchSuggesterOptions(quite fast according to experience with edge) is not very long
// we may implement this later if gap is too big
if (event.key === "Enter" && !isSuggesterOpen && !pendingSearch) {
redirectSearch();
}
},
[redirectSearch]
[pendingSearch, redirectSearch]
);

const onFilterClick = useCallback(() => {
const handleSearchClick = useCallback(() => {
if (!pendingSearch) redirectSearch();
}, [pendingSearch, redirectSearch]);

const handleFilterClick = useCallback(() => {
setShowFilters(true);
}, [setShowFilters]);

Expand All @@ -64,7 +75,10 @@ const ComplexTextSearch = () => {
>
<SearchIcon />
</IconButton>
<InputWithSuggester handleEnterPressed={handleEnterPressed} />
<InputWithSuggester
handleEnterPressed={handleEnterPressed}
setPendingSearch={setPendingSearch}
/>
<Button
variant="text"
sx={{
Expand All @@ -75,7 +89,7 @@ const ComplexTextSearch = () => {
backgroundColor: color.gray.xxLight,
}}
startIcon={<Tune />}
onClick={onFilterClick}
onClick={handleFilterClick}
data-testid={"filtersBtn"}
>
Filters
Expand All @@ -100,7 +114,7 @@ const ComplexTextSearch = () => {
},
}}
fullWidth
onClick={redirectSearch}
onClick={handleSearchClick}
>
Search
</Button>
Expand Down
154 changes: 92 additions & 62 deletions src/components/search/InputWithSuggester.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,18 @@ import {
fetchParameterCategoriesWithStore,
fetchSuggesterOptions,
} from "../common/store/searchReducer";
import _ from "lodash";
import { borderRadius, color, padding } from "../../styles/constants";
import { filterButtonWidth, searchIconWidth } from "./ComplexTextSearch";

import _ from "lodash";
import { sortByRelevance } from "../../utils/Helpers";

interface InputWithSuggesterProps {
handleEnterPressed?: (
event: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>,
isSuggesterOpen: boolean
) => void;
setPendingSearch?: React.Dispatch<React.SetStateAction<boolean>>;
}

// TODO: Try to only use these two classes inside this file to maintain high cohesion.
Expand All @@ -70,6 +73,7 @@ const textfieldMinWidth = 200;
*/
const InputWithSuggester: FC<InputWithSuggesterProps> = ({
handleEnterPressed = () => {},
setPendingSearch = () => {},
}) => {
const theme = useTheme();
const dispatch = useDispatch<AppDispatch>();
Expand Down Expand Up @@ -111,6 +115,7 @@ const InputWithSuggester: FC<InputWithSuggesterProps> = ({
},
[categorySet, dispatch, selectedCategories]
);

const removeCategory = useCallback(
(category: string) => {
const currentCategories = new Array(...selectedCategories);
Expand All @@ -135,50 +140,82 @@ const InputWithSuggester: FC<InputWithSuggesterProps> = ({
[categorySet, dispatch, selectedCategories]
);

const refreshOptions = useCallback(async () => {
try {
const currentState: ParameterState = getComponentState(store.getState());
dispatch(fetchSuggesterOptions(createSuggesterParamFrom(currentState)))
.unwrap()
.then((data) => {
const options: OptionType[] = [];

const categorySuggestions = new Set<string>(
data.category_suggestions
);
const phrasesSuggestions = new Set<string>(
data.record_suggestions.suggest_phrases
);

// Get the intersection of categorySuggestions and phrasesSuggestions
const commonSuggestions = [...categorySuggestions].filter((item) => {
return phrasesSuggestions.has(item);
});

commonSuggestions.forEach((common: string) => {
// Remove the commonSuggestions from categorySuggestions and phrasesSuggestions
categorySuggestions.delete(common);
phrasesSuggestions.delete(common);

options.push({ text: common, group: OptionGroup.COMMON });
});

categorySuggestions.forEach((category: string) => {
options.push({ text: category, group: OptionGroup.CATEGORY });
});
phrasesSuggestions.forEach((phrase: string) => {
options.push({ text: phrase, group: OptionGroup.PHRASE });
const refreshOptions = useCallback(
async (inputValue: string) => {
// setPendingSearch to true to prevent doing search before refreshing options is finished
setPendingSearch(true);
try {
const currentState: ParameterState = getComponentState(
store.getState()
);
dispatch(fetchSuggesterOptions(createSuggesterParamFrom(currentState)))
.unwrap()
.then((data) => {
const categorySuggestions = new Set<string>(
data.category_suggestions
);
const phrasesSuggestions = new Set<string>(
data.record_suggestions.suggest_phrases
);

// Get the intersection of categorySuggestions and phrasesSuggestions
const commonSuggestions = [...categorySuggestions].filter(
(item) => {
return phrasesSuggestions.has(item);
}
);

// check if the current inputValue is a common key or not, then dispatch updateCommonKey
if (
commonSuggestions.some(
(item) => item.toLowerCase() === inputValue.toLowerCase()
)
) {
dispatch(updateCommonKey(inputValue));
} else {
dispatch(updateCommonKey(""));
}

// Create array of all unique suggestions
const allSuggestions = new Set([
...commonSuggestions,
...categorySuggestions,
...phrasesSuggestions,
]);

// Sort suggestions by relevance
const sortedSuggestions = sortByRelevance(
allSuggestions,
inputValue
);

// Create sorted options array
const options: OptionType[] = sortedSuggestions.map(
(suggestion) => {
if (commonSuggestions.includes(suggestion)) {
return { text: suggestion, group: OptionGroup.COMMON };
} else if (categorySuggestions.has(suggestion)) {
return { text: suggestion, group: OptionGroup.CATEGORY };
} else {
return { text: suggestion, group: OptionGroup.PHRASE };
}
}
);

setOptions(options);
});

setOptions(options);
});
} catch (error) {
// TODO: Add error handling in the future.(toast, alert, etc)
// Also need to apply error handing
// in some other places if needed.
console.error("Error fetching data:", error);
}
}, [dispatch]);
} catch (error) {
// TODO: Add error handling in the future.(toast, alert, etc)
// Also need to apply error handing
// in some other places if needed.
console.error("Error fetching data:", error);
} finally {
// when refreshing options is done, allow to search
setPendingSearch(false);
}
},
[dispatch, setPendingSearch]
);

const debounceRefreshOptions = useRef(
_.debounce(refreshOptions, 300)
Expand All @@ -191,29 +228,21 @@ const InputWithSuggester: FC<InputWithSuggesterProps> = ({
};
}, [debounceRefreshOptions]);

const getGroup: (option: string) => string | undefined = useCallback(
(option: string) => {
return options.find((o) => o.text.toLowerCase() === option.toLowerCase())
?.group;
},
[options]
);

const onInputChange = useCallback(
async (_: any, newInputValue: string) => {
// If user type anything, then it is not a title search anymore
dispatch(updateSearchText(newInputValue));
if (newInputValue?.length > 1) {
await debounceRefreshOptions(); // wait for the debounced refresh to complete
// TODO: getGroup has delay, needs to instantly return group value or await until getGroup returns,
// otherwise, commonKey will be falsely ignored in the store (e.g. you type 'salinity' and quickly select it from the list, but it is not recognised as common key)
const group = getGroup(newInputValue);
if (group === "common") {
dispatch(updateCommonKey(newInputValue));
}
if (newInputValue?.length > 0) {
// wait for the debounced refresh to complete
// dispatch updateCommonKey if there is any during the refreshing-options to ensure the commonKey comes from the latest options given any inputValue changed
await debounceRefreshOptions(newInputValue);
} else {
// update to clear the commonKey
// this happens when user clear input text with button "X"
dispatch(updateCommonKey(""));
}
},
[debounceRefreshOptions, dispatch, getGroup]
[debounceRefreshOptions, dispatch]
);

useEffect(() => {
Expand Down Expand Up @@ -248,8 +277,9 @@ const InputWithSuggester: FC<InputWithSuggesterProps> = ({
if (event.key === "Enter") {
if (open) {
setOpen(false);
} else {
handleEnterPressed(event, false);
}
handleEnterPressed(event, false);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "../../../../components/common/store/OGCCollectionDefinitions";
import AssociatedRecordList from "../../../../components/list/AssociatedRecordList";
import NavigatablePanel from "./NavigatablePanel";
import { parseJson } from "../../../../utils/JsonUtils";
import { parseJson } from "../../../../utils/Helpers";

const AssociatedRecordsPanel = () => {
const context = useDetailPageContext();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useDetailPageContext } from "../../context/detail-page-context";
import NavigatablePanel from "./NavigatablePanel";
import ContactList from "../../../../components/list/ContactList";
import TextList from "../../../../components/list/TextList";
import { convertDateFormat } from "../../../../utils/DateFormatUtils";
import { convertDateFormat } from "../../../../utils/DateUtils";
import MetadataUrlList from "../../../../components/list/MetadataUrlList";

const MetadataInformationPanel = () => {
Expand Down
1 change: 1 addition & 0 deletions src/utils/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ const router = createBrowserRouter([
]);

export default router;
// TODO: move this to different place that is more related
1 change: 1 addition & 0 deletions src/utils/AppTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,4 @@ const theme: ThemeOptions = {
const AppTheme = createTheme(theme);

export default AppTheme;
// TODO: move this to different place that is more related
12 changes: 0 additions & 12 deletions src/utils/DateFormatUtils.ts

This file was deleted.

22 changes: 22 additions & 0 deletions src/utils/DateUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// This file is only for date time related helper methods e.g comparing dates, convert timezone, etc.

/**
* Converts a date string from ISO 8601 format to a more readable format.
*
* @param dateString A date string in ISO 8601 format (e.g., "2021-08-01T00:00:00.000Z")
* @returns A string representing the date in a more readable format (e.g., "Sun Aug 01 2021 05:30:00 GMT+0530")
*
* @example
* const isoDate = "2021-08-01T00:00:00.000Z";
* const result = convertDateFormat(isoDate);
* // result: "Sun Aug 01 2021 05:30:00 GMT+0530" (actual result may vary based on local timezone)
*
* @note The exact output format may vary depending on the local timezone of the system running the code.
* @note This function assumes the input is a valid ISO 8601 date string. Invalid inputs may produce unexpected results.
*/
export const convertDateFormat = (dateString: string): string => {
const date = new Date(dateString);
const convertedString = date.toString();
const index = convertedString.indexOf("(");
return convertedString.substring(0, index).trim();
};
1 change: 1 addition & 0 deletions src/utils/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,4 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
export { createErrorResponse, ErrorResponse };

export default ErrorBoundary;
// TODO: move this to different place that is more related
Loading

0 comments on commit dcc63e5

Please sign in to comment.