diff --git a/src/components/vanilla/controls/MultiSelectDropdownWithIds/MultiSelectDropdownWithIds.emb.ts b/src/components/vanilla/controls/MultiSelectDropdownWithIds/MultiSelectDropdownWithIds.emb.ts new file mode 100644 index 00000000..d4350e8d --- /dev/null +++ b/src/components/vanilla/controls/MultiSelectDropdownWithIds/MultiSelectDropdownWithIds.emb.ts @@ -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' + // }, + { + 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(Component, meta, { + props: (inputs: Inputs, [embState]) => { + if (!inputs.ds) + return { + ...inputs, + options: [] as never + }; + + return { + ...inputs, + options: loadData({ + from: inputs.ds, + dimensions: inputs.property ? [inputs.property, inputs.id] : [], + limit: inputs.limit || 1000, + 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() + }; + } + } +}); diff --git a/src/components/vanilla/controls/MultiSelectDropdownWithIds/index.tsx b/src/components/vanilla/controls/MultiSelectDropdownWithIds/index.tsx new file mode 100644 index 00000000..de81c8b0 --- /dev/null +++ b/src/components/vanilla/controls/MultiSelectDropdownWithIds/index.tsx @@ -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; +} + +export type Props = { + className?: string; + options: DataResponse; + unclearable?: boolean; + onChange: (v: EventPayload[]) => void; + 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; + +export default (props: Props) => { + const [focus, setFocus] = useState(false); + const ref = useRef(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); + } else { + newValues = [...newValues, newValue]; + } + } + + props.onChange(newValues); + setValue(newValues); + setServerSearch((s) => ({ ...s, [props.searchProperty || 'search']: '' })); + clearTimeout(debounce); + }, + // 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( +
{ + setTriggerBlur(false); + set(o[props.property?.name || ''] || ''); + }} + 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 || '']) ? : } + {o[props.property?.name || '']} + {o.note && ( + {o.note} + )} +
, + ); + + return memo; + }, []), + [props, value, set], + ) as ReactNode[]; + + return ( + +
+ 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 && ( + + Selected {value.length} {value.length === 1 ? 'option' : 'options'} + + )} + + {focus && ( +
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 && ( +
No results
+ )} +
+ )} + + {props.options.isLoading ? ( + + ) : ( + + )} + + {!props.unclearable && !!value && ( +
{ + set({value: '', id: ''}); + }} + className="absolute right-10 top-0 h-10 flex items-center z-10 cursor-pointer" + > + +
+ )} +
+
+ ); +};