Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
},
Comment on lines +30 to +38
Copy link
Author

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 id dimension that later will be linked to a variable to filter other charts data using id (built in index)

{
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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temporarily comment this out due to a type inconsistent issue.
Screenshot 2025-01-09 at 2 01 36 pm
Screenshot 2025-01-09 at 2 01 42 pm

{
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] : [],
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where we can query the id field when doing the dropdown search:
Screenshot 2025-01-09 at 1 44 04 pm

Copy link

Choose a reason for hiding this comment

The 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
dimensions: inputs.property ? [inputs.property, inputs.id] : [],
dimensions: inputs.property && inputs.id ? [inputs.property, inputs.id] : [],

limit: inputs.limit || 1000,
Copy link

Choose a reason for hiding this comment

The 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
Copy link
Author

@kevinleeTCA kevinleeTCA Jan 9, 2025

Choose a reason for hiding this comment

The 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

}
}
});
196 changes: 196 additions & 0 deletions src/components/vanilla/controls/MultiSelectDropdownWithIds/index.tsx
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
Copy link
Author

Choose a reason for hiding this comment

The 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;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and onChange should capture event payload containing both value and id

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;
Copy link

Choose a reason for hiding this comment

The 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
let debounce: number | undefined = undefined;
const debounce = useRef<number>();


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
Copy link

Choose a reason for hiding this comment

The 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
if (newValues?.includes(newValue)) {
newValues = newValues.filter((v) => v.value !== newValue.value);
if (newValues?.find(v => v.value === newValue.value && v.id === newValue.id)) {
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);
},
Comment on lines +70 to +89
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main change in the index to support an object set from old to new. But I am still encountering issues in the preview mode when select any item from dropdown:
Screenshot 2025-01-09 at 1 44 34 pm

// 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 || ''] || '');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Missing ID field in set() call. Should be set({value: o[props.property?.name || ''], id: o.id}) to properly handle both value and ID

Suggested change
set(o[props.property?.name || ''] || '');
set({value: o[props.property?.name || ''] || '', id: o.id});

}}
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>
);
};