Skip to content
Snippets Groups Projects
Unverified Commit 85be305a authored by Aleksandr Lesnenko's avatar Aleksandr Lesnenko Committed by GitHub
Browse files

Fix alerts and subscriptions bugs (#23523)

* fix subscriptions and alerts

* review

* specs
parent 2486c54f
No related merge requests found
Showing
with 252 additions and 36 deletions
......@@ -15,3 +15,4 @@ export * from "./permissions";
export * from "./question";
export * from "./dataset";
export * from "./models";
export * from "./notifications";
export type Channel = {
details: Record<string, string>;
};
type ChannelField = {
name: string;
displayName: string;
options?: string[];
};
export type ChannelSpec = {
fields: ChannelField[];
};
......@@ -42,6 +42,7 @@ export type RenderTrigger = (
type RenderTriggerArgs = {
visible: boolean;
onClick: () => void;
closePopover: () => void;
};
function ControlledPopoverWithTrigger({
......@@ -65,7 +66,11 @@ function ControlledPopoverWithTrigger({
};
const computedTrigger = _.isFunction(renderTrigger) ? (
renderTrigger({ visible, onClick: handleTriggerClick })
renderTrigger({
visible,
onClick: handleTriggerClick,
closePopover: onClose,
})
) : (
<TriggerButton
disabled={disabled}
......
......@@ -25,6 +25,7 @@ export function BaseSelectListItem({
className,
as = BaseItemRoot,
children,
...rest
}) {
const ref = useScrollOnMount();
const Root = as;
......@@ -38,6 +39,7 @@ export function BaseSelectListItem({
onClick={() => onSelect(id)}
onKeyDown={e => e.key === "Enter" && onSelect(id)}
className={className}
{...rest}
>
{children}
</Root>
......
import React, { forwardRef } from "react";
import { BaseSelectListItem } from "./BaseSelectListItem";
import { SelectListItem } from "./SelectListItem";
type SelectListProps = Omit<React.HTMLProps<HTMLUListElement>, "role">;
const SelectList = forwardRef<HTMLUListElement, SelectListProps>(
function SelectList(props: SelectListProps, ref) {
return <ul {...props} ref={ref} role="menu" data-testid="select-list" />;
},
);
export default Object.assign(SelectList, {
BaseItem: BaseSelectListItem,
Item: SelectListItem,
});
......@@ -14,8 +14,7 @@ const iconPropType = PropTypes.oneOfType([
const propTypes = {
...BaseSelectListItem.propTypes,
icon: iconPropType.isRequired,
iconColor: PropTypes.string,
icon: iconPropType,
rightIcon: iconPropType,
};
......@@ -28,9 +27,14 @@ export function SelectListItem(props) {
: { name: rightIcon };
return (
<BaseSelectListItem as={ItemRoot} {...props}>
<ItemIcon color="brand" {...iconProps} />
<ItemTitle>{name}</ItemTitle>
<BaseSelectListItem
as={ItemRoot}
{...props}
hasLeftIcon={!!icon}
hasRightIcon={!!rightIcon}
>
{icon && <ItemIcon color="brand" {...iconProps} />}
<ItemTitle data-testid="option-text">{name}</ItemTitle>
{rightIconProps.name && <ItemIcon {...rightIconProps} />}
</BaseSelectListItem>
);
......
......@@ -48,8 +48,14 @@ export const BaseItemRoot = styled.li`
}
`;
const getGridTemplateColumns = (hasLeftIcon, hasRightIcon) =>
`${hasLeftIcon ? "min-content" : ""} 1fr ${
hasRightIcon ? "min-content" : ""
}`;
export const ItemRoot = styled(BaseItemRoot)`
display: grid;
grid-template-columns: min-content 1fr min-content;
grid-template-columns: ${props =>
getGridTemplateColumns(props.hasLeftIcon, props.hasRightIcon)};
gap: 0.5rem;
`;
export { default } from "./SelectList";
......@@ -13,7 +13,7 @@ import {
export type ColorScheme = "default" | "admin" | "transparent";
export type Size = "sm" | "md";
type TextInputProps = {
export type TextInputProps = {
as?: ElementType<HTMLElement>;
value?: string;
placeholder?: string;
......
import React from "react";
import PropTypes from "prop-types";
import { BaseSelectListItem } from "./BaseSelectListItem";
import { SelectListItem } from "./SelectListItem";
const propTypes = {
children: PropTypes.node,
className: PropTypes.string,
};
export function SelectList({ children, className }) {
return (
<ul role="menu" className={className} data-testid="select-list">
{children}
</ul>
);
}
SelectList.propTypes = propTypes;
SelectList.BaseItem = BaseSelectListItem;
SelectList.Item = SelectListItem;
export * from "./SelectList";
import React, { useState } from "react";
import { ComponentStory } from "@storybook/react";
import AutocompleteInput from "./AutocompleteInput";
export default {
title: "Core/AutocompleteInput",
component: AutocompleteInput,
};
const Template: ComponentStory<typeof AutocompleteInput> = args => {
const [value, setValue] = useState("");
return (
<AutocompleteInput
{...args}
value={value}
onChange={setValue}
placeholder={"Fruits"}
options={[
"Apple",
"Orange",
"Dragonfruit",
"Durian",
"Mango",
"Lime",
"Guava",
"Feijoa",
]}
/>
);
};
export const Default = Template.bind({});
import styled from "@emotion/styled";
import SelectList from "metabase/components/SelectList";
export const OptionsList = styled(SelectList)`
padding: 0.5rem;
min-width: 300px;
`;
import React, { useMemo, useRef } from "react";
import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
import SelectList from "metabase/components/SelectList";
import TextInput from "metabase/components/TextInput";
import { TextInputProps } from "metabase/components/TextInput/TextInput";
import { OptionsList } from "./AutocompleteInput.styled";
import { composeEventHandlers } from "metabase/lib/compose-event-handlers";
interface AutocompleteInputProps extends TextInputProps {
options?: string[];
}
const AutocompleteInput = ({
value,
onChange,
options = [],
onBlur,
...rest
}: AutocompleteInputProps) => {
const optionsListRef = useRef<HTMLUListElement>();
const filteredOptions = useMemo(() => {
if (!value || value.length === 0) {
return options;
}
return options.filter(option => {
const optionLowerCase = option.toLowerCase().trim();
const valueLowerCase = value.toLowerCase().trim();
return (
optionLowerCase.includes(value) && !(optionLowerCase === valueLowerCase)
);
});
}, [value, options]);
const handleListMouseDown = (event: React.MouseEvent<HTMLElement>) => {
if (optionsListRef.current?.contains(event.target as Node)) {
event.preventDefault();
}
};
return (
<TippyPopoverWithTrigger
sizeToFit
renderTrigger={({ onClick: handleShowPopover, closePopover }) => (
<TextInput
role="combobox"
aria-autocomplete="list"
{...rest}
value={value}
onClick={handleShowPopover}
onFocus={handleShowPopover}
onChange={composeEventHandlers(onChange, handleShowPopover)}
onBlur={composeEventHandlers<React.FocusEvent<HTMLInputElement>>(
onBlur,
closePopover,
)}
/>
)}
placement="bottom-start"
popoverContent={({ closePopover }) => {
if (filteredOptions.length === 0) {
return null;
}
return (
<OptionsList
ref={optionsListRef as any}
onMouseDown={handleListMouseDown}
>
{filteredOptions.map(option => (
<SelectList.Item
key={option}
id={option}
name={option}
onSelect={option => {
onChange(option);
closePopover();
}}
>
{option}
</SelectList.Item>
))}
</OptionsList>
);
}}
/>
);
};
export default AutocompleteInput;
import React from "react";
import { fireEvent, render } from "@testing-library/react";
import AutocompleteInput from "./AutocompleteInput";
const OPTIONS = ["Banana", "Orange", "Mango"];
const setup = ({ value = "", onChange = jest.fn() } = {}) => {
const { getByRole, rerender, findAllByRole } = render(
<AutocompleteInput value={value} options={OPTIONS} onChange={onChange} />,
);
const input = getByRole("combobox");
const getOptions = async () => findAllByRole("menuitem");
return { input, getOptions, rerender };
};
describe("AutocompleteInput", () => {
it("shows all options on focus", async () => {
const { input, getOptions } = setup();
fireEvent.focus(input);
const options = await getOptions();
options.forEach((option, index) =>
expect(option).toHaveTextContent(OPTIONS[index]),
);
});
it("shows filter options", async () => {
const { input, getOptions } = setup({
value: "or",
});
fireEvent.click(input);
const options = await getOptions();
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent("Orange");
});
it("updates value on selecting an option", async () => {
const onChange = jest.fn();
const { input, getOptions } = setup({
value: "or",
onChange,
});
fireEvent.click(input);
const options = await getOptions();
fireEvent.click(options[0]);
expect(onChange).toHaveBeenCalledWith("Orange");
});
});
export { default } from "./AutocompleteInput";
......@@ -6,7 +6,7 @@ import { PLUGIN_MODERATION } from "metabase/plugins";
import EmptyState from "metabase/components/EmptyState";
import Search from "metabase/entities/search";
import { SelectList } from "metabase/components/select-list";
import SelectList from "metabase/components/SelectList";
import { DEFAULT_SEARCH_LIMIT } from "metabase/lib/constants";
import { EmptyStateContainer, QuestionListItem } from "./QuestionList.styled";
......
import styled from "@emotion/styled";
import { SelectList } from "metabase/components/select-list";
import SelectList from "metabase/components/SelectList";
export const EmptyStateContainer = styled.div`
margin: 4rem 0;
......
......@@ -21,7 +21,7 @@ import {
SearchInput,
} from "./QuestionPicker.styled";
import { SEARCH_DEBOUNCE_DURATION } from "metabase/lib/constants";
import { SelectList } from "metabase/components/select-list";
import SelectList from "metabase/components/SelectList";
const { isRegularCollection } = PLUGIN_COLLECTIONS;
......
type HandlerType<E> = (event: E) => void;
type HandlerType<E> = ((event: E) => void) | undefined;
export const composeEventHandlers = <E>(...handlers: HandlerType<E>[]) => {
return function handleEvent(event: E) {
......
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