diff --git a/.storybook/main.js b/.storybook/main.js index f4d46fb062f7be4054f52bd4b9a3ca3fec0efb79..2211f3f0f8745ae6448c3143f0f2d89077fae009 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -21,6 +21,7 @@ module.exports = { resolve: { ...storybookConfig.resolve, alias: appConfig.resolve.alias, + extensions: appConfig.resolve.extensions, }, }), }; diff --git a/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.jsx b/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.jsx deleted file mode 100644 index eec2dda0e4f883286298f16080a96e7d89f427d8..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.jsx +++ /dev/null @@ -1,217 +0,0 @@ -import React, { useCallback, useMemo, useState } from "react"; -import PropTypes from "prop-types"; -import { jt, t } from "ttag"; -import { getEngineLogo } from "metabase/lib/engine"; -import Settings from "metabase/lib/settings"; -import TextInput from "metabase/components/TextInput"; -import ExternalLink from "metabase/core/components/ExternalLink"; -import { - EngineCardIcon, - EngineCardImage, - EngineCardRoot, - EngineCardTitle, - EngineEmptyIcon, - EngineEmptyStateRoot, - EngineEmptyText, - EngineExpandButton, - EngineInfoIcon, - EngineInfoRoot, - EngineInfoTitle, - EngineListRoot, - EngineSearchRoot, -} from "./EngineWidget.styled"; - -const DEFAULT_OPTIONS_COUNT = 6; - -const EngineWidget = ({ field, options, isHosted }) => { - if (field.value) { - return <EngineInfo field={field} options={options} />; - } else { - return <EngineSearch field={field} options={options} isHosted={isHosted} />; - } -}; - -EngineWidget.propTypes = { - field: PropTypes.object.isRequired, - options: PropTypes.array.isRequired, - isHosted: PropTypes.bool, -}; - -const EngineInfo = ({ field, options }) => { - const option = options.find(option => option.value === field.value); - - const handleClick = useCallback(() => { - field.onChange(undefined); - }, [field]); - - return ( - <EngineInfoRoot> - <EngineInfoTitle>{option ? option.name : field.value}</EngineInfoTitle> - <EngineInfoIcon - name="close" - size={18} - aria-label={t`Remove database`} - onClick={handleClick} - /> - </EngineInfoRoot> - ); -}; - -EngineInfo.propTypes = { - field: PropTypes.object.isRequired, - options: PropTypes.array.isRequired, -}; - -const EngineSearch = ({ field, options, isHosted }) => { - const [searchText, setSearchText] = useState(""); - const [isExpanded, setIsExpanded] = useState(false); - const isSearching = searchText.length > 0; - const hasMoreOptions = options.length > DEFAULT_OPTIONS_COUNT; - - const sortedOptions = useMemo(() => { - return getSortedOptions(options); - }, [options]); - - const visibleOptions = useMemo( - () => getVisibleOptions(sortedOptions, isExpanded, isSearching, searchText), - [sortedOptions, isExpanded, isSearching, searchText], - ); - - return ( - <EngineSearchRoot> - <TextInput - value={searchText} - placeholder={t`Search for a database…`} - onChange={setSearchText} - /> - {visibleOptions.length ? ( - <EngineList field={field} options={visibleOptions} /> - ) : ( - <EngineEmptyState isHosted={isHosted} /> - )} - {!isSearching && hasMoreOptions && ( - <EngineToggle - isExpanded={isExpanded} - onExpandedChange={setIsExpanded} - /> - )} - </EngineSearchRoot> - ); -}; - -EngineSearch.propTypes = { - field: PropTypes.object.isRequired, - options: PropTypes.array.isRequired, - isHosted: PropTypes.bool, -}; - -const EngineList = ({ field, options }) => { - return ( - <EngineListRoot> - {options.map(option => ( - <EngineCard key={option.value} field={field} option={option} /> - ))} - </EngineListRoot> - ); -}; - -EngineList.propTypes = { - field: PropTypes.object, - options: PropTypes.array, -}; - -const EngineCard = ({ field, option }) => { - const logo = getEngineLogo(option.value); - - const handleClick = useCallback(() => { - field.onChange(option.value); - }, [field, option]); - - return ( - <EngineCardRoot key={option.value} onClick={handleClick}> - {logo ? ( - <EngineCardImage src={logo} /> - ) : ( - <EngineCardIcon name="database" /> - )} - <EngineCardTitle>{option.name}</EngineCardTitle> - </EngineCardRoot> - ); -}; - -EngineCard.propTypes = { - field: PropTypes.object, - option: PropTypes.object, -}; - -const EngineEmptyState = ({ isHosted }) => { - return ( - <EngineEmptyStateRoot> - <EngineEmptyIcon name="search" size={32} /> - {isHosted ? ( - <EngineEmptyText>{t`Didn’t find anything`}</EngineEmptyText> - ) : ( - <EngineEmptyText>{jt`Don’t see your database? Check out our ${( - <ExternalLink href={Settings.docsUrl("developers-guide-drivers")}> - {t`Community Drivers`} - </ExternalLink> - )} page to see if it’s available for self-hosting.`}</EngineEmptyText> - )} - </EngineEmptyStateRoot> - ); -}; - -EngineEmptyState.propTypes = { - isHosted: PropTypes.bool, -}; - -const EngineToggle = ({ isExpanded, onExpandedChange }) => { - const handleClick = useCallback(() => { - onExpandedChange(!isExpanded); - }, [isExpanded, onExpandedChange]); - - return ( - <EngineExpandButton - primary - iconRight={isExpanded ? "chevronup" : "chevrondown"} - onClick={handleClick} - > - {isExpanded ? t`Show less options` : t`Show more options`} - </EngineExpandButton> - ); -}; - -EngineToggle.propTypes = { - isExpanded: PropTypes.bool, - onExpandedChange: PropTypes.func, -}; - -const getSortedOptions = options => { - return options.sort((a, b) => { - if (a.index >= 0 && b.index >= 0) { - return a.index - b.index; - } else if (a.index >= 0) { - return -1; - } else if (b.index >= 0) { - return 1; - } else { - return a.name.localeCompare(b.name); - } - }); -}; - -const getVisibleOptions = (options, isExpanded, isSearching, searchText) => { - if (isSearching) { - return options.filter(e => includesIgnoreCase(e.name, searchText)); - } else if (isExpanded) { - return options; - } else { - return options.slice(0, DEFAULT_OPTIONS_COUNT); - } -}; - -const includesIgnoreCase = (sourceText, searchText) => { - return sourceText.toLowerCase().includes(searchText.toLowerCase()); -}; - -export default EngineWidget; diff --git a/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.styled.jsx b/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.styled.tsx similarity index 65% rename from frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.styled.jsx rename to frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.styled.tsx index 78af41a3191f201bc94506d95c0f5f13e04354eb..e01a2170e82c9c6eb8ccffdfcda73521583059ae 100644 --- a/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.styled.jsx +++ b/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.styled.tsx @@ -1,15 +1,14 @@ import styled from "styled-components"; import { color, lighten } from "metabase/lib/colors"; import { breakpointMinSmall } from "metabase/styled-components/theme"; -import Icon from "metabase/components/Icon"; -import IconButtonWrapper from "metabase/components/IconButtonWrapper"; import Button from "metabase/core/components/Button"; +import Icon from "metabase/components/Icon"; export const EngineSearchRoot = styled.div` display: block; `; -export const EngineListRoot = styled.div` +export const EngineListRoot = styled.ul` display: grid; grid-template-columns: 1fr; gap: 1.5rem; @@ -20,13 +19,24 @@ export const EngineListRoot = styled.div` } `; -export const EngineCardRoot = styled(IconButtonWrapper)` +export interface EngineCardRootProps { + isActive: boolean; +} + +export const EngineCardRoot = styled.li<EngineCardRootProps>` + display: flex; flex: 1 1 auto; flex-direction: column; + align-items: center; + justify-content: center; height: 5.375rem; padding: 1rem; border: 1px solid ${color("bg-medium")}; + border-radius: 0.375rem; background-color: ${color("white")}; + cursor: pointer; + outline: ${props => + props.isActive ? `2px solid ${color("brand-light")}` : ""}; &:hover { border-color: ${color("brand")}; @@ -73,26 +83,44 @@ export const EngineEmptyText = styled.div` text-align: center; `; -export const EngineExpandButton = styled(Button)` - width: 100%; -`; - -export const EngineInfoRoot = styled.div` +export const EngineButtonRoot = styled.button` display: flex; + justify-content: space-between; align-items: center; color: ${color("white")}; + width: 100%; padding: 0.75rem; border-radius: 0.5rem; border: 1px solid ${color("brand")}; background-color: ${color("brand")}; + transition: all 200ms linear; + transition-property: color, background-color; + cursor: pointer; + + &:hover { + color: ${color("white")}; + background-color: ${lighten("brand", 0.12)}; + } + + &:focus { + outline: 2px solid ${color("brand-light")}; + } + + &:focus:not(:focus-visible) { + outline: none; + } `; -export const EngineInfoTitle = styled.div` - flex: 1 0 auto; +export const EngineButtonTitle = styled.span` + flex: 0 1 auto; font-size: 1rem; font-weight: bold; `; -export const EngineInfoIcon = styled(Icon)` +export const EngineButtonIcon = styled(Icon)` cursor: pointer; `; + +export const EngineToggleRoot = styled(Button)` + width: 100%; +`; diff --git a/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.tsx b/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d55501ff1da002a62a96c5a4c5c77675408c6ee --- /dev/null +++ b/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.tsx @@ -0,0 +1,339 @@ +import React, { KeyboardEvent, useCallback, useMemo, useState } from "react"; +import { jt, t } from "ttag"; +import _ from "underscore"; +import { getEngineLogo } from "metabase/lib/engine"; +import Settings from "metabase/lib/settings"; +import TextInput from "metabase/components/TextInput"; +import ExternalLink from "metabase/core/components/ExternalLink"; +import { + EngineButtonIcon, + EngineButtonRoot, + EngineButtonTitle, + EngineCardIcon, + EngineCardImage, + EngineCardRoot, + EngineCardTitle, + EngineEmptyIcon, + EngineEmptyStateRoot, + EngineEmptyText, + EngineListRoot, + EngineSearchRoot, + EngineToggleRoot, +} from "./EngineWidget.styled"; +import { EngineField, EngineOption } from "./types"; + +const DEFAULT_OPTIONS_COUNT = 6; + +export interface EngineWidget { + field: EngineField; + options: EngineOption[]; + isHosted?: boolean; +} + +const EngineWidget = ({ + field, + options, + isHosted, +}: EngineWidget): JSX.Element => { + if (field.value) { + return <EngineButton field={field} options={options} />; + } else { + return <EngineSearch field={field} options={options} isHosted={isHosted} />; + } +}; + +interface EngineButtonProps { + field: EngineField; + options: EngineOption[]; +} + +const EngineButton = ({ field, options }: EngineButtonProps): JSX.Element => { + const option = options.find(option => option.value === field.value); + + const handleClick = useCallback(() => { + field.onChange?.(undefined); + }, [field]); + + return ( + <EngineButtonRoot autoFocus onClick={handleClick}> + <EngineButtonTitle> + {option ? option.name : field.value} + </EngineButtonTitle> + <EngineButtonIcon + name="close" + size={18} + aria-label={t`Remove database`} + /> + </EngineButtonRoot> + ); +}; + +interface EngineSearchProps { + field: EngineField; + options: EngineOption[]; + isHosted?: boolean; +} + +const EngineSearch = ({ + field, + options, + isHosted, +}: EngineSearchProps): JSX.Element => { + const rootId = useMemo(() => _.uniqueId(), []); + const [searchText, setSearchText] = useState(""); + const [activeIndex, setActiveIndex] = useState<number>(); + const [isExpanded, setIsExpanded] = useState(false); + const isSearching = searchText.length > 0; + const isNavigating = activeIndex != null; + const hasMoreOptions = options.length > DEFAULT_OPTIONS_COUNT; + + const sortedOptions = useMemo(() => { + return getSortedOptions(options); + }, [options]); + + const visibleOptions = useMemo( + () => getVisibleOptions(sortedOptions, isExpanded, isSearching, searchText), + [sortedOptions, isExpanded, isSearching, searchText], + ); + + const optionCount = visibleOptions.length; + const activeOption = isNavigating ? visibleOptions[activeIndex] : undefined; + + const handleSearch = useCallback((value: string) => { + setSearchText(value); + setActiveIndex(undefined); + }, []); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + switch (event.key) { + case "Enter": + activeOption && field.onChange?.(activeOption.value); + event.preventDefault(); + break; + case "ArrowUp": + case "ArrowDown": + setIsExpanded(true); + setActiveIndex(getActiveIndex(event.key, activeIndex, optionCount)); + event.preventDefault(); + break; + } + }, + [field, activeIndex, activeOption, optionCount], + ); + + return ( + <EngineSearchRoot role="combobox" aria-expanded="true"> + <TextInput + value={searchText} + placeholder={t`Search for a database…`} + autoFocus + aria-autocomplete="list" + aria-controls={getListBoxId(rootId)} + aria-activedescendant={getListOptionId(rootId, activeOption)} + onChange={handleSearch} + onKeyDown={handleKeyDown} + /> + {visibleOptions.length ? ( + <EngineList + rootId={rootId} + options={visibleOptions} + activeIndex={activeIndex} + onOptionChange={field.onChange} + /> + ) : ( + <EngineEmptyState isHosted={isHosted} /> + )} + {!isSearching && hasMoreOptions && ( + <EngineToggle + isExpanded={isExpanded} + onExpandedChange={setIsExpanded} + /> + )} + </EngineSearchRoot> + ); +}; + +interface EngineListProps { + rootId: string; + options: EngineOption[]; + activeIndex?: number; + onOptionChange?: (value: string) => void; +} + +const EngineList = ({ + rootId, + options, + activeIndex, + onOptionChange, +}: EngineListProps): JSX.Element => { + return ( + <EngineListRoot role="listbox" id={getListBoxId(rootId)}> + {options.map((option, optionIndex) => ( + <EngineCard + key={option.value} + rootId={rootId} + option={option} + isActive={optionIndex === activeIndex} + onOptionChange={onOptionChange} + /> + ))} + </EngineListRoot> + ); +}; + +export interface EngineCardProps { + rootId: string; + option: EngineOption; + isActive: boolean; + onOptionChange?: (value: string) => void; +} + +const EngineCard = ({ + rootId, + option, + isActive, + onOptionChange, +}: EngineCardProps): JSX.Element => { + const logo = getEngineLogo(option.value); + + const handleClick = useCallback(() => { + onOptionChange?.(option.value); + }, [option, onOptionChange]); + + return ( + <EngineCardRoot + role="option" + id={getListOptionId(rootId, option)} + isActive={isActive} + onClick={handleClick} + > + {logo ? ( + <EngineCardImage src={logo} /> + ) : ( + <EngineCardIcon name="database" /> + )} + <EngineCardTitle>{option.name}</EngineCardTitle> + </EngineCardRoot> + ); +}; + +interface EngineEmptyStateProps { + isHosted?: boolean; +} + +const EngineEmptyState = ({ isHosted }: EngineEmptyStateProps): JSX.Element => { + return ( + <EngineEmptyStateRoot> + <EngineEmptyIcon name="search" size={32} /> + {isHosted ? ( + <EngineEmptyText>{t`Didn’t find anything`}</EngineEmptyText> + ) : ( + <EngineEmptyText>{jt`Don’t see your database? Check out our ${( + <ExternalLink + key="link" + href={Settings.docsUrl("developers-guide-drivers")} + > + {t`Community Drivers`} + </ExternalLink> + )} page to see if it’s available for self-hosting.`}</EngineEmptyText> + )} + </EngineEmptyStateRoot> + ); +}; + +interface EngineToggleProps { + isExpanded: boolean; + onExpandedChange: (isExpanded: boolean) => void; +} + +const EngineToggle = ({ + isExpanded, + onExpandedChange, +}: EngineToggleProps): JSX.Element => { + const handleClick = useCallback(() => { + onExpandedChange(!isExpanded); + }, [isExpanded, onExpandedChange]); + + return ( + <EngineToggleRoot + primary + iconRight={isExpanded ? "chevronup" : "chevrondown"} + onClick={handleClick} + > + {isExpanded ? t`Show fewer options` : t`Show more options`} + </EngineToggleRoot> + ); +}; + +const getSortedOptions = (options: EngineOption[]): EngineOption[] => { + return options.sort((a, b) => { + if (a.index >= 0 && b.index >= 0) { + return a.index - b.index; + } else if (a.index >= 0) { + return -1; + } else if (b.index >= 0) { + return 1; + } else { + return a.name.localeCompare(b.name); + } + }); +}; + +const getVisibleOptions = ( + options: EngineOption[], + isExpanded: boolean, + isSearching: boolean, + searchText: string, +): EngineOption[] => { + if (isSearching) { + return options.filter(e => includesIgnoreCase(e.name, searchText)); + } else if (isExpanded) { + return options; + } else { + return options.slice(0, DEFAULT_OPTIONS_COUNT); + } +}; + +const includesIgnoreCase = ( + sourceText: string, + searchText: string, +): boolean => { + return sourceText.toLowerCase().includes(searchText.toLowerCase()); +}; + +const getListBoxId = (rootId: string): string => { + return `${rootId}-listbox`; +}; + +const getListOptionId = ( + rootId: string, + option?: EngineOption, +): string | undefined => { + return option ? `${rootId}-option-${option.value}` : undefined; +}; + +const getActiveIndex = ( + key: string, + activeIndex: number | undefined, + optionCount: number, +): number | undefined => { + switch (key) { + case "ArrowDown": + if (activeIndex != null) { + return Math.min(activeIndex + 1, optionCount - 1); + } else { + return 0; + } + case "ArrowUp": + if (activeIndex != null) { + return Math.max(activeIndex - 1, 0); + } else { + return optionCount; + } + default: + return activeIndex; + } +}; + +export default EngineWidget; diff --git a/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.unit.spec.js b/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.unit.spec.tsx similarity index 80% rename from frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.unit.spec.js rename to frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.unit.spec.tsx index fe16e28de6423f11969ada0413d84c3887f15823..b20dfe4feb6c4d095a6023d62b8f3d699b3662a3 100644 --- a/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.unit.spec.js +++ b/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/EngineWidget.unit.spec.tsx @@ -1,7 +1,8 @@ import React from "react"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import userEvent, { specialChars } from "@testing-library/user-event"; import EngineWidget from "./EngineWidget"; +import { EngineField, EngineOption } from "./types"; describe("EngineWidget", () => { it("should allow choosing a database", () => { @@ -35,7 +36,7 @@ describe("EngineWidget", () => { expect(screen.getByText("H2")).toBeInTheDocument(); expect(screen.getByText("Presto")).toBeInTheDocument(); - userEvent.click(screen.getByText("Show less options")); + userEvent.click(screen.getByText("Show fewer options")); expect(screen.getByText("MySQL")).toBeInTheDocument(); expect(screen.getByText("H2")).toBeInTheDocument(); expect(screen.queryByText("Presto")).not.toBeInTheDocument(); @@ -74,14 +75,29 @@ describe("EngineWidget", () => { expect(screen.getByText(/Didn’t find anything/)).toBeInTheDocument(); }); + + it("should allow selection via keyboard", () => { + const field = getField(); + const options = getOptions(); + + render(<EngineWidget field={field} options={options} />); + + const input = screen.getByRole("textbox"); + userEvent.type(input, "sql"); + userEvent.type(input, specialChars.arrowDown); + userEvent.type(input, specialChars.arrowDown); + userEvent.type(input, specialChars.enter); + + expect(field.onChange).toHaveBeenCalledWith("postgres"); + }); }); -const getField = value => ({ +const getField = (value?: string): EngineField => ({ value, onChange: jest.fn(), }); -const getOptions = () => [ +const getOptions = (): EngineOption[] => [ { name: "MySQL", value: "mysql", diff --git a/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/index.js b/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/index.ts similarity index 100% rename from frontend/src/metabase/admin/databases/components/widgets/EngineWidget/index.js rename to frontend/src/metabase/admin/databases/components/widgets/EngineWidget/index.ts diff --git a/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/types.ts b/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..11fc018baeec0466118868e08dc7376c7422df99 --- /dev/null +++ b/frontend/src/metabase/admin/databases/components/widgets/EngineWidget/types.ts @@ -0,0 +1,10 @@ +export interface EngineField { + value?: string; + onChange?: (value: string | undefined) => void; +} + +export interface EngineOption { + name: string; + value: string; + index: number; +}