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
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/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 46d97d78..4ec1c549 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(
@@ -132,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..cc34dad3
--- /dev/null
+++ b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.stories.tsx
@@ -0,0 +1,231 @@
+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..42d284e2
--- /dev/null
+++ b/packages/ui-kit/src/components/MultiSelectFilter/MultiSelectFilter.tsx
@@ -0,0 +1,82 @@
+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?.length === 0 || valueList == null) {
+ const filterList = filters.filter((filter) => filter.id !== id)
+ setFilters(filterList)
+ 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 (
+
+ {selectOptions?.map((option, idx) => {
+ const value = typeof option === 'string' ? option : option.value ?? option.label ?? ''
+ const label = typeof option === 'string' ? option : option.label ?? option.value ?? ''
+
+ return (
+
+ {label}
+
+ )
+ })}
+
+ )
+}
+
+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..7ac38939
--- /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, 'card'> {
+ /** 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/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 = {}
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..86b17281
--- /dev/null
+++ b/packages/ui-kit/src/components/Select/Select.module.scss
@@ -0,0 +1,114 @@
+.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(
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ )
+
+ 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 (
+
+
+ {renderSelectedValue(value ?? '', options) || (
+ {placeholder ?? ' '}
+ )}
+
+ {listboxVisible ? : }
+
+
+
+
+ )
+}
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 78dbc769..0ac281e5 100644
--- a/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.tsx
+++ b/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.tsx
@@ -2,7 +2,7 @@ import React, { SyntheticEvent, useEffect, useRef } from 'react'
import { FilterInput, FilterOperator, getTimeZone, useForwardedRefCallback, withThemeWrapper } from '../../helpers'
import { useTopValues } from '../../hooks'
import { Autocomplete } from '../Autocomplete'
-import { AutocompleteOption } from '../Autocomplete/Autocomplete.types'
+import { DropdownOption } from '../shared.types'
import { ErrorFallback } from '../ErrorFallback'
import { useFilters } from '../FilterProvider/useFilters'
import { Loader, LoaderProps } from '../Loader'
@@ -46,7 +46,7 @@ const SimpleFilterComponent = React.forwardRef, selectedOption: AutocompleteOption | string | null) => {
+ const handleChange = (_: SyntheticEvent, selectedOption: DropdownOption | string | null) => {
if (selectedOption == null) {
const filterList = filters.filter((filter) => filter.id !== id)
setFilters(filterList)
diff --git a/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.types.ts b/packages/ui-kit/src/components/SimpleFilter/SimpleFilter.types.ts
index 514f16eb..87393518 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, 'car
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 6e6b77fd..0ebe6bc1 100644
--- a/packages/ui-kit/src/components/shared.types.ts
+++ b/packages/ui-kit/src/components/shared.types.ts
@@ -113,3 +113,11 @@ export interface QueryProps {
/** When false, the component will not make any GraphQL requests, default is true. */
enabled?: boolean
}
+
+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..8bf06ead
--- /dev/null
+++ b/packages/ui-kit/src/helpers/buildOrFilter/buildOrFilter.test.ts
@@ -0,0 +1,29 @@
+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]
+ }
+ ]
+ })
+ })
+})
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 17625e16..0185788f 100644
--- a/packages/ui-kit/src/helpers/index.ts
+++ b/packages/ui-kit/src/helpers/index.ts
@@ -16,4 +16,7 @@ export * from './useCombinedRefs'
export * from './useCombinedRefsCallback'
export * from './useForwardedRefCallback'
export * from './useStorybookAccessToken'
+export * from './fetchStorybookAccessToken'
+export * from './sleep'
+export * from './buildOrFilter'
export * from './withThemeWrapper'