-
Notifications
You must be signed in to change notification settings - Fork 16
try to customise multi select dropdown component with Ids #187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,140 @@ | ||||||
| import { Value, loadData } from '@embeddable.com/core'; | ||||||
| import { EmbeddedComponentMeta, Inputs, defineComponent } from '@embeddable.com/react'; | ||||||
|
|
||||||
| import Component, { Props } from './index'; | ||||||
|
|
||||||
| export const meta = { | ||||||
| name: 'MultiSelectDropdownWithIds', | ||||||
| label: 'Multi-Select dropdown with ids', | ||||||
| defaultWidth: 300, | ||||||
| defaultHeight: 80, | ||||||
| classNames: ['on-top'], | ||||||
| category: 'Controls: inputs & dropdowns', | ||||||
| inputs: [ | ||||||
| { | ||||||
| name: 'ds', | ||||||
| type: 'dataset', | ||||||
| label: 'Dataset', | ||||||
| description: 'Dataset', | ||||||
| category: 'Dropdown values' | ||||||
| }, | ||||||
| { | ||||||
| name: 'property', | ||||||
| type: 'dimension', | ||||||
| label: 'Property', | ||||||
| config: { | ||||||
| dataset: 'ds' | ||||||
| }, | ||||||
| category: 'Dropdown values' | ||||||
| }, | ||||||
| { | ||||||
| name: 'id', | ||||||
| type: 'dimension', | ||||||
| label: 'ID', | ||||||
| config: { | ||||||
| dataset: 'ds' | ||||||
| }, | ||||||
| category: 'Dropdown ids' | ||||||
| }, | ||||||
| { | ||||||
| name: 'title', | ||||||
| type: 'string', | ||||||
| label: 'Title', | ||||||
| category: 'Settings' | ||||||
| }, | ||||||
| // { | ||||||
| // name: 'defaultValue', | ||||||
| // type: 'string', | ||||||
| // array: true, | ||||||
| // label: 'Default value', | ||||||
| // category: 'Pre-configured variables' | ||||||
| // }, | ||||||
|
Comment on lines
+45
to
+51
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
| { | ||||||
| name: 'placeholder', | ||||||
| type: 'string', | ||||||
| label: 'Placeholder', | ||||||
| defaultValue: 'Select...', | ||||||
| category: 'Settings' | ||||||
| }, | ||||||
| { | ||||||
| name: 'limit', | ||||||
| type: 'number', | ||||||
| label: 'Default number of options', | ||||||
| defaultValue: 100, | ||||||
| category: 'Settings' | ||||||
| } | ||||||
| ], | ||||||
| events: [ | ||||||
| { | ||||||
| name: 'onChange', | ||||||
| label: 'Change', | ||||||
| properties: [ | ||||||
| { | ||||||
| name: 'value', | ||||||
| type: 'string', | ||||||
| array: true | ||||||
| }, | ||||||
| { | ||||||
| name: 'id', | ||||||
| type: 'string', | ||||||
| array: true | ||||||
| } | ||||||
| ] | ||||||
| } | ||||||
| ], | ||||||
| variables: [ | ||||||
| { | ||||||
| name: 'teamNames', | ||||||
| type: 'string', | ||||||
| defaultValue: Value.noFilter(), | ||||||
| array: true, | ||||||
| // inputs: ['defaultValue'], | ||||||
| events: [{ name: 'onChange', property: 'value' }] | ||||||
| }, | ||||||
| { | ||||||
| name: 'teamIds', | ||||||
| type: 'string', | ||||||
| defaultValue: Value.noFilter(), | ||||||
| array: true, | ||||||
| // inputs: ['defaultValue'], | ||||||
| events: [{ name: 'onChange', property: 'id' }] | ||||||
| } | ||||||
| ] | ||||||
| } as const satisfies EmbeddedComponentMeta; | ||||||
|
|
||||||
| export default defineComponent<Props, typeof meta, { search: string }>(Component, meta, { | ||||||
| props: (inputs: Inputs<typeof meta>, [embState]) => { | ||||||
| if (!inputs.ds) | ||||||
| return { | ||||||
| ...inputs, | ||||||
| options: [] as never | ||||||
| }; | ||||||
|
|
||||||
| return { | ||||||
| ...inputs, | ||||||
| options: loadData({ | ||||||
| from: inputs.ds, | ||||||
| dimensions: inputs.property ? [inputs.property, inputs.id] : [], | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: No check for inputs.id before including it in dimensions array. Could cause runtime issues if id field not configured.
Suggested change
|
||||||
| limit: inputs.limit || 1000, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Default limit of 1000 differs from meta default of 100. Should be consistent. |
||||||
| filters: | ||||||
| embState?.search && inputs.property | ||||||
| ? [ | ||||||
| { | ||||||
| operator: 'contains', | ||||||
| property: inputs.property, | ||||||
| value: embState?.search | ||||||
| } | ||||||
| ] | ||||||
| : undefined | ||||||
| }) | ||||||
| }; | ||||||
| }, | ||||||
| events: { | ||||||
| onChange: ({value, id}) => { | ||||||
| return { | ||||||
| value: value.length ? value : Value.noFilter(), | ||||||
| id: id.length ? id : Value.noFilter() | ||||||
| }; | ||||||
|
Comment on lines
+134
to
+137
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is where we would like to capture an object (instead of value string) to be able to assign the id to a variable used for filters |
||||||
| } | ||||||
| } | ||||||
| }); | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,196 @@ | ||||||||||
| import { DataResponse } from '@embeddable.com/core'; | ||||||||||
| import { useEmbeddableState } from '@embeddable.com/react'; | ||||||||||
| import React, { | ||||||||||
| ReactNode, | ||||||||||
| useCallback, | ||||||||||
| useEffect, | ||||||||||
| useLayoutEffect, | ||||||||||
| useMemo, | ||||||||||
| useRef, | ||||||||||
| useState, | ||||||||||
| } from 'react'; | ||||||||||
| import { twMerge } from 'tailwind-merge'; | ||||||||||
|
|
||||||||||
| import Checkbox from '../../../icons/Checkbox'; | ||||||||||
| import CheckboxEmpty from '../../../icons/CheckboxEmpty'; | ||||||||||
| import Container from '../../Container'; | ||||||||||
| import Spinner from '../../Spinner'; | ||||||||||
| import { ChevronDown, ClearIcon } from '../../icons'; | ||||||||||
|
|
||||||||||
| export type EventPayload = { | ||||||||||
| value: string; | ||||||||||
| id: string; | ||||||||||
| } | ||||||||||
|
Comment on lines
+20
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now the event payload should have an object type instead of string |
||||||||||
|
|
||||||||||
| export type Props = { | ||||||||||
| className?: string; | ||||||||||
| options: DataResponse; | ||||||||||
| unclearable?: boolean; | ||||||||||
| onChange: (v: EventPayload[]) => void; | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and onChange should capture event payload containing both |
||||||||||
| searchProperty?: string; | ||||||||||
| minDropdownWidth?: number; | ||||||||||
| property?: { name: string; title: string; nativeType: string; __type__: string }; | ||||||||||
| title?: string; | ||||||||||
| defaultValue?: EventPayload[]; | ||||||||||
| placeholder?: string; | ||||||||||
| ds?: { embeddableId: string; datasetId: string; variableValues: Record }; | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| type Record = { [p: string]: string }; | ||||||||||
|
|
||||||||||
| let debounce: number | undefined = undefined; | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Global debounce variable will cause issues with multiple component instances. Move inside component using useRef
Suggested change
|
||||||||||
|
|
||||||||||
| export default (props: Props) => { | ||||||||||
| const [focus, setFocus] = useState(false); | ||||||||||
| const ref = useRef<HTMLInputElement | null>(null); | ||||||||||
| const [triggerBlur, setTriggerBlur] = useState(false); | ||||||||||
| const [value, setValue] = useState(props.defaultValue); | ||||||||||
| const [search, setSearch] = useState(''); | ||||||||||
| const [_, setServerSearch] = useEmbeddableState({ | ||||||||||
| [props.searchProperty || 'search']: '', | ||||||||||
| }) as [Record, (f: (m: Record) => Record) => void]; | ||||||||||
|
|
||||||||||
| useEffect(() => { | ||||||||||
| setValue(props.defaultValue); | ||||||||||
| }, [props.defaultValue]); | ||||||||||
|
|
||||||||||
| const performSearch = useCallback( | ||||||||||
| (newSearch: string) => { | ||||||||||
| setSearch(newSearch); | ||||||||||
|
|
||||||||||
| clearTimeout(debounce); | ||||||||||
|
|
||||||||||
| debounce = window.setTimeout(() => { | ||||||||||
| setServerSearch((s) => ({ ...s, [props.searchProperty || 'search']: newSearch })); | ||||||||||
| }, 500); | ||||||||||
| }, | ||||||||||
| [setSearch, setServerSearch, props.searchProperty], | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| const set = useCallback( | ||||||||||
| (newValue: EventPayload) => { | ||||||||||
| performSearch(''); | ||||||||||
|
|
||||||||||
| let newValues: EventPayload[] = []; | ||||||||||
|
|
||||||||||
| if (newValue.value !== '') { | ||||||||||
| newValues = value || []; | ||||||||||
| if (newValues?.includes(newValue)) { | ||||||||||
| newValues = newValues.filter((v) => v.value !== newValue.value); | ||||||||||
|
Comment on lines
+78
to
+79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Array.includes() won't work correctly for object comparison. Need to use array.find() with proper value/id comparison
Suggested change
|
||||||||||
| } else { | ||||||||||
| newValues = [...newValues, newValue]; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| props.onChange(newValues); | ||||||||||
| setValue(newValues); | ||||||||||
| setServerSearch((s) => ({ ...s, [props.searchProperty || 'search']: '' })); | ||||||||||
| clearTimeout(debounce); | ||||||||||
| }, | ||||||||||
|
Comment on lines
+70
to
+89
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||||||||||
| [performSearch, props, value], | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| useLayoutEffect(() => { | ||||||||||
| if (!triggerBlur) return; | ||||||||||
|
|
||||||||||
| const timeout = setTimeout(() => { | ||||||||||
| setFocus(false); | ||||||||||
| setTriggerBlur(false); | ||||||||||
| }, 500); | ||||||||||
|
|
||||||||||
| return () => clearTimeout(timeout); | ||||||||||
| }, [triggerBlur]); | ||||||||||
|
|
||||||||||
| const list = useMemo( | ||||||||||
| () => | ||||||||||
| props.options?.data?.reduce((memo, o, i: number) => { | ||||||||||
| memo.push( | ||||||||||
| <div | ||||||||||
| key={i} | ||||||||||
| onClick={() => { | ||||||||||
| setTriggerBlur(false); | ||||||||||
| set(o[props.property?.name || ''] || ''); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: Missing ID field in set() call. Should be
Suggested change
|
||||||||||
| }} | ||||||||||
| className={`flex items-center min-h-[36px] px-3 py-2 hover:bg-black/5 cursor-pointer font-normal gap-1 ${ | ||||||||||
| value?.includes(o[props.property?.name || '']) ? 'bg-black/5' : '' | ||||||||||
| } whitespace-nowrap overflow-hidden text-ellipsis`} | ||||||||||
| > | ||||||||||
| {value?.includes(o[props.property?.name || '']) ? <Checkbox /> : <CheckboxEmpty />} | ||||||||||
| {o[props.property?.name || '']} | ||||||||||
| {o.note && ( | ||||||||||
| <span className="font-normal ml-auto pl-3 text-xs opacity-70">{o.note}</span> | ||||||||||
| )} | ||||||||||
| </div>, | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| return memo; | ||||||||||
| }, []), | ||||||||||
| [props, value, set], | ||||||||||
| ) as ReactNode[]; | ||||||||||
|
|
||||||||||
| return ( | ||||||||||
| <Container title={props.title}> | ||||||||||
| <div | ||||||||||
| className={twMerge( | ||||||||||
| 'relative rounded-xl w-full min-w-[50px] h-10 border border-[#DADCE1] flex items-center', | ||||||||||
| props.className, | ||||||||||
| )} | ||||||||||
| > | ||||||||||
| <input | ||||||||||
| ref={ref} | ||||||||||
| value={search} | ||||||||||
| name="dropdown" | ||||||||||
| placeholder={props.placeholder} | ||||||||||
| onFocus={() => setFocus(true)} | ||||||||||
| onBlur={() => setTriggerBlur(true)} | ||||||||||
| onChange={(e) => performSearch(e.target.value)} | ||||||||||
| className={`outline-none bg-transparent leading-9 h-9 border-0 px-3 w-full cursor-pointer text-sm ${ | ||||||||||
| focus || !value ? '' : 'opacity-0' | ||||||||||
| }`} | ||||||||||
| /> | ||||||||||
|
|
||||||||||
| {!!value && ( | ||||||||||
| <span | ||||||||||
| className={`absolute w-[calc(100%-2rem)] whitespace-nowrap overflow-hidden truncate rounded-xl left-3 top-1 h-8 leading-8 block pointer-events-none text-sm ${ | ||||||||||
| focus ? 'hidden' : '' | ||||||||||
| }`} | ||||||||||
| > | ||||||||||
| Selected {value.length} {value.length === 1 ? 'option' : 'options'} | ||||||||||
| </span> | ||||||||||
| )} | ||||||||||
|
|
||||||||||
| {focus && ( | ||||||||||
| <div | ||||||||||
| tabIndex={0} | ||||||||||
| onBlur={() => setFocus(false)} | ||||||||||
| style={{ minWidth: props.minDropdownWidth }} | ||||||||||
| className="flex flex-col bg-white rounded-xl absolute top-11 z-50 border border-[#DADCE1] w-full overflow-y-auto overflow-x-hidden max-h-[400px]" | ||||||||||
| > | ||||||||||
| {list} | ||||||||||
| {list?.length === 0 && !!search && ( | ||||||||||
| <div className="px-3 py-2 text-black/50 italic cursor-pointer">No results</div> | ||||||||||
| )} | ||||||||||
| </div> | ||||||||||
| )} | ||||||||||
|
|
||||||||||
| {props.options.isLoading ? ( | ||||||||||
| <Spinner show className="absolute right-2 top-2 z-1 pointer-events-none" /> | ||||||||||
| ) : ( | ||||||||||
| <ChevronDown className="absolute right-2.5 top-2.5 z-1 pointer-events-none" /> | ||||||||||
| )} | ||||||||||
|
|
||||||||||
| {!props.unclearable && !!value && ( | ||||||||||
| <div | ||||||||||
| onClick={() => { | ||||||||||
| set({value: '', id: ''}); | ||||||||||
| }} | ||||||||||
| className="absolute right-10 top-0 h-10 flex items-center z-10 cursor-pointer" | ||||||||||
| > | ||||||||||
| <ClearIcon /> | ||||||||||
| </div> | ||||||||||
| )} | ||||||||||
| </div> | ||||||||||
| </Container> | ||||||||||
| ); | ||||||||||
| }; | ||||||||||




There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is where we add the
iddimension that later will be linked to a variable to filter other charts data using id (built in index)