Skip to content
Snippets Groups Projects
Unverified Commit f852b48f authored by Nick Fitzpatrick's avatar Nick Fitzpatrick Committed by GitHub
Browse files

41994 command pallet search descriptions (#42117)

* Adding Descriptions to search results in palette

* Palette Results List component

* adjusting e2e and unit tests

* type fix

* removing duplicate theme font

* fix typo, palette types adjustment
parent 6d2ae82b
No related branches found
No related tags found
No related merge requests found
import { USERS } from "e2e/support/cypress_data"; import { USERS } from "e2e/support/cypress_data";
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data"; import {
ORDERS_DASHBOARD_ID,
ORDERS_COUNT_QUESTION_ID,
} from "e2e/support/cypress_sample_instance_data";
import { import {
restore, restore,
openCommandPalette, openCommandPalette,
...@@ -22,6 +25,11 @@ describe("command palette", () => { ...@@ -22,6 +25,11 @@ describe("command palette", () => {
}); });
it("should render a searchable command palette", () => { it("should render a searchable command palette", () => {
//Add a description for a check
cy.request("PUT", `/api/card/${ORDERS_COUNT_QUESTION_ID}`, {
description: "The best question",
});
//Request to have an item in the recents list //Request to have an item in the recents list
cy.request(`/api/dashboard/${ORDERS_DASHBOARD_ID}`); cy.request(`/api/dashboard/${ORDERS_DASHBOARD_ID}`);
cy.visit("/"); cy.visit("/");
...@@ -56,10 +64,9 @@ describe("command palette", () => { ...@@ -56,10 +64,9 @@ describe("command palette", () => {
cy.log("Should search entities and docs"); cy.log("Should search entities and docs");
commandPaletteSearch().type("Orders, Count"); commandPaletteSearch().type("Orders, Count");
cy.findByRole("option", { name: "Orders, Count" }).should( cy.findByRole("option", { name: "Orders, Count" })
"contain.text", .should("contain.text", "Our analytics")
"Our analytics", .should("contain.text", "The best question");
);
cy.findByText('Search documentation for "Orders, Count"').should("exist"); cy.findByText('Search documentation for "Orders, Count"').should("exist");
......
import { t } from "ttag"; import { t } from "ttag";
import { color } from "metabase/lib/colors"; import { color } from "metabase/lib/colors";
import { Flex, Text, Icon, Box, type IconName } from "metabase/ui"; import { Flex, Text, Icon, Box } from "metabase/ui";
import type { PaletteAction } from "../types"; import type { PaletteActionImpl } from "../types";
interface PaletteResultItemProps { interface PaletteResultItemProps {
item: PaletteAction; item: PaletteActionImpl;
active: boolean; active: boolean;
} }
...@@ -35,22 +35,29 @@ export const PaletteResultItem = ({ item, active }: PaletteResultItemProps) => { ...@@ -35,22 +35,29 @@ export const PaletteResultItem = ({ item, active }: PaletteResultItemProps) => {
c={active ? color("white") : color("text-dark")} c={active ? color("white") : color("text-dark")}
aria-label={item.name} aria-label={item.name}
> >
<Flex gap=".5rem" style={{ minWidth: 0 }}> {/** Icon Container */}
{item.icon && ( {item.icon && (
<Icon <Icon
aria-hidden aria-hidden
name={(item.icon as IconName) || "click"} name={item.icon || "click"}
color={iconColor} color={iconColor}
style={{ style={{
flexBasis: "16px", flexBasis: "16px",
}} }}
/> />
)} )}
{/**Text container */}
<Flex
direction="column"
style={{
flexGrow: 1,
flexBasis: 0,
overflowX: "hidden",
}}
>
<Box <Box
component="span" component="span"
style={{ style={{
flexGrow: 1,
flexBasis: 0,
textOverflow: "ellipsis", textOverflow: "ellipsis",
overflow: "hidden", overflow: "hidden",
whiteSpace: "nowrap", whiteSpace: "nowrap",
...@@ -80,7 +87,20 @@ export const PaletteResultItem = ({ item, active }: PaletteResultItemProps) => { ...@@ -80,7 +87,20 @@ export const PaletteResultItem = ({ item, active }: PaletteResultItemProps) => {
>{`— ${parentName}`}</Text> >{`— ${parentName}`}</Text>
)} )}
</Box> </Box>
<Text
component="span"
color={active ? "white" : "text-light"}
fw="normal"
style={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{item.subtitle}
</Text>
</Flex> </Flex>
{/** Active container */}
{active && ( {active && (
<Flex <Flex
aria-hidden aria-hidden
......
import { useKBar, useMatches, KBarResults } from "kbar"; import { useKBar, useMatches } from "kbar";
import { useState, useMemo } from "react"; import { useMemo } from "react";
import { useDebounce, useKeyPressEvent } from "react-use"; import { useKeyPressEvent } from "react-use";
import _ from "underscore"; import _ from "underscore";
import { color } from "metabase/lib/colors"; import { color } from "metabase/lib/colors";
import { SEARCH_DEBOUNCE_DURATION } from "metabase/lib/constants";
import { Flex, Box } from "metabase/ui"; import { Flex, Box } from "metabase/ui";
import { useCommandPalette } from "../hooks/useCommandPalette"; import { useCommandPalette } from "../hooks/useCommandPalette";
import type { PaletteAction } from "../types"; import type { PaletteActionImpl } from "../types";
import { processResults, findClosesestActionIndex } from "../utils"; import { processResults, findClosestActionIndex } from "../utils";
import { PaletteResultItem } from "./PaletteResultItem"; import { PaletteResultItem } from "./PaletteResultItem";
import { PaletteResultList } from "./PaletteResultsList";
const PAGE_SIZE = 4; const PAGE_SIZE = 4;
export const PaletteResults = () => { export const PaletteResults = () => {
// Used for finding actions within the list // Used for finding actions within the list
const { searchQuery, query } = useKBar(state => ({ const { query } = useKBar();
searchQuery: state.searchQuery,
}));
const trimmedQuery = searchQuery.trim();
// Used for finding objects across the Metabase instance useCommandPalette();
const [debouncedSearchText, setDebouncedSearchText] = useState(trimmedQuery);
useDebounce(
() => {
setDebouncedSearchText(trimmedQuery);
},
SEARCH_DEBOUNCE_DURATION,
[trimmedQuery],
);
useCommandPalette({
query: trimmedQuery,
debouncedSearchText,
});
const { results } = useMatches(); const { results } = useMatches();
const processedResults = useMemo(() => processResults(results), [results]); const processedResults = useMemo(
() => processResults(results as (PaletteActionImpl | string)[]),
[results],
);
useKeyPressEvent("End", () => { useKeyPressEvent("End", () => {
const lastIndex = processedResults.length - 1; const lastIndex = processedResults.length - 1;
...@@ -53,26 +39,26 @@ export const PaletteResults = () => { ...@@ -53,26 +39,26 @@ export const PaletteResults = () => {
useKeyPressEvent("PageDown", () => { useKeyPressEvent("PageDown", () => {
query.setActiveIndex(i => query.setActiveIndex(i =>
findClosesestActionIndex(processedResults, i, PAGE_SIZE), findClosestActionIndex(processedResults, i, PAGE_SIZE),
); );
}); });
useKeyPressEvent("PageUp", () => { useKeyPressEvent("PageUp", () => {
query.setActiveIndex(i => query.setActiveIndex(i =>
findClosesestActionIndex(processedResults, i, -PAGE_SIZE), findClosestActionIndex(processedResults, i, -PAGE_SIZE),
); );
}); });
return ( return (
<Flex align="stretch" direction="column" p="0.75rem 0"> <Flex align="stretch" direction="column" p="0.75rem 0">
<KBarResults <PaletteResultList
items={processedResults} // items needs to be a stable reference, otherwise the activeIndex will constantly be hijacked items={processedResults} // items needs to be a stable reference, otherwise the activeIndex will constantly be hijacked
maxHeight={530} maxHeight={530}
onRender={({ onRender={({
item, item,
active, active,
}: { }: {
item: string | PaletteAction; item: string | PaletteActionImpl;
active: boolean; active: boolean;
}) => { }) => {
const isFirst = processedResults[0] === item; const isFirst = processedResults[0] === item;
......
...@@ -10,10 +10,9 @@ import { ...@@ -10,10 +10,9 @@ import {
import { import {
renderWithProviders, renderWithProviders,
screen, screen,
mockGetBoundingClientRect,
within, within,
waitFor, waitFor,
mockOffsetHeightAndWidth, mockScrollTo,
} from "__support__/ui"; } from "__support__/ui";
import { getAdminPaths } from "metabase/admin/app/reducers"; import { getAdminPaths } from "metabase/admin/app/reducers";
import { import {
...@@ -74,6 +73,7 @@ const dashboard = createMockCollectionItem({ ...@@ -74,6 +73,7 @@ const dashboard = createMockCollectionItem({
model: "dashboard", model: "dashboard",
name: "Bar Dashboard", name: "Bar Dashboard",
collection: collection_1, collection: collection_1,
description: "Such Bar. Much Wow.",
}); });
const recents_1 = createMockRecentItem({ const recents_1 = createMockRecentItem({
...@@ -93,8 +93,7 @@ const recents_2 = createMockRecentItem({ ...@@ -93,8 +93,7 @@ const recents_2 = createMockRecentItem({
}), }),
}); });
mockGetBoundingClientRect(); mockScrollTo();
mockOffsetHeightAndWidth(10); // This is absurdley small, but it allows all the items to render in the "virtual list"
const setup = ({ query }: { query?: string } = {}) => { const setup = ({ query }: { query?: string } = {}) => {
setupDatabasesEndpoints([DATABASE]); setupDatabasesEndpoints([DATABASE]);
...@@ -165,6 +164,10 @@ describe("PaletteResults", () => { ...@@ -165,6 +164,10 @@ describe("PaletteResults", () => {
expect( expect(
await screen.findByRole("option", { name: "Bar Dashboard" }), await screen.findByRole("option", { name: "Bar Dashboard" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
await screen.findByRole("option", { name: "Bar Dashboard" }),
).toHaveTextContent("Such Bar. Much Wow.");
expect( expect(
await screen.findByText('Search documentation for "Bar"'), await screen.findByText('Search documentation for "Bar"'),
).toBeInTheDocument(); ).toBeInTheDocument();
......
/**
* This component was actually copied from the kbar library, but
* modified to remove virtualization of the list. This was due to virtualization
* libraries not handling dynamically sized lists where the list changes from render to
* render very well (it seemed to recompute when the list length changed, not the contents)
*
* Original can be found at https://github.com/timc1/kbar/blob/846b2c1a89f6cbff1ce947b82d04cb96a5066fbb/src/KBarResults.tsx
*/
import { useKBar, KBAR_LISTBOX, getListboxItemId } from "kbar";
import * as React from "react";
import type { PaletteActionImpl } from "../types";
const START_INDEX = 0;
interface RenderParams<T = PaletteActionImpl | string> {
item: T;
active: boolean;
}
interface PaletteResultListProps {
items: (PaletteActionImpl | string)[];
onRender: (params: RenderParams) => React.ReactElement;
maxHeight?: number;
}
export const PaletteResultList: React.FC<PaletteResultListProps> = props => {
const activeRef = React.useRef<HTMLDivElement>(null);
const parentRef = React.useRef<HTMLDivElement>(null);
// store a ref to all items so we do not have to pass
// them as a dependency when setting up event listeners.
const itemsRef = React.useRef(props.items);
itemsRef.current = props.items;
const { query, search, currentRootActionId, activeIndex, options } = useKBar(
state => ({
search: state.searchQuery,
currentRootActionId: state.currentRootActionId,
activeIndex: state.activeIndex,
}),
);
React.useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.isComposing) {
return;
}
if (event.key === "ArrowUp" || (event.ctrlKey && event.key === "p")) {
event.preventDefault();
event.stopPropagation();
query.setActiveIndex(index => {
let nextIndex = index > START_INDEX ? index - 1 : index;
// avoid setting active index on a group
if (typeof itemsRef.current[nextIndex] === "string") {
if (nextIndex === 0) {
return index;
}
nextIndex -= 1;
}
return nextIndex;
});
} else if (
event.key === "ArrowDown" ||
(event.ctrlKey && event.key === "n")
) {
event.preventDefault();
event.stopPropagation();
query.setActiveIndex(index => {
let nextIndex =
index < itemsRef.current.length - 1 ? index + 1 : index;
// avoid setting active index on a group
if (typeof itemsRef.current[nextIndex] === "string") {
if (nextIndex === itemsRef.current.length - 1) {
return index;
}
nextIndex += 1;
}
return nextIndex;
});
} else if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
// storing the active dom element in a ref prevents us from
// having to calculate the current action to perform based
// on the `activeIndex`, which we would have needed to add
// as part of the dependencies array.
activeRef.current?.click();
}
};
window.addEventListener("keydown", handler, { capture: true });
return () =>
window.removeEventListener("keydown", handler, { capture: true });
}, [query]);
React.useEffect(() => {
if (activeIndex > 1) {
activeRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
} else {
parentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}
}, [activeIndex]);
React.useEffect(() => {
// TODO(tim): fix scenario where async actions load in
// and active index is reset to the first item. i.e. when
// users register actions and bust the `useRegisterActions`
// cache, we won't want to reset their active index as they
// are navigating the list.
query.setActiveIndex(
// avoid setting active index on a group
typeof props.items[START_INDEX] === "string"
? START_INDEX + 1
: START_INDEX,
);
}, [search, currentRootActionId, props.items, query]);
const execute = React.useCallback(
(item: RenderParams["item"]) => {
if (typeof item === "string") {
return;
}
if (item.command) {
item.command.perform(item);
query.toggle();
} else {
query.setSearch("");
query.setCurrentRootAction(item.id);
}
options.callbacks?.onSelectAction?.(item);
},
[query, options],
);
return (
<div
ref={parentRef}
style={{
maxHeight: props.maxHeight || 400,
overflow: "auto",
}}
>
<div
role="listbox"
id={KBAR_LISTBOX}
style={{
position: "relative",
width: "100%",
}}
>
{props.items.map((item, index) => {
const handlers = typeof item !== "string" && {
onPointerMove: () =>
activeIndex !== index && query.setActiveIndex(index),
onPointerDown: () => query.setActiveIndex(index),
onClick: () => execute(item),
};
const active = index === activeIndex;
return (
<div
ref={active ? activeRef : null}
id={getListboxItemId(index)}
role="option"
aria-selected={active}
key={typeof item === "string" ? item : item.id}
{...handlers}
>
{React.cloneElement(
props.onRender({
item,
active,
}),
)}
</div>
);
})}
</div>
</div>
);
};
import { useRegisterActions } from "kbar"; import { useRegisterActions, useKBar } from "kbar";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { push } from "react-router-redux"; import { push } from "react-router-redux";
import { useDebounce } from "react-use";
import { t } from "ttag"; import { t } from "ttag";
import { getAdminPaths } from "metabase/admin/app/selectors"; import { getAdminPaths } from "metabase/admin/app/selectors";
import { getSectionsWithPlugins } from "metabase/admin/settings/selectors"; import { getSectionsWithPlugins } from "metabase/admin/settings/selectors";
import { useListRecentItemsQuery, skipToken } from "metabase/api"; import { useListRecentItemsQuery, useSearchQuery } from "metabase/api";
import { useSearchListQuery } from "metabase/common/hooks";
import { ROOT_COLLECTION } from "metabase/entities/collections"; import { ROOT_COLLECTION } from "metabase/entities/collections";
import Search from "metabase/entities/search"; import Search from "metabase/entities/search";
import { SEARCH_DEBOUNCE_DURATION } from "metabase/lib/constants";
import { getIcon } from "metabase/lib/icon"; import { getIcon } from "metabase/lib/icon";
import { getName } from "metabase/lib/name"; import { getName } from "metabase/lib/name";
import { useDispatch, useSelector } from "metabase/lib/redux"; import { useDispatch, useSelector } from "metabase/lib/redux";
...@@ -20,42 +21,52 @@ import { ...@@ -20,42 +21,52 @@ import {
getSettings, getSettings,
} from "metabase/selectors/settings"; } from "metabase/selectors/settings";
import { getShowMetabaseLinks } from "metabase/selectors/whitelabel"; import { getShowMetabaseLinks } from "metabase/selectors/whitelabel";
import type { SearchResult } from "metabase-types/api";
import type { PaletteAction } from "../types"; import type { PaletteAction } from "../types";
export type PalettePageId = "root" | "admin_settings"; export const useCommandPalette = () => {
export const useCommandPalette = ({
query,
debouncedSearchText,
}: {
query: string;
debouncedSearchText: string;
}) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const docsUrl = useSelector(state => getDocsUrl(state, {})); const docsUrl = useSelector(state => getDocsUrl(state, {}));
const showMetabaseLinks = useSelector(getShowMetabaseLinks); const showMetabaseLinks = useSelector(getShowMetabaseLinks);
const hasQuery = query.length > 0; // Used for finding actions within the list
const { searchQuery } = useKBar(state => ({
searchQuery: state.searchQuery,
}));
const trimmedQuery = searchQuery.trim();
// Used for finding objects across the Metabase instance
const [debouncedSearchText, setDebouncedSearchText] = useState(trimmedQuery);
useDebounce(
() => {
setDebouncedSearchText(trimmedQuery);
},
SEARCH_DEBOUNCE_DURATION,
[trimmedQuery],
);
const hasQuery = searchQuery.length > 0;
const { const {
data: searchResults, currentData: searchResults,
isFetching: isSearchLoading,
error: searchError, error: searchError,
isLoading: isSearchLoading, } = useSearchQuery(
} = useSearchListQuery<SearchResult>({ {
enabled: !!debouncedSearchText, q: debouncedSearchText,
query: { q: debouncedSearchText, limit: 20 }, limit: 20,
reload: true, },
});
const { data: recentItems } = useListRecentItemsQuery(
debouncedSearchText ? skipToken : undefined,
{ {
skip: !debouncedSearchText,
refetchOnMountOrArgChange: true, refetchOnMountOrArgChange: true,
}, },
); );
const { data: recentItems } = useListRecentItemsQuery(undefined, {
refetchOnMountOrArgChange: true,
});
const adminPaths = useSelector(getAdminPaths); const adminPaths = useSelector(getAdminPaths);
const settingValues = useSelector(getSettings); const settingValues = useSelector(getSettings);
const settingsSections = useMemo<Record<string, any>>( const settingsSections = useMemo<Record<string, any>>(
...@@ -67,15 +78,15 @@ export const useCommandPalette = ({ ...@@ -67,15 +78,15 @@ export const useCommandPalette = ({
const ret: PaletteAction[] = [ const ret: PaletteAction[] = [
{ {
id: "search_docs", id: "search_docs",
name: query name: debouncedSearchText
? `Search documentation for "${query}"` ? `Search documentation for "${debouncedSearchText}"`
: t`View documentation`, : t`View documentation`,
section: "docs", section: "docs",
keywords: query, // Always match the query string keywords: debouncedSearchText, // Always match the debouncedSearchText string
icon: "document", icon: "document",
perform: () => { perform: () => {
if (query) { if (debouncedSearchText) {
window.open(getDocsSearchUrl({ query })); window.open(getDocsSearchUrl({ debouncedSearchText }));
} else { } else {
window.open(docsUrl); window.open(docsUrl);
} }
...@@ -83,7 +94,7 @@ export const useCommandPalette = ({ ...@@ -83,7 +94,7 @@ export const useCommandPalette = ({
}, },
]; ];
return ret; return ret;
}, [query, docsUrl]); }, [debouncedSearchText, docsUrl]);
const showDocsAction = showMetabaseLinks && hasQuery; const showDocsAction = showMetabaseLinks && hasQuery;
...@@ -98,7 +109,7 @@ export const useCommandPalette = ({ ...@@ -98,7 +109,7 @@ export const useCommandPalette = ({
{ {
id: "search-is-loading", id: "search-is-loading",
name: "Loading...", name: "Loading...",
keywords: query, keywords: searchQuery,
section: "search", section: "search",
}, },
]; ];
...@@ -111,15 +122,16 @@ export const useCommandPalette = ({ ...@@ -111,15 +122,16 @@ export const useCommandPalette = ({
}, },
]; ];
} else if (debouncedSearchText) { } else if (debouncedSearchText) {
if (searchResults?.length) { if (searchResults?.data?.length) {
return searchResults.map(result => { return searchResults.data.map(result => {
const wrappedResult = Search.wrapEntity(result, dispatch); const wrappedResult = Search.wrapEntity(result, dispatch);
return { return {
id: `search-result-${result.id}`, id: `search-result-${result.model}-${result.id}`,
name: result.name, name: result.name,
icon: wrappedResult.getIcon().name, icon: wrappedResult.getIcon().name,
section: "search", section: "search",
keywords: debouncedSearchText, keywords: debouncedSearchText,
subtitle: result.description || "",
perform: () => { perform: () => {
dispatch(closeModal()); dispatch(closeModal());
dispatch(push(wrappedResult.getUrl())); dispatch(push(wrappedResult.getUrl()));
...@@ -145,8 +157,8 @@ export const useCommandPalette = ({ ...@@ -145,8 +157,8 @@ export const useCommandPalette = ({
return []; return [];
}, [ }, [
dispatch, dispatch,
query,
debouncedSearchText, debouncedSearchText,
searchQuery,
isSearchLoading, isSearchLoading,
searchError, searchError,
searchResults, searchResults,
......
import type { Action } from "kbar"; import type { Action, ActionImpl } from "kbar";
export interface PaletteAction extends Action { import type { IconName } from "metabase/ui";
interface PaletteActionExtras {
extra?: { extra?: {
parentCollection?: string | null; parentCollection?: string | null;
isVerified?: boolean; isVerified?: boolean;
database?: string | null; database?: string | null;
}; };
} }
export type PaletteAction = Action &
PaletteActionExtras & {
subtitle?: Action["subtitle"];
icon?: IconName;
};
export type PaletteActionImpl = ActionImpl &
PaletteActionExtras & {
subtitle?: Action["subtitle"];
icon?: IconName;
};
import type { ActionImpl } from "kbar";
import { t } from "ttag"; import { t } from "ttag";
import _ from "underscore"; import _ from "underscore";
import type { PaletteActionImpl } from "./types";
export const processResults = ( export const processResults = (
results: (string | ActionImpl)[], results: (string | PaletteActionImpl)[],
): (string | ActionImpl)[] => { ): (string | PaletteActionImpl)[] => {
const groupedResults = _.groupBy( const groupedResults = _.groupBy(
results.filter((r): r is ActionImpl => !(typeof r === "string")), results.filter((r): r is PaletteActionImpl => !(typeof r === "string")),
"section", "section",
); );
...@@ -19,7 +20,10 @@ export const processResults = ( ...@@ -19,7 +20,10 @@ export const processResults = (
return [...recent, ...actions.slice(0, 6), ...admin, ...search, ...docs]; return [...recent, ...actions.slice(0, 6), ...admin, ...search, ...docs];
}; };
export const processSection = (sectionName: string, items?: ActionImpl[]) => { export const processSection = (
sectionName: string,
items?: PaletteActionImpl[],
) => {
if (items && items.length > 0) { if (items && items.length > 0) {
return [sectionName, ...items]; return [sectionName, ...items];
} else { } else {
...@@ -27,20 +31,20 @@ export const processSection = (sectionName: string, items?: ActionImpl[]) => { ...@@ -27,20 +31,20 @@ export const processSection = (sectionName: string, items?: ActionImpl[]) => {
} }
}; };
export const findClosesestActionIndex = ( export const findClosestActionIndex = (
actions: (string | ActionImpl)[], actions: (string | PaletteActionImpl)[],
index: number, index: number,
diff: number, diff: number,
): number => { ): number => {
if (index + diff < 0) { if (index + diff < 0) {
return findClosesestActionIndex(actions, -1, 1); return findClosestActionIndex(actions, -1, 1);
} else if (index + diff > actions.length - 1) { } else if (index + diff > actions.length - 1) {
return findClosesestActionIndex(actions, actions.length, -1); return findClosestActionIndex(actions, actions.length, -1);
} else if (typeof actions[index + diff] === "string") { } else if (typeof actions[index + diff] === "string") {
if (diff < 0) { if (diff < 0) {
return findClosesestActionIndex(actions, index, diff - 1); return findClosestActionIndex(actions, index, diff - 1);
} else { } else {
return findClosesestActionIndex(actions, index, diff + 1); return findClosestActionIndex(actions, index, diff + 1);
} }
} }
......
import type { ActionImpl } from "kbar"; import type { PaletteActionImpl } from "./types";
import { processResults, processSection } from "./utils"; import { processResults, processSection } from "./utils";
interface mockAction { interface mockAction {
...@@ -10,7 +9,7 @@ interface mockAction { ...@@ -10,7 +9,7 @@ interface mockAction {
const createMockAction = ({ const createMockAction = ({
name, name,
section = "basic", section = "basic",
}: mockAction): ActionImpl => ({ name, section } as ActionImpl); }: mockAction): PaletteActionImpl => ({ name, section } as PaletteActionImpl);
describe("command palette utils", () => { describe("command palette utils", () => {
describe("processSection", () => { describe("processSection", () => {
...@@ -25,7 +24,7 @@ describe("command palette utils", () => { ...@@ -25,7 +24,7 @@ describe("command palette utils", () => {
expect(result[0]).toBe("Basic"); expect(result[0]).toBe("Basic");
}); });
it("should return an empty list if there are no items", () => { it("should return an empty list if there are no items", () => {
const items: ActionImpl[] = []; const items: PaletteActionImpl[] = [];
const result = processSection("Basic", items); const result = processSection("Basic", items);
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
}); });
......
...@@ -319,6 +319,13 @@ export const mockScrollBy = () => { ...@@ -319,6 +319,13 @@ export const mockScrollBy = () => {
window.Element.prototype.scrollBy = jest.fn(); window.Element.prototype.scrollBy = jest.fn();
}; };
/**
* jsdom doesn't have scrollBy, so we need to mock it
*/
export const mockScrollTo = () => {
window.Element.prototype.scrollTo = jest.fn();
};
/** /**
* jsdom doesn't have DataTransfer * jsdom doesn't have DataTransfer
*/ */
......
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