diff --git a/frontend/src/metabase/nav/components/SearchBar.jsx b/frontend/src/metabase/nav/components/SearchBar.jsx index 35943215357cf31ac9f0185ec953bcc1c1407fb3..ba723b656a05b4b93d330c65f312b57fdfbe07d7 100644 --- a/frontend/src/metabase/nav/components/SearchBar.jsx +++ b/frontend/src/metabase/nav/components/SearchBar.jsx @@ -1,58 +1,18 @@ /* eslint-disable react/prop-types */ import React from "react"; import ReactDOM from "react-dom"; -import { Flex } from "grid-styled"; -import styled from "styled-components"; -import { space } from "styled-system"; import { t } from "ttag"; -import { color, lighten } from "metabase/lib/colors"; - import Card from "metabase/components/Card"; import Icon from "metabase/components/Icon"; import OnClickOutsideWrapper from "metabase/components/OnClickOutsideWrapper"; -import SearchResult from "metabase/search/components/SearchResult"; -import { DefaultSearchColor } from "metabase/nav/constants"; import MetabaseSettings from "metabase/lib/settings"; -const ActiveSearchColor = lighten(color("nav"), 0.1); - -import Search from "metabase/entities/search"; - -const SearchWrapper = styled(Flex)` - position: relative; - background-color: ${props => - props.active ? ActiveSearchColor : DefaultSearchColor}; - border-radius: 6px; - flex: 1 1 auto; - max-width: 50em; - align-items: center; - color: white; - transition: background 300ms ease-in; - &:hover { - background-color: ${ActiveSearchColor}; - } -`; - -const SearchInput = styled.input` - ${space}; - background-color: transparent; - width: 100%; - border: none; - color: white; - font-size: 1em; - font-weight: 700; - &:focus { - outline: none; - } - &::placeholder { - color: ${color("text-white")}; - } -`; +import { SearchInput, SearchWrapper } from "./SearchBar.styled"; +import { SearchResults } from "./SearchResults"; const ALLOWED_SEARCH_FOCUS_ELEMENTS = new Set(["BODY", "A"]); -const SEARCH_LIMIT = 50; export default class SearchBar extends React.Component { state = { @@ -84,7 +44,7 @@ export default class SearchBar extends React.Component { this.setState({ searchText: "" }); } } - handleKeyUp = (e: KeyboardEvent) => { + handleKeyUp = e => { const FORWARD_SLASH_KEY = 191; if ( e.keyCode === FORWARD_SLASH_KEY && @@ -95,25 +55,6 @@ export default class SearchBar extends React.Component { } }; - renderResults(results) { - if (results.length === 0) { - return ( - <li className="flex flex-column align-center justify-center p4 text-medium text-centered"> - <div className="my3"> - <Icon name="search" mb={1} size={24} /> - <h3 className="text-light">{t`Didn't find anything`}</h3> - </div> - </li> - ); - } else { - return results.map(l => ( - <li key={`${l.model}:${l.id}`}> - <SearchResult result={l} compact={true} /> - </li> - )); - } - } - render() { const { active, searchText } = this.state; return ( @@ -152,20 +93,7 @@ export default class SearchBar extends React.Component { style={{ maxHeight: 400 }} py={1} > - <Search.ListLoader - query={{ q: searchText.trim(), limit: SEARCH_LIMIT }} - wrapped - reload - debounced - > - {({ list }) => { - return ( - <ol data-testid="search-results-list"> - {this.renderResults(list)} - </ol> - ); - }} - </Search.ListLoader> + <SearchResults searchText={searchText.trim()} /> </Card> ) : null} </div> diff --git a/frontend/src/metabase/nav/components/SearchBar.styled.jsx b/frontend/src/metabase/nav/components/SearchBar.styled.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ce0239fe01390b51ef07ea180e798dbbaf9da9a7 --- /dev/null +++ b/frontend/src/metabase/nav/components/SearchBar.styled.jsx @@ -0,0 +1,39 @@ +import styled from "styled-components"; +import { Flex } from "grid-styled"; +import { space } from "styled-system"; + +import { DefaultSearchColor } from "metabase/nav/constants"; +import { color, lighten } from "metabase/lib/colors"; + +const ActiveSearchColor = lighten(color("nav"), 0.1); + +export const SearchWrapper = styled(Flex)` + position: relative; + background-color: ${props => + props.active ? ActiveSearchColor : DefaultSearchColor}; + border-radius: 6px; + flex: 1 1 auto; + max-width: 50em; + align-items: center; + color: white; + transition: background 300ms ease-in; + &:hover { + background-color: ${ActiveSearchColor}; + } +`; + +export const SearchInput = styled.input` + ${space}; + background-color: transparent; + width: 100%; + border: none; + color: white; + font-size: 1em; + font-weight: 700; + &:focus { + outline: none; + } + &::placeholder { + color: ${color("text-white")}; + } +`; diff --git a/frontend/src/metabase/nav/components/SearchResults.jsx b/frontend/src/metabase/nav/components/SearchResults.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5ecca27f29fb943abbd299075b9fc51ec125430d --- /dev/null +++ b/frontend/src/metabase/nav/components/SearchResults.jsx @@ -0,0 +1,46 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Box } from "grid-styled"; +import { t } from "ttag"; +import Search from "metabase/entities/search"; +import SearchResult from "metabase/search/components/SearchResult"; +import EmptyState from "metabase/components/EmptyState"; + +const SEARCH_LIMIT = 50; + +const propTypes = { + searchText: PropTypes.string, +}; + +export const SearchResults = ({ searchText }) => { + return ( + <Search.ListLoader + query={{ q: searchText, limit: SEARCH_LIMIT }} + wrapped + reload + debounced + > + {({ list }) => { + const hasResults = list.length > 0; + + return ( + <ul data-testid="search-results-list"> + {hasResults ? ( + list.map(item => ( + <li key={`${item.model}:${item.id}`}> + <SearchResult result={item} compact={true} /> + </li> + )) + ) : ( + <Box mt={4} mb={3}> + <EmptyState message={t`Didn't find anything`} icon="search" /> + </Box> + )} + </ul> + ); + }} + </Search.ListLoader> + ); +}; + +SearchResults.propTypes = propTypes; diff --git a/frontend/src/metabase/search/components/CollectionBadge.jsx b/frontend/src/metabase/search/components/CollectionBadge.jsx new file mode 100644 index 0000000000000000000000000000000000000000..71bece7d5a2438d099ede48721c39dfd6e6fee2e --- /dev/null +++ b/frontend/src/metabase/search/components/CollectionBadge.jsx @@ -0,0 +1,29 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import * as Urls from "metabase/lib/urls"; + +import { + CollectionBadgeRoot, + CollectionLink, + AuthorityLevelIcon, +} from "./CollectionBadge.styled"; + +const propTypes = { + collection: PropTypes.shape({ + name: PropTypes.string, + }), +}; + +export function CollectionBadge({ collection }) { + return ( + <CollectionBadgeRoot> + <CollectionLink to={Urls.collection(collection)}> + <AuthorityLevelIcon collection={collection} /> + {collection.name} + </CollectionLink> + </CollectionBadgeRoot> + ); +} + +CollectionBadge.propTypes = propTypes; diff --git a/frontend/src/metabase/search/components/CollectionBadge.styled.jsx b/frontend/src/metabase/search/components/CollectionBadge.styled.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c259dee3242d415145ad1c0732e2ee3cd58f73eb --- /dev/null +++ b/frontend/src/metabase/search/components/CollectionBadge.styled.jsx @@ -0,0 +1,26 @@ +import styled from "styled-components"; +import { color } from "metabase/lib/colors"; +import Link from "metabase/components/Link"; + +import { PLUGIN_COLLECTION_COMPONENTS } from "metabase/plugins"; + +const { CollectionAuthorityLevelIcon } = PLUGIN_COLLECTION_COMPONENTS; + +export const CollectionBadgeRoot = styled.div` + display: inline-block; +`; + +export const CollectionLink = styled(Link)` + display: flex; + align-items: center; + text-decoration: dashed; + &:hover { + color: ${color("brand")}; + } +`; + +export const AuthorityLevelIcon = styled(CollectionAuthorityLevelIcon).attrs({ + size: 13, +})` + padding-right: 2px; +`; diff --git a/frontend/src/metabase/search/components/InfoText.jsx b/frontend/src/metabase/search/components/InfoText.jsx new file mode 100644 index 0000000000000000000000000000000000000000..bf750400f00325307b6f6220ff0170fb80872543 --- /dev/null +++ b/frontend/src/metabase/search/components/InfoText.jsx @@ -0,0 +1,87 @@ +import React from "react"; +import { t, jt } from "ttag"; + +import * as Urls from "metabase/lib/urls"; +import { capitalize } from "metabase/lib/formatting"; + +import Icon from "metabase/components/Icon"; +import Link from "metabase/components/Link"; + +import Schema from "metabase/entities/schemas"; +import Database from "metabase/entities/databases"; +import Table from "metabase/entities/tables"; +import { PLUGIN_COLLECTIONS } from "metabase/plugins"; +import { CollectionBadge } from "./CollectionBadge"; + +function formatCollection(collection) { + return collection.id && <CollectionBadge collection={collection} />; +} + +function getCollectionInfoText(collection) { + if (PLUGIN_COLLECTIONS.isRegularCollection(collection)) { + return t`Collection`; + } + const level = PLUGIN_COLLECTIONS.AUTHORITY_LEVEL[collection.authority_level]; + return `${level.name} ${t`Collection`}`; +} + +export function InfoText({ result }) { + const collection = result.getCollection(); + switch (result.model) { + case "card": + return jt`Saved question in ${formatCollection(collection)}`; + case "collection": + return getCollectionInfoText(result.collection); + case "database": + return t`Database`; + case "table": + return ( + <span> + {jt`Table in ${( + <span> + <Database.Link id={result.database_id} />{" "} + {result.table_schema && ( + <Schema.ListLoader + query={{ dbId: result.database_id }} + loadingAndErrorWrapper={false} + > + {({ list }) => + list && list.length > 1 ? ( + <span> + <Icon name="chevronright" mx="4px" size={10} /> + {/* we have to do some {} manipulation here to make this look like the table object that browseSchema was written for originally */} + <Link + to={Urls.browseSchema({ + db: { id: result.database_id }, + schema_name: result.table_schema, + })} + > + {result.table_schema} + </Link> + </span> + ) : null + } + </Schema.ListLoader> + )} + </span> + )}`} + </span> + ); + case "segment": + case "metric": + return ( + <span> + {result.model === "segment" ? t`Segment of ` : t`Metric for `} + <Link to={Urls.tableRowsQuery(result.database_id, result.table_id)}> + <Table.Loader id={result.table_id} loadingAndErrorWrapper={false}> + {({ table }) => + table ? <span>{table.display_name}</span> : null + } + </Table.Loader> + </Link> + </span> + ); + default: + return jt`${capitalize(result.model)} in ${formatCollection(collection)}`; + } +} diff --git a/frontend/src/metabase/search/components/SearchResult.jsx b/frontend/src/metabase/search/components/SearchResult.jsx index 04da1b6e5cf0638e4d3a4eb1d25e7321ca92d552..587fb4f9d1d2e5b7f448b18b44c11a6a3524a12f 100644 --- a/frontend/src/metabase/search/components/SearchResult.jsx +++ b/frontend/src/metabase/search/components/SearchResult.jsx @@ -1,103 +1,24 @@ /* eslint-disable react/prop-types */ import React from "react"; import { Box, Flex } from "grid-styled"; -import styled from "styled-components"; -import { t, jt } from "ttag"; -import * as Urls from "metabase/lib/urls"; -import { color, lighten } from "metabase/lib/colors"; -import { capitalize } from "metabase/lib/formatting"; +import { color } from "metabase/lib/colors"; import Icon from "metabase/components/Icon"; -import Link from "metabase/components/Link"; import Text from "metabase/components/type/Text"; -import { - PLUGIN_COLLECTIONS, - PLUGIN_COLLECTION_COMPONENTS, - PLUGIN_MODERATION, -} from "metabase/plugins"; - -import Schema from "metabase/entities/schemas"; -import Database from "metabase/entities/databases"; -import Table from "metabase/entities/tables"; - -const { CollectionAuthorityLevelIcon } = PLUGIN_COLLECTION_COMPONENTS; +import { PLUGIN_COLLECTIONS, PLUGIN_MODERATION } from "metabase/plugins"; -function getColorForIconWrapper(props) { - if (props.item.collection_position) { - return color("saturated-yellow"); - } - switch (props.type) { - case "collection": - return lighten("brand", 0.35); - default: - return color("brand"); - } -} - -const IconWrapper = styled.div` - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - color: ${getColorForIconWrapper}; - margin-right: 10px; - flex-shrink: 0; -`; - -const ResultLink = styled(Link)` - display: block; - background-color: transparent; - min-height: ${props => (props.compact ? "36px" : "54px")}; - padding-top: 8px; - padding-bottom: 8px; - padding-left: 14px; - padding-right: ${props => (props.compact ? "20px" : "32px")}; - - &:hover { - background-color: ${lighten("brand", 0.63)}; - - h3 { - color: ${color("brand")}; - } - } - - ${Link} { - text-underline-position: under; - text-decoration: underline ${color("text-light")}; - text-decoration-style: dashed; - &:hover { - color: ${color("brand")}; - text-decoration-color: ${color("brand")}; - } - } - - ${Text} { - margin-top: 0; - margin-bottom: 0; - font-size: 13px; - line-height: 19px; - } - - h3 { - font-size: ${props => (props.compact ? "14px" : "16px")}; - line-height: 1.2em; - word-wrap: break-word; - margin-bottom: 0; - } - - .Icon-info { - color: ${color("text-light")}; - } -`; +import { + IconWrapper, + ResultLink, + Title, + TitleWrapper, + Description, + ContextText, +} from "./SearchResult.styled"; +import { InfoText } from "./InfoText"; -const TitleWrapper = styled.div` - display: flex; - grid-gap: 0.25rem; - align-items: center; -`; const DEFAULT_ICON_SIZE = 20; function TableIcon() { @@ -134,154 +55,35 @@ function ItemIcon({ item, type }) { ); } -const CollectionBadgeRoot = styled.div` - display: inline-block; -`; - -const CollectionLink = styled(Link)` - display: flex; - align-items: center; - text-decoration: dashed; - &:hover { - color: ${color("brand")}; - } -`; - -const AuthorityLevelIcon = styled(CollectionAuthorityLevelIcon).attrs({ - size: 13, -})` - padding-right: 2px; -`; - -function CollectionBadge({ collection }) { - return ( - <CollectionBadgeRoot> - <CollectionLink to={Urls.collection(collection)}> - <AuthorityLevelIcon collection={collection} /> - {collection.name} - </CollectionLink> - </CollectionBadgeRoot> - ); -} - -const Title = styled("h3")` - margin-bottom: 4px; -`; - function Score({ scores }) { return ( <pre className="hide search-score">{JSON.stringify(scores, null, 2)}</pre> ); } -const Context = styled("p")` - line-height: 1.4em; - color: ${color("text-medium")}; - margin-top: 0; -`; -function formatContext(context, compact) { - return ( - !compact && - context && ( - <Box ml="42px" mt="12px" style={{ maxWidth: 620 }}> - <Context>{contextText(context)}</Context> - </Box> - ) - ); -} - -function formatCollection(collection) { - return collection.id && <CollectionBadge collection={collection} />; -} - -const Description = styled(Text)` - padding-left: 8px; - margin-top: 6px !important; - border-left: 2px solid ${lighten("brand", 0.45)}; -`; - -function contextText(context) { - return context.map(function({ is_match, text }, i) { - if (is_match) { - return ( - <strong key={i} style={{ color: color("brand") }}> - {" "} - {text} - </strong> - ); - } else { - return <span key={i}> {text}</span>; - } - }); -} - -function getCollectionInfoText(collection) { - if (PLUGIN_COLLECTIONS.isRegularCollection(collection)) { - return t`Collection`; +function Context({ context }) { + if (!context) { + return null; } - const level = PLUGIN_COLLECTIONS.AUTHORITY_LEVEL[collection.authority_level]; - return `${level.name} ${t`Collection`}`; -} -function InfoText({ result }) { - const collection = result.getCollection(); - switch (result.model) { - case "card": - return jt`Saved question in ${formatCollection(collection)}`; - case "collection": - return getCollectionInfoText(result.collection); - case "database": - return t`Database`; - case "table": - return ( - <span> - {jt`Table in ${( - <span> - <Database.Link id={result.database_id} />{" "} - {result.table_schema && ( - <Schema.ListLoader - query={{ dbId: result.database_id }} - loadingAndErrorWrapper={false} - > - {({ list }) => - list && list.length > 1 ? ( - <span> - <Icon name="chevronright" mx="4px" size={10} /> - {/* we have to do some {} manipulation here to make this look like the table object that browseSchema was written for originally */} - <Link - to={Urls.browseSchema({ - db: { id: result.database_id }, - schema_name: result.table_schema, - })} - > - {result.table_schema} - </Link> - </span> - ) : null - } - </Schema.ListLoader> - )} - </span> - )}`} - </span> - ); - case "segment": - case "metric": - return ( - <span> - {result.model === "segment" ? t`Segment of ` : t`Metric for `} - <Link to={Urls.tableRowsQuery(result.database_id, result.table_id)}> - <Table.Loader id={result.table_id} loadingAndErrorWrapper={false}> - {({ table }) => - table ? <span>{table.display_name}</span> : null - } - </Table.Loader> - </Link> - </span> - ); - default: - return jt`${capitalize(result.model)} in ${formatCollection(collection)}`; - } + return ( + <Box ml="42px" mt="12px" style={{ maxWidth: 620 }}> + <ContextText> + {context.map(({ is_match, text }, i) => { + if (!is_match) { + return <span key={i}> {text}</span>; + } + + return ( + <strong key={i} style={{ color: color("brand") }}> + {" "} + {text} + </strong> + ); + })} + </ContextText> + </Box> + ); } export default function SearchResult({ result, compact }) { @@ -310,7 +112,7 @@ export default function SearchResult({ result, compact }) { <Score scores={result.scores} /> </Box> </Flex> - {formatContext(result.context, compact)} + {!compact && <Context context={result.context} />} </ResultLink> ); } diff --git a/frontend/src/metabase/search/components/SearchResult.styled.jsx b/frontend/src/metabase/search/components/SearchResult.styled.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b5341a9ce97cc55facdcf7072d3c501594592bf7 --- /dev/null +++ b/frontend/src/metabase/search/components/SearchResult.styled.jsx @@ -0,0 +1,98 @@ +import styled from "styled-components"; + +import { color, lighten } from "metabase/lib/colors"; + +import Link from "metabase/components/Link"; +import Text from "metabase/components/type/Text"; +import { space } from "metabase/styled-components/theme"; + +function getColorForIconWrapper(props) { + if (props.item.collection_position) { + return color("saturated-yellow"); + } + switch (props.type) { + case "collection": + return lighten("brand", 0.35); + default: + return color("brand"); + } +} + +export const IconWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + color: ${getColorForIconWrapper}; + margin-right: 10px; + flex-shrink: 0; +`; + +export const ResultLink = styled(Link)` + display: block; + background-color: transparent; + min-height: ${props => (props.compact ? "36px" : "54px")}; + padding-top: ${space(1)}; + padding-bottom: ${space(1)}; + padding-left: 14px; + padding-right: ${props => (props.compact ? "20px" : space(3))}; + + &:hover { + background-color: ${lighten("brand", 0.63)}; + + h3 { + color: ${color("brand")}; + } + } + + ${Link} { + text-underline-position: under; + text-decoration: underline ${color("text-light")}; + text-decoration-style: dashed; + &:hover { + color: ${color("brand")}; + text-decoration-color: ${color("brand")}; + } + } + + ${Text} { + margin-top: 0; + margin-bottom: 0; + font-size: 13px; + line-height: 19px; + } + + h3 { + font-size: ${props => (props.compact ? "14px" : "16px")}; + line-height: 1.2em; + word-wrap: break-word; + margin-bottom: 0; + } + + .Icon-info { + color: ${color("text-light")}; + } +`; + +export const TitleWrapper = styled.div` + display: flex; + grid-gap: 0.25rem; + align-items: center; +`; + +export const ContextText = styled("p")` + line-height: 1.4em; + color: ${color("text-medium")}; + margin-top: 0; +`; + +export const Title = styled("h3")` + margin-bottom: 4px; +`; + +export const Description = styled(Text)` + padding-left: ${space(1)}; + margin-top: ${space(1)} !important; + border-left: 2px solid ${lighten("brand", 0.45)}; +`;