Skip to content
Snippets Groups Projects
Unverified Commit 5ba1b69e authored by Ryan Laurie's avatar Ryan Laurie Committed by GitHub
Browse files

Convert SearchResult Component to Typescript (#31783)

* convert searchResult to typescript

* expand searchResult tests and convert to typescript

* remove prop types

* cleanup tests and infoText component
parent 58c7b0ff
No related tags found
No related merge requests found
Showing
with 463 additions and 123 deletions
......@@ -3,7 +3,7 @@ import { REGULAR_COLLECTION } from "./constants";
export function isRegularCollection({
authority_level,
}: Bookmark | Collection) {
}: Bookmark | Partial<Collection>) {
// Root, personal collections don't have `authority_level`
return !authority_level || authority_level === REGULAR_COLLECTION.type;
}
......@@ -15,6 +15,7 @@ export * from "./modelIndexes";
export * from "./parameters";
export * from "./query";
export * from "./schema";
export * from "./search";
export * from "./segment";
export * from "./series";
export * from "./session";
......
import { SearchResult, SearchScore } from "metabase-types/api";
import { createMockCollection } from "./collection";
export const createMockSearchResult = (
options: Partial<SearchResult> = {},
): SearchResult => {
const collection = createMockCollection(options?.collection ?? undefined);
return {
id: 1,
name: "Mock search result",
description: "Mock search result description",
model: "card",
model_id: null,
archived: null,
collection,
collection_position: null,
table_id: 1,
table_name: null,
bookmark: null,
database_id: 1,
pk_ref: null,
table_schema: null,
collection_authority_level: null,
updated_at: "2023-01-01T00:00:00.000Z",
moderated_status: null,
model_name: null,
table_description: null,
initial_sync_status: null,
dashboard_count: null,
context: null,
scores: [createMockSearchScore()],
...options,
};
};
export const createMockSearchScore = (
options: Partial<SearchScore> = {},
): SearchScore => ({
score: 1,
weight: 1,
name: "text-total-occurrences",
...options,
});
import { CardId } from "./card";
import { Collection } from "./collection";
import { DatabaseId } from "./database";
import { FieldReference } from "./query";
import { TableId } from "./table";
export type SearchModelType =
| "card"
......@@ -8,7 +12,65 @@ export type SearchModelType =
| "dataset"
| "table"
| "indexed-entity"
| "pulse";
| "pulse"
| "segment"
| "metric"
| "action";
export interface SearchScore {
weight: number;
score: number;
name:
| "pinned"
| "bookmarked"
| "recency"
| "dashboard"
| "model"
| "official collection score"
| "verified"
| "text-consecutivity"
| "text-total-occurrences"
| "text-fullness";
match?: string;
"match-context-thunk"?: string;
column?: string;
}
export interface SearchResults {
data: SearchResult[];
models: SearchModelType[] | null;
available_models: SearchModelType[];
limit: number;
offset: number;
table_db_id: DatabaseId | null;
total: number;
}
export interface SearchResult {
id: number | undefined;
name: string;
model: SearchModelType;
description: string | null;
archived: boolean | null;
collection_position: number | null;
collection: Pick<Collection, "id" | "name" | "authority_level">;
table_id: TableId;
bookmark: boolean | null;
database_id: DatabaseId;
pk_ref: FieldReference | null;
table_schema: string | null;
collection_authority_level: "official" | null;
updated_at: string;
moderated_status: boolean | null;
model_id: CardId | null;
model_name: string | null;
table_description: string | null;
table_name: string | null;
initial_sync_status: "complete" | "incomplete" | null;
dashboard_count: number | null;
context: any; // this might be a dead property
scores: SearchScore[];
}
export interface SearchListQuery {
q?: string;
......
......@@ -7,7 +7,7 @@ import _ from "underscore";
import { DEFAULT_SEARCH_LIMIT } from "metabase/lib/constants";
import Search from "metabase/entities/search";
import SearchResult from "metabase/search/components/SearchResult";
import { SearchResult } from "metabase/search/components/SearchResult";
import EmptyState from "metabase/components/EmptyState";
import { useListKeyboardNavigation } from "metabase/hooks/use-list-keyboard-navigation";
import { EmptyStateContainer } from "./SearchResults.styled";
......
......@@ -150,7 +150,7 @@ export const PLUGIN_COLLECTIONS = {
[JSON.stringify(AUTHORITY_LEVEL_REGULAR.type)]: AUTHORITY_LEVEL_REGULAR,
},
REGULAR_COLLECTION: AUTHORITY_LEVEL_REGULAR,
isRegularCollection: (_: Collection | Bookmark) => true,
isRegularCollection: (_: Partial<Collection> | Bookmark) => true,
getAuthorityLevelMenuItems: (
_collection: Collection,
_onUpdate: (collection: Collection, values: Partial<Collection>) => void,
......
......@@ -3,7 +3,7 @@ import PropTypes from "prop-types";
import { t } from "ttag";
import { Icon } from "metabase/core/components/Icon";
import SearchResult from "metabase/search/components/SearchResult";
import { SearchResult } from "metabase/search/components/SearchResult";
import { DEFAULT_SEARCH_LIMIT } from "metabase/lib/constants";
import Search from "metabase/entities/search";
......
import PropTypes from "prop-types";
import { t, jt } from "ttag";
import * as Urls from "metabase/lib/urls";
......@@ -11,55 +10,64 @@ import Database from "metabase/entities/databases";
import Table from "metabase/entities/tables";
import { PLUGIN_COLLECTIONS } from "metabase/plugins";
import { getTranslatedEntityName } from "metabase/nav/utils";
import { CollectionBadge } from "./CollectionBadge";
const searchResultPropTypes = {
database_id: PropTypes.number,
table_id: PropTypes.number,
model: PropTypes.string,
getCollection: PropTypes.func,
collection: PropTypes.object,
table_schema: PropTypes.string,
};
import type { Collection } from "metabase-types/api";
import type TableType from "metabase-lib/metadata/Table";
import { CollectionBadge } from "./CollectionBadge";
import type { WrappedResult } from "./types";
const infoTextPropTypes = {
result: PropTypes.shape(searchResultPropTypes),
};
export function InfoText({ result }: { result: WrappedResult }) {
let textContent: string | string[] | JSX.Element;
export function InfoText({ result }) {
switch (result.model) {
case "card":
return jt`Saved question in ${formatCollection(
textContent = jt`Saved question in ${formatCollection(
result,
result.getCollection(),
)}`;
break;
case "dataset":
return jt`Model in ${formatCollection(result, result.getCollection())}`;
textContent = jt`Model in ${formatCollection(
result,
result.getCollection(),
)}`;
break;
case "collection":
return getCollectionInfoText(result.collection);
textContent = getCollectionInfoText(result.collection);
break;
case "database":
return t`Database`;
textContent = t`Database`;
break;
case "table":
return <TablePath result={result} />;
textContent = <TablePath result={result} />;
break;
case "segment":
return jt`Segment of ${(<TableLink result={result} />)}`;
textContent = jt`Segment of ${(<TableLink result={result} />)}`;
break;
case "metric":
return jt`Metric for ${(<TableLink result={result} />)}`;
textContent = jt`Metric for ${(<TableLink result={result} />)}`;
break;
case "action":
return jt`for ${result.model_name}`;
textContent = jt`for ${result.model_name}`;
break;
case "indexed-entity":
return jt`in ${result.model_name}`;
textContent = jt`in ${result.model_name}`;
break;
default:
return jt`${getTranslatedEntityName(result.model)} in ${formatCollection(
result,
result.getCollection(),
)}`;
textContent = jt`${getTranslatedEntityName(
result.model,
)} in ${formatCollection(result, result.getCollection())}`;
break;
}
}
InfoText.propTypes = infoTextPropTypes;
return <>{textContent}</>;
}
function formatCollection(result, collection) {
function formatCollection(
result: WrappedResult,
collection: Partial<Collection>,
) {
return (
collection.id && (
<CollectionBadge key={result.model} collection={collection} />
......@@ -67,59 +75,60 @@ function formatCollection(result, collection) {
);
}
function getCollectionInfoText(collection) {
if (PLUGIN_COLLECTIONS.isRegularCollection(collection)) {
function getCollectionInfoText(collection: Partial<Collection>) {
if (
PLUGIN_COLLECTIONS.isRegularCollection(collection) ||
!collection.authority_level
) {
return t`Collection`;
}
const level = PLUGIN_COLLECTIONS.AUTHORITY_LEVEL[collection.authority_level];
return `${level.name} ${t`Collection`}`;
}
function TablePath({ result }) {
return jt`Table in ${(
<span key="table-path">
<Database.Link id={result.database_id} />{" "}
{result.table_schema && (
<Schema.ListLoader
query={{ dbId: result.database_id }}
loadingAndErrorWrapper={false}
>
{({ 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>
)}`;
function TablePath({ result }: { result: WrappedResult }) {
return (
<>
{jt`Table in ${(
<span key="table-path">
<Database.Link id={result.database_id} />{" "}
{result.table_schema && (
<Schema.ListLoader
query={{ dbId: result.database_id }}
loadingAndErrorWrapper={false}
>
{({ list }: { list: typeof Schema[] }) =>
list?.length > 1 ? (
<span>
<Icon name="chevronright" 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,
} as TableType)}
>
{result.table_schema}
</Link>
</span>
) : null
}
</Schema.ListLoader>
)}
</span>
)}`}
</>
);
}
TablePath.propTypes = {
result: PropTypes.shape(searchResultPropTypes),
};
function TableLink({ result }) {
function TableLink({ result }: { result: WrappedResult }) {
return (
<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 }: { table: TableType }) =>
table ? <span>{table.display_name}</span> : null
}
</Table.Loader>
</Link>
);
}
TableLink.propTypes = {
result: PropTypes.shape(searchResultPropTypes),
};
......@@ -6,23 +6,41 @@ import Link from "metabase/core/components/Link";
import Text from "metabase/components/type/Text";
import LoadingSpinner from "metabase/components/LoadingSpinner";
function getColorForIconWrapper(props: {
import type { SearchModelType } from "metabase-types/api";
type SearchEntity = any;
interface ResultStylesProps {
compact: boolean;
active: boolean;
isSelected: boolean;
}
function getColorForIconWrapper({
item,
active,
type,
}: {
item: SearchEntity;
active: boolean;
type: string;
item: { collection_position?: unknown };
type: SearchModelType;
}) {
if (!props.active) {
if (!active) {
return color("text-medium");
} else if (props.item.collection_position) {
} else if (item.collection_position) {
return color("saturated-yellow");
} else if (props.type === "collection") {
} else if (type === "collection") {
return lighten("brand", 0.35);
} else {
return color("brand");
}
}
export const IconWrapper = styled.div`
export const IconWrapper = styled.div<{
item: SearchEntity;
active: boolean;
type: SearchModelType;
}>`
display: flex;
align-items: center;
justify-content: center;
......@@ -50,13 +68,7 @@ export const Title = styled("h3")<{ active: boolean }>`
color: ${props => color(props.active ? "text-dark" : "text-medium")};
`;
interface ResultButtonProps {
isSelected: boolean;
compact: boolean;
active: boolean;
}
export const ResultButton = styled.button<ResultButtonProps>`
export const ResultButton = styled.button<ResultStylesProps>`
${props => resultStyles(props)}
padding-right: 0.5rem;
text-align: left;
......@@ -68,27 +80,25 @@ export const ResultButton = styled.button<ResultButtonProps>`
}
`;
export const ResultLink = styled(Link)<ResultButtonProps>`
export const ResultLink = styled(Link)<ResultStylesProps>`
${props => resultStyles(props)}
`;
const resultStyles = (props: ResultButtonProps) => `
const resultStyles = ({ compact, active, isSelected }: ResultStylesProps) => `
display: block;
background-color: ${
props.isSelected ? lighten("brand", 0.63) : "transparent"
};
min-height: ${props.compact ? "36px" : "54px"};
background-color: ${isSelected ? lighten("brand", 0.63) : "transparent"};
min-height: ${compact ? "36px" : "54px"};
padding-top: ${space(1)};
padding-bottom: ${space(1)};
padding-left: 14px;
padding-right: ${props.compact ? "20px" : space(3)};
cursor: ${props.active ? "pointer" : "default"};
padding-right: ${compact ? "20px" : space(3)};
cursor: ${active ? "pointer" : "default"};
&:hover {
background-color: ${props.active ? lighten("brand", 0.63) : ""};
background-color: ${active ? lighten("brand", 0.63) : ""};
h3 {
color: ${props.active || props.isSelected ? color("brand") : ""};
color: ${active || isSelected ? color("brand") : ""};
}
}
......@@ -98,8 +108,8 @@ const resultStyles = (props: ResultButtonProps) => `
text-decoration-style: dashed;
&:hover {
color: ${props.active ? color("brand") : ""};
text-decoration-color: ${props.active ? color("brand") : ""};
color: ${active ? color("brand") : ""};
text-decoration-color: ${active ? color("brand") : ""};
}
}
......@@ -111,11 +121,11 @@ const resultStyles = (props: ResultButtonProps) => `
}
h3 {
font-size: ${props.compact ? "14px" : "16px"};
font-size: ${compact ? "14px" : "16px"};
line-height: 1.2em;
overflow-wrap: anywhere;
margin-bottom: 0;
color: ${props.active && props.isSelected ? color("brand") : ""};
color: ${active && isSelected ? color("brand") : ""};
}
.Icon-info {
......
/* eslint-disable react/prop-types */
import { color } from "metabase/lib/colors";
import { isSyncCompleted } from "metabase/lib/syncing";
......@@ -7,6 +6,10 @@ import Text from "metabase/components/type/Text";
import { PLUGIN_COLLECTIONS, PLUGIN_MODERATION } from "metabase/plugins";
import type { SearchScore, SearchModelType } from "metabase-types/api";
import type { WrappedResult } from "./types";
import {
IconWrapper,
ResultButton,
......@@ -27,7 +30,7 @@ function TableIcon() {
return <Icon name="database" />;
}
function CollectionIcon({ item }) {
function CollectionIcon({ item }: { item: WrappedResult }) {
const iconProps = { ...item.getIcon() };
const isRegular = PLUGIN_COLLECTIONS.isRegularCollection(item.collection);
if (isRegular) {
......@@ -44,12 +47,24 @@ const ModelIconComponentMap = {
collection: CollectionIcon,
};
function DefaultIcon({ item }) {
function DefaultIcon({ item }: { item: WrappedResult }) {
return <Icon {...item.getIcon()} size={DEFAULT_ICON_SIZE} />;
}
export function ItemIcon({ item, type, active }) {
const IconComponent = ModelIconComponentMap[type] || DefaultIcon;
export function ItemIcon({
item,
type,
active,
}: {
item: WrappedResult;
type: SearchModelType;
active: boolean;
}) {
const IconComponent =
type in Object.keys(ModelIconComponentMap)
? ModelIconComponentMap[type as keyof typeof ModelIconComponentMap]
: DefaultIcon;
return (
<IconWrapper item={item} type={type} active={active}>
<IconComponent item={item} />
......@@ -57,13 +72,14 @@ export function ItemIcon({ item, type, active }) {
);
}
function Score({ scores }) {
function Score({ scores }: { scores: SearchScore[] }) {
return (
<pre className="hide search-score">{JSON.stringify(scores, null, 2)}</pre>
);
}
function Context({ context }) {
// I think it's very likely that this is a dead codepath: RL 2023-06-21
function Context({ context }: { context: any[] }) {
if (!context) {
return null;
}
......@@ -71,7 +87,7 @@ function Context({ context }) {
return (
<ContextContainer>
<ContextText>
{context.map(({ is_match, text }, i) => {
{context.map(({ is_match, text }, i: number) => {
if (!is_match) {
return <span key={i}> {text}</span>;
}
......@@ -88,12 +104,18 @@ function Context({ context }) {
);
}
export default function SearchResult({
export function SearchResult({
result,
compact = false,
hasDescription = true,
onClick = undefined,
isSelected = false,
}: {
result: WrappedResult;
compact?: boolean;
hasDescription?: boolean;
onClick?: (result: WrappedResult) => void;
isSelected?: boolean;
}) {
const active = isItemActive(result);
const loading = isItemLoading(result);
......@@ -106,7 +128,7 @@ export default function SearchResult({
isSelected={isSelected}
active={active}
compact={compact}
to={!onClick ? result.getUrl() : undefined}
to={!onClick ? result.getUrl() : ""}
onClick={onClick ? () => onClick(result) : undefined}
data-testid="search-result-item"
>
......@@ -137,7 +159,7 @@ export default function SearchResult({
);
}
const isItemActive = result => {
const isItemActive = (result: WrappedResult) => {
switch (result.model) {
case "table":
return isSyncCompleted(result);
......@@ -146,7 +168,7 @@ const isItemActive = result => {
}
};
const isItemLoading = result => {
const isItemLoading = (result: WrappedResult) => {
switch (result.model) {
case "database":
case "table":
......
import { render, screen } from "@testing-library/react";
import { setupEnterpriseTest } from "__support__/enterprise";
import SearchResult from "./SearchResult";
function collection({
id = 1,
name = "Marketing",
authority_level = null,
getIcon = () => ({ name: "folder" }),
getUrl = () => `/collection/${id}`,
getCollection = () => {},
} = {}) {
const collection = {
id,
name,
authority_level,
getIcon,
getUrl,
getCollection,
model: "collection",
};
collection.collection = collection;
return collection;
}
describe("SearchResult > Collections", () => {
const regularCollection = collection();
describe("OSS", () => {
const officialCollection = collection({
authority_level: "official",
});
it("renders regular collection correctly", () => {
render(<SearchResult result={regularCollection} />);
expect(screen.getByText(regularCollection.name)).toBeInTheDocument();
expect(screen.getByText("Collection")).toBeInTheDocument();
expect(screen.getByLabelText("folder icon")).toBeInTheDocument();
expect(screen.queryByLabelText("badge icon")).not.toBeInTheDocument();
});
it("renders official collections as regular", () => {
render(<SearchResult result={officialCollection} />);
expect(screen.getByText(regularCollection.name)).toBeInTheDocument();
expect(screen.getByText("Collection")).toBeInTheDocument();
expect(screen.getByLabelText("folder icon")).toBeInTheDocument();
expect(screen.queryByLabelText("badge icon")).not.toBeInTheDocument();
});
});
describe("EE", () => {
const officialCollection = collection({
authority_level: "official",
getIcon: () => ({ name: "badge" }),
});
beforeAll(() => {
setupEnterpriseTest();
});
it("renders regular collection correctly", () => {
render(<SearchResult result={regularCollection} />);
expect(screen.getByText(regularCollection.name)).toBeInTheDocument();
expect(screen.getByText("Collection")).toBeInTheDocument();
expect(screen.getByLabelText("folder icon")).toBeInTheDocument();
expect(screen.queryByLabelText("badge icon")).not.toBeInTheDocument();
});
it("renders official collections correctly", () => {
render(<SearchResult result={officialCollection} />);
expect(screen.getByText(regularCollection.name)).toBeInTheDocument();
expect(screen.getByText("Official Collection")).toBeInTheDocument();
expect(screen.getByLabelText("badge icon")).toBeInTheDocument();
expect(screen.queryByLabelText("folder icon")).not.toBeInTheDocument();
});
});
});
import { render, screen } from "@testing-library/react";
import { setupEnterpriseTest } from "__support__/enterprise";
import { createMockSearchResult } from "metabase-types/api/mocks";
import { getIcon, queryIcon } from "__support__/ui";
import type { WrappedResult } from "./types";
import { SearchResult } from "./SearchResult";
const createWrappedSearchResult = (
options: Partial<WrappedResult>,
): WrappedResult => {
const result = createMockSearchResult(options);
return {
...result,
getUrl: options.getUrl ?? (() => "/collection/root"),
getIcon: options.getIcon ?? (() => ({ name: "folder" })),
getCollection: options.getCollection ?? (() => result.collection),
};
};
describe("SearchResult", () => {
it("renders a search result question item", () => {
const result = createWrappedSearchResult({
name: "My Item",
model: "card",
description: "My Item Description",
getIcon: () => ({ name: "table" }),
});
render(<SearchResult result={result} />);
expect(screen.getByText(result.name)).toBeInTheDocument();
expect(screen.getByText(result.description as string)).toBeInTheDocument();
expect(getIcon("table")).toBeInTheDocument();
});
it("renders a search result collection item", () => {
const result = createWrappedSearchResult({
name: "My Folder of Goodies",
model: "collection",
collection: {
id: 1,
name: "This should not appear",
authority_level: null,
},
});
render(<SearchResult result={result} />);
expect(screen.getByText(result.name)).toBeInTheDocument();
expect(screen.getByText("Collection")).toBeInTheDocument();
expect(screen.queryByText(result.collection.name)).not.toBeInTheDocument();
expect(getIcon("folder")).toBeInTheDocument();
});
});
describe("SearchResult > Collections", () => {
const resultInRegularCollection = createWrappedSearchResult({
name: "My Regular Item",
collection_authority_level: null,
collection: {
id: 1,
name: "Regular Collection",
authority_level: null,
},
});
const resultInOfficalCollection = createWrappedSearchResult({
name: "My Official Item",
collection_authority_level: "official",
collection: {
id: 1,
name: "Official Collection",
authority_level: "official",
},
});
describe("OSS", () => {
it("renders regular collection correctly", () => {
render(<SearchResult result={resultInRegularCollection} />);
expect(
screen.getByText(resultInRegularCollection.name),
).toBeInTheDocument();
expect(screen.getByText("Regular Collection")).toBeInTheDocument();
expect(getIcon("folder")).toBeInTheDocument();
expect(queryIcon("badge")).not.toBeInTheDocument();
});
it("renders official collections as regular", () => {
render(<SearchResult result={resultInOfficalCollection} />);
expect(
screen.getByText(resultInOfficalCollection.name),
).toBeInTheDocument();
expect(screen.getByText("Official Collection")).toBeInTheDocument();
expect(getIcon("folder")).toBeInTheDocument();
expect(queryIcon("badge")).not.toBeInTheDocument();
});
});
describe("EE", () => {
const resultInOfficalCollectionEE: WrappedResult = {
...resultInOfficalCollection,
getIcon: () => ({ name: "badge" }),
};
beforeAll(() => {
setupEnterpriseTest();
});
it("renders regular collection correctly", () => {
render(<SearchResult result={resultInRegularCollection} />);
expect(
screen.getByText(resultInRegularCollection.name),
).toBeInTheDocument();
expect(screen.getByText("Regular Collection")).toBeInTheDocument();
expect(getIcon("folder")).toBeInTheDocument();
expect(queryIcon("badge")).not.toBeInTheDocument();
});
it("renders official collections correctly", () => {
render(<SearchResult result={resultInOfficalCollectionEE} />);
expect(
screen.getByText(resultInOfficalCollectionEE.name),
).toBeInTheDocument();
expect(screen.getByText("Official Collection")).toBeInTheDocument();
expect(getIcon("badge")).toBeInTheDocument();
expect(queryIcon("folder")).not.toBeInTheDocument();
});
});
});
import type { IconName } from "metabase/core/components/Icon";
import type { SearchResult, Collection } from "metabase-types/api";
export interface WrappedResult extends SearchResult {
getUrl: () => string;
getIcon: () => {
name: IconName;
size?: number;
width?: number;
height?: number;
};
getCollection: () => Partial<Collection>;
}
......@@ -9,7 +9,7 @@ import Search from "metabase/entities/search";
import Card from "metabase/components/Card";
import EmptyState from "metabase/components/EmptyState";
import SearchResult from "metabase/search/components/SearchResult";
import { SearchResult } from "metabase/search/components/SearchResult";
import Subhead from "metabase/components/type/Subhead";
import { Icon } from "metabase/core/components/Icon";
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment