mirror of https://github.com/buster-so/buster.git
select component update
This commit is contained in:
parent
8a95ff8d30
commit
bc5fd922fe
|
@ -320,7 +320,13 @@ describe('data-transformers', () => {
|
|||
});
|
||||
|
||||
it('should handle empty conversation history', () => {
|
||||
const result = buildWorkflowInput(baseMessageContext, [], basePreviousResults, baseDatasets, false);
|
||||
const result = buildWorkflowInput(
|
||||
baseMessageContext,
|
||||
[],
|
||||
basePreviousResults,
|
||||
baseDatasets,
|
||||
false
|
||||
);
|
||||
expect(result.conversationHistory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -127,7 +127,7 @@ export async function getExistingSlackMessageForChat(chatId: string): Promise<{
|
|||
* Send a Slack notification based on post-processing results
|
||||
*/
|
||||
export async function sendSlackNotification(
|
||||
params: SlackNotificationParams,
|
||||
params: SlackNotificationParams
|
||||
): Promise<SlackNotificationResult> {
|
||||
try {
|
||||
// Step 1: Check if organization has active Slack integration
|
||||
|
@ -139,8 +139,8 @@ export async function sendSlackNotification(
|
|||
and(
|
||||
eq(slackIntegrations.organizationId, params.organizationId),
|
||||
eq(slackIntegrations.status, 'active'),
|
||||
isNull(slackIntegrations.deletedAt),
|
||||
),
|
||||
isNull(slackIntegrations.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
|
@ -189,7 +189,7 @@ export async function sendSlackNotification(
|
|||
const result = await sendSlackMessage(
|
||||
tokenSecret.secret,
|
||||
integration.defaultChannel.id,
|
||||
slackMessage,
|
||||
slackMessage
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
|
@ -229,7 +229,7 @@ export async function sendSlackNotification(
|
|||
* Send a Slack reply notification to an existing thread
|
||||
*/
|
||||
export async function sendSlackReplyNotification(
|
||||
params: SlackReplyNotificationParams,
|
||||
params: SlackReplyNotificationParams
|
||||
): Promise<SlackNotificationResult> {
|
||||
try {
|
||||
// Step 1: Check if we should send a notification
|
||||
|
@ -257,7 +257,7 @@ export async function sendSlackReplyNotification(
|
|||
tokenSecret.secret,
|
||||
params.channelId,
|
||||
slackMessage,
|
||||
params.threadTs,
|
||||
params.threadTs
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
|
@ -372,7 +372,7 @@ function formatSlackReplyMessage(params: SlackReplyNotificationParams): SlackMes
|
|||
|
||||
throw new Error(
|
||||
'Invalid reply notification parameters: Missing required fields. ' +
|
||||
'Requires either formattedMessage, summaryTitle with summaryMessage, or toolCalled="flagChat" with message',
|
||||
'Requires either formattedMessage, summaryTitle with summaryMessage, or toolCalled="flagChat" with message'
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -456,7 +456,7 @@ function formatSlackMessage(params: SlackNotificationParams): SlackMessage {
|
|||
throw new Error(
|
||||
`Invalid notification parameters: Missing required fields. Requires either formattedMessage, summaryTitle with summaryMessage, or toolCalled="flagChat" with message. Received: formattedMessage=${!!params.formattedMessage}, summaryTitle=${!!params.summaryTitle}, summaryMessage=${!!params.summaryMessage}, toolCalled="${
|
||||
params.toolCalled
|
||||
}", message=${!!params.message}`,
|
||||
}", message=${!!params.message}`
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -467,7 +467,7 @@ async function sendSlackMessage(
|
|||
accessToken: string,
|
||||
channelId: string,
|
||||
message: SlackMessage,
|
||||
threadTs?: string,
|
||||
threadTs?: string
|
||||
): Promise<{ success: boolean; messageTs?: string; error?: string }> {
|
||||
try {
|
||||
const response = await fetch('https://slack.com/api/chat.postMessage', {
|
||||
|
@ -540,8 +540,8 @@ export async function trackSlackNotification(params: {
|
|||
content: params.slackBlocks
|
||||
? JSON.stringify({ blocks: params.slackBlocks })
|
||||
: params.summaryTitle && params.summaryMessage
|
||||
? `${params.summaryTitle}\n\n${params.summaryMessage}`
|
||||
: 'Notification sent',
|
||||
? `${params.summaryTitle}\n\n${params.summaryMessage}`
|
||||
: 'Notification sent',
|
||||
senderInfo: {
|
||||
sentBy: 'buster-post-processing',
|
||||
userName: params.userName,
|
||||
|
|
|
@ -24,14 +24,16 @@ vi.mock('@buster/database', () => ({
|
|||
getDb: vi.fn(),
|
||||
eq: vi.fn((a, b) => ({ type: 'eq', a, b })),
|
||||
messages: { id: 'messages.id' },
|
||||
getBraintrustMetadata: vi.fn(() => Promise.resolve({
|
||||
userName: 'John Doe',
|
||||
userId: 'user-123',
|
||||
organizationName: 'Test Org',
|
||||
organizationId: 'org-123',
|
||||
messageId: 'msg-12345',
|
||||
chatId: 'chat-123',
|
||||
})),
|
||||
getBraintrustMetadata: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
userName: 'John Doe',
|
||||
userId: 'user-123',
|
||||
organizationName: 'Test Org',
|
||||
organizationId: 'org-123',
|
||||
messageId: 'msg-12345',
|
||||
chatId: 'chat-123',
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@buster/ai/workflows/post-processing-workflow', () => ({
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { useGetDatasets } from '@/api/buster_rest/datasets';
|
||||
import { Select, type SelectItem } from '@/components/ui/select/Select';
|
||||
import { Select, type SelectItem } from '@/components/ui/select/SelectOld';
|
||||
import { SelectMultiple } from '@/components/ui/select/SelectMultiple';
|
||||
import { useMemoizedFn } from '@/hooks';
|
||||
|
||||
|
|
|
@ -1,127 +1,236 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import React from 'react';
|
||||
import { fn } from '@storybook/test';
|
||||
import { Mailbox, MapSettings, User } from '../icons';
|
||||
import { Select } from './Select';
|
||||
import { Select, type SelectItem, type SelectProps } from './Select';
|
||||
import { User, Gear, PowerOff } from '@/components/ui/icons/NucleoIconOutlined';
|
||||
|
||||
const meta: Meta<typeof Select> = {
|
||||
title: 'UI/Select/Select',
|
||||
const meta = {
|
||||
title: 'UI/select/Select2',
|
||||
component: Select,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
args: {
|
||||
onChange: fn()
|
||||
argTypes: {
|
||||
search: {
|
||||
control: { type: 'boolean' },
|
||||
description: 'Enable/disable search functionality'
|
||||
},
|
||||
disabled: {
|
||||
control: { type: 'boolean' },
|
||||
description: 'Disable the select'
|
||||
},
|
||||
loading: {
|
||||
control: { type: 'boolean' },
|
||||
description: 'Show loading state'
|
||||
},
|
||||
showIndex: {
|
||||
control: { type: 'boolean' },
|
||||
description: 'Show index numbers for items'
|
||||
},
|
||||
placeholder: {
|
||||
control: { type: 'text' },
|
||||
description: 'Placeholder text when no item is selected'
|
||||
},
|
||||
onChange: {
|
||||
action: 'onChange'
|
||||
}
|
||||
},
|
||||
tags: ['autodocs']
|
||||
};
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: '300px' }}>
|
||||
<Story />
|
||||
</div>
|
||||
)
|
||||
]
|
||||
} satisfies Meta<typeof Select>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Select>;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const basicItems = [
|
||||
{ value: 'apples', label: 'Apples' },
|
||||
{ value: 'bananas', label: 'Bananas' },
|
||||
{ value: 'cherries', label: 'Cherries' }
|
||||
];
|
||||
|
||||
const itemsWithIcons = [
|
||||
{ value: 'profile', label: 'Profile', icon: <User /> },
|
||||
{ value: 'settings', label: 'Settings', icon: <MapSettings /> },
|
||||
{ value: 'messages', label: 'Messages', icon: <Mailbox /> }
|
||||
];
|
||||
|
||||
const groupedItems = [
|
||||
{
|
||||
label: 'Fruits',
|
||||
// Basic select with simple string options
|
||||
export const BasicSelect: Story = {
|
||||
args: {
|
||||
placeholder: 'Select a fruit',
|
||||
items: [
|
||||
{ value: 'apple', label: 'Apple' },
|
||||
{ value: 'banana', label: 'Banana' },
|
||||
{ value: 'orange', label: 'Orange' }
|
||||
]
|
||||
{ value: 'orange', label: 'Orange' },
|
||||
{ value: 'grape', label: 'Grape' },
|
||||
{ value: 'strawberry', label: 'Strawberry' },
|
||||
{ value: 'watermelon', label: 'Watermelon' },
|
||||
{ value: 'pineapple', label: 'Pineapple' },
|
||||
{ value: 'mango', label: 'Mango' }
|
||||
] as SelectItem<string>[],
|
||||
onChange: fn()
|
||||
},
|
||||
{
|
||||
label: 'Vegetables',
|
||||
items: [
|
||||
{ value: 'carrot', label: 'Carrot' },
|
||||
{ value: 'broccoli', label: 'Broccoli' },
|
||||
{ value: 'spinach', label: 'Spinach' }
|
||||
]
|
||||
}
|
||||
];
|
||||
render: function RenderBasicSelect(args) {
|
||||
const [value, setValue] = React.useState<string | undefined>();
|
||||
|
||||
const itemsWithSecondaryLabel = [
|
||||
{ value: 'user1', label: 'John Doe', secondaryLabel: 'Admin' },
|
||||
{ value: 'user2', label: 'Jane Smith', secondaryLabel: 'Editor' },
|
||||
{ value: 'user3', label: 'Bob Johnson', secondaryLabel: 'Viewer', disabled: true }
|
||||
];
|
||||
|
||||
const itemsWithSomeDisabled = [
|
||||
{ value: 'active1', label: 'Available Option 1' },
|
||||
{ value: 'disabled1', label: 'Unavailable Option 1', disabled: true },
|
||||
{ value: 'active2', label: 'Available Option 2' },
|
||||
{ value: 'disabled2', label: 'Unavailable Option 2', disabled: true },
|
||||
{ value: 'active3', label: 'Available Option 3' }
|
||||
];
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
items: basicItems,
|
||||
placeholder: 'Select an option'
|
||||
return (
|
||||
<Select
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as string);
|
||||
args.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
args: {
|
||||
items: itemsWithIcons,
|
||||
placeholder: 'Select an option'
|
||||
}
|
||||
};
|
||||
|
||||
export const Grouped: Story = {
|
||||
args: {
|
||||
items: groupedItems,
|
||||
placeholder: 'Select an option'
|
||||
}
|
||||
};
|
||||
|
||||
export const WithSecondaryLabels: Story = {
|
||||
args: {
|
||||
items: itemsWithSecondaryLabel,
|
||||
placeholder: 'Select a user'
|
||||
}
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
items: basicItems,
|
||||
placeholder: 'Select an option',
|
||||
disabled: true
|
||||
}
|
||||
};
|
||||
|
||||
export const WithShowIndex: Story = {
|
||||
args: {
|
||||
items: basicItems,
|
||||
placeholder: 'Select an option',
|
||||
showIndex: true
|
||||
}
|
||||
};
|
||||
|
||||
export const PartiallyDisabled: Story = {
|
||||
args: {
|
||||
items: itemsWithSomeDisabled,
|
||||
placeholder: 'Select an available option'
|
||||
}
|
||||
};
|
||||
|
||||
export const WithLowCharacters: Story = {
|
||||
// Advanced select with grouped items, icons, secondary labels, and custom search
|
||||
export const AdvancedSelect: Story = {
|
||||
args: {
|
||||
placeholder: 'Select an action',
|
||||
items: [
|
||||
{
|
||||
value: 'gyj',
|
||||
label: 'gyj - GYJ'
|
||||
label: 'Account',
|
||||
items: [
|
||||
{
|
||||
value: 'profile',
|
||||
label: 'View Profile',
|
||||
icon: <User />,
|
||||
secondaryLabel: 'See your profile details'
|
||||
},
|
||||
{
|
||||
value: 'settings',
|
||||
label: 'Settings',
|
||||
icon: <Gear />,
|
||||
secondaryLabel: 'Manage your preferences'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Session',
|
||||
items: [
|
||||
{
|
||||
value: 'logout',
|
||||
label: 'Log Out',
|
||||
icon: <PowerOff />,
|
||||
secondaryLabel: 'End your session',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
value: 'switch-account',
|
||||
label: 'Switch Account',
|
||||
secondaryLabel: 'Change to another account',
|
||||
disabled: true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
placeholder: 'Select an option'
|
||||
search: (item, searchTerm) => {
|
||||
// Custom search that also searches in secondary labels
|
||||
const term = searchTerm.toLowerCase();
|
||||
const labelText = typeof item.label === 'string' ? item.label.toLowerCase() : '';
|
||||
const secondaryText = item.secondaryLabel?.toLowerCase() || '';
|
||||
return labelText.includes(term) || secondaryText.includes(term);
|
||||
},
|
||||
onChange: fn()
|
||||
},
|
||||
render: function RenderAdvancedSelect(args) {
|
||||
const [value, setValue] = React.useState<string | undefined>();
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as string);
|
||||
args.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Select with search disabled
|
||||
export const NoSearchSelect: Story = {
|
||||
args: {
|
||||
placeholder: 'Select a color',
|
||||
search: false,
|
||||
items: [
|
||||
{ value: 'red', label: 'Red' },
|
||||
{ value: 'green', label: 'Green' },
|
||||
{ value: 'blue', label: 'Blue' },
|
||||
{ value: 'yellow', label: 'Yellow' },
|
||||
{ value: 'purple', label: 'Purple' },
|
||||
{ value: 'orange', label: 'Orange' }
|
||||
] as SelectItem<string>[],
|
||||
onChange: fn()
|
||||
},
|
||||
render: function RenderNoSearchSelect(args) {
|
||||
const [value, setValue] = React.useState<string | undefined>();
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as string);
|
||||
args.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Select with pre-selected value
|
||||
export const PreSelectedValue: Story = {
|
||||
args: {
|
||||
placeholder: 'Select a size',
|
||||
items: [
|
||||
{ value: 'xs', label: 'Extra Small' },
|
||||
{ value: 's', label: 'Small' },
|
||||
{ value: 'm', label: 'Medium' },
|
||||
{ value: 'l', label: 'Large' },
|
||||
{ value: 'xl', label: 'Extra Large' }
|
||||
] as SelectItem<string>[],
|
||||
onChange: fn()
|
||||
},
|
||||
render: function RenderPreSelectedValue(args) {
|
||||
const [value, setValue] = React.useState<string | undefined>('m');
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as string);
|
||||
args.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Select with clearable option
|
||||
export const ClearableSelect: Story = {
|
||||
args: {
|
||||
placeholder: 'Select an option (clearable)',
|
||||
clearable: true,
|
||||
items: [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
{ value: 'option3', label: 'Option 3' },
|
||||
{ value: 'option4', label: 'Option 4' },
|
||||
{ value: 'option5', label: 'Option 5' }
|
||||
] as SelectItem<string>[],
|
||||
onChange: fn()
|
||||
},
|
||||
render: function RenderClearableSelect(args) {
|
||||
const [value, setValue] = React.useState<string | undefined>('option2');
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as string | undefined);
|
||||
args.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
import type React from 'react';
|
||||
import { useMemoizedFn } from '@/hooks';
|
||||
import React from 'react';
|
||||
import {
|
||||
Select as SelectBase,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem as SelectItemComponent,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './SelectBase';
|
||||
import { CircleSpinnerLoader } from '../loaders';
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandInput
|
||||
} from '@/components/ui/command';
|
||||
import { PopoverRoot, PopoverContent, PopoverTrigger } from '@/components/ui/popover/PopoverBase';
|
||||
import { cn } from '@/lib/classMerge';
|
||||
import { Check, ChevronDown, Xmark } from '@/components/ui/icons';
|
||||
|
||||
interface SelectItemGroup<T = string> {
|
||||
label: string;
|
||||
|
@ -26,10 +25,12 @@ export interface SelectItem<T = string> {
|
|||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type SearchFunction<T> = (item: SelectItem<T>, searchTerm: string) => boolean;
|
||||
|
||||
export interface SelectProps<T> {
|
||||
items: SelectItem<T>[] | SelectItemGroup[];
|
||||
items: SelectItem<T>[] | SelectItemGroup<T>[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: T) => void;
|
||||
onChange: (value: T | null) => void;
|
||||
placeholder?: string;
|
||||
value?: string | undefined;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
@ -39,91 +40,342 @@ export interface SelectProps<T> {
|
|||
defaultValue?: string;
|
||||
dataTestId?: string;
|
||||
loading?: boolean;
|
||||
search?: boolean | SearchFunction<T>;
|
||||
clearable?: boolean;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export const Select = <T extends string>({
|
||||
items,
|
||||
showIndex,
|
||||
disabled,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
onOpenChange,
|
||||
open,
|
||||
loading = false,
|
||||
className = '',
|
||||
defaultValue,
|
||||
dataTestId
|
||||
}: SelectProps<T>) => {
|
||||
const onValueChange = useMemoizedFn((value: string) => {
|
||||
onChange(value as T);
|
||||
});
|
||||
return (
|
||||
<SelectBase
|
||||
disabled={disabled}
|
||||
onOpenChange={onOpenChange}
|
||||
open={open}
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
onValueChange={onValueChange}>
|
||||
<SelectTrigger className={className} data-testid={dataTestId} loading={loading}>
|
||||
<SelectValue placeholder={placeholder} defaultValue={value || defaultValue} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((item, index) => (
|
||||
<SelectItemSelector
|
||||
key={index.toString()}
|
||||
item={item}
|
||||
index={index}
|
||||
showIndex={showIndex}
|
||||
/>
|
||||
))}
|
||||
</SelectContent>
|
||||
</SelectBase>
|
||||
);
|
||||
};
|
||||
Select.displayName = 'Select';
|
||||
function isGroupedItems<T>(
|
||||
items: SelectItem<T>[] | SelectItemGroup<T>[]
|
||||
): items is SelectItemGroup<T>[] {
|
||||
return items.length > 0 && 'items' in items[0];
|
||||
}
|
||||
|
||||
const SelectItemSelector = <T,>({
|
||||
item,
|
||||
index,
|
||||
showIndex
|
||||
}: {
|
||||
item: SelectItem<T> | SelectItemGroup;
|
||||
index: number;
|
||||
showIndex?: boolean;
|
||||
}) => {
|
||||
const isGroup = typeof item === 'object' && 'items' in item;
|
||||
function defaultSearchFunction<T>(item: SelectItem<T>, searchTerm: string): boolean {
|
||||
const term = searchTerm.toLowerCase();
|
||||
const labelText = typeof item.label === 'string' ? item.label : '';
|
||||
const searchText = item.searchLabel || labelText;
|
||||
const valueText = String(item.value);
|
||||
|
||||
return searchText.toLowerCase().includes(term) || valueText.toLowerCase().includes(term);
|
||||
}
|
||||
|
||||
// Memoized SelectItem component to avoid re-renders
|
||||
const SelectItemComponent = React.memo(
|
||||
<T,>({
|
||||
item,
|
||||
index,
|
||||
value,
|
||||
showIndex,
|
||||
onSelect
|
||||
}: {
|
||||
item: SelectItem<T>;
|
||||
index: number;
|
||||
value: string | undefined;
|
||||
showIndex: boolean;
|
||||
onSelect: (value: string) => void;
|
||||
}) => {
|
||||
const isSelected = String(item.value) === String(value);
|
||||
|
||||
if (isGroup) {
|
||||
const _item = item as SelectItemGroup;
|
||||
return (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{_item.label}</SelectLabel>
|
||||
{_item.items?.map((item) => (
|
||||
<SelectItemSelector key={item.value} item={item} index={index} showIndex={showIndex} />
|
||||
))}
|
||||
</SelectGroup>
|
||||
<CommandItem
|
||||
value={String(item.value)}
|
||||
onSelect={onSelect}
|
||||
disabled={item.disabled}
|
||||
className={cn(
|
||||
'flex h-7 items-center gap-2 px-2',
|
||||
item.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
||||
isSelected && 'bg-item-select'
|
||||
)}>
|
||||
{item.icon}
|
||||
<span className="flex-1">
|
||||
{showIndex && `${index + 1}. `}
|
||||
{item.label}
|
||||
{item.secondaryLabel && (
|
||||
<span className="text-text-secondary ml-2 text-sm">{item.secondaryLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<div className="text-icon-color flex h-4 w-4 items-center">
|
||||
<Check />
|
||||
</div>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const { value, label, icon, secondaryLabel, disabled, ...rest } = item as SelectItem;
|
||||
SelectItemComponent.displayName = 'SelectItemComponent';
|
||||
|
||||
function SelectComponent<T = string>({
|
||||
items,
|
||||
disabled = false,
|
||||
onChange,
|
||||
placeholder = 'Select an option',
|
||||
emptyMessage = 'No options found.',
|
||||
value,
|
||||
onOpenChange,
|
||||
open: controlledOpen,
|
||||
showIndex = false,
|
||||
className,
|
||||
defaultValue,
|
||||
dataTestId,
|
||||
loading = false,
|
||||
search = false,
|
||||
clearable = false
|
||||
}: SelectProps<T>) {
|
||||
const [internalOpen, setInternalOpen] = React.useState(false);
|
||||
const [searchValue, setSearchValue] = React.useState('');
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const commandRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!disabled) {
|
||||
setInternalOpen(newOpen);
|
||||
onOpenChange?.(newOpen);
|
||||
if (!newOpen) {
|
||||
setSearchValue('');
|
||||
setIsFocused(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, onOpenChange]
|
||||
);
|
||||
|
||||
// Get all items in a flat array for easier processing
|
||||
const flatItems = React.useMemo(() => {
|
||||
if (isGroupedItems(items)) {
|
||||
return items.flatMap((group) => group.items);
|
||||
}
|
||||
return items;
|
||||
}, [items]);
|
||||
|
||||
// Find the selected item
|
||||
const selectedItem = React.useMemo(
|
||||
() => flatItems.find((item) => String(item.value) === String(value)),
|
||||
[flatItems, value]
|
||||
);
|
||||
|
||||
// Filter items based on search
|
||||
const filterItem = React.useCallback(
|
||||
(item: SelectItem<T>): boolean => {
|
||||
if (!search || !searchValue) return true;
|
||||
|
||||
if (typeof search === 'function') {
|
||||
return search(item, searchValue);
|
||||
}
|
||||
|
||||
return defaultSearchFunction(item, searchValue);
|
||||
},
|
||||
[search, searchValue]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(itemValue: string) => {
|
||||
const item = flatItems.find((i) => String(i.value) === itemValue);
|
||||
if (item) {
|
||||
onChange(item.value);
|
||||
handleOpenChange(false);
|
||||
setSearchValue('');
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[flatItems, onChange, handleOpenChange]
|
||||
);
|
||||
|
||||
const handleClear = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onChange(null);
|
||||
setSearchValue('');
|
||||
handleOpenChange(false);
|
||||
},
|
||||
[onChange, handleOpenChange]
|
||||
);
|
||||
|
||||
const handleInputChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setSearchValue(newValue);
|
||||
|
||||
if (search !== false && newValue && !open) {
|
||||
handleOpenChange(true);
|
||||
}
|
||||
},
|
||||
[search, open, handleOpenChange]
|
||||
);
|
||||
|
||||
const handleInputFocus = React.useCallback(() => {
|
||||
setIsFocused(true);
|
||||
if (!open) {
|
||||
handleOpenChange(true);
|
||||
}
|
||||
}, [open, handleOpenChange]);
|
||||
|
||||
const handleInputKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(e.key)) {
|
||||
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
e.preventDefault();
|
||||
handleOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward the event to the command component
|
||||
if (open && commandRef.current) {
|
||||
const commandInput = commandRef.current.querySelector('[cmdk-input]');
|
||||
if (commandInput) {
|
||||
const newEvent = new KeyboardEvent('keydown', {
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
keyCode: e.keyCode,
|
||||
which: e.which,
|
||||
shiftKey: e.shiftKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
altKey: e.altKey,
|
||||
metaKey: e.metaKey,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
commandInput.dispatchEvent(newEvent);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
handleOpenChange(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[open, handleOpenChange]
|
||||
);
|
||||
|
||||
// Render items with memoization to prevent unnecessary re-renders
|
||||
const renderedItems = React.useMemo(() => {
|
||||
if (isGroupedItems(items)) {
|
||||
return items.map((group, groupIndex) => {
|
||||
const filteredItems = group.items.filter(filterItem);
|
||||
if (filteredItems.length === 0 && searchValue) return null;
|
||||
|
||||
return (
|
||||
<CommandGroup key={`${group.label}-${groupIndex}`} heading={group.label}>
|
||||
{filteredItems.map((item, index) => (
|
||||
<SelectItemComponent
|
||||
key={String(item.value)}
|
||||
item={item}
|
||||
index={index}
|
||||
value={value}
|
||||
showIndex={showIndex}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const filteredItems = flatItems.filter(filterItem);
|
||||
return filteredItems.map((item, index) => (
|
||||
<SelectItemComponent
|
||||
key={String(item.value)}
|
||||
item={item}
|
||||
index={index}
|
||||
value={value}
|
||||
showIndex={showIndex}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
));
|
||||
}, [items, flatItems, filterItem, searchValue, value, showIndex, handleSelect]);
|
||||
|
||||
// Display value in input when not focused/searching
|
||||
const inputDisplayValue = React.useMemo(() => {
|
||||
if (isFocused || searchValue) {
|
||||
return searchValue;
|
||||
}
|
||||
if (selectedItem) {
|
||||
return typeof selectedItem.label === 'string' ? selectedItem.label : '';
|
||||
}
|
||||
return '';
|
||||
}, [isFocused, searchValue, selectedItem]);
|
||||
|
||||
// Compute placeholder once
|
||||
const computedPlaceholder = React.useMemo(() => {
|
||||
return typeof selectedItem?.label === 'string' ? selectedItem.label : placeholder;
|
||||
}, [selectedItem, placeholder]);
|
||||
|
||||
return (
|
||||
<SelectItemComponent
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
icon={icon}
|
||||
index={showIndex ? index : undefined}
|
||||
secondaryChildren={
|
||||
secondaryLabel && <SelectItemSecondaryText>{secondaryLabel}</SelectItemSecondaryText>
|
||||
}>
|
||||
{label}
|
||||
</SelectItemComponent>
|
||||
<PopoverRoot open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className={cn('relative w-full', className)}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-label={placeholder}
|
||||
disabled={disabled || loading}
|
||||
value={inputDisplayValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={computedPlaceholder}
|
||||
data-testid={dataTestId}
|
||||
readOnly={search === false}
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center justify-between rounded border px-2.5 text-base',
|
||||
'bg-background cursor-pointer transition-all duration-300',
|
||||
'focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
disabled ? 'bg-disabled text-gray-light' : '',
|
||||
!selectedItem && !searchValue && 'text-text-secondary'
|
||||
)}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
{clearable && selectedItem && !isFocused && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="hover:text-foreground text-icon-color pointer-events-auto mr-1 flex h-4 w-4 cursor-pointer items-center justify-center rounded"
|
||||
aria-label="Clear selection">
|
||||
<Xmark />
|
||||
</button>
|
||||
)}
|
||||
{!open && (
|
||||
<div className="flex h-4 w-4 shrink-0 items-center opacity-50">
|
||||
<ChevronDown />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}}>
|
||||
<Command ref={commandRef} shouldFilter={false}>
|
||||
{/* Hidden input that Command uses for keyboard navigation */}
|
||||
<CommandInput
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
parentClassName="sr-only hidden h-0 border-0 p-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="scrollbar-hide max-h-[300px] overflow-y-auto">
|
||||
<CommandList className="p-1">
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
{renderedItems}
|
||||
</CommandList>
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
SelectItemSelector.displayName = 'SelectItemSelector';
|
||||
const SelectItemSecondaryText: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return <span className="text-gray-light text2xs">{children}</span>;
|
||||
};
|
||||
export const Select = React.memo(SelectComponent) as typeof SelectComponent;
|
||||
|
|
|
@ -1,236 +0,0 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import React from 'react';
|
||||
import { fn } from '@storybook/test';
|
||||
import { Select, type SelectItem, type SelectProps } from './Select2';
|
||||
import { User, Gear, PowerOff } from '@/components/ui/icons/NucleoIconOutlined';
|
||||
|
||||
const meta = {
|
||||
title: 'UI/select/Select2',
|
||||
component: Select,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {
|
||||
search: {
|
||||
control: { type: 'boolean' },
|
||||
description: 'Enable/disable search functionality'
|
||||
},
|
||||
disabled: {
|
||||
control: { type: 'boolean' },
|
||||
description: 'Disable the select'
|
||||
},
|
||||
loading: {
|
||||
control: { type: 'boolean' },
|
||||
description: 'Show loading state'
|
||||
},
|
||||
showIndex: {
|
||||
control: { type: 'boolean' },
|
||||
description: 'Show index numbers for items'
|
||||
},
|
||||
placeholder: {
|
||||
control: { type: 'text' },
|
||||
description: 'Placeholder text when no item is selected'
|
||||
},
|
||||
onChange: {
|
||||
action: 'onChange'
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: '300px' }}>
|
||||
<Story />
|
||||
</div>
|
||||
)
|
||||
]
|
||||
} satisfies Meta<typeof Select>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Basic select with simple string options
|
||||
export const BasicSelect: Story = {
|
||||
args: {
|
||||
placeholder: 'Select a fruit',
|
||||
items: [
|
||||
{ value: 'apple', label: 'Apple' },
|
||||
{ value: 'banana', label: 'Banana' },
|
||||
{ value: 'orange', label: 'Orange' },
|
||||
{ value: 'grape', label: 'Grape' },
|
||||
{ value: 'strawberry', label: 'Strawberry' },
|
||||
{ value: 'watermelon', label: 'Watermelon' },
|
||||
{ value: 'pineapple', label: 'Pineapple' },
|
||||
{ value: 'mango', label: 'Mango' }
|
||||
] as SelectItem<string>[],
|
||||
onChange: fn()
|
||||
},
|
||||
render: function RenderBasicSelect(args) {
|
||||
const [value, setValue] = React.useState<string | undefined>();
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as string);
|
||||
args.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Advanced select with grouped items, icons, secondary labels, and custom search
|
||||
export const AdvancedSelect: Story = {
|
||||
args: {
|
||||
placeholder: 'Select an action',
|
||||
items: [
|
||||
{
|
||||
label: 'Account',
|
||||
items: [
|
||||
{
|
||||
value: 'profile',
|
||||
label: 'View Profile',
|
||||
icon: <User />,
|
||||
secondaryLabel: 'See your profile details'
|
||||
},
|
||||
{
|
||||
value: 'settings',
|
||||
label: 'Settings',
|
||||
icon: <Gear />,
|
||||
secondaryLabel: 'Manage your preferences'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Session',
|
||||
items: [
|
||||
{
|
||||
value: 'logout',
|
||||
label: 'Log Out',
|
||||
icon: <PowerOff />,
|
||||
secondaryLabel: 'End your session',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
value: 'switch-account',
|
||||
label: 'Switch Account',
|
||||
secondaryLabel: 'Change to another account',
|
||||
disabled: true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
search: (item, searchTerm) => {
|
||||
// Custom search that also searches in secondary labels
|
||||
const term = searchTerm.toLowerCase();
|
||||
const labelText = typeof item.label === 'string' ? item.label.toLowerCase() : '';
|
||||
const secondaryText = item.secondaryLabel?.toLowerCase() || '';
|
||||
return labelText.includes(term) || secondaryText.includes(term);
|
||||
},
|
||||
onChange: fn()
|
||||
},
|
||||
render: function RenderAdvancedSelect(args) {
|
||||
const [value, setValue] = React.useState<string | undefined>();
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as string);
|
||||
args.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Select with search disabled
|
||||
export const NoSearchSelect: Story = {
|
||||
args: {
|
||||
placeholder: 'Select a color',
|
||||
search: false,
|
||||
items: [
|
||||
{ value: 'red', label: 'Red' },
|
||||
{ value: 'green', label: 'Green' },
|
||||
{ value: 'blue', label: 'Blue' },
|
||||
{ value: 'yellow', label: 'Yellow' },
|
||||
{ value: 'purple', label: 'Purple' },
|
||||
{ value: 'orange', label: 'Orange' }
|
||||
] as SelectItem<string>[],
|
||||
onChange: fn()
|
||||
},
|
||||
render: function RenderNoSearchSelect(args) {
|
||||
const [value, setValue] = React.useState<string | undefined>();
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as string);
|
||||
args.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Select with pre-selected value
|
||||
export const PreSelectedValue: Story = {
|
||||
args: {
|
||||
placeholder: 'Select a size',
|
||||
items: [
|
||||
{ value: 'xs', label: 'Extra Small' },
|
||||
{ value: 's', label: 'Small' },
|
||||
{ value: 'm', label: 'Medium' },
|
||||
{ value: 'l', label: 'Large' },
|
||||
{ value: 'xl', label: 'Extra Large' }
|
||||
] as SelectItem<string>[],
|
||||
onChange: fn()
|
||||
},
|
||||
render: function RenderPreSelectedValue(args) {
|
||||
const [value, setValue] = React.useState<string | undefined>('m');
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as string);
|
||||
args.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Select with clearable option
|
||||
export const ClearableSelect: Story = {
|
||||
args: {
|
||||
placeholder: 'Select an option (clearable)',
|
||||
clearable: true,
|
||||
items: [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
{ value: 'option3', label: 'Option 3' },
|
||||
{ value: 'option4', label: 'Option 4' },
|
||||
{ value: 'option5', label: 'Option 5' }
|
||||
] as SelectItem<string>[],
|
||||
onChange: fn()
|
||||
},
|
||||
render: function RenderClearableSelect(args) {
|
||||
const [value, setValue] = React.useState<string | undefined>('option2');
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue as string | undefined);
|
||||
args.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
|
@ -1,381 +0,0 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandInput
|
||||
} from '@/components/ui/command';
|
||||
import { PopoverRoot, PopoverContent, PopoverTrigger } from '@/components/ui/popover/PopoverBase';
|
||||
import { cn } from '@/lib/classMerge';
|
||||
import { Check, ChevronDown, Xmark } from '@/components/ui/icons';
|
||||
|
||||
interface SelectItemGroup<T = string> {
|
||||
label: string;
|
||||
items: SelectItem<T>[];
|
||||
}
|
||||
|
||||
export interface SelectItem<T = string> {
|
||||
value: T;
|
||||
label: string | React.ReactNode; //this will be used in the select item text
|
||||
secondaryLabel?: string;
|
||||
icon?: React.ReactNode;
|
||||
searchLabel?: string; // Used for filtering
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type SearchFunction<T> = (item: SelectItem<T>, searchTerm: string) => boolean;
|
||||
|
||||
export interface SelectProps<T> {
|
||||
items: SelectItem<T>[] | SelectItemGroup<T>[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: T) => void;
|
||||
placeholder?: string;
|
||||
value?: string | undefined;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
open?: boolean;
|
||||
showIndex?: boolean;
|
||||
className?: string;
|
||||
defaultValue?: string;
|
||||
dataTestId?: string;
|
||||
loading?: boolean;
|
||||
search?: boolean | SearchFunction<T>;
|
||||
clearable?: boolean;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
function isGroupedItems<T>(
|
||||
items: SelectItem<T>[] | SelectItemGroup<T>[]
|
||||
): items is SelectItemGroup<T>[] {
|
||||
return items.length > 0 && 'items' in items[0];
|
||||
}
|
||||
|
||||
function defaultSearchFunction<T>(item: SelectItem<T>, searchTerm: string): boolean {
|
||||
const term = searchTerm.toLowerCase();
|
||||
const labelText = typeof item.label === 'string' ? item.label : '';
|
||||
const searchText = item.searchLabel || labelText;
|
||||
const valueText = String(item.value);
|
||||
|
||||
return searchText.toLowerCase().includes(term) || valueText.toLowerCase().includes(term);
|
||||
}
|
||||
|
||||
// Memoized SelectItem component to avoid re-renders
|
||||
const SelectItemComponent = React.memo(
|
||||
<T,>({
|
||||
item,
|
||||
index,
|
||||
value,
|
||||
showIndex,
|
||||
onSelect
|
||||
}: {
|
||||
item: SelectItem<T>;
|
||||
index: number;
|
||||
value: string | undefined;
|
||||
showIndex: boolean;
|
||||
onSelect: (value: string) => void;
|
||||
}) => {
|
||||
const isSelected = String(item.value) === String(value);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
value={String(item.value)}
|
||||
onSelect={onSelect}
|
||||
disabled={item.disabled}
|
||||
className={cn(
|
||||
'flex h-7 items-center gap-2 px-2',
|
||||
item.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
||||
isSelected && 'bg-item-select'
|
||||
)}>
|
||||
{item.icon}
|
||||
<span className="flex-1">
|
||||
{showIndex && `${index + 1}. `}
|
||||
{item.label}
|
||||
{item.secondaryLabel && (
|
||||
<span className="text-text-secondary ml-2 text-sm">{item.secondaryLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<div className="text-icon-color flex h-4 w-4 items-center">
|
||||
<Check />
|
||||
</div>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SelectItemComponent.displayName = 'SelectItemComponent';
|
||||
|
||||
function SelectComponent<T = string>({
|
||||
items,
|
||||
disabled = false,
|
||||
onChange,
|
||||
placeholder = 'Select an option',
|
||||
emptyMessage = 'No options found.',
|
||||
value,
|
||||
onOpenChange,
|
||||
open: controlledOpen,
|
||||
showIndex = false,
|
||||
className,
|
||||
defaultValue,
|
||||
dataTestId,
|
||||
loading = false,
|
||||
search = false,
|
||||
clearable = false
|
||||
}: SelectProps<T>) {
|
||||
const [internalOpen, setInternalOpen] = React.useState(false);
|
||||
const [searchValue, setSearchValue] = React.useState('');
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const commandRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!disabled) {
|
||||
setInternalOpen(newOpen);
|
||||
onOpenChange?.(newOpen);
|
||||
if (!newOpen) {
|
||||
setSearchValue('');
|
||||
setIsFocused(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, onOpenChange]
|
||||
);
|
||||
|
||||
// Get all items in a flat array for easier processing
|
||||
const flatItems = React.useMemo(() => {
|
||||
if (isGroupedItems(items)) {
|
||||
return items.flatMap((group) => group.items);
|
||||
}
|
||||
return items;
|
||||
}, [items]);
|
||||
|
||||
// Find the selected item
|
||||
const selectedItem = React.useMemo(
|
||||
() => flatItems.find((item) => String(item.value) === String(value)),
|
||||
[flatItems, value]
|
||||
);
|
||||
|
||||
// Filter items based on search
|
||||
const filterItem = React.useCallback(
|
||||
(item: SelectItem<T>): boolean => {
|
||||
if (!search || !searchValue) return true;
|
||||
|
||||
if (typeof search === 'function') {
|
||||
return search(item, searchValue);
|
||||
}
|
||||
|
||||
return defaultSearchFunction(item, searchValue);
|
||||
},
|
||||
[search, searchValue]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(itemValue: string) => {
|
||||
const item = flatItems.find((i) => String(i.value) === itemValue);
|
||||
if (item) {
|
||||
onChange(item.value);
|
||||
handleOpenChange(false);
|
||||
setSearchValue('');
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[flatItems, onChange, handleOpenChange]
|
||||
);
|
||||
|
||||
const handleClear = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onChange(undefined as any);
|
||||
setSearchValue('');
|
||||
handleOpenChange(false);
|
||||
},
|
||||
[onChange, handleOpenChange]
|
||||
);
|
||||
|
||||
const handleInputChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setSearchValue(newValue);
|
||||
|
||||
if (search !== false && newValue && !open) {
|
||||
handleOpenChange(true);
|
||||
}
|
||||
},
|
||||
[search, open, handleOpenChange]
|
||||
);
|
||||
|
||||
const handleInputFocus = React.useCallback(() => {
|
||||
setIsFocused(true);
|
||||
if (!open) {
|
||||
handleOpenChange(true);
|
||||
}
|
||||
}, [open, handleOpenChange]);
|
||||
|
||||
const handleInputKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(e.key)) {
|
||||
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
e.preventDefault();
|
||||
handleOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward the event to the command component
|
||||
if (open && commandRef.current) {
|
||||
const commandInput = commandRef.current.querySelector('[cmdk-input]');
|
||||
if (commandInput) {
|
||||
const newEvent = new KeyboardEvent('keydown', {
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
keyCode: e.keyCode,
|
||||
which: e.which,
|
||||
shiftKey: e.shiftKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
altKey: e.altKey,
|
||||
metaKey: e.metaKey,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
commandInput.dispatchEvent(newEvent);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
handleOpenChange(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[open, handleOpenChange]
|
||||
);
|
||||
|
||||
// Render items with memoization to prevent unnecessary re-renders
|
||||
const renderedItems = React.useMemo(() => {
|
||||
if (isGroupedItems(items)) {
|
||||
return items.map((group, groupIndex) => {
|
||||
const filteredItems = group.items.filter(filterItem);
|
||||
if (filteredItems.length === 0 && searchValue) return null;
|
||||
|
||||
return (
|
||||
<CommandGroup key={`${group.label}-${groupIndex}`} heading={group.label}>
|
||||
{filteredItems.map((item, index) => (
|
||||
<SelectItemComponent
|
||||
key={String(item.value)}
|
||||
item={item}
|
||||
index={index}
|
||||
value={value}
|
||||
showIndex={showIndex}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const filteredItems = flatItems.filter(filterItem);
|
||||
return filteredItems.map((item, index) => (
|
||||
<SelectItemComponent
|
||||
key={String(item.value)}
|
||||
item={item}
|
||||
index={index}
|
||||
value={value}
|
||||
showIndex={showIndex}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
));
|
||||
}, [items, flatItems, filterItem, searchValue, value, showIndex, handleSelect]);
|
||||
|
||||
// Display value in input when not focused/searching
|
||||
const inputDisplayValue = React.useMemo(() => {
|
||||
if (isFocused || searchValue) {
|
||||
return searchValue;
|
||||
}
|
||||
if (selectedItem) {
|
||||
return typeof selectedItem.label === 'string' ? selectedItem.label : '';
|
||||
}
|
||||
return '';
|
||||
}, [isFocused, searchValue, selectedItem]);
|
||||
|
||||
// Compute placeholder once
|
||||
const computedPlaceholder = React.useMemo(() => {
|
||||
return typeof selectedItem?.label === 'string' ? selectedItem.label : placeholder;
|
||||
}, [selectedItem, placeholder]);
|
||||
|
||||
return (
|
||||
<PopoverRoot open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className={cn('relative w-full', className)}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-label={placeholder}
|
||||
disabled={disabled || loading}
|
||||
value={inputDisplayValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={computedPlaceholder}
|
||||
data-testid={dataTestId}
|
||||
readOnly={search === false}
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center justify-between rounded border px-2.5 text-base',
|
||||
'bg-background cursor-pointer transition-all duration-300',
|
||||
'focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
disabled ? 'bg-disabled text-gray-light' : '',
|
||||
!selectedItem && !searchValue && 'text-text-secondary'
|
||||
)}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
{clearable && selectedItem && !isFocused && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="hover:text-foreground text-icon-color pointer-events-auto mr-1 flex h-4 w-4 cursor-pointer items-center justify-center rounded"
|
||||
aria-label="Clear selection">
|
||||
<Xmark />
|
||||
</button>
|
||||
)}
|
||||
{!open && (
|
||||
<div className="flex h-4 w-4 shrink-0 items-center opacity-50">
|
||||
<ChevronDown />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}}>
|
||||
<Command ref={commandRef} shouldFilter={false}>
|
||||
{/* Hidden input that Command uses for keyboard navigation */}
|
||||
<CommandInput
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
parentClassName="sr-only hidden h-0 border-0 p-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="scrollbar-hide max-h-[300px] overflow-y-auto">
|
||||
<CommandList className="p-1">
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
{renderedItems}
|
||||
</CommandList>
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export const Select = React.memo(SelectComponent) as typeof SelectComponent;
|
|
@ -4,7 +4,7 @@ import { faker } from '@faker-js/faker';
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { fn } from '@storybook/test';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { SelectItem } from './Select';
|
||||
import type { SelectItem } from './SelectOld';
|
||||
import { SelectMultiple } from './SelectMultiple';
|
||||
|
||||
const meta = {
|
||||
|
|
|
@ -6,7 +6,7 @@ import { useMemoizedFn } from '@/hooks';
|
|||
import { cn } from '@/lib/classMerge';
|
||||
import { Dropdown, type DropdownItem, type DropdownProps } from '../dropdown/Dropdown';
|
||||
import { InputTag } from '../inputs/InputTag';
|
||||
import type { SelectItem } from './Select';
|
||||
import type { SelectItem } from './SelectOld';
|
||||
import { selectVariants } from './SelectBase';
|
||||
import { CircleSpinnerLoader } from '../loaders';
|
||||
|
||||
|
|
|
@ -0,0 +1,127 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { fn } from '@storybook/test';
|
||||
import { Mailbox, MapSettings, User } from '../icons';
|
||||
import { Select } from './SelectOld';
|
||||
|
||||
const meta: Meta<typeof Select> = {
|
||||
title: 'UI/Select/Select',
|
||||
component: Select,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
args: {
|
||||
onChange: fn()
|
||||
},
|
||||
tags: ['autodocs']
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Select>;
|
||||
|
||||
const basicItems = [
|
||||
{ value: 'apples', label: 'Apples' },
|
||||
{ value: 'bananas', label: 'Bananas' },
|
||||
{ value: 'cherries', label: 'Cherries' }
|
||||
];
|
||||
|
||||
const itemsWithIcons = [
|
||||
{ value: 'profile', label: 'Profile', icon: <User /> },
|
||||
{ value: 'settings', label: 'Settings', icon: <MapSettings /> },
|
||||
{ value: 'messages', label: 'Messages', icon: <Mailbox /> }
|
||||
];
|
||||
|
||||
const groupedItems = [
|
||||
{
|
||||
label: 'Fruits',
|
||||
items: [
|
||||
{ value: 'apple', label: 'Apple' },
|
||||
{ value: 'banana', label: 'Banana' },
|
||||
{ value: 'orange', label: 'Orange' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Vegetables',
|
||||
items: [
|
||||
{ value: 'carrot', label: 'Carrot' },
|
||||
{ value: 'broccoli', label: 'Broccoli' },
|
||||
{ value: 'spinach', label: 'Spinach' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const itemsWithSecondaryLabel = [
|
||||
{ value: 'user1', label: 'John Doe', secondaryLabel: 'Admin' },
|
||||
{ value: 'user2', label: 'Jane Smith', secondaryLabel: 'Editor' },
|
||||
{ value: 'user3', label: 'Bob Johnson', secondaryLabel: 'Viewer', disabled: true }
|
||||
];
|
||||
|
||||
const itemsWithSomeDisabled = [
|
||||
{ value: 'active1', label: 'Available Option 1' },
|
||||
{ value: 'disabled1', label: 'Unavailable Option 1', disabled: true },
|
||||
{ value: 'active2', label: 'Available Option 2' },
|
||||
{ value: 'disabled2', label: 'Unavailable Option 2', disabled: true },
|
||||
{ value: 'active3', label: 'Available Option 3' }
|
||||
];
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
items: basicItems,
|
||||
placeholder: 'Select an option'
|
||||
}
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
args: {
|
||||
items: itemsWithIcons,
|
||||
placeholder: 'Select an option'
|
||||
}
|
||||
};
|
||||
|
||||
export const Grouped: Story = {
|
||||
args: {
|
||||
items: groupedItems,
|
||||
placeholder: 'Select an option'
|
||||
}
|
||||
};
|
||||
|
||||
export const WithSecondaryLabels: Story = {
|
||||
args: {
|
||||
items: itemsWithSecondaryLabel,
|
||||
placeholder: 'Select a user'
|
||||
}
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
items: basicItems,
|
||||
placeholder: 'Select an option',
|
||||
disabled: true
|
||||
}
|
||||
};
|
||||
|
||||
export const WithShowIndex: Story = {
|
||||
args: {
|
||||
items: basicItems,
|
||||
placeholder: 'Select an option',
|
||||
showIndex: true
|
||||
}
|
||||
};
|
||||
|
||||
export const PartiallyDisabled: Story = {
|
||||
args: {
|
||||
items: itemsWithSomeDisabled,
|
||||
placeholder: 'Select an available option'
|
||||
}
|
||||
};
|
||||
|
||||
export const WithLowCharacters: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{
|
||||
value: 'gyj',
|
||||
label: 'gyj - GYJ'
|
||||
}
|
||||
],
|
||||
placeholder: 'Select an option'
|
||||
}
|
||||
};
|
|
@ -0,0 +1,129 @@
|
|||
import type React from 'react';
|
||||
import { useMemoizedFn } from '@/hooks';
|
||||
import {
|
||||
Select as SelectBase,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem as SelectItemComponent,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './SelectBase';
|
||||
import { CircleSpinnerLoader } from '../loaders';
|
||||
import { cn } from '@/lib/classMerge';
|
||||
|
||||
interface SelectItemGroup<T = string> {
|
||||
label: string;
|
||||
items: SelectItem<T>[];
|
||||
}
|
||||
|
||||
export interface SelectItem<T = string> {
|
||||
value: T;
|
||||
label: string | React.ReactNode; //this will be used in the select item text
|
||||
secondaryLabel?: string;
|
||||
icon?: React.ReactNode;
|
||||
searchLabel?: string; // Used for filtering
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectProps<T> {
|
||||
items: SelectItem<T>[] | SelectItemGroup[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: T) => void;
|
||||
placeholder?: string;
|
||||
value?: string | undefined;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
open?: boolean;
|
||||
showIndex?: boolean;
|
||||
className?: string;
|
||||
defaultValue?: string;
|
||||
dataTestId?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Select = <T extends string>({
|
||||
items,
|
||||
showIndex,
|
||||
disabled,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
onOpenChange,
|
||||
open,
|
||||
loading = false,
|
||||
className = '',
|
||||
defaultValue,
|
||||
dataTestId
|
||||
}: SelectProps<T>) => {
|
||||
const onValueChange = useMemoizedFn((value: string) => {
|
||||
onChange(value as T);
|
||||
});
|
||||
return (
|
||||
<SelectBase
|
||||
disabled={disabled}
|
||||
onOpenChange={onOpenChange}
|
||||
open={open}
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
onValueChange={onValueChange}>
|
||||
<SelectTrigger className={className} data-testid={dataTestId} loading={loading}>
|
||||
<SelectValue placeholder={placeholder} defaultValue={value || defaultValue} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((item, index) => (
|
||||
<SelectItemSelector
|
||||
key={index.toString()}
|
||||
item={item}
|
||||
index={index}
|
||||
showIndex={showIndex}
|
||||
/>
|
||||
))}
|
||||
</SelectContent>
|
||||
</SelectBase>
|
||||
);
|
||||
};
|
||||
Select.displayName = 'Select';
|
||||
|
||||
const SelectItemSelector = <T,>({
|
||||
item,
|
||||
index,
|
||||
showIndex
|
||||
}: {
|
||||
item: SelectItem<T> | SelectItemGroup;
|
||||
index: number;
|
||||
showIndex?: boolean;
|
||||
}) => {
|
||||
const isGroup = typeof item === 'object' && 'items' in item;
|
||||
|
||||
if (isGroup) {
|
||||
const _item = item as SelectItemGroup;
|
||||
return (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{_item.label}</SelectLabel>
|
||||
{_item.items?.map((item) => (
|
||||
<SelectItemSelector key={item.value} item={item} index={index} showIndex={showIndex} />
|
||||
))}
|
||||
</SelectGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const { value, label, icon, secondaryLabel, disabled, ...rest } = item as SelectItem;
|
||||
|
||||
return (
|
||||
<SelectItemComponent
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
icon={icon}
|
||||
index={showIndex ? index : undefined}
|
||||
secondaryChildren={
|
||||
secondaryLabel && <SelectItemSecondaryText>{secondaryLabel}</SelectItemSecondaryText>
|
||||
}>
|
||||
{label}
|
||||
</SelectItemComponent>
|
||||
);
|
||||
};
|
||||
|
||||
SelectItemSelector.displayName = 'SelectItemSelector';
|
||||
const SelectItemSecondaryText: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return <span className="text-gray-light text2xs">{children}</span>;
|
||||
};
|
|
@ -1 +1 @@
|
|||
export * from './Select';
|
||||
export * from './SelectOld';
|
||||
|
|
|
@ -19,7 +19,9 @@ export const combineParallelResultsOutputSchema = z.object({
|
|||
userId: z.string().describe('User ID for the current operation'),
|
||||
chatId: z.string().describe('Chat ID for the current operation'),
|
||||
isFollowUp: z.boolean().describe('Whether this is a follow-up message'),
|
||||
isSlackFollowUp: z.boolean().describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
isSlackFollowUp: z
|
||||
.boolean()
|
||||
.describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
previousMessages: z.array(z.string()).describe('Array of previous messages for context'),
|
||||
datasets: z.string().describe('Assembled YAML content of all available datasets for context'),
|
||||
|
||||
|
|
|
@ -15,7 +15,9 @@ const inputSchema = z.object({
|
|||
userId: z.string().describe('User ID for the current operation'),
|
||||
chatId: z.string().describe('Chat ID for the current operation'),
|
||||
isFollowUp: z.boolean().describe('Whether this is a follow-up message'),
|
||||
isSlackFollowUp: z.boolean().describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
isSlackFollowUp: z
|
||||
.boolean()
|
||||
.describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
previousMessages: z.array(z.string()).describe('Array of previous messages for context'),
|
||||
datasets: z.string().describe('Assembled YAML content of all available datasets for context'),
|
||||
});
|
||||
|
@ -28,7 +30,9 @@ export const flagChatOutputSchema = z.object({
|
|||
userId: z.string().describe('User ID for the current operation'),
|
||||
chatId: z.string().describe('Chat ID for the current operation'),
|
||||
isFollowUp: z.boolean().describe('Whether this is a follow-up message'),
|
||||
isSlackFollowUp: z.boolean().describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
isSlackFollowUp: z
|
||||
.boolean()
|
||||
.describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
previousMessages: z.array(z.string()).describe('Array of previous messages for context'),
|
||||
datasets: z.string().describe('Assembled YAML content of all available datasets for context'),
|
||||
|
||||
|
|
|
@ -18,7 +18,9 @@ const inputSchema = z.object({
|
|||
userId: z.string().describe('User ID for the current operation'),
|
||||
chatId: z.string().describe('Chat ID for the current operation'),
|
||||
isFollowUp: z.boolean().describe('Whether this is a follow-up message'),
|
||||
isSlackFollowUp: z.boolean().describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
isSlackFollowUp: z
|
||||
.boolean()
|
||||
.describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
previousMessages: z.array(z.string()).describe('Array of previous messages for context'),
|
||||
datasets: z.string().describe('Assembled YAML content of all available datasets for context'),
|
||||
});
|
||||
|
@ -31,7 +33,9 @@ export const identifyAssumptionsOutputSchema = z.object({
|
|||
userId: z.string().describe('User ID for the current operation'),
|
||||
chatId: z.string().describe('Chat ID for the current operation'),
|
||||
isFollowUp: z.boolean().describe('Whether this is a follow-up message'),
|
||||
isSlackFollowUp: z.boolean().describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
isSlackFollowUp: z
|
||||
.boolean()
|
||||
.describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
previousMessages: z.array(z.string()).describe('Array of previous messages for context'),
|
||||
datasets: z.string().describe('Assembled YAML content of all available datasets for context'),
|
||||
|
||||
|
|
|
@ -9,7 +9,9 @@ export const postProcessingWorkflowInputSchema = z.object({
|
|||
userId: z.string().describe('User ID for the current operation'),
|
||||
chatId: z.string().describe('Chat ID for the current operation'),
|
||||
isFollowUp: z.boolean().describe('Whether this is a follow-up message'),
|
||||
isSlackFollowUp: z.boolean().describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
isSlackFollowUp: z
|
||||
.boolean()
|
||||
.describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
previousMessages: z.array(z.string()).describe('Array of the previous post-processing messages'),
|
||||
datasets: z.string().describe('Assembled YAML content of all available datasets for context'),
|
||||
});
|
||||
|
@ -23,7 +25,9 @@ export const postProcessingWorkflowOutputSchema = z.object({
|
|||
userId: z.string().describe('User ID for the current operation'),
|
||||
chatId: z.string().describe('Chat ID for the current operation'),
|
||||
isFollowUp: z.boolean().describe('Whether this is a follow-up message'),
|
||||
isSlackFollowUp: z.boolean().describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
isSlackFollowUp: z
|
||||
.boolean()
|
||||
.describe('Whether this is a follow-up message for an existing Slack thread'),
|
||||
previousMessages: z.array(z.string()).describe('Array of the previous post-processing messages'),
|
||||
datasets: z.string().describe('Assembled YAML content of all available datasets for context'),
|
||||
|
||||
|
|
|
@ -30,7 +30,8 @@ export function validateAndAdjustBarLineAxes(metricYml: MetricYml): AxisValidati
|
|||
return {
|
||||
isValid: false,
|
||||
shouldSwapAxes: false,
|
||||
error: 'Bar and line charts require at least one column for each axis. Please specify both X and Y axis columns.',
|
||||
error:
|
||||
'Bar and line charts require at least one column for each axis. Please specify both X and Y axis columns.',
|
||||
};
|
||||
}
|
||||
const xColumns = barAndLineAxis.x;
|
||||
|
|
Loading…
Reference in New Issue