Skip to content

Commit

Permalink
Merge pull request BloomBooks#508 from StephenMcConnel/BL-11565-Progr…
Browse files Browse the repository at this point in the history
…essivelyDeeperSearch

Limit initial search to exact language, bookshelf, or title (BL-11565)
  • Loading branch information
andrew-polk authored Oct 6, 2023
2 parents 7167baa + fa6bed9 commit f24415f
Show file tree
Hide file tree
Showing 10 changed files with 274 additions and 43 deletions.
7 changes: 7 additions & 0 deletions src/assets/SearchingDeeper.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 71 additions & 12 deletions src/components/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ import { CollectionInfoWidget } from "./CollectionInfoWidget";
import { appHostedSegment, useIsAppHosted } from "./appHosted/AppHostedUtils";
import { getDisplayNamesFromLanguageCode } from "../model/Language";
import { CachedTablesContext } from "../model/CacheProvider";
import { isFacetedSearchString } from "../connection/LibraryQueryHooks";

export const Breadcrumbs: React.FunctionComponent<{ className?: string }> = (
props
) => {
export const Breadcrumbs: React.FunctionComponent<{
className?: string;
}> = (props) => {
const location = useLocation();
const l10n = useIntl();
const { languagesByBookCount: languages } = useContext(CachedTablesContext);
Expand Down Expand Up @@ -118,8 +119,13 @@ export const Breadcrumbs: React.FunctionComponent<{ className?: string }> = (
}
for (const item of filters) {
let label = item;
const labelParts = item.split(":");
const labelParts = item.replace(/%3A/g, ":").split(":");
const prefix = labelParts[0];
// Text to be bolded in the label. This may be left empty.
// Only the first occurrence of the this text is bolded.
// The text to be bolded is enclosed in quotes in the label string
// to prevent matching against other parts of the label.
let boldedText = "";
switch (prefix.toLowerCase()) {
case "level":
label = l10n.formatMessage(
Expand All @@ -131,13 +137,42 @@ export const Breadcrumbs: React.FunctionComponent<{ className?: string }> = (
);
break;
case "search":
label = l10n.formatMessage(
{
id: "search.booksMatching",
defaultMessage: 'Books matching "{searchTerms}"',
},
{ searchTerms: labelParts.slice(1).join(":") }
);
const searchString = labelParts.slice(1).join(":");
if (searchString.startsWith("deeper:")) {
boldedText = labelParts
.slice(2)
.join(":")
.replace(/\\"/g, '"');
label = l10n.formatMessage(
{
id: "search.booksLooseMatching",
defaultMessage:
'Books with a loose match to "{searchTerms}"',
},
{ searchTerms: boldedText }
);
} else if (isFacetedSearchString(searchString)) {
boldedText = searchString.replace(/\\"/g, '"');
label = l10n.formatMessage(
{
id: "search.booksMatching",
defaultMessage: 'Books matching "{searchTerms}"',
},
{
searchTerms: boldedText,
}
);
} else {
boldedText = searchString.replace(/\\"/g, '"');
label = l10n.formatMessage(
{
id: "search.booksStrongMatching",
defaultMessage:
'Books with a strong match to "{searchTerms}"',
},
{ searchTerms: boldedText }
);
}
break;
case "language":
const languageNames = getDisplayNamesFromLanguageCode(
Expand All @@ -150,9 +185,33 @@ export const Breadcrumbs: React.FunctionComponent<{ className?: string }> = (
case "all":
continue;
}
const displayLabel = decodeURIComponent(label);
// If we have a boldedText, we need to find it in the label and bold it.
let displayWithBold: JSX.Element | null = null;
if (boldedText) {
const indexBold = label.indexOf('"' + boldedText + '"');
if (indexBold) {
const prebold = decodeURIComponent(
label.substring(0, indexBold)
);
const postbold = decodeURIComponent(
label.substring(indexBold + boldedText.length + 2)
);
const bolded = (
<strong>{decodeURIComponent(boldedText)}</strong>
);
displayWithBold = (
<span>
{prebold}
{bolded}
{postbold}
</span>
);
}
}
crumbs.push(
<li key={item}>
{decodeURIComponent(label)}
{displayWithBold ? displayWithBold : displayLabel}
{/* enhance: reinstate if we come up with a destination for the link.<BlorgLink
css={css`
text-decoration: none !important;
Expand Down
4 changes: 3 additions & 1 deletion src/components/CollectionSubsetPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
useIsAppHosted,
} from "./appHosted/AppHostedUtils";
import { makeVirtualCollectionOfBooksInCollectionThatHaveLanguage } from "./ByLanguageCards";
import { SearchDeeper } from "./SearchDeeper";

// Given a collection and a string like level:1/topic:anthropology/search:dogs,
// creates a corresponding collection by adding appropriate filters.
Expand Down Expand Up @@ -77,7 +78,7 @@ export function generateCollectionFromFilters(
case "search":
filteredCollection = makeVirtualCollectionForSearch(
collection,
decodeURIComponent(parts[1]), // the search term
decodeURIComponent(parts.slice(1).join(":")), // the search term
l10n,
filteredCollection
);
Expand Down Expand Up @@ -274,6 +275,7 @@ export const CollectionSubsetPage: React.FunctionComponent<{
>
{subList}
</ListOfBookGroups>
<SearchDeeper />
</React.Fragment>
);
};
2 changes: 1 addition & 1 deletion src/components/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ let currentPathname = "";

// The main set of switches that loads different things into the main content area of Blorg
// based on the current window location.
export const Routes: React.FunctionComponent<{}> = () => {
export const Routes: React.FunctionComponent<{}> = (props) => {
const location = useLocation();
useSetEmbeddedUrl();
if (currentPathname !== location.pathname) {
Expand Down
45 changes: 41 additions & 4 deletions src/components/SearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useLocation, useHistory } from "react-router-dom";
import { useIntl } from "react-intl";
import { CachedTablesContext } from "../model/CacheProvider";
import { trySpecialSearch, noPushCode } from "../model/SpecialSearch";
import { isFacetedSearchString } from "../connection/LibraryQueryHooks";

//import Typography from "@material-ui/core/Typography";
//import Tooltip from "@material-ui/core/Tooltip";
Expand Down Expand Up @@ -49,6 +50,7 @@ export const SearchBox: React.FunctionComponent<{
}> = (props) => {
const location = useLocation();
const history = useHistory();

const search = location.pathname
.split("/")
.filter((x) => x.startsWith(":search:"))[0];
Expand All @@ -60,6 +62,10 @@ export const SearchBox: React.FunctionComponent<{
if (initialSearchString.startsWith("phash")) {
initialSearchString = "";
}
if (initialSearchString === "deeper:") {
initialSearchString = "";
}
initialSearchString = initialSearchString.replace(/\\"/g, '"');
const [searchString, setSearchString] = useState(initialSearchString);
// This is a bit subtle. SearchString needs to be state to get modified
// as the user types. But another thing that can happen is that our location
Expand Down Expand Up @@ -100,7 +106,23 @@ export const SearchBox: React.FunctionComponent<{
const { languagesByBookCount } = useContext(CachedTablesContext);

const handleSearch = () => {
const trimmedSearchString = searchString.trim();
let trimmedSearchString = searchString.trim();
if (
trimmedSearchString.startsWith('"') &&
trimmedSearchString.endsWith('"')
) {
// If the user merely types a single " in the search box, this code
// will not remove it due to the way javascript defines substring.
// But if the user types "foo", then it will remove the quotes. This
// is what we want. Titles can have quotes in them, so we don't want
// to remove them if they look like they're part of the search.
trimmedSearchString = trimmedSearchString.substring(
1,
trimmedSearchString.length - 1
);
}
trimmedSearchString = trimmedSearchString.replace(/"/g, '\\"');

if (trimmedSearchString.length === 0) {
// delete everything and press enter is the same as "cancel"
cancelSearch();
Expand Down Expand Up @@ -156,8 +178,23 @@ export const SearchBox: React.FunctionComponent<{
["/create", "/grid", "/bulk"].find((x) =>
history.location.pathname.startsWith(x)
) || "";
const newUrl =
prefix + "/:search:" + encodeURIComponent(trimmedSearchString);
let newUrl: string = "";
if (trimmedSearchString.startsWith("deeper:")) {
if (trimmedSearchString === "deeper:") {
newUrl = prefix + "/";
} else {
newUrl =
prefix +
"/:search:" +
encodeURIComponent(trimmedSearchString);
}
} else if (isFacetedSearchString(trimmedSearchString)) {
newUrl =
prefix + "/:search:" + encodeURIComponent(trimmedSearchString);
} else {
newUrl =
prefix + "/:search:" + encodeURIComponent(trimmedSearchString);
}
if (replaceInHistory) {
history.replace(newUrl);
} else {
Expand All @@ -180,7 +217,7 @@ export const SearchBox: React.FunctionComponent<{
// ensure we return whence we came.
const searchIdx = location.pathname.indexOf("/:search:");
if (searchIdx >= 0) {
let newLocationPath = location.pathname.substr(0, searchIdx);
let newLocationPath = location.pathname.slice(0, searchIdx);
if (newLocationPath.length === 0) {
newLocationPath = "/";
}
Expand Down
85 changes: 85 additions & 0 deletions src/components/SearchDeeper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import css from "@emotion/css/macro";
// these two lines make the css prop work on react elements
import { jsx } from "@emotion/core";
/** @jsx jsx */

import React, { useEffect, useState } from "react";
import { useLocation, useHistory } from "react-router-dom";
import { Button, SvgIcon } from "@material-ui/core";
import { ReactComponent as SearchingDeeper } from "../assets/SearchingDeeper.svg";
import { commonUI } from "../theme";
import { useIntl } from "react-intl";
import { isFacetedSearchString } from "../connection/LibraryQueryHooks";

// This implements the "Search Deeper" button that appears on the search
// results page when appropriate. All of the logic for determining whether
// to display the button is contained here, as well as the logic for when
// the button is clicked for a "deeper search".

export const SearchDeeper: React.FunctionComponent<{}> = (props) => {
const location = useLocation();
const history = useHistory();
const l10n = useIntl();

const [shallowSearchResults, setShallowSearchResults] = useState(false);
useEffect(() => {
let isShallow = false;
const search = location.pathname
.split("/")
.filter((x) => x.startsWith(":search:"))[0];
if (search) {
const searchString = search.substring(":search:".length);
isShallow =
!searchString.startsWith("deeper:") &&
!isFacetedSearchString(searchString);
}
setShallowSearchResults(isShallow);
}, [location.pathname]);

function HandleDeeperSearch(): void {
const newPath = location.pathname.replace(
/^\/:search:/,
"/:search:deeper:"
);
history.push(newPath);
}

if (shallowSearchResults) {
return (
<Button
variant="contained"
css={css`
margin-left: 20px;
margin-bottom: 12px;
color: white;
background-color: ${commonUI.colors.bloomRed};
width: 160px;
`}
onClick={() => HandleDeeperSearch()}
>
<SvgIcon
css={css`
padding-right: 5px;
width: 39px;
`}
component={SearchingDeeper}
viewBox="0 0 39 33"
></SvgIcon>
<div
css={css`
line-height: 1.2;
padding-top: 5px;
padding-bottom: 5px;
`}
>
{l10n.formatMessage({
id: "header.searchDeeper",
defaultMessage: "Search Deeper",
})}
</div>
</Button>
);
} else {
return null;
}
};
2 changes: 1 addition & 1 deletion src/components/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { useHistory, useLocation } from "react-router-dom";
import { useIntl } from "react-intl";
import { BlorgLink } from "../BlorgLink";

export const Header: React.FunctionComponent = () => {
export const Header: React.FunctionComponent<{}> = (props) => {
const location = useLocation();
const createTabSelected = location.pathname.indexOf("/create") > -1;
const routerHistory = useHistory();
Expand Down
Loading

0 comments on commit f24415f

Please sign in to comment.