From e3ff306508000f0ecce5c94b4320baddc9a4eb5e Mon Sep 17 00:00:00 2001 From: Stezido Date: Mon, 9 Dec 2019 17:35:58 +0100 Subject: [PATCH 01/19] ui: add complete search feature Following prefixes are supported: -tag -name -status Without a prefix the properties tag, displayName and status are searched for hits Tag buttons support search on click Filter projects is executed by a web worker besides the UI-thread TODO: see added TODO in code remove filterProjects code or outsource the workers code --- frontend/src/WebWorker.js | 7 ++ frontend/src/filterProjects.worker.js | 73 ++++++++++++++++++ frontend/src/pages/Common/filterProjects.js | 66 ++++++++++++++++ frontend/src/pages/Navbar/ProjectSearch.js | 75 +++++++++++-------- frontend/src/pages/Navbar/reducer.js | 2 +- .../src/pages/Overview/OverviewContainer.js | 51 +++++++++---- frontend/src/pages/Overview/OverviewTable.js | 26 +++---- frontend/src/pages/Overview/ProjectCard.js | 2 - frontend/src/pages/Overview/actions.js | 9 +++ frontend/src/pages/Overview/reducer.js | 9 ++- 10 files changed, 250 insertions(+), 70 deletions(-) create mode 100644 frontend/src/WebWorker.js create mode 100644 frontend/src/filterProjects.worker.js create mode 100644 frontend/src/pages/Common/filterProjects.js diff --git a/frontend/src/WebWorker.js b/frontend/src/WebWorker.js new file mode 100644 index 000000000..2dc838b6a --- /dev/null +++ b/frontend/src/WebWorker.js @@ -0,0 +1,7 @@ +export default class WebWorker { + constructor(worker) { + const code = worker.toString(); + const blob = new Blob(["(" + code + ")()"]); + return new Worker(URL.createObjectURL(blob)); + } +} diff --git a/frontend/src/filterProjects.worker.js b/frontend/src/filterProjects.worker.js new file mode 100644 index 000000000..27886b98f --- /dev/null +++ b/frontend/src/filterProjects.worker.js @@ -0,0 +1,73 @@ +export default () => { + onmessage = ({ data: { projects, searchTerm } }) => { + const filterProjects = (projects, searchTermString) => { + const unfilteredSearchTerms = searchTermString.split(" "); + const searchedDisplayNames = extractFromSearchTerms(unfilteredSearchTerms, "name"); + const searchedTags = extractFromSearchTerms(unfilteredSearchTerms, "tag"); + const searchedStatus = extractFromSearchTerms(unfilteredSearchTerms, "status"); + const searchTermsWithoutPrefix = unfilteredSearchTerms.filter( + searchTerm => !searchTerm.includes(":") && searchTerm.length !== 0 + ); + + return projects.filter(project => { + let hasDisplayName = true; + let hasStatus = true; + let hasTag = true; + let hasSearchTerm = true; + // Only call functions when searching for it explicitly + if (searchedDisplayNames.length !== 0) hasDisplayName = includesDisplayName(project, searchedDisplayNames); + if (searchedStatus.length !== 0) hasStatus = includesStatus(project, searchedStatus); + if (searchedTags.length !== 0) hasTag = includesTag(project, searchedTags); + if (searchTermsWithoutPrefix.length !== 0) + hasSearchTerm = includesSearchTerm(project, searchTermsWithoutPrefix); + return hasDisplayName && hasStatus && hasTag && hasSearchTerm; + }); + }; + + const extractFromSearchTerms = (searchTerms, prefix) => { + return searchTerms.reduce((extractedTerms, searchTerm) => { + const searchTermPrefix = searchTerm.replace(/:/, " ").split(" ")[0]; + if (searchTermPrefix === prefix) { + const searchTermWithoutPrefix = searchTerm.replace(/:/, " ").split(" ")[1]; + if (searchTermWithoutPrefix) { + extractedTerms.push(searchTermWithoutPrefix); + } else { + extractedTerms.push(""); + } + } + return extractedTerms; + }, []); + }; + + function includesSearchTerm(project, searchTermsWithoutPrefix) { + return searchTermsWithoutPrefix.every(searchTerm => { + return ( + project.data.displayName.toLowerCase().includes(searchTerm.toLowerCase()) || + project.data.status.toLowerCase().includes(searchTerm.toLowerCase()) || + project.data.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())) + ); + }); + } + + function includesTag(project, searchedTags) { + return project.data.tags.some(projectTag => + searchedTags.some(extractedTag => projectTag.toLowerCase().includes(extractedTag.toLowerCase())) + ); + } + + function includesStatus(project, searchedStatus) { + return searchedStatus.some(status => { + return project.data.status.toLowerCase().includes(status.toLowerCase()); + }); + } + + function includesDisplayName(project, searchedDisplayNames) { + return searchedDisplayNames.some(displayName => + project.data.displayName.toLowerCase().includes(displayName.toLowerCase()) + ); + } + + const filteredProjects = filterProjects(projects, searchTerm); + postMessage(filteredProjects); + }; +}; diff --git a/frontend/src/pages/Common/filterProjects.js b/frontend/src/pages/Common/filterProjects.js new file mode 100644 index 000000000..f69dfbd9f --- /dev/null +++ b/frontend/src/pages/Common/filterProjects.js @@ -0,0 +1,66 @@ +import _isEmpty from "lodash/isEmpty"; + +const filterProjects = (projects, searchTermString) => { + const unfilteredSearchTerms = searchTermString.split(" "); + const searchedDisplayNames = extractFromSearchTerms(unfilteredSearchTerms, "name"); + const searchedTags = extractFromSearchTerms(unfilteredSearchTerms, "tag"); + const searchedStatus = extractFromSearchTerms(unfilteredSearchTerms, "status"); + const searchTermsWithoutPrefix = unfilteredSearchTerms.filter(searchTerm => !searchTerm.includes(":")); + + return projects.filter(project => { + let hasDisplayName = true; + let hasStatus = true; + let hasTag = true; + let hasSearchTerm = true; + + // Only call functions when searching for it explicitly + if (!_isEmpty(searchedDisplayNames)) hasDisplayName = includesDisplayName(project, searchedDisplayNames); + if (!_isEmpty(searchedStatus)) hasStatus = includesStatus(project, searchedStatus); + if (!_isEmpty(searchedTags)) hasTag = includesTag(project, searchedTags); + if (!_isEmpty(searchTermsWithoutPrefix)) hasSearchTerm = includesSearchTerm(project, searchTermsWithoutPrefix); + + return hasDisplayName && hasStatus && hasTag && hasSearchTerm; + }); +}; + +const extractFromSearchTerms = (searchTerms, prefix) => { + return searchTerms.reduce((extractedTerms, searchTerm) => { + const searchTermPrefix = searchTerm.replace(/:/, " ").split(" ")[0]; + if (searchTermPrefix === prefix) { + const searchTermWithoutPrefix = searchTerm.replace(/:/, " ").split(" ")[1]; + if (searchTermWithoutPrefix) extractedTerms.push(searchTermWithoutPrefix); + } + return extractedTerms; + }, []); +}; + +function includesSearchTerm(project, searchTermsWithoutPrefix) { + return searchTermsWithoutPrefix.some(searchTerm => { + return ( + project.data.displayName.toLowerCase().includes(searchTerm.toLowerCase()) || + project.data.status.toLowerCase().includes(searchTerm.toLowerCase()) || + project.data.description.toLowerCase().includes(searchTerm.toLowerCase()) || + project.data.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())) + ); + }); +} + +function includesTag(project, searchedTags) { + return project.data.tags.some(projectTag => + searchedTags.some(extractedTag => projectTag.toLowerCase().includes(extractedTag.toLowerCase())) + ); +} + +function includesStatus(project, searchedStatus) { + return searchedStatus.some(status => { + return project.data.status.toLowerCase().includes(status.toLowerCase()); + }); +} + +function includesDisplayName(project, searchedDisplayNames) { + return searchedDisplayNames.some(displayName => + project.data.displayName.toLowerCase().includes(displayName.toLowerCase()) + ); +} + +export default filterProjects; diff --git a/frontend/src/pages/Navbar/ProjectSearch.js b/frontend/src/pages/Navbar/ProjectSearch.js index 20ca3bfdf..ddb825dc9 100644 --- a/frontend/src/pages/Navbar/ProjectSearch.js +++ b/frontend/src/pages/Navbar/ProjectSearch.js @@ -7,6 +7,19 @@ import Tooltip from "@material-ui/core/Tooltip"; import CancelIcon from "@material-ui/icons/Cancel"; import SearchIcon from "@material-ui/icons/Search"; import React from "react"; +import { withStyles } from "@material-ui/core"; + +const styles = { + searchField: { + padding: "2px", + margin: "5px", + width: "270px", + display: "flex", + flexDirection: "row", + opacity: "0.8", + boxShadow: "none" + } +}; const ProjectSearch = ({ searchBarDisplayed, @@ -17,37 +30,35 @@ const ProjectSearch = ({ }) => { return (
-
- {searchBarDisplayed && !searchDisabled ? ( - -
e.preventDefault()} style={{ width: "90%" }}> - - storeSearchTerm(event.target.value)} - onKeyDown={e => { - if (e.key === "Escape" || e.key === "Esc") { - storeSearchTerm(""); - storeSearchBarDisplayed(false); - } - }} - style={{ width: "100%" }} - value={searchTerm} - autoFocus={true} - /> - -
- { - storeSearchBarDisplayed(false); - storeSearchTerm(""); - }} - > - - -
- ) : null} -
+ {searchBarDisplayed && !searchDisabled ? ( + +
e.preventDefault()} style={{ width: "90%" }}> + + storeSearchTerm(event.target.value)} + onKeyDown={e => { + if (e.key === "Escape" || e.key === "Esc") { + storeSearchTerm(""); + storeSearchBarDisplayed(false); + } + }} + style={{ width: "100%" }} + value={searchTerm} + autoFocus={true} + /> + +
+ { + storeSearchBarDisplayed(false); + storeSearchTerm(""); + }} + > + + +
+ ) : null}
{ + const filteredProjects = event.data ? event.data : this.props.projects; + this.props.storeFilteredProjects(filteredProjects); + }); + this.props.fetchAllProjects(true); } + componentDidUpdate(prevProps) { + const searchTermChanges = this.props.searchTerm !== prevProps.searchTerm; + const projectsChange = !_isEqual(this.props.projects, prevProps.projects); + if (this.props.searchTerm && (searchTermChanges || projectsChange)) { + this.worker.postMessage({ projects: this.props.projects, searchTerm: this.props.searchTerm }); + } + // TODO: After project creation the filteredProjects in state should not update + if (!this.props.searchTerm && prevProps.searchTerm) { + this.props.storeFilteredProjects(this.props.projects); + } + } + render() { return (
@@ -49,21 +69,20 @@ const mapDispatchToProps = dispatch => { showCreationDialog: () => dispatch(showCreationDialog()), showEditDialog: (id, displayName, description, thumbnail, projectedBudgets, tags) => dispatch(showEditDialog(id, displayName, description, thumbnail, projectedBudgets, tags)), - fetchAllProjects: showLoading => dispatch(fetchAllProjects(showLoading)), showProjectPermissions: (id, displayName) => dispatch(showProjectPermissions(id, displayName)), showProjectAdditionalData: id => dispatch(showProjectAdditionalData(id)), hideProjectAdditionalData: () => dispatch(hideProjectAdditionalData()), - closeSearchBar: () => { - dispatch(storeSearchTerm("")); - dispatch(storeSearchBarDisplayed(false)); - } + storeFilteredProjects: filteredProjects => dispatch(storeFilteredProjects(filteredProjects)), + storeSearchTerm: searchTerm => dispatch(storeSearchTerm(searchTerm)), + showSearchBar: () => dispatch(storeSearchBarDisplayed(true)) }; }; const mapStateToProps = state => { return { projects: state.getIn(["overview", "projects"]), + filteredProjects: state.getIn(["overview", "filteredProjects"]), allowedIntents: state.getIn(["login", "allowedIntents"]), loggedInUser: state.getIn(["login", "loggedInUser"]), roles: state.getIn(["login", "roles"]), diff --git a/frontend/src/pages/Overview/OverviewTable.js b/frontend/src/pages/Overview/OverviewTable.js index c2fd7e54d..a20a0d400 100644 --- a/frontend/src/pages/Overview/OverviewTable.js +++ b/frontend/src/pages/Overview/OverviewTable.js @@ -9,7 +9,6 @@ import Tooltip from "@material-ui/core/Tooltip"; import ContentAdd from "@material-ui/icons/Add"; import _isEmpty from "lodash/isEmpty"; import React from "react"; - import { formattedTag, statusMapping, toAmountString, unixTsToString } from "../../helper"; import strings from "../../localizeStrings"; import { canCreateProject, canUpdateProject, canViewProjectPermissions } from "../../permissions"; @@ -98,12 +97,14 @@ const displayProjectBudget = budgets => { ); }; -const displayTags = tags => { +const displayTags = (tags, storeSearchTerm, showSearchBar) => { return tags.map((tag, i) => (