From 6d26e480dbdef8283040e23e7c84f958598b90d2 Mon Sep 17 00:00:00 2001 From: Felipe Cadavid Date: Mon, 22 Jan 2024 17:22:59 -0500 Subject: [PATCH 1/9] feat: MultiSelect filter --- .../Autocomplete/Autocomplete.module.scss | 5 +- .../Autocomplete/Autocomplete.test.tsx | 5 +- .../components/Autocomplete/Autocomplete.tsx | 10 +- .../Autocomplete/Autocomplete.types.ts | 11 +- .../MultiSelectFilter.module.scss | 4 + .../MultiSelectFilter.stories.tsx | 227 ++++++++++++++++++ .../MultiSelectFilter.test.tsx | 153 ++++++++++++ .../MultiSelectFilter/MultiSelectFilter.tsx | 78 ++++++ .../MultiSelectFilter.types.ts | 34 +++ .../src/components/MultiSelectFilter/index.ts | 1 + .../ui-kit/src/components/Select/Select.css | 8 + .../src/components/Select/Select.module.scss | 116 +++++++++ .../src/components/Select/Select.test.tsx | 48 ++++ .../ui-kit/src/components/Select/Select.tsx | 114 +++++++++ .../src/components/Select/Select.types.ts | 30 +++ .../ui-kit/src/components/Select/index.ts | 1 + .../components/SimpleFilter/SimpleFilter.tsx | 4 +- .../SimpleFilter/SimpleFilter.types.ts | 6 +- .../ui-kit/src/components/shared.types.ts | 8 + .../buildOrFilter/buildOrFilter.test.ts | 1 + .../helpers/buildOrFilter/buildOrFilter.ts | 28 +++ .../ui-kit/src/helpers/buildOrFilter/index.ts | 1 + packages/ui-kit/src/helpers/index.ts | 1 + 23 files changed, 873 insertions(+), 21 deletions(-) create mode 100644 packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.module.scss create mode 100644 packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.stories.tsx create mode 100644 packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.test.tsx create mode 100644 packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx create mode 100644 packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.types.ts create mode 100644 packages/ui-kit/src/components/MultiSelectFilter/index.ts create mode 100644 packages/ui-kit/src/components/Select/Select.css create mode 100644 packages/ui-kit/src/components/Select/Select.module.scss create mode 100644 packages/ui-kit/src/components/Select/Select.test.tsx create mode 100644 packages/ui-kit/src/components/Select/Select.tsx create mode 100644 packages/ui-kit/src/components/Select/Select.types.ts create mode 100644 packages/ui-kit/src/components/Select/index.ts create mode 100644 packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.test.ts create mode 100644 packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.ts create mode 100644 packages/ui-kit/src/helpers/buildOrFilter/index.ts diff --git a/packages/ui-kit/src/components/Autocomplete/Autocomplete.module.scss b/packages/ui-kit/src/components/Autocomplete/Autocomplete.module.scss index e7965103..698d642f 100644 --- a/packages/ui-kit/src/components/Autocomplete/Autocomplete.module.scss +++ b/packages/ui-kit/src/components/Autocomplete/Autocomplete.module.scss @@ -8,7 +8,9 @@ gap: var(--propel-space-xxs); padding: var(--propel-space-xxs); overflow: hidden; + transition: all 100ms; + box-sizing: border-box; &:hover { @@ -29,6 +31,7 @@ padding: var(--propel-space-xxs); outline: 0; flex: 1 0 auto; + width: 100%; } @@ -41,6 +44,7 @@ align-self: center; padding: 0 2px; margin-right: 4px; + display: flex; align-items: center; @@ -59,7 +63,6 @@ overflow: auto; outline: 0px; max-height: 300px; - z-index: 1; background-color: var(--propel-bg-primary); border: 1px solid var(--propel-border-primary); color: var(--propel-text-primary); diff --git a/packages/ui-kit/src/components/Autocomplete/Autocomplete.test.tsx b/packages/ui-kit/src/components/Autocomplete/Autocomplete.test.tsx index 958680ca..f922ae86 100644 --- a/packages/ui-kit/src/components/Autocomplete/Autocomplete.test.tsx +++ b/packages/ui-kit/src/components/Autocomplete/Autocomplete.test.tsx @@ -2,8 +2,9 @@ import React, { SyntheticEvent } from 'react' import { render, fireEvent, waitFor } from '@testing-library/react' import { Dom } from 'src/testing' +import { DropdownOption } from '../shared.types' + import { Autocomplete } from './Autocomplete' -import { AutocompleteOption } from './Autocomplete.types' const options = [ { @@ -23,7 +24,7 @@ describe('Autocomplete', () => { it('should enable selecting an option', async () => { const onChangeValue = jest.fn() - const onChange = (_: SyntheticEvent, value: AutocompleteOption | string | null) => { + const onChange = (_: SyntheticEvent, value: DropdownOption | string | null) => { onChangeValue(value) } diff --git a/packages/ui-kit/src/components/Autocomplete/Autocomplete.tsx b/packages/ui-kit/src/components/Autocomplete/Autocomplete.tsx index ca8bb125..0c35b4f4 100644 --- a/packages/ui-kit/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/ui-kit/src/components/Autocomplete/Autocomplete.tsx @@ -6,9 +6,10 @@ import * as React from 'react' import classnames from 'classnames' import componentStyles from './Autocomplete.module.scss' import { useCombinedRefs } from '../../helpers' -import { AutocompleteOption, AutocompleteProps } from './Autocomplete.types' +import { AutocompleteProps } from './Autocomplete.types' import { ChevronUpIcon } from '../Icons/ChevronUp' import { ChevronDownIcon } from '../Icons/ChevronDown' +import { DropdownOption } from '../shared.types' // See full Autocomplete example here: https://mui.com/base-ui/react-autocomplete/#introduction export const Autocomplete = React.forwardRef(function Autocomplete( @@ -121,7 +122,8 @@ export const Autocomplete = React.forwardRef(function Autocomplete( { name: 'preventOverflow', enabled: false } ]} style={{ - width: anchorEl.clientWidth + width: anchorEl.clientWidth, + zIndex: 9999 }} disablePortal > @@ -131,11 +133,11 @@ export const Autocomplete = React.forwardRef(function Autocomplete( style={{ ...getListboxProps().style, ...listStyle }} > {groupedOptions.map((option, index) => { - let optionLabel: AutocompleteOption + let optionLabel: DropdownOption if (typeof option === 'string') optionLabel = { label: option } else optionLabel = { ...option } - const optionProps = getOptionProps({ option: option as AutocompleteOption, index }) + const optionProps = getOptionProps({ option: option as DropdownOption, index }) return (
  • { +export interface AutocompleteProps extends UseAutocompleteProps { placeholder?: string /** Styles to be applied to the container element */ diff --git a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.module.scss b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.module.scss new file mode 100644 index 00000000..99156bc7 --- /dev/null +++ b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.module.scss @@ -0,0 +1,4 @@ +.loader { + height: 34.2px !important; + overflow: hidden; +} diff --git a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.stories.tsx b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.stories.tsx new file mode 100644 index 00000000..18f467ee --- /dev/null +++ b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.stories.tsx @@ -0,0 +1,227 @@ +import React from 'react' + +import { Meta, StoryObj } from '@storybook/react' +import axiosInstance from '../../../../../app/storybook/src/axios' +import { + quotedStringRegex, + RelativeTimeRange, + storybookCodeTemplate, + TimeSeriesGranularity, + useStorybookAccessToken +} from '../../helpers' + +import { MultiSelectFilter as MultiSelectFilterSource } from './MultiSelectFilter' +import { TimeSeries as TimeSeriesSource, TimeSeriesProps } from '../TimeSeries' +import { FilterProvider } from '../FilterProvider' + +const meta: Meta = { + title: 'Components/MultiSelectFilter', + component: MultiSelectFilterSource, + tags: ['tag'], + parameters: { + controls: { sort: 'alpha' }, + imports: 'SimpleFilter, FilterProvider', + transformBody: (body: string) => body.replace(quotedStringRegex('LAST_N_DAYS'), 'RelativeTimeRange.LastNDays'), + codeTemplate: storybookCodeTemplate + } +} + +export default meta + +type Story = StoryObj + +const MultiSelectFilter = (args: Story['args']) => { + const { accessToken } = useStorybookAccessToken(axiosInstance) + + if (!accessToken && args?.query) { + return null + } + + return ( + + ) +} + +const TimeSeries = (args: TimeSeriesProps) => { + const { accessToken } = useStorybookAccessToken(axiosInstance) + + if (!accessToken && args?.query) { + return null + } + + return ( + + ) +} + +export const StaticStory: Story = { + name: 'Static', + args: { + columnName: 'Color', + options: ['Red', 'Green', 'Blue'], + selectProps: { placeholder: 'Select a color' } + }, + render: (args) => ( + + + + ) +} + +export const StaticCustomLabelStory: Story = { + name: 'Static Custom Label', + args: { + columnName: 'Color', + options: [ + { label: 'Red', value: '#FF0000' }, + { label: 'Green', value: '#00FF00' }, + { label: 'Blue', value: '#0000FF' } + ], + selectProps: { placeholder: 'Select a color' } + }, + render: (args) => ( + + + + ) +} + +export const ConnectedStory: Story = { + name: 'Connected', + args: { + query: { + columnName: process.env.STORYBOOK_DIMENSION_1 ?? '', + dataPool: { + name: process.env.STORYBOOK_DATA_POOL_UNIQUE_NAME_1 ?? '' + }, + maxValues: 1000, + timeRange: { + relative: RelativeTimeRange.LastNDays, + n: 30 + } + }, + selectProps: { + placeholder: 'Select a sauce name' + } + }, + render: (args) => ( + + + + + ) +} + +export const MultipleStory: Story = { + name: 'Multiple', + args: { + query: { + columnName: process.env.STORYBOOK_DIMENSION_1 ?? '', + dataPool: { + name: process.env.STORYBOOK_DATA_POOL_UNIQUE_NAME_1 ?? '' + }, + maxValues: 1000, + timeRange: { + relative: RelativeTimeRange.LastNDays, + n: 30 + } + }, + selectProps: { + placeholder: 'Select a sauce name' + } + }, + render: (args) => ( + + + + + + ) +} + +export const ErrorStory: Story = { + name: 'Error', + args: { + query: { + columnName: 'sauce_name', + dataPool: { + name: 'invalid_data_pool_name' + }, + maxValues: 3, + timeRange: { + relative: RelativeTimeRange.LastNDays, + n: 30 + }, + retry: false + }, + selectProps: { + placeholder: 'Select a sauce name' + } + }, + render: (args) => ( + + + + + ) +} diff --git a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.test.tsx b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.test.tsx new file mode 100644 index 00000000..27a42d00 --- /dev/null +++ b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.test.tsx @@ -0,0 +1,153 @@ +import React from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' + +import { FilterProvider } from '../FilterProvider' +import { Dom, FilterOperator, mockTopValuesQuery, RelativeTimeRange, setupTestHandlers } from '../../testing' + +import { MultiSelectFilter } from './MultiSelectFilter' + +const setFilterMock = jest.fn() + +jest.mock('../FilterProvider/useFilters', () => ({ + useFilters: () => ({ + filters: [], + setFilters: setFilterMock + }) +})) + +const handlers = [ + mockTopValuesQuery((req, res, ctx) => { + const columnName = req.variables.topValuesInput.columnName + + if (columnName === 'should-fail') { + return res( + ctx.errors([ + { + message: 'Something went wrong' + } + ]) + ) + } + + return res( + ctx.data({ + topValues: { + values: ['option 1', 'option 2', 'option 3'] + } + }) + ) + }) +] + +describe('MultiSelectFilter', () => { + let dom: Dom + + beforeEach(() => { + setupTestHandlers(handlers) + }) + + it('should work in static mode', () => { + dom = render( + + + + ) + + fireEvent.click(dom.getByRole('button')) + fireEvent.click(dom.getByText('option 1')) + fireEvent.click(dom.getByText('option 2')) + + expect(setFilterMock).toHaveBeenCalledWith([ + { + id: expect.any(Symbol), + column: 'test column', + operator: FilterOperator.Equals, + value: 'option 1', + or: [ + { + column: 'test column', + operator: FilterOperator.Equals, + value: 'option 2' + } + ] + } + ]) + }) + + it('should work in connected mode', async () => { + dom = render( + + + + ) + + await waitFor(() => { + fireEvent.click(dom.getByRole('button')) + fireEvent.click(dom.getByText('option 1')) + fireEvent.click(dom.getByText('option 2')) + }) + + expect(setFilterMock).toHaveBeenCalledWith([ + { + id: expect.any(Symbol), + column: 'test column', + operator: FilterOperator.Equals, + value: 'option 1', + or: [ + { + column: 'test column', + operator: FilterOperator.Equals, + value: 'option 2' + } + ] + } + ]) + }) + + it('should build labeled filters correctly', async () => { + dom = render( + + + + ) + + fireEvent.click(dom.getByRole('button')) + fireEvent.click(await dom.findByText('Option 1')) + fireEvent.click(await dom.findByText('Option 2')) + + expect(setFilterMock).toHaveBeenCalledWith([ + { + id: expect.any(Symbol), + column: 'test column', + operator: FilterOperator.Equals, + value: 'option_1', + or: [ + { + column: 'test column', + operator: FilterOperator.Equals, + value: 'option_2' + } + ] + } + ]) + }) +}) diff --git a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx new file mode 100644 index 00000000..5d3fcc2b --- /dev/null +++ b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useRef } from 'react' +import { useTopValues } from '../../hooks' +import { buildOrFilter, getTimeZone } from '../../helpers' +import { useFilters } from '../FilterProvider' +import { useLog } from '../Log' +import { Option, Select } from '../Select' +import { Loader } from '../Loader' + +import { withContainer } from '../withContainer' +import { ErrorFallback } from '../ErrorFallback' + +import { MultiSelectFilterProps } from './MultiSelectFilter.types' +import componentStyles from './MultiSelectFilter.module.scss' + +const MultiSelectFilterBase = ({ + options, + columnName: columnNameProp, + selectProps, + query, + error, + loading, + loaderProps +}: MultiSelectFilterProps) => { + const id = useRef(Symbol()).current + + const isStatic = !query + + const columnName = query?.columnName ?? columnNameProp + const timeZone = query?.timeZone ?? getTimeZone() + + const log = useLog() + + const { data, error: queryError, isLoading } = useTopValues({ ...query, timeZone }) + + const isError = queryError != null || error != null + + const { filters, setFilters } = useFilters() + + const handleChange = (_: React.SyntheticEvent | null, valueList: string | string[] | null) => { + if (typeof valueList === 'string') throw new Error('MultiSelectFilter should be used with multiple enabled') + if (valueList == null) return + + const orFilter = { id, ...buildOrFilter(columnName ?? '', valueList) } + + const filterList = filters.filter((filter) => filter.id !== id).concat(orFilter) + + setFilters(filterList) + } + + useEffect(() => { + if (queryError != null) { + log.error('Error fetching Top Values for MultiSelectFilter:', queryError.message) + } + }, [log, queryError]) + + const selectOptions = isStatic ? options : data?.topValues.values ?? [] + + if (loading || (!isStatic && isLoading)) { + return + } + + return ( + + ) +} + +export const MultiSelectFilter = withContainer(MultiSelectFilterBase, ErrorFallback) diff --git a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.types.ts b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.types.ts new file mode 100644 index 00000000..89a5460a --- /dev/null +++ b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.types.ts @@ -0,0 +1,34 @@ +import { DataPoolInput } from '../../helpers' +import { SelectProps } from '../Select/Select.types' +import { DataComponentProps, DropdownOption, QueryProps } from '../shared.types' + +export interface MultiSelectFilterQueryProps extends Omit { + /** The column to fetch the unique values from */ + columnName?: string + + /** The Data Pool to be queried. A Data Pool ID or unique name can be provided */ + dataPool?: DataPoolInput + + /** The maximum number of values to return. It can be a number between 1 and 1,000. If the parameter is omitted, default value 10 is used */ + maxValues?: number +} + +export interface MultiSelectFilterProps extends Omit { + /** The possible values for the selected column, will be ignored if `query` is passed */ + options?: DropdownOption[] | string[] + + /** The name of the column to which the filter will be applied, will be ignored if `query` is passed */ + columnName?: string + + /** Props to be applied to the `Select` component */ + selectProps?: Omit + + /** When true, shows a skeleton loader */ + loading?: boolean + + /** SimpleFilter query props */ + query?: MultiSelectFilterQueryProps + + /** Whether there was an error or not, setting to `true` will enable freeSolo mode */ + error?: boolean +} diff --git a/packages/ui-kit/src/components/MultiSelectFilter/index.ts b/packages/ui-kit/src/components/MultiSelectFilter/index.ts new file mode 100644 index 00000000..2f724b3c --- /dev/null +++ b/packages/ui-kit/src/components/MultiSelectFilter/index.ts @@ -0,0 +1 @@ +export * from './MultiSelectFilter' diff --git a/packages/ui-kit/src/components/Select/Select.css b/packages/ui-kit/src/components/Select/Select.css new file mode 100644 index 00000000..328b642f --- /dev/null +++ b/packages/ui-kit/src/components/Select/Select.css @@ -0,0 +1,8 @@ +.highlighted { + background-color: var(--propel-color-blue25); + outline: none; +} + +.highlighted[aria-selected='true'] { + background-color: var(--propel-accent-hover); +} diff --git a/packages/ui-kit/src/components/Select/Select.module.scss b/packages/ui-kit/src/components/Select/Select.module.scss new file mode 100644 index 00000000..fefbfa2e --- /dev/null +++ b/packages/ui-kit/src/components/Select/Select.module.scss @@ -0,0 +1,116 @@ +.CustomSelectIntroduction { + font-family: 'IBM Plex Sans', sans-serif; + box-sizing: border-box; + min-height: 34.2px; + padding: 8px; + width: 100%; + border-radius: var(--propel-border-radius-sm); + text-align: left; + line-height: 1.5; + background: var(--propel-bg-primary); + border: 1px solid var(--propel-border-primary); + color: var(--propel-text-primary); + position: relative; + box-shadow: var(--propel-shadow-sm); + + cursor: pointer; + + display: flex; + justify-content: space-between; + align-items: center; + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 120ms; + + &:hover { + border: 1px solid var(--propel-accent); + } + + &[aria-expanded='true'] { + border: 1px solid var(--propel-accent); + box-shadow: 0 0 0 3px #c7def7; + } +} + +.CustomSelectIntroduction__disabled { + background-color: #cfcfcf; + cursor: not-allowed; + border: none; + + &:hover { + border: none; + } +} + +.CustomSelectIntroduction__listbox { + font-family: 'IBM Plex Sans', sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: var(--propel-space-xxs); + margin: var(--propel-space-xxs) 0; + border-radius: var(--propel-border-radius-sm); + overflow: auto; + outline: 0px; + background-color: var(--propel-bg-primary); + border: 1px solid var(--propel-border-primary); + color: var(--propel-text-primary); + box-shadow: var(--propel-shadow-sm); + position: absolute; + z-index: 9999; +} + +.CustomSelectIntroduction__popper { + z-index: 1; +} + +.CustomSelectIntroduction__option { + list-style: none; + padding: var(--propel-space-sm); + border-radius: var(--propel-border-radius-sm); + cursor: pointer; + display: flex; + align-items: center; + gap: var(--propel-space-sm); + + &:last-of-type { + border-bottom: none; + } + + &[aria-selected='true'] { + background-color: var(--propel-accent); + color: var(--propel-text-primary); + } + + &[aria-selected='true']:hover { + background-color: var(--propel-accent-hover); + } + + &:hover { + background-color: var(--propel-color-blue25); + } +} + +.CustomSelectIntroduction__indicator { + outline: 0; + box-shadow: none; + border: 0; + border-radius: 4px; + background-color: transparent; + align-self: center; + padding: 0 2px; + display: flex; + align-items: center; + + &:hover { + background-color: var(--propel-bg-secondary); + cursor: pointer; + } +} + +.hidden { + display: none; +} + +.placeholder { + color: #757575; +} diff --git a/packages/ui-kit/src/components/Select/Select.test.tsx b/packages/ui-kit/src/components/Select/Select.test.tsx new file mode 100644 index 00000000..9bf4499a --- /dev/null +++ b/packages/ui-kit/src/components/Select/Select.test.tsx @@ -0,0 +1,48 @@ +import React, { SyntheticEvent } from 'react' +import { render, fireEvent, waitFor } from '@testing-library/react' + +import { Dom } from 'src/testing' + +import { Option, Select } from './Select' + +const options = [ + { + label: 'Option 1' + }, + { + label: 'Option 2' + }, + { + label: 'Option 3' + } +] + +describe('Select', () => { + let dom: Dom + + it('should enable selecting an option', async () => { + const onChangeValue = jest.fn() + + const onChange = (_: SyntheticEvent | null, value: string[] | string | null) => { + onChangeValue(value) + } + + dom = render( + + ) + + const button = dom.getByRole('button') + fireEvent.click(button) + await waitFor(async () => { + fireEvent.click(await dom.findByText('Option 1')) + }) + + expect(onChangeValue).toHaveBeenCalledWith('Option 1') + }) +}) diff --git a/packages/ui-kit/src/components/Select/Select.tsx b/packages/ui-kit/src/components/Select/Select.tsx new file mode 100644 index 00000000..2a41f178 --- /dev/null +++ b/packages/ui-kit/src/components/Select/Select.tsx @@ -0,0 +1,114 @@ +import * as React from 'react' +import { useSelect, SelectProvider } from '@mui/base/useSelect' +import { useOption } from '@mui/base/useOption' +import classnames from 'classnames' + +import { OptionProps, SelectProps } from './Select.types' +import { DropdownOption } from '../shared.types' +import { ChevronDownIcon, ChevronUpIcon } from '../Icons' + +import './Select.css' +import componentStyles from './Select.module.scss' + +function renderSelectedValue(value: string | string[], options: DropdownOption[]) { + if (typeof value === 'string') { + const selectedOption = options.find((option) => option.value === value) + + return selectedOption?.label ?? selectedOption?.value ?? null + } + + const selectedOptionsLabels = value.map( + (optionValue) => options.find((option) => option.value === optionValue)?.label + ) + + return selectedOptionsLabels.join(', ') +} + +export function Option(props: OptionProps) { + const { children, value, className, checkbox = false } = props + const { getRootProps, highlighted, selected } = useOption({ + value, + label: children, + disabled: false + }) + + return ( +
  • + {checkbox && } + {children} +
  • + ) +} + +export function Select({ children, placeholder, multiple, containerClassname, containerStyle, ...rest }: SelectProps) { + const listboxRef = React.useRef(null) + const buttonRef = React.useRef(null) + const [listboxVisible, setListboxVisible] = React.useState(false) + + const options: DropdownOption[] = React.useMemo( + () => + React.Children.map(children, (child) => { + if (!React.isValidElement(child) || child.type !== Option) return null + + const { value, children } = child.props + + const label = [...children] + .filter((child: React.ReactElement) => typeof child === 'string') + .join('') + .trim() + + return { value, label } + })?.filter((option) => option != null) ?? [], + [children] + ) + + const { getButtonProps, getListboxProps, contextValue, disabled, value } = useSelect({ + listboxRef, + onOpenChange: setListboxVisible, + open: listboxVisible, + multiple, + buttonRef, + ...rest + }) + + return ( +
    + +
      + {children} +
    +
    + ) +} diff --git a/packages/ui-kit/src/components/Select/Select.types.ts b/packages/ui-kit/src/components/Select/Select.types.ts new file mode 100644 index 00000000..b4f4c87f --- /dev/null +++ b/packages/ui-kit/src/components/Select/Select.types.ts @@ -0,0 +1,30 @@ +import { UseSelectParameters } from '@mui/base' +import { CSSProperties } from 'react' + +export interface OptionProps { + /** Element that will be displayed in the component */ + children: React.ReactNode + + /** Classname that will be applied to option container */ + className?: string + + /** Value that will be used in the option */ + value: string + + /** If `true`, will show a checkbox before the label */ + checkbox?: boolean +} + +export interface SelectProps extends Omit, 'options'> { + /** A list of options with the possibility of adding additional elements */ + children?: React.ReactNode + + /** Placeholder to show when no value is selected */ + placeholder?: string + + /** Styles to be applied to the container element */ + containerStyle?: CSSProperties + + /** Classname that will be applied to the input container */ + containerClassname?: string +} diff --git a/packages/ui-kit/src/components/Select/index.ts b/packages/ui-kit/src/components/Select/index.ts new file mode 100644 index 00000000..3e383f07 --- /dev/null +++ b/packages/ui-kit/src/components/Select/index.ts @@ -0,0 +1 @@ +export * from './Select' diff --git a/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.tsx b/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.tsx index 2cb20779..417b04d9 100644 --- a/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.tsx +++ b/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.tsx @@ -3,8 +3,8 @@ import { useTopValues } from '../../hooks' import { FilterInput, FilterOperator, getTimeZone } from '../../helpers' import { Autocomplete } from '../Autocomplete' -import { AutocompleteOption } from '../Autocomplete/Autocomplete.types' import { useFilters } from '../FilterProvider/useFilters' +import { DropdownOption } from '../shared.types' import { SimpleFilterProps } from './SimpleFilter.types' import { withContainer } from '../withContainer' @@ -37,7 +37,7 @@ const SimpleFilterComponent = ({ const isError = queryError != null || error != null - const handleChange = (_: SyntheticEvent, selectedOption: AutocompleteOption | string | null) => { + const handleChange = (_: SyntheticEvent, selectedOption: DropdownOption | string | null) => { if (selectedOption == null) return const filter: FilterInput = { diff --git a/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.types.ts b/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.types.ts index 253d1217..6d5713ac 100644 --- a/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.types.ts +++ b/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.types.ts @@ -1,6 +1,6 @@ import { DataPoolInput } from '../../helpers' -import { AutocompleteOption, AutocompleteProps } from '../Autocomplete/Autocomplete.types' -import { DataComponentProps, QueryProps } from '../shared.types' +import { AutocompleteProps } from '../Autocomplete/Autocomplete.types' +import { DataComponentProps, QueryProps, DropdownOption } from '../shared.types' export interface SimpleFilterQueryProps extends Omit { /** The column to fetch the unique values from */ @@ -18,7 +18,7 @@ export interface SimpleFilterProps extends Omit { autocompleteProps?: Omit /** The possible values for the selected column, will be ignored if `query` is passed */ - options?: AutocompleteOption[] | string[] + options?: DropdownOption[] | string[] /** When true, shows a skeleton loader */ loading?: boolean diff --git a/packages/ui-kit/src/components/shared.types.ts b/packages/ui-kit/src/components/shared.types.ts index bc684af3..6c7e97af 100644 --- a/packages/ui-kit/src/components/shared.types.ts +++ b/packages/ui-kit/src/components/shared.types.ts @@ -58,3 +58,11 @@ export interface QueryProps { /** This prop allows you to override the URL for Propel's GraphQL API. You shouldn't need to set this unless you are testing. */ propelApiUrl?: string } + +export interface DropdownOption { + /** The display label */ + label?: string + /** The value that will be used when the option is selected */ + value?: string + [x: string | number | symbol]: unknown +} diff --git a/packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.test.ts b/packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.test.ts new file mode 100644 index 00000000..e94989f0 --- /dev/null +++ b/packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.test.ts @@ -0,0 +1 @@ +// TODO: WRITE TESTS diff --git a/packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.ts b/packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.ts new file mode 100644 index 00000000..40e97c6a --- /dev/null +++ b/packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.ts @@ -0,0 +1,28 @@ +import { FilterInput, FilterOperator } from '../graphql' + +/** + * Takes a columnName and an array of values and returns an OR filter list + * @param columnName The name of the column to which the filter will be applied + * @param valueList The list of values to be filtered + */ +export function buildOrFilter(columnName: string, value: string[]): FilterInput { + const valueList = [...value] + + const OrFilters: FilterInput[] = [] + + const filter: FilterInput = { + column: columnName, + operator: FilterOperator.Equals, + value: valueList[0] + } + + valueList.shift() + + valueList.forEach((value) => { + OrFilters.push({ column: columnName, operator: FilterOperator.Equals, value }) + }) + + filter.or = OrFilters + + return filter +} diff --git a/packages/ui-kit/src/helpers/buildOrFilter/index.ts b/packages/ui-kit/src/helpers/buildOrFilter/index.ts new file mode 100644 index 00000000..0a23c39e --- /dev/null +++ b/packages/ui-kit/src/helpers/buildOrFilter/index.ts @@ -0,0 +1 @@ +export * from './buildOrFilter' diff --git a/packages/ui-kit/src/helpers/index.ts b/packages/ui-kit/src/helpers/index.ts index 2f63d77a..3a46aa3a 100644 --- a/packages/ui-kit/src/helpers/index.ts +++ b/packages/ui-kit/src/helpers/index.ts @@ -16,3 +16,4 @@ export * from './useForwardedRefCallback' export * from './useStorybookAccessToken' export * from './fetchStorybookAccessToken' export * from './sleep' +export * from './buildOrFilter' From 7c1a9a7691d61405e7cabe557303a3e687e85ab0 Mon Sep 17 00:00:00 2001 From: Felipe Cadavid Date: Tue, 23 Jan 2024 08:51:15 -0500 Subject: [PATCH 2/9] test: add test for buildOrFilter --- .../buildOrFilter/buildOrFilter.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.test.ts b/packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.test.ts index e94989f0..8bf06ead 100644 --- a/packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.test.ts +++ b/packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.test.ts @@ -1 +1,29 @@ -// TODO: WRITE TESTS +import { FilterOperator } from '../graphql' +import { buildOrFilter } from './buildOrFilter' + +const columnName = 'some column' +const valuesToFilter = ['value_1', 'value_2', 'value_3'] + +describe('buildOrFilter', () => { + it('Should build filter correctly', () => { + const result = buildOrFilter(columnName, valuesToFilter) + + expect(result).toEqual({ + column: columnName, + operator: FilterOperator.Equals, + value: valuesToFilter[0], + or: [ + { + column: columnName, + operator: FilterOperator.Equals, + value: valuesToFilter[1] + }, + { + column: columnName, + operator: FilterOperator.Equals, + value: valuesToFilter[2] + } + ] + }) + }) +}) From 8972c84c10bb7e72d1664526b9fc2e4e26fdd8cd Mon Sep 17 00:00:00 2001 From: Felipe Cadavid Date: Tue, 23 Jan 2024 10:12:45 -0500 Subject: [PATCH 3/9] chore: add stories --- .../ui-kit/components/MultiSelectFilter.mdx | 45 +++++++++++++++++++ .../MultiSelectFilterQueryProps.stories.tsx | 13 ++++++ 2 files changed, 58 insertions(+) create mode 100644 app/storybook/documentation/ui-kit/components/MultiSelectFilter.mdx create mode 100644 packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilterQueryProps.stories.tsx diff --git a/app/storybook/documentation/ui-kit/components/MultiSelectFilter.mdx b/app/storybook/documentation/ui-kit/components/MultiSelectFilter.mdx new file mode 100644 index 00000000..e46e0eec --- /dev/null +++ b/app/storybook/documentation/ui-kit/components/MultiSelectFilter.mdx @@ -0,0 +1,45 @@ +import { ArgTypes, Meta } from '@storybook/blocks' +import LinkTo from '@storybook/addon-links/react' + +import { SourceCode } from '../../../.storybook/blocks/SourceCode' +import * as MultiSelectFilterStories from '../../../../../packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.stories' +import * as AutocompleteStories from '../../../../../packages/ui-kit/src/components/Autocomplete/Autocomplete.stories' +import * as MultiSelectFilterQueryPropsStories from '../../../../../packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilterQueryProps.stories' + + + +# MultiSelectFilter Overview + +The MultiSelectFilter component is a dropdown designed to build metric filters and supply them to the components in +your dashboard. You can either use it in `static` mode by providing a set of `options` and a `columnName` that the +component can use to build the filter, or use it in `connected` mode by providing a `query` object which uses +[Propel's Top Values API](https://www.propeldata.com/docs/query-your-data/top-values) to populate the dropdown. + +## Basic usage + +This components require wrapping your app in a `FilterProvider` which will provide the built filter to the child +components. All the components wrapped within the same `FilterProvider` will share the same filters. + +You can import the component in your React file and use it as shown in the following example: + + + +## Props API + +The following table describes the props available for the SimpleFilter component: + + + +### `SimpleFilterQueryProps` + + + +See the documentation below for a complete reference to all of the props and classes available to the components +mentioned here. + +- + LoaderProps + +- + TimeRangeInput + diff --git a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilterQueryProps.stories.tsx b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilterQueryProps.stories.tsx new file mode 100644 index 00000000..df1ae7e5 --- /dev/null +++ b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilterQueryProps.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react' +import type { MultiSelectFilterQueryProps } from './MultiSelectFilter.types' + +export const MultiSelectFilterQueryPropsComponent: React.FC = () => null + +const meta = { + title: 'API/MultiSelectFilterQueryProps', + tags: ['pattern'], + component: MultiSelectFilterQueryPropsComponent +} satisfies Meta + +export default meta +export const Primary: StoryObj = {} From cce9504392664c630395e98be53be173eaa71653 Mon Sep 17 00:00:00 2001 From: Felipe Cadavid Date: Tue, 23 Jan 2024 10:16:02 -0500 Subject: [PATCH 4/9] chore: add changeset --- .changeset/curly-jokes-invent.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/curly-jokes-invent.md diff --git a/.changeset/curly-jokes-invent.md b/.changeset/curly-jokes-invent.md new file mode 100644 index 00000000..ac393797 --- /dev/null +++ b/.changeset/curly-jokes-invent.md @@ -0,0 +1,5 @@ +--- +'@propeldata/ui-kit': minor +--- + +Added MultiSelectFilter component From c6185884138b323d0f404d186c335085bb1ae90d Mon Sep 17 00:00:00 2001 From: Felipe Cadavid Date: Tue, 23 Jan 2024 10:44:24 -0500 Subject: [PATCH 5/9] fix: should be able to clear the filters --- .../src/components/MultiSelectFilter/MultiSelectFilter.tsx | 6 +++++- .../ui-kit/src/components/SimpleFilter/SimpleFilter.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx index 5d3fcc2b..041e4972 100644 --- a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx +++ b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx @@ -38,7 +38,11 @@ const MultiSelectFilterBase = ({ const handleChange = (_: React.SyntheticEvent | null, valueList: string | string[] | null) => { if (typeof valueList === 'string') throw new Error('MultiSelectFilter should be used with multiple enabled') - if (valueList == null) return + if (valueList == null) { + const filterList = filters.filter((filter) => filter.id !== id) + setFilters(filterList) + return + } const orFilter = { id, ...buildOrFilter(columnName ?? '', valueList) } diff --git a/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.tsx b/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.tsx index f3cce7fe..13895454 100644 --- a/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.tsx +++ b/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.tsx @@ -38,7 +38,11 @@ const SimpleFilterComponent = ({ const isError = queryError != null || error != null const handleChange = (_: SyntheticEvent, selectedOption: DropdownOption | string | null) => { - if (selectedOption == null) return + if (selectedOption == null) { + const filterList = filters.filter((filter) => filter.id !== id) + setFilters(filterList) + return + } const filter: FilterInput = { column: columnName, From 081bb68baad037121d53841652a85b1667b2678c Mon Sep 17 00:00:00 2001 From: Felipe Cadavid Date: Wed, 24 Jan 2024 08:34:23 -0500 Subject: [PATCH 6/9] fix: enable unselecting all filters --- .../src/components/MultiSelectFilter/MultiSelectFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx index 041e4972..42d284e2 100644 --- a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx +++ b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx @@ -38,7 +38,7 @@ const MultiSelectFilterBase = ({ const handleChange = (_: React.SyntheticEvent | null, valueList: string | string[] | null) => { if (typeof valueList === 'string') throw new Error('MultiSelectFilter should be used with multiple enabled') - if (valueList == null) { + if (valueList?.length === 0 || valueList == null) { const filterList = filters.filter((filter) => filter.id !== id) setFilters(filterList) return From f05994261bb2eff484f724c87a8fed6d94161d86 Mon Sep 17 00:00:00 2001 From: Felipe Cadavid Date: Tue, 30 Jan 2024 13:52:46 -0500 Subject: [PATCH 7/9] refactor: remove unnecessary spaces --- .../src/components/Autocomplete/Autocomplete.module.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/ui-kit/src/components/Autocomplete/Autocomplete.module.scss b/packages/ui-kit/src/components/Autocomplete/Autocomplete.module.scss index c3cb6be1..ff7ad6dc 100644 --- a/packages/ui-kit/src/components/Autocomplete/Autocomplete.module.scss +++ b/packages/ui-kit/src/components/Autocomplete/Autocomplete.module.scss @@ -8,9 +8,7 @@ gap: var(--propel-space-xxs); padding: var(--propel-space-xxs); overflow: hidden; - transition: all 100ms; - box-sizing: border-box; &:hover { @@ -31,7 +29,6 @@ padding: var(--propel-space-xxs); outline: 0; flex: 1 0 auto; - width: 100%; } @@ -44,7 +41,6 @@ align-self: center; padding: 0 2px; margin-right: 4px; - display: flex; align-items: center; From d957d7b53b4fe3ddfba1a63cf6bec8fe93d0d161 Mon Sep 17 00:00:00 2001 From: Felipe Cadavid Date: Tue, 30 Jan 2024 14:05:24 -0500 Subject: [PATCH 8/9] refactor: remove unnecessary spaces --- packages/ui-kit/src/components/Select/Select.module.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ui-kit/src/components/Select/Select.module.scss b/packages/ui-kit/src/components/Select/Select.module.scss index fefbfa2e..86b17281 100644 --- a/packages/ui-kit/src/components/Select/Select.module.scss +++ b/packages/ui-kit/src/components/Select/Select.module.scss @@ -12,9 +12,7 @@ color: var(--propel-text-primary); position: relative; box-shadow: var(--propel-shadow-sm); - cursor: pointer; - display: flex; justify-content: space-between; align-items: center; From b0d56d4d82347f21fc5ebfba148a82e36e61d266 Mon Sep 17 00:00:00 2001 From: Felipe Cadavid Date: Mon, 4 Mar 2024 16:35:41 -0500 Subject: [PATCH 9/9] style: change select width --- .../MultiSelectFilter/MultiSelectFilter.stories.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.stories.tsx b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.stories.tsx index 18f467ee..cc34dad3 100644 --- a/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.stories.tsx +++ b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.stories.tsx @@ -48,6 +48,10 @@ const MultiSelectFilter = (args: Story['args']) => { } : undefined }} + selectProps={{ + ...args?.selectProps, + containerStyle: { width: '350px' }, + }} /> ) }