feat: Enhance data source select with context-aware icons and inline actions (#1948)

## Summary

Closes [HDX-3784](https://linear.app/clickhouse/issue/HDX-3784/increase-discoverability-of-trace-sources)

- **Context-aware icons for all source kinds**: The data source dropdown now shows icons for every source type — `IconLogs` for logs, `IconConnection` for traces, `IconDeviceLaptop` for sessions, and `IconChartLine` for metrics. Falls back to `IconStack` when no source is selected.
- **Inline source actions**: "Create New Source" and "Edit Sources" actions are now part of the dropdown itself under an "Actions" group with a labeled separator, replacing the separate gear icon menu (`SourceEditMenu`).
- **Dependency update**: Updated `@tabler/icons-react` from v3.5.0 to v3.40.0 to get `IconConnection`.
- **Fix: source management regression when `HDX_LOCAL_DEFAULT_SOURCES` is set**: Before this PR, there were two ways to create/edit sources: (1) options inside the dropdown, which were hidden when `HDX_LOCAL_DEFAULT_SOURCES` is set, and (2) a gear icon button next to the dropdown, which was always visible. This PR removed the gear icon and kept only the dropdown options, but they were still configured to hide when `HDX_LOCAL_DEFAULT_SOURCES` is set — leaving users with no way to manage sources. Fixed by removing that guard so the dropdown options always appear.

<img width="1236" height="492" alt="image" src="https://github.com/user-attachments/assets/6999626b-685b-4037-a003-b09018cfbadf" />

<img width="426" height="240" alt="Screenshot 2026-03-20 at 17 49 30" src="https://github.com/user-attachments/assets/28aaef44-7574-4c54-b721-b2a3a79b3507" />

## Changes

- `packages/app/src/components/SourceSelect.tsx` -- Dynamic left icon based on selected source kind (all 4 kinds: log, trace, session, metric), `onEdit` prop, grouped action items with icons, `renderOption` for source kind and action item icons. Removed `hasLocalDefaultSources` guard so source management actions are always available.
- `packages/app/src/components/SelectControlled.tsx` -- Added `onEdit` callback support, fixed `selected` check to handle grouped data.
- `packages/app/src/DBSearchPage.tsx` -- Removed `SourceEditMenu` component, added `onEditSources` callback, wired `onEdit` to `SourceSelectControlled`.
- `packages/app/styles/SourceSelectControlled.module.scss` -- Group label separator styling with semantic `--color-border` token.
- `packages/app/package.json` -- Updated `@tabler/icons-react` to `^3.39.0`.

## Test plan

- [ ] Select a log source and verify `IconLogs` appears as the left icon
- [ ] Select a trace source and verify `IconConnection` appears as the left icon
- [ ] Select a session source and verify `IconDeviceLaptop` appears as the left icon
- [ ] Select a metric source and verify `IconChartLine` appears as the left icon
- [ ] Verify each source in the dropdown shows its corresponding kind icon
- [ ] Open the dropdown and verify "Create New Source" and "Edit Sources" appear under the "Actions" group with icons
- [ ] Click "Create New Source" and verify the modal opens
- [ ] Click "Edit Sources" and verify navigation to edit (local mode: modal, cloud mode: /team)
- [ ] Verify the gear icon menu is no longer present next to the select
- [ ] **With `NEXT_PUBLIC_HDX_LOCAL_DEFAULT_SOURCES` set**: verify "Create New Source" and "Edit Sources" still appear in the dropdown and work correctly
This commit is contained in:
Elizabet Oliveira 2026-03-20 19:21:51 +00:00 committed by GitHub
parent e1cf4bca56
commit 3d15b3de93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 197 additions and 157 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Enhance data source select with context-aware icons and inline actions

View file

@ -42,7 +42,7 @@
"@mantine/notifications": "^7.17.8",
"@mantine/spotlight": "^7.17.8",
"@microsoft/fetch-event-source": "^2.0.1",
"@tabler/icons-react": "^3.5.0",
"@tabler/icons-react": "^3.39.0",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-query-devtools": "^5.56.2",
"@tanstack/react-table": "^8.7.9",

View file

@ -11,7 +11,6 @@ import {
} from 'react';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import Link from 'next/link';
import router from 'next/router';
import {
parseAsBoolean,
@ -51,7 +50,6 @@ import {
Flex,
Grid,
Group,
Menu,
Modal,
Paper,
Select,
@ -67,10 +65,8 @@ import {
import { notifications } from '@mantine/notifications';
import {
IconBolt,
IconCirclePlus,
IconPlayerPlay,
IconPlus,
IconSettings,
IconTags,
IconX,
} from '@tabler/icons-react';
@ -191,59 +187,6 @@ export function getDefaultSourceId(
return sources[0].id;
}
function SourceEditMenu({
setModalOpen,
setModelFormExpanded,
}: {
setModalOpen: (val: SetStateAction<boolean>) => void;
setModelFormExpanded: (val: SetStateAction<boolean>) => void;
}) {
return (
<Menu withArrow position="bottom-start">
<Menu.Target>
<ActionIcon
data-testid="source-settings-menu"
variant="subtle"
size="sm"
title="Edit Source"
>
<Text size="xs">
<IconSettings size={14} />
</Text>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Sources</Menu.Label>
<Menu.Item
data-testid="create-new-source-menu-item"
leftSection={<IconCirclePlus size={14} />}
onClick={() => setModalOpen(true)}
>
Create New Source
</Menu.Item>
{IS_LOCAL_MODE ? (
<Menu.Item
data-testid="edit-sources-menu-item"
leftSection={<IconSettings size={14} />}
onClick={() => setModelFormExpanded(v => !v)}
>
Edit Source
</Menu.Item>
) : (
<Menu.Item
data-testid="edit-sources-menu-item"
leftSection={<IconSettings size={14} />}
component={Link}
href="/team"
>
Edit Sources
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
);
}
function SourceEditModal({
opened,
onClose,
@ -1568,6 +1511,14 @@ function DBSearchPage() {
setModelFormExpanded(false);
}, [setModelFormExpanded]);
const onEditSources = useCallback(() => {
if (IS_LOCAL_MODE) {
setModelFormExpanded(v => !v);
} else {
router.push('/team');
}
}, [setModelFormExpanded]);
const setNewSourceModalClosed = useCallback(
() => setNewSourceModalOpened(false),
[setNewSourceModalOpened],
@ -1609,22 +1560,18 @@ function DBSearchPage() {
>
{/* <DevTool control={control} /> */}
<Flex gap="sm" px="sm" pt="sm" wrap="nowrap">
<Group gap="4px" wrap="nowrap" style={{ minWidth: 150 }}>
<SourceSelectControlled
key={`${savedSearchId}`}
size="xs"
control={control}
name="source"
onCreate={openNewSourceModal}
allowedSourceKinds={ALLOWED_SOURCE_KINDS}
data-testid="source-selector"
sourceSchemaPreview={sourceSchemaPreview}
/>
<SourceEditMenu
setModalOpen={setNewSourceModalOpened}
setModelFormExpanded={setModelFormExpanded}
/>
</Group>
<SourceSelectControlled
key={`${savedSearchId}`}
size="xs"
control={control}
name="source"
onCreate={openNewSourceModal}
onEdit={onEditSources}
allowedSourceKinds={ALLOWED_SOURCE_KINDS}
data-testid="source-selector"
sourceSchemaPreview={sourceSchemaPreview}
style={{ minWidth: 150 }}
/>
<Box style={{ flex: '1 1 0%', minWidth: 100 }}>
<SQLInlineEditorControlled
tableConnection={inputSourceTableConnection}

View file

@ -2,9 +2,15 @@ import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { Select, SelectProps } from '@mantine/core';
export enum SelectControlledSpecialValues {
CreateNewValue = '_create_new_value',
EditValue = '_edit_value',
}
export type SelectControlledProps = SelectProps &
UseControllerProps<any> & {
onCreate?: () => void;
onEdit?: () => void;
allowDeselect?: boolean;
};
@ -19,35 +25,47 @@ export default function SelectControlled(props: SelectControlledProps) {
},
fieldState,
} = useController(props);
const { onCreate, allowDeselect = true, ...restProps } = props;
const { onCreate, onEdit, allowDeselect = true, ...restProps } = props;
// This is needed as mantine does not clear the select
// if the value is not in the data after
// if it was previously in the data (ex. data was deleted)
const selected = props.data?.find(d =>
typeof d === 'string'
? d === fieldValue
: 'value' in d
? d.value === fieldValue
: true,
);
// Mantine does not clear the select if the value is removed from data
// after it was previously present (ex. data was deleted)
const selected = props.data?.some(d => {
if (typeof d === 'string') return d === fieldValue;
if ('value' in d) return d.value === fieldValue;
if ('items' in d) {
return d.items.some(item =>
typeof item === 'string'
? item === fieldValue
: item.value === fieldValue,
);
}
return false;
});
const onChange = useCallback(
(value: string | null) => {
if (value === '_create_new_value' && onCreate != null) {
if (
value === SelectControlledSpecialValues.CreateNewValue &&
onCreate != null
) {
onCreate();
} else if (
value === SelectControlledSpecialValues.EditValue &&
onEdit != null
) {
onEdit();
} else if (value !== null || allowDeselect) {
fieldOnChange(value);
}
},
[fieldOnChange, onCreate, allowDeselect],
[fieldOnChange, onCreate, onEdit, allowDeselect],
);
return (
<Select
{...restProps}
error={fieldState.error?.message}
value={selected == null ? null : fieldValue}
value={selected ? fieldValue : null}
onChange={onChange}
onBlur={fieldOnBlur}
name={fieldName}

View file

@ -1,12 +1,26 @@
import { memo, useMemo } from 'react';
import { UseControllerProps } from 'react-hook-form';
import { memo, useCallback, useMemo } from 'react';
import { UseControllerProps, useWatch } from 'react-hook-form';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import { SelectProps, UnstyledButton } from '@mantine/core';
import { ComboboxChevron } from '@mantine/core';
import { IconStack } from '@tabler/icons-react';
import {
ComboboxChevron,
ComboboxItem,
Group,
SelectProps,
UnstyledButton,
} from '@mantine/core';
import {
IconChartLine,
IconConnection,
IconDeviceLaptop,
IconLogs,
IconPlus,
IconSettings,
IconStack,
} from '@tabler/icons-react';
import SelectControlled from '@/components/SelectControlled';
import { HDX_LOCAL_DEFAULT_SOURCES } from '@/config';
import SelectControlled, {
SelectControlledSpecialValues,
} from '@/components/SelectControlled';
import { useSources } from '@/source';
import styles from '../../styles/SourceSelectControlled.module.scss';
@ -43,9 +57,22 @@ export const SourceSelectRightSection = ({
};
};
const SOURCE_KIND_ICONS: Record<string, React.ReactNode> = {
[SourceKind.Log]: <IconLogs size={16} />,
[SourceKind.Trace]: <IconConnection size={16} />,
[SourceKind.Session]: <IconDeviceLaptop size={16} />,
[SourceKind.Metric]: <IconChartLine size={16} />,
};
const OPTION_ICONS: Record<string, React.ReactNode> = {
[SelectControlledSpecialValues.CreateNewValue]: <IconPlus size={14} />,
[SelectControlledSpecialValues.EditValue]: <IconSettings size={14} />,
};
function SourceSelectControlledComponent({
size,
onCreate,
onEdit,
allowedSourceKinds,
connectionId,
comboboxProps,
@ -54,41 +81,86 @@ function SourceSelectControlledComponent({
}: {
size?: string;
onCreate?: () => void;
onEdit?: () => void;
allowedSourceKinds?: SourceKind[];
connectionId?: string;
sourceSchemaPreview?: React.ReactNode;
} & UseControllerProps<any> &
SelectProps) {
const { data } = useSources();
const hasLocalDefaultSources = !!HDX_LOCAL_DEFAULT_SOURCES;
const selectedSourceId = useWatch({
control: props.control,
name: props.name,
});
const values = useMemo(
() => [
...(
data
?.filter(
source =>
(!allowedSourceKinds ||
allowedSourceKinds.includes(source.kind)) &&
(!connectionId || source.connection === connectionId),
)
.map(d => ({
value: d.id,
label: d.name,
})) ?? []
).sort((a, b) => a.label.localeCompare(b.label)),
...(onCreate && !hasLocalDefaultSources
? [
{
value: '_create_new_value',
label: 'Create New Source',
},
]
: []),
],
[data, onCreate, allowedSourceKinds, connectionId, hasLocalDefaultSources],
const selectedSourceKind = useMemo(
() => data?.find(s => s.id === selectedSourceId)?.kind,
[data, selectedSourceId],
);
const leftIcon = SOURCE_KIND_ICONS[selectedSourceKind ?? ''] ?? (
<IconStack size={16} />
);
const sourceKindMap = useMemo(() => {
const map = new Map<string, SourceKind>();
data?.forEach(s => map.set(s.id, s.kind));
return map;
}, [data]);
const renderOption = useCallback(
({ option }: { option: ComboboxItem }) => {
const icon =
OPTION_ICONS[option.value] ??
SOURCE_KIND_ICONS[sourceKindMap.get(option.value) ?? ''];
if (!icon) return option.label;
return (
<Group gap="xs" wrap="nowrap">
{icon}
{option.label}
</Group>
);
},
[sourceKindMap],
);
const hasActions = !!onCreate || !!onEdit;
const values = useMemo(() => {
const sourceItems = (
data
?.filter(
source =>
(!allowedSourceKinds || allowedSourceKinds.includes(source.kind)) &&
(!connectionId || source.connection === connectionId),
)
.map(d => ({
value: d.id,
label: d.name,
})) ?? []
).sort((a, b) => a.label.localeCompare(b.label));
if (!hasActions) {
return sourceItems;
}
const actionItems: { value: string; label: string }[] = [];
if (onCreate) {
actionItems.push({
value: SelectControlledSpecialValues.CreateNewValue,
label: 'Create New Source',
});
}
if (onEdit) {
actionItems.push({
value: SelectControlledSpecialValues.EditValue,
label: 'Edit Sources',
});
}
return [...sourceItems, { group: 'Actions', items: actionItems }];
}, [data, onCreate, onEdit, allowedSourceKinds, connectionId, hasActions]);
const rightSectionProps = SourceSelectRightSection({ sourceSchemaPreview });
return (
@ -96,12 +168,15 @@ function SourceSelectControlledComponent({
{...props}
data={values}
comboboxProps={{ withinPortal: false, ...comboboxProps }}
classNames={{ groupLabel: styles.groupLabel }}
renderOption={renderOption}
searchable
placeholder="Data Source"
leftSection={<IconStack size={16} />}
leftSection={leftIcon}
maxDropdownHeight={280}
size={size}
onCreate={onCreate}
onEdit={onEdit}
{...rightSectionProps}
/>
);

View file

@ -10,3 +10,9 @@
cursor: pointer;
}
}
.groupLabel {
&::after {
border-color: var(--color-border);
}
}

View file

@ -102,15 +102,13 @@ test.describe('Sources Functionality', { tag: ['@sources'] }, () => {
await searchPage.goto();
});
test('should open source settings menu', async () => {
// Click source settings menu
await searchPage.sourceMenu.click();
test('should show source actions in dropdown', async () => {
// Open source selector dropdown
await searchPage.sourceDropdown.click();
// Verify create new source menu item is visible
// Verify action items are visible in the dropdown
await expect(searchPage.createNewSourceItem).toBeVisible();
// Verify edit source menu items are visible
await expect(searchPage.editSourceMenuItem).toBeVisible();
await expect(searchPage.editSourcesItem).toBeVisible();
});
test(
@ -144,7 +142,7 @@ test.describe('Sources Functionality', { tag: ['@sources'] }, () => {
);
test('should show proper fields when creating a new source', async () => {
await searchPage.sourceMenu.click();
await searchPage.sourceDropdown.click();
await searchPage.createNewSourceItem.click();
// for each source type (log, trace, session, metric), verify the correct fields are shown
for (const sourceData of allSourcesData) {

View file

@ -251,7 +251,7 @@ async function globalSetup(_config: FullConfig) {
await page.goto('/search', { timeout: PAGE_LOAD_TIMEOUT_MS });
// Wait for source selector to be ready (indicates sources are loaded)
await page.waitForSelector('[data-testid="source-settings-menu"]', {
await page.waitForSelector('[data-testid="source-selector"]', {
state: 'visible',
timeout: SOURCE_SELECTOR_TIMEOUT_MS,
});

View file

@ -25,8 +25,6 @@ export class SearchPage {
readonly savedSearchModal: SavedSearchModalComponent;
readonly alertModal: SearchPageAlertModalComponent;
readonly defaultTimeout: number = 3000;
readonly editSourceMenuItem: Locator;
private readonly alertsButtonLocator: Locator;
// Page-specific locators
@ -39,8 +37,6 @@ export class SearchPage {
private readonly luceneTab: Locator;
private readonly sqlTab: Locator;
private readonly sourceSelector: Locator;
private readonly sourceSettingsMenu: Locator;
private readonly createNewSourceMenuItem: Locator;
constructor(page: Page, defaultTimeout: number = 3000) {
this.page = page;
@ -71,19 +67,14 @@ export class SearchPage {
this.sqlTab = page.getByRole('option', { name: 'SQL', exact: true });
this.luceneTab = page.getByRole('option', { name: 'Lucene', exact: true });
this.sourceSelector = page.getByTestId('source-selector');
this.sourceSettingsMenu = page.getByTestId('source-settings-menu');
this.editSourceMenuItem = page.getByTestId('edit-sources-menu-item');
this.createNewSourceMenuItem = page.getByTestId(
'create-new-source-menu-item',
);
}
get sourceMenu() {
return this.sourceSettingsMenu;
}
get createNewSourceItem() {
return this.createNewSourceMenuItem;
return this.page.getByRole('option', { name: 'Create New Source' });
}
get editSourcesItem() {
return this.page.getByRole('option', { name: 'Edit Sources' });
}
/**
@ -103,8 +94,8 @@ export class SearchPage {
}
async openEditSourceModal() {
await this.sourceSettingsMenu.click();
await this.editSourceMenuItem.click();
await this.sourceSelector.click();
await this.editSourcesItem.click();
}
async sourceModalShowOptionalFields() {

View file

@ -4340,7 +4340,7 @@ __metadata:
"@storybook/addon-themes": "npm:^10.1.4"
"@storybook/nextjs": "npm:^10.1.4"
"@storybook/react": "npm:^10.1.4"
"@tabler/icons-react": "npm:^3.5.0"
"@tabler/icons-react": "npm:^3.39.0"
"@tanstack/react-query": "npm:^5.56.2"
"@tanstack/react-query-devtools": "npm:^5.56.2"
"@tanstack/react-table": "npm:^8.7.9"
@ -8798,21 +8798,21 @@ __metadata:
languageName: node
linkType: hard
"@tabler/icons-react@npm:^3.5.0":
version: 3.5.0
resolution: "@tabler/icons-react@npm:3.5.0"
"@tabler/icons-react@npm:^3.39.0":
version: 3.40.0
resolution: "@tabler/icons-react@npm:3.40.0"
dependencies:
"@tabler/icons": "npm:3.5.0"
"@tabler/icons": "npm:3.40.0"
peerDependencies:
react: ">= 16"
checksum: 10c0/9669d26e2ab4654d3d1c124a5ffd42c276d5a7ebc83b81ecb200e1f89dc3e9271557cf6eccc78bdd0ebdd0536fa4cd566e2b638ab4407e9415457c28ef05786e
checksum: 10c0/a6985c9e8d1986c14594f5bc7007d2398b7b1efd2202304a24e23eb1fa6a48a23f9a6aaa3c5626781990cc0f0a46773c0a0cb747b6984dde26f03ba4ebd1d9cf
languageName: node
linkType: hard
"@tabler/icons@npm:3.5.0":
version: 3.5.0
resolution: "@tabler/icons@npm:3.5.0"
checksum: 10c0/0d6b15266e116dcf56bca417d013f1027b57404e5ed7f9a2a14e216eabdd04980ff971129491b666ffdc11953107c0b4540033c92d611fa74e127fc9d443fc4f
"@tabler/icons@npm:3.40.0":
version: 3.40.0
resolution: "@tabler/icons@npm:3.40.0"
checksum: 10c0/d335323695dff9218b486bb84a79893f86d5c284dc022c86bfdcfc550605c48ddffe72161c93f6fcab47ba6d828311c101fc5e306fb698b5b5104cf6250ed314
languageName: node
linkType: hard