diff --git a/.changeset/source-chip-kebab-menu.md b/.changeset/source-chip-kebab-menu.md
new file mode 100644
index 0000000000..9ec99239ab
--- /dev/null
+++ b/.changeset/source-chip-kebab-menu.md
@@ -0,0 +1,5 @@
+---
+'@hyperdx/app': patch
+---
+
+feat(source-picker): chip + kebab menu UX
diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx
index 4e7a6a21de..2862d3b857 100644
--- a/packages/app/src/DBSearchPage.tsx
+++ b/packages/app/src/DBSearchPage.tsx
@@ -127,7 +127,9 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar';
import PatternTable from './components/PatternTable';
import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart';
import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel';
-import SourceSchemaPreview from './components/SourceSchemaPreview';
+import SourceSchemaPreview, {
+ isSourceSchemaPreviewEnabled,
+} from './components/SourceSchemaPreview';
import {
getRelativeTimeOptionLabel,
LIVE_TAIL_DURATION_MS,
@@ -1665,10 +1667,8 @@ export function DBSearchPage() {
[inputSourceObj],
);
- const sourceSchemaPreview = useMemo(
- () => ,
- [inputSourceObj],
- );
+ const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] =
+ useState(false);
const onTimePickerSearch = useCallback(
(range: string) => {
@@ -1743,13 +1743,38 @@ export function DBSearchPage() {
setModelFormExpanded(false);
}, [setModelFormExpanded]);
- const onEditSources = useCallback(() => {
+ // `Edit source` (singular): operate on the currently selected source.
+ // Local mode opens the inline edit modal seeded with `inputSource`;
+ // non-local uses a hard navigation so the page's `useQueryStates`
+ // (source/where/select/whereLanguage/filters/orderBy) can't merge
+ // stale /search state into the destination URL, and so
+ // `router.basePath` is correctly prepended for the /clickstack build.
+ const onEditCurrentSource = useCallback(() => {
if (IS_LOCAL_MODE) {
- setModelFormExpanded(v => !v);
+ setModelFormExpanded(true);
+ return;
+ }
+ if (inputSource) {
+ window.location.assign(`${router.basePath}/team?source=${inputSource}`);
} else {
- router.push('/team');
+ window.location.assign(`${router.basePath}/team`);
}
- }, [setModelFormExpanded]);
+ }, [inputSource, setModelFormExpanded]);
+
+ // `Manage sources`: open the all-sources list view. Only wired in
+ // non-local mode; local has no list-view surface so the menu item
+ // hides itself when this prop is undefined. We use `window.location`
+ // for a hard navigation instead of `router.push` so the page's
+ // `useQueryStates` (source/where/select/whereLanguage/filters/orderBy)
+ // can't restore its state into the new URL during the client-side
+ // transition, and so `router.basePath` is correctly prepended for
+ // the /clickstack build.
+ const onManageSources = useMemo(() => {
+ if (IS_LOCAL_MODE) return undefined;
+ return () => {
+ window.location.assign(`${router.basePath}/team`);
+ };
+ }, []);
const setNewSourceModalClosed = useCallback(
() => setNewSourceModalOpened(false),
@@ -1886,12 +1911,22 @@ export function DBSearchPage() {
control={control}
name="source"
onCreate={openNewSourceModal}
- onEdit={onEditSources}
+ onEdit={onEditCurrentSource}
+ onManageSources={onManageSources}
+ onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)}
+ isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(
+ inputSourceObj,
+ )}
allowedSourceKinds={ALLOWED_SOURCE_KINDS}
data-testid="source-selector"
- sourceSchemaPreview={sourceSchemaPreview}
style={{ minWidth: 150 }}
/>
+ setIsSourceSchemaPreviewOpen(false)}
+ />
{
if (watchedSource !== sourceId) {
@@ -116,15 +120,22 @@ function DBServiceMapPage() {
);
const sourceSelect = source ? (
-
- }
- />
+ <>
+ setIsSourceSchemaPreviewOpen(true)}
+ isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(source)}
+ />
+ setIsSourceSchemaPreviewOpen(false)}
+ />
+ >
) : null;
const headerActions = (
diff --git a/packages/app/src/DashboardFiltersModal.tsx b/packages/app/src/DashboardFiltersModal.tsx
index 94b5fcb77a..c773922d12 100644
--- a/packages/app/src/DashboardFiltersModal.tsx
+++ b/packages/app/src/DashboardFiltersModal.tsx
@@ -37,7 +37,9 @@ import SearchWhereInput, {
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { SourceMultiSelectControlled } from './components/SourceMultiSelect';
-import SourceSchemaPreview from './components/SourceSchemaPreview';
+import SourceSchemaPreview, {
+ isSourceSchemaPreviewEnabled,
+} from './components/SourceSchemaPreview';
import { SourceSelectControlled } from './components/SourceSelect';
import { useSource, useSources } from './source';
import { getMetricTableName } from './utils';
@@ -139,6 +141,8 @@ const DashboardFilterEditForm = ({
const [modalContentRef, setModalContentRef] = useState(
null,
);
+ const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] =
+ useState(false);
return (
- }
+ onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)}
+ isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(source)}
disabled={!!presetSource}
/>
+ setIsSourceSchemaPreviewOpen(false)}
+ />
{!presetSource && (
- }
+ onSchemaPreview={() => setIsLogSchemaPreviewOpen(true)}
+ isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(logSource)}
+ />
+ setIsLogSchemaPreviewOpen(false)}
/>
- }
+ onSchemaPreview={() => setIsMetricSchemaPreviewOpen(true)}
+ isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(metricSource)}
+ />
+ setIsMetricSchemaPreviewOpen(false)}
/>
);
diff --git a/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx b/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx
index ac3b7e5c63..cb5327aa5c 100644
--- a/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx
+++ b/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx
@@ -208,7 +208,12 @@ jest.mock('../components/PatternTable', () => () => );
jest.mock('../components/Search/DBSearchHeatmapChart', () => ({
DBSearchHeatmapChart: () => ,
}));
-jest.mock('../components/SourceSchemaPreview', () => () => );
+jest.mock('../components/SourceSchemaPreview', () => ({
+ __esModule: true,
+ default: () => ,
+ isSourceSchemaPreviewEnabled: () => false,
+ getSourceSchemaTables: () => [],
+}));
jest.mock('../components/Error/ErrorBoundary', () => ({
ErrorBoundary: ({ children }: { children: React.ReactNode }) => (
<>{children}>
diff --git a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx
index 04b9a2a746..fb3a441601 100644
--- a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx
+++ b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { Control, UseFormSetValue, useWatch } from 'react-hook-form';
import {
TableConnection,
@@ -31,7 +31,9 @@ import { DEFAULT_TILE_ALERT } from '@/utils/alerts';
import { ConnectionSelectControlled } from '../ConnectionSelect';
import { OnClickFormButton } from '../DBEditTimeChartForm/OnClickForm/OnClickFormButton';
-import SourceSchemaPreview from '../SourceSchemaPreview';
+import SourceSchemaPreview, {
+ isSourceSchemaPreviewEnabled,
+} from '../SourceSchemaPreview';
import { SourceSelectControlled } from '../SourceSelect';
import { SQL_PLACEHOLDERS } from './constants';
@@ -137,9 +139,8 @@ export default function RawSqlChartEditor({
return [...paramCompletions, ...macroCompletions];
}, [displayType]);
- const sourceSchemaPreview = useMemo(() => {
- return ;
- }, [sourceObject]);
+ const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] =
+ useState(false);
const tableConnections: TableConnection[] = useMemo(() => {
if (!sources) return [];
@@ -206,7 +207,14 @@ export default function RawSqlChartEditor({
size="xs"
clearable
placeholder="None"
- sourceSchemaPreview={sourceSchemaPreview}
+ onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)}
+ isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(sourceObject)}
+ />
+ setIsSourceSchemaPreviewOpen(false)}
/>
diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx
index fdaa884e8c..62948f7109 100644
--- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx
+++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react';
import {
Control,
FieldArrayWithId,
@@ -25,7 +26,9 @@ import {
} from '@/components/ChartEditor/types';
import MVOptimizationIndicator from '@/components/MaterializedViews/MVOptimizationIndicator';
import SearchWhereInput from '@/components/SearchInput/SearchWhereInput';
-import SourceSchemaPreview from '@/components/SourceSchemaPreview';
+import SourceSchemaPreview, {
+ isSourceSchemaPreviewEnabled,
+} from '@/components/SourceSchemaPreview';
import { SourceSelectControlled } from '@/components/SourceSelect';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { IS_LOCAL_MODE } from '@/config';
@@ -91,6 +94,9 @@ export function ChartEditorControls({
openDisplaySettings,
openHeatmapSettings,
}: ChartEditorControlsProps) {
+ const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] =
+ useState(false);
+
return (
<>
@@ -108,9 +114,14 @@ export function ChartEditorControls({
? [...HEATMAP_ALLOWED_SOURCE_KINDS]
: undefined
}
- sourceSchemaPreview={
-
- }
+ onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)}
+ isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(tableSource)}
+ />
+ setIsSourceSchemaPreviewOpen(false)}
/>
diff --git a/packages/app/src/components/DBTableSelect.tsx b/packages/app/src/components/DBTableSelect.tsx
index e76740536d..0228db08f7 100644
--- a/packages/app/src/components/DBTableSelect.tsx
+++ b/packages/app/src/components/DBTableSelect.tsx
@@ -1,11 +1,14 @@
+import { useState } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
-import { Flex, Select } from '@mantine/core';
+import { ComboboxChevron, Flex, Select } from '@mantine/core';
import { IconTable } from '@tabler/icons-react';
import { useTablesDirect } from '@/clickhouse';
-import SourceSchemaPreview from './SourceSchemaPreview';
-import { SourceSelectRightSection } from './SourceSelect';
+import SourceSchemaPreview, {
+ isSourceSchemaPreviewEnabled,
+} from './SourceSchemaPreview';
+import { SourceManagementMenu } from './SourceSelect';
function DBTableSelect({
database,
@@ -38,25 +41,22 @@ function DBTableSelect({
label: db.name,
}));
- const rightSectionProps = SourceSelectRightSection({
- sourceSchemaPreview:
- connectionId && database && table ? (
-
- ) : undefined,
- });
+ const [isSchemaPreviewOpen, setIsSchemaPreviewOpen] = useState(false);
+ const previewSource =
+ connectionId && database && table
+ ? {
+ connection: connectionId,
+ from: { databaseName: database, tableName: table },
+ }
+ : undefined;
return (
-
+
}
+ rightSection={}
maxDropdownHeight={280}
data={data}
disabled={isTablesLoading}
@@ -68,7 +68,17 @@ function DBTableSelect({
ref={inputRef}
size={size}
className="flex-grow-1"
- {...rightSectionProps}
+ />
+ setIsSchemaPreviewOpen(true)}
+ isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(previewSource)}
+ />
+ setIsSchemaPreviewOpen(false)}
/>
);
diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx
index f1ffcb00e2..b6deae7923 100644
--- a/packages/app/src/components/DBTracePanel.tsx
+++ b/packages/app/src/components/DBTracePanel.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useEffect, useMemo, useState } from 'react';
+import { ReactNode, useEffect, useState } from 'react';
import { useQueryState } from 'nuqs';
import { useForm, useWatch } from 'react-hook-form';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
@@ -28,7 +28,9 @@ import { parseAsJsonEncoded } from '@/utils/queryParsers';
import { RowDataPanel } from './DBRowDataPanel';
import { RowOverviewPanel } from './DBRowOverviewPanel';
-import SourceSchemaPreview from './SourceSchemaPreview';
+import SourceSchemaPreview, {
+ isSourceSchemaPreviewEnabled,
+} from './SourceSchemaPreview';
import { SourceSelectControlled } from './SourceSelect';
const eventRowWhereParser = parseAsJsonEncoded<{
@@ -141,9 +143,8 @@ export default function DBTracePanel({
};
}, [traceId, setEventRowWhere]);
- const sourceSchemaPreview = useMemo(() => {
- return ;
- }, [childSourceData]);
+ const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] =
+ useState(false);
const [displayedTab, setDisplayedTab] = useState(Tab.Overview);
return (
@@ -177,7 +178,16 @@ export default function DBTracePanel({
control={control}
name="source"
size="xs"
- sourceSchemaPreview={sourceSchemaPreview}
+ onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)}
+ isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(
+ childSourceData,
+ )}
+ />
+ setIsSourceSchemaPreviewOpen(false)}
/>
diff --git a/packages/app/src/components/SelectControlled.tsx b/packages/app/src/components/SelectControlled.tsx
index b4653198a1..439a263f10 100644
--- a/packages/app/src/components/SelectControlled.tsx
+++ b/packages/app/src/components/SelectControlled.tsx
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { Select, SelectProps } from '@mantine/core';
-export enum SelectControlledSpecialValues {
+enum SelectControlledSpecialValues {
CreateNewValue = '_create_new_value',
EditValue = '_edit_value',
}
diff --git a/packages/app/src/components/SourceSchemaPreview.tsx b/packages/app/src/components/SourceSchemaPreview.tsx
index 3dc10b5ef5..0b62799182 100644
--- a/packages/app/src/components/SourceSchemaPreview.tsx
+++ b/packages/app/src/components/SourceSchemaPreview.tsx
@@ -44,7 +44,7 @@ const SourceSchemaInfoIcon = ({
Schema
@@ -128,6 +128,15 @@ interface SourceSchemaPreviewProps {
source?: SourceSchemaPreviewSource;
iconStyles?: React.CSSProperties;
variant?: 'icon' | 'text';
+ /**
+ * When true, the trigger element (icon or text button) is NOT rendered;
+ * the parent owns open state and drives the modal via `open` / `onClose`.
+ * Useful when the trigger lives outside this component (e.g. a kebab menu
+ * adjacent to a source picker).
+ */
+ controlled?: boolean;
+ open?: boolean;
+ onClose?: () => void;
}
const METRIC_TYPE_NAMES: Record = {
@@ -138,16 +147,21 @@ const METRIC_TYPE_NAMES: Record = {
[MetricsDataType.ExponentialHistogram]: 'Exponential Histogram',
};
-const SourceSchemaPreview = ({
- source,
- iconStyles,
- variant = 'icon',
-}: SourceSchemaPreviewProps) => {
- const [isModalOpen, setIsModalOpen] = useState(false);
-
- const isMetricSource = source?.kind === 'metric';
+/**
+ * Build the list of tables (and their titles) to show in the schema preview
+ * modal for a given source. Internal helper that powers both
+ * `` rendering and `isSourceSchemaPreviewEnabled`,
+ * so callers can decide whether the preview has anything to show before
+ * rendering a trigger.
+ */
+function getSourceSchemaTables(
+ source?: SourceSchemaPreviewSource,
+): (TableSchemaPreviewProps & { title: string })[] {
const tables: (TableSchemaPreviewProps & { title: string })[] = [];
- if (source && isMetricSource) {
+ if (!source) return tables;
+
+ const isMetricSource = source.kind === 'metric';
+ if (isMetricSource) {
tables.push(
...Object.values(MetricsDataType)
.map(metricType => ({
@@ -162,7 +176,7 @@ const SourceSchemaPreview = ({
title: METRIC_TYPE_NAMES[metricType],
})),
);
- } else if (source && source.from.tableName) {
+ } else if (source.from.tableName) {
tables.push({
databaseName: source.from.databaseName,
tableName: source.from.tableName,
@@ -171,31 +185,61 @@ const SourceSchemaPreview = ({
});
}
- const mvConfigs = source?.materializedViews ?? [];
+ const mvConfigs = source.materializedViews ?? [];
tables.push(
...mvConfigs.map(({ tableName, databaseName }) => ({
databaseName,
tableName,
- connectionId: source!.connection,
+ connectionId: source.connection,
title: `${tableName} (MV)`,
})),
);
- const isEnabled = !!source && tables.length > 0;
+ return tables;
+}
+
+export function isSourceSchemaPreviewEnabled(
+ source?: SourceSchemaPreviewSource,
+): boolean {
+ return !!source && getSourceSchemaTables(source).length > 0;
+}
+
+const SourceSchemaPreview = ({
+ source,
+ iconStyles,
+ variant = 'icon',
+ controlled = false,
+ open,
+ onClose,
+}: SourceSchemaPreviewProps) => {
+ const [internalOpen, setInternalOpen] = useState(false);
+ const isModalOpen = controlled ? !!open : internalOpen;
+ const handleClose = () => {
+ if (controlled) {
+ onClose?.();
+ } else {
+ setInternalOpen(false);
+ }
+ };
+
+ const tables = getSourceSchemaTables(source);
+ const isEnabled = isSourceSchemaPreviewEnabled(source);
return (
<>
- setIsModalOpen(true)}
- iconStyles={iconStyles}
- tableCount={tables.length}
- variant={variant}
- />
+ {!controlled && (
+ setInternalOpen(true)}
+ iconStyles={iconStyles}
+ tableCount={tables.length}
+ variant={variant}
+ />
+ )}
{isEnabled && (
setIsModalOpen(false)}
+ onClose={handleClose}
size="auto"
title={tables.length > 1 ? `Table Schemas` : `Table Schema`}
>
diff --git a/packages/app/src/components/SourceSelect.tsx b/packages/app/src/components/SourceSelect.tsx
index ef015b9597..d00e23e656 100644
--- a/packages/app/src/components/SourceSelect.tsx
+++ b/packages/app/src/components/SourceSelect.tsx
@@ -2,17 +2,24 @@ import { memo, useCallback, useMemo } from 'react';
import { UseControllerProps, useWatch } from 'react-hook-form';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import {
+ ActionIcon,
ComboboxChevron,
ComboboxItem,
Group,
+ Menu,
SelectProps,
- UnstyledButton,
+ Tooltip,
} from '@mantine/core';
-import { IconPlus, IconSettings, IconStack } from '@tabler/icons-react';
-
-import SelectControlled, {
- SelectControlledSpecialValues,
-} from '@/components/SelectControlled';
+import {
+ IconCode,
+ IconDotsVertical,
+ IconPencil,
+ IconPlus,
+ IconSettings,
+ IconStack,
+} from '@tabler/icons-react';
+
+import SelectControlled from '@/components/SelectControlled';
import {
SOURCE_KIND_ICONS,
useFilteredSortedSourceItems,
@@ -22,59 +29,144 @@ import { useSources } from '@/source';
import styles from '../../styles/SourceSelectControlled.module.scss';
-interface SourceSelectRightSectionProps {
- sourceSchemaPreview?: React.ReactNode;
+interface SourceManagementMenuProps {
+ hasSelection: boolean;
+ onSchemaPreview?: () => void;
+ isSchemaPreviewEnabled?: boolean;
+ /**
+ * Open the edit form for the currently selected source. Should act
+ * on `hasSelection ? currentSourceId : null`. Hidden from the menu
+ * when no handler is passed.
+ */
+ onEdit?: () => void;
+ /**
+ * Open the list view of all sources (e.g. `/team#sources`). Hidden
+ * from the menu when no handler is passed; typically the case in
+ * local mode where no list-view surface exists.
+ */
+ onManageSources?: () => void;
+ onCreate?: () => void;
}
-export const SourceSelectRightSection = ({
- sourceSchemaPreview,
-}: SourceSelectRightSectionProps) => {
- if (!sourceSchemaPreview) {
- return {
- rightSection: ,
- };
+/**
+ * Adjacent kebab menu that consolidates source-management actions:
+ * View schema, Edit source, Manage sources, Create new source.
+ *
+ * `Edit source` operates on the current selection; `Manage sources`
+ * opens the all-sources list view. Each item hides if its handler
+ * isn't wired, so callers can pick the right combination for their
+ * surface (local mode, for example, has no list view and should leave
+ * `onManageSources` unset).
+ *
+ * Exposed so non-`SourceSelectControlled` callers (e.g. `DBTableSelect`)
+ * can attach the same surface if needed.
+ */
+export const SourceManagementMenu = ({
+ hasSelection,
+ onSchemaPreview,
+ isSchemaPreviewEnabled = true,
+ onEdit,
+ onManageSources,
+ onCreate,
+}: SourceManagementMenuProps) => {
+ const items: React.ReactNode[] = [];
+
+ if (onSchemaPreview) {
+ items.push(
+ }
+ onClick={onSchemaPreview}
+ disabled={!hasSelection || !isSchemaPreviewEnabled}
+ >
+ View schema
+ ,
+ );
+ }
+
+ if (onEdit) {
+ items.push(
+ }
+ onClick={onEdit}
+ disabled={!hasSelection}
+ >
+ Edit source
+ ,
+ );
}
- return {
- rightSection: (
- <>
- ) => {
- e.stopPropagation();
- e.preventDefault();
- }}
- className={styles.sourceSchemaPreviewButton}
- >
- {sourceSchemaPreview}
-
-
- >
- ),
- rightSectionWidth: 70,
- };
-};
+ if (onManageSources) {
+ items.push(
+ }
+ onClick={onManageSources}
+ >
+ Manage sources
+ ,
+ );
+ }
-const OPTION_ICONS: Record = {
- [SelectControlledSpecialValues.CreateNewValue]: ,
- [SelectControlledSpecialValues.EditValue]: ,
+ if (onCreate) {
+ if (items.length > 0) {
+ items.push();
+ }
+ items.push(
+ }
+ onClick={onCreate}
+ >
+ Create new source
+ ,
+ );
+ }
+
+ if (items.length === 0) return null;
+
+ return (
+
+ );
};
function SourceSelectControlledComponent({
size,
onCreate,
onEdit,
+ onManageSources,
+ onSchemaPreview,
+ isSchemaPreviewEnabled,
allowedSourceKinds,
connectionId,
comboboxProps,
- sourceSchemaPreview,
...props
}: {
size?: string;
onCreate?: () => void;
onEdit?: () => void;
+ onManageSources?: () => void;
+ onSchemaPreview?: () => void;
+ isSchemaPreviewEnabled?: boolean;
allowedSourceKinds?: SourceKind[];
connectionId?: string;
- sourceSchemaPreview?: React.ReactNode;
} & UseControllerProps &
SelectProps) {
const { data } = useSources();
@@ -96,9 +188,7 @@ function SourceSelectControlledComponent({
const renderOption = useCallback(
({ option }: { option: ComboboxItem }) => {
- const icon =
- OPTION_ICONS[option.value] ??
- SOURCE_KIND_ICONS[sourceKindMap.get(option.value) ?? ''];
+ const icon = SOURCE_KIND_ICONS[sourceKindMap.get(option.value) ?? ''];
if (!icon) return option.label;
return (
@@ -110,54 +200,50 @@ function SourceSelectControlledComponent({
[sourceKindMap],
);
- const hasActions = !!onCreate || !!onEdit;
-
const sourceItems = useFilteredSortedSourceItems({
sources: data,
allowedSourceKinds,
connectionId,
});
- const values = useMemo(() => {
- 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 }];
- }, [sourceItems, onCreate, onEdit, hasActions]);
-
- const rightSectionProps = SourceSelectRightSection({ sourceSchemaPreview });
+ const hasSelection = !!selectedSourceId;
+ const hasMenu =
+ !!onCreate || !!onEdit || !!onManageSources || !!onSchemaPreview;
return (
-
+
+ }
+ />
+ {hasMenu && (
+
+ )}
+
);
}
diff --git a/packages/app/src/components/Sources/SourceForm.tsx b/packages/app/src/components/Sources/SourceForm.tsx
index 7544b14bc1..a73ed1ca40 100644
--- a/packages/app/src/components/Sources/SourceForm.tsx
+++ b/packages/app/src/components/Sources/SourceForm.tsx
@@ -1521,6 +1521,59 @@ function TraceTableModelForm(props: TableModelProps) {
]}
value={value}
onChange={onChange}
+ // Mantine 9's Slider styles use the pattern
+ // `:where([data-orientation="vertical"]) .`,
+ // which matches when ANY ancestor has
+ // `data-orientation="vertical"`. Mantine Card sets
+ // `data-orientation="vertical"` by default, and the
+ // SourceForm renders inside a Card, so the slider's
+ // trackContainer/track/bar/thumb/markWrapper/
+ // markLabel all pick up the vertical-orientation
+ // styling: the track collapses to 8px wide and the
+ // four marks stack on top of each other. Override
+ // every affected part back to its horizontal
+ // default so the slider renders correctly inside
+ // the Card.
+ styles={{
+ trackContainer: {
+ width: '100%',
+ flexDirection: 'row',
+ height: 'calc(var(--slider-size) * 2)',
+ },
+ track: {
+ width: '100%',
+ height: 'var(--slider-size)',
+ },
+ bar: {
+ top: 0,
+ bottom: 0,
+ height: '100%',
+ insetInlineStart: 'var(--slider-bar-offset)',
+ width: 'var(--slider-bar-width)',
+ },
+ thumb: {
+ left: 'var(--slider-thumb-offset)',
+ top: '50%',
+ right: 'auto',
+ bottom: 'auto',
+ transform: 'translate(-50%, -50%)',
+ },
+ markWrapper: {
+ insetInlineStart:
+ 'calc(var(--mark-offset) - var(--slider-size) / 2)',
+ top: 0,
+ bottom: 'auto',
+ width: 'auto',
+ },
+ markLabel: {
+ transform:
+ 'translate(calc(-50% + var(--slider-size) / 2), calc(var(--mantine-spacing-xs) / 2))',
+ },
+ label: {
+ top: '-36px',
+ insetInlineStart: 'auto',
+ },
+ }}
/>
)}
@@ -1913,7 +1966,7 @@ function PromqlTableModelForm({
}, [setValue]);
// PromQL sources use the standard database + table fields from BaseSourceSchema.
- // No additional fields needed — the table should point to the TimeSeries engine table.
+ // No additional fields needed; the table should point to the TimeSeries engine table.
return null;
}
diff --git a/packages/app/src/components/Sources/SourcesList.tsx b/packages/app/src/components/Sources/SourcesList.tsx
index 54961ca122..56732610c9 100644
--- a/packages/app/src/components/Sources/SourcesList.tsx
+++ b/packages/app/src/components/Sources/SourcesList.tsx
@@ -1,4 +1,5 @@
-import React, { useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
+import { useRouter } from 'next/router';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
@@ -73,6 +74,29 @@ export function SourcesList({
const [editedSourceId, setEditedSourceId] = useState(null);
const [isCreatingSource, setIsCreatingSource] = useState(false);
+ // Honor `?source=` from the URL so kebab "Edit source" deep-links
+ // expand the right entry. We auto-expand once per param value, then
+ // strip it from the URL so subsequent in-page interactions don't
+ // re-fight the user. `expandedFromQueryRef` prevents the effect from
+ // toggling back on every keystroke that ticks `router.query`.
+ const router = useRouter();
+ const expandedFromQueryRef = useRef(null);
+ useEffect(() => {
+ if (!router.isReady) return;
+ const sourceParam = router.query.source;
+ const sourceId = Array.isArray(sourceParam) ? sourceParam[0] : sourceParam;
+ if (!sourceId || expandedFromQueryRef.current === sourceId) return;
+ if (!sources?.some(s => s.id === sourceId)) return;
+ expandedFromQueryRef.current = sourceId;
+ setEditedSourceId(sourceId);
+ const { source: _omit, ...rest } = router.query;
+ void router.replace(
+ { pathname: router.pathname, query: rest, hash: 'sources' },
+ undefined,
+ { shallow: true },
+ );
+ }, [router, sources]);
+
const isLoading = isLoadingConnections || isLoadingSources;
const error = connectionsError || sourcesError;
diff --git a/packages/app/src/components/__tests__/DBTracePanel.test.tsx b/packages/app/src/components/__tests__/DBTracePanel.test.tsx
index 8594db58da..ca95483eb4 100644
--- a/packages/app/src/components/__tests__/DBTracePanel.test.tsx
+++ b/packages/app/src/components/__tests__/DBTracePanel.test.tsx
@@ -39,6 +39,8 @@ jest.mock('../SourceSelect', () => ({
jest.mock('../SourceSchemaPreview', () => ({
__esModule: true,
default: () => ,
+ isSourceSchemaPreviewEnabled: () => false,
+ getSourceSchemaTables: () => [],
}));
describe('DBTracePanel', () => {
diff --git a/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx b/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx
new file mode 100644
index 0000000000..d033a5a3fb
--- /dev/null
+++ b/packages/app/src/components/__tests__/SourceSchemaPreview.test.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import SourceSchemaPreview from '../SourceSchemaPreview';
+
+jest.mock('@/hooks/useMetadata', () => ({
+ useTableMetadata: () => ({ data: undefined, isLoading: false }),
+}));
+
+jest.mock('../ChartSQLPreview', () => ({
+ SQLPreview: () => SQL Preview
,
+}));
+
+const mockLogSource = {
+ connection: 'conn-1',
+ from: { databaseName: 'default', tableName: 'logs' },
+};
+
+describe('SourceSchemaPreview — controlled mode', () => {
+ it('does not render the uncontrolled trigger when controlled is true', () => {
+ renderWithMantine(
+ ,
+ );
+ // The non-controlled mode would render a clickable icon/text trigger;
+ // in controlled mode nothing should render when the modal is closed.
+ expect(screen.queryByText('Schema')).not.toBeInTheDocument();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('opens the modal when open is true', () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('does not show the modal when open is false', () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('calls onClose when the modal close button is clicked', async () => {
+ const onClose = jest.fn();
+ renderWithMantine(
+ ,
+ );
+ // Mantine 9 CloseButton renders without aria-label; find by its class.
+ const closeBtn = document.querySelector(
+ '.mantine-Modal-close',
+ ) as HTMLElement;
+ expect(closeBtn).toBeTruthy();
+ await userEvent.click(closeBtn);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClose on Escape key press', async () => {
+ const onClose = jest.fn();
+ renderWithMantine(
+ ,
+ );
+ await userEvent.keyboard('{Escape}');
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/app/src/components/__tests__/SourceSelect.test.tsx b/packages/app/src/components/__tests__/SourceSelect.test.tsx
new file mode 100644
index 0000000000..08b1e23b37
--- /dev/null
+++ b/packages/app/src/components/__tests__/SourceSelect.test.tsx
@@ -0,0 +1,137 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { SourceManagementMenu } from '../SourceSelect';
+
+describe('SourceManagementMenu', () => {
+ it('does not render the kebab trigger when no actions are wired', () => {
+ renderWithMantine();
+ expect(screen.queryByTestId('source-actions-menu')).not.toBeInTheDocument();
+ });
+
+ it('renders the kebab trigger when onSchemaPreview is wired', () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument();
+ });
+
+ it('renders the kebab trigger when only onEdit is wired', () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument();
+ });
+
+ it('renders the kebab trigger when only onManageSources is wired', () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument();
+ });
+
+ it('renders the kebab trigger when only onCreate is wired', () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByTestId('source-actions-menu')).toBeInTheDocument();
+ });
+
+ describe('View schema item', () => {
+ it('is disabled when hasSelection is false', async () => {
+ renderWithMantine(
+ ,
+ );
+ await userEvent.click(screen.getByTestId('source-actions-menu'));
+ const viewSchemaBtn = await screen.findByText('View schema');
+ expect(viewSchemaBtn.closest('button')).toHaveAttribute(
+ 'data-disabled',
+ 'true',
+ );
+ });
+
+ it('is disabled when isSchemaPreviewEnabled is false', async () => {
+ renderWithMantine(
+ ,
+ );
+ await userEvent.click(screen.getByTestId('source-actions-menu'));
+ const viewSchemaBtn = await screen.findByText('View schema');
+ expect(viewSchemaBtn.closest('button')).toHaveAttribute(
+ 'data-disabled',
+ 'true',
+ );
+ });
+
+ it('is enabled when hasSelection and isSchemaPreviewEnabled are both true', async () => {
+ renderWithMantine(
+ ,
+ );
+ await userEvent.click(screen.getByTestId('source-actions-menu'));
+ const viewSchemaBtn = await screen.findByText('View schema');
+ expect(viewSchemaBtn.closest('button')).not.toHaveAttribute(
+ 'data-disabled',
+ );
+ });
+
+ it('calls onSchemaPreview when clicked', async () => {
+ const onSchemaPreview = jest.fn();
+ renderWithMantine(
+ ,
+ );
+ await userEvent.click(screen.getByTestId('source-actions-menu'));
+ await userEvent.click(await screen.findByText('View schema'));
+ expect(onSchemaPreview).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('calls onEdit when Edit source is clicked', async () => {
+ const onEdit = jest.fn();
+ renderWithMantine(
+ // hasSelection required: Edit source is disabled without a selection.
+ ,
+ );
+ await userEvent.click(screen.getByTestId('source-actions-menu'));
+ await userEvent.click(await screen.findByText('Edit source'));
+ expect(onEdit).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onManageSources when Manage sources is clicked', async () => {
+ const onManageSources = jest.fn();
+ renderWithMantine(
+ ,
+ );
+ await userEvent.click(screen.getByTestId('source-actions-menu'));
+ await userEvent.click(await screen.findByText('Manage sources'));
+ expect(onManageSources).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onCreate when Create new source is clicked', async () => {
+ const onCreate = jest.fn();
+ renderWithMantine(
+ ,
+ );
+ await userEvent.click(screen.getByTestId('source-actions-menu'));
+ await userEvent.click(await screen.findByText('Create new source'));
+ expect(onCreate).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/app/styles/SourceSelectControlled.module.scss b/packages/app/styles/SourceSelectControlled.module.scss
index f586b5d2a7..6f648df5be 100644
--- a/packages/app/styles/SourceSelectControlled.module.scss
+++ b/packages/app/styles/SourceSelectControlled.module.scss
@@ -1,3 +1,6 @@
+/* Legacy class. Still used by DBTableSelect for its inline schema-preview
+ * text button. New code uses SourceManagementMenu (kebab) instead.
+ */
.sourceSchemaPreviewButton {
pointer-events: all;
font-size: var(--mantine-font-size-xxs);
@@ -16,3 +19,74 @@
border-color: var(--color-border);
}
}
+
+/* Chip + kebab wrapper. Holds the Mantine Select and the SourceManagementMenu
+ * ActionIcon as a single inline-flex unit. When the kebab is present
+ * (`[data-with-menu]`), the two surfaces are "glued" into a split-button chip:
+ * the adjoining corners are squared, the kebab overlaps the input's right
+ * border by 1px so they share a single vertical seam, and the kebab stretches
+ * to match the input's height so the chip reads as one unit.
+ *
+ * Override Mantine via its own CSS variables (`--input-radius`, `--ai-radius`)
+ * rather than fighting class-level `border-radius`: Mantine v7 reads these
+ * vars to compute the rounded corners, so setting them is the canonical
+ * override path.
+ */
+.sourceSelectGroup {
+ display: inline-flex;
+ align-items: stretch;
+ gap: 0;
+
+ &[data-with-menu] {
+ /* Square only the right edge of the input. */
+ .sourceSelectInput {
+ --input-radius: 0;
+
+ border-top-left-radius: var(--mantine-radius-default);
+ border-bottom-left-radius: var(--mantine-radius-default);
+ }
+
+ /* Square only the left edge of the kebab. Fixed 30px height matches the
+ * input chip; -1px margin overlaps the input's right edge so the shared
+ * border is one pixel wide, not two. */
+ .sourceMenuButton {
+ --ai-radius: 0;
+
+ width: 30px;
+ height: 30px;
+ margin-left: -1px;
+ background-color: var(--color-bg-field-addon);
+ border: 1px solid var(--input-bd, var(--color-border));
+ border-top-right-radius: var(--mantine-radius-default);
+ border-bottom-right-radius: var(--mantine-radius-default);
+ }
+ }
+}
+
+/* Chip hover state. The Mantine Select input is the "chip" surface;
+ * we tint its background and force a pointer cursor on hover so the
+ * chevron + label feel like a single clickable affordance even though
+ * the input itself is searchable.
+ */
+.sourceSelectInput {
+ cursor: pointer;
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease;
+
+ &:hover:not(:focus-within) {
+ background-color: var(--mantine-color-default-hover);
+ }
+
+ /* Once the user actually starts typing, keep text cursor semantics. */
+ &:focus-within {
+ cursor: text;
+ }
+}
+
+/* Kebab button. Mantine's subtle variant gives the hover tint; layout shared
+ * with the input is set up in the `[data-with-menu]` block above.
+ */
+.sourceMenuButton {
+ flex-shrink: 0;
+}
diff --git a/packages/app/tests/e2e/features/sources.spec.ts b/packages/app/tests/e2e/features/sources.spec.ts
index b56bad6fbf..3490a016d3 100644
--- a/packages/app/tests/e2e/features/sources.spec.ts
+++ b/packages/app/tests/e2e/features/sources.spec.ts
@@ -102,13 +102,20 @@ test.describe('Sources Functionality', { tag: ['@sources'] }, () => {
await searchPage.goto();
});
- test('should show source actions in dropdown', async () => {
- // Open source selector dropdown
- await searchPage.sourceDropdown.click();
+ test('should show source actions in kebab menu', async () => {
+ // Open the source-actions kebab menu next to the source picker.
+ await searchPage.sourceActionsMenu.click();
- // Verify action items are visible in the dropdown
+ // Per-source and create actions are always wired.
+ await expect(searchPage.editSourceItem).toBeVisible();
await expect(searchPage.createNewSourceItem).toBeVisible();
- await expect(searchPage.editSourcesItem).toBeVisible();
+
+ // `Manage sources` is wired only in non-local (full-stack) mode.
+ if (process.env.E2E_FULLSTACK === 'true') {
+ await expect(searchPage.manageSourcesItem).toBeVisible();
+ } else {
+ await expect(searchPage.manageSourcesItem).toHaveCount(0);
+ }
});
test(
@@ -142,7 +149,7 @@ test.describe('Sources Functionality', { tag: ['@sources'] }, () => {
);
test('should show proper fields when creating a new source', async () => {
- await searchPage.sourceDropdown.click();
+ await searchPage.sourceActionsMenu.click();
await searchPage.createNewSourceItem.click();
// for each source type (log, trace, session, metric), verify the correct fields are shown
for (const sourceData of allSourcesData) {
diff --git a/packages/app/tests/e2e/page-objects/SearchPage.ts b/packages/app/tests/e2e/page-objects/SearchPage.ts
index 6734ab72b9..9682c977f1 100644
--- a/packages/app/tests/e2e/page-objects/SearchPage.ts
+++ b/packages/app/tests/e2e/page-objects/SearchPage.ts
@@ -73,12 +73,24 @@ export class SearchPage {
this.sourceSelector = page.getByTestId('source-selector');
}
+ get sourceActionsMenu() {
+ return this.page.getByTestId('source-actions-menu');
+ }
+
get createNewSourceItem() {
- return this.page.getByRole('option', { name: 'Create New Source' });
+ return this.page.getByRole('menuitem', { name: 'Create new source' });
+ }
+
+ get editSourceItem() {
+ return this.page.getByRole('menuitem', { name: 'Edit source' });
}
- get editSourcesItem() {
- return this.page.getByRole('option', { name: 'Edit Sources' });
+ get manageSourcesItem() {
+ return this.page.getByRole('menuitem', { name: 'Manage sources' });
+ }
+
+ get viewSchemaItem() {
+ return this.page.getByRole('menuitem', { name: 'View schema' });
}
/**
@@ -98,8 +110,8 @@ export class SearchPage {
}
async openEditSourceModal() {
- await this.sourceSelector.click();
- await this.editSourcesItem.click();
+ await this.sourceActionsMenu.click();
+ await this.editSourceItem.click();
}
async sourceModalShowOptionalFields() {