style: Improve dashboard filter modal UX (#1215)

Closes HDX-2502

# Summary

This PR improves the Dashboard Filter Modal UX.

## Before

https://github.com/user-attachments/assets/39a6f17e-fc76-42fc-a288-8a1c2d1949fa

## After

https://github.com/user-attachments/assets/8d6f0183-9de9-4401-be99-902293b86878



Co-authored-by: Elizabet Oliveira <2750668+elizabetdev@users.noreply.github.com>
This commit is contained in:
Drew Davis 2025-09-30 10:43:04 -04:00 committed by GitHub
parent 730325a5cc
commit bd940f300f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 448 additions and 294 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
style: Improve dashboard filter modal UX

View file

@ -71,7 +71,7 @@ import { DEFAULT_CHART_CONFIG } from './ChartUtils';
import { IS_LOCAL_MODE } from './config';
import { useDashboard } from './dashboard';
import DashboardFilters from './DashboardFilters';
import DashboardFiltersEditModal from './DashboardFiltersEditModal';
import DashboardFiltersModal from './DashboardFiltersModal';
import { GranularityPickerControlled } from './GranularityPicker';
import HDXMarkdownChart from './HDXMarkdownChart';
import { withAppNav } from './layout';
@ -517,6 +517,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
dashboardHash,
isLocalDashboard,
isLocalDashboardEmpty,
isFetching: isFetchingDashboard,
isSetting: isSavingDashboard,
} = useDashboard({
dashboardId: dashboardId as string | undefined,
presetConfig,
@ -562,7 +564,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
parseAsString.withDefault('lucene'),
);
const [showVariablesModal, setShowVariablesModal] = useState(false);
const [showFiltersModal, setShowFiltersModal] = useState(false);
const filters = dashboard?.filters ?? [];
const { filterValues, setFilterValue, filterQueries } =
@ -1084,7 +1086,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
color="gray"
px="xs"
mr={6}
onClick={() => setShowVariablesModal(true)}
onClick={() => setShowFiltersModal(true)}
>
<IconFilterEdit strokeWidth={1} />
</Button>
@ -1161,12 +1163,13 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
>
+ Add New Tile
</Button>
<DashboardFiltersEditModal
opened={showVariablesModal}
onClose={() => setShowVariablesModal(false)}
<DashboardFiltersModal
opened={showFiltersModal}
onClose={() => setShowFiltersModal(false)}
filters={filters}
onSaveFilter={handleSaveFilter}
onRemoveFilter={handleRemoveFilter}
isLoading={isSavingDashboard || isFetchingDashboard}
/>
</Box>
);

View file

@ -1,277 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { TableConnection } from '@hyperdx/common-utils/dist/metadata';
import {
DashboardFilter,
MetricsDataType,
SourceKind,
} from '@hyperdx/common-utils/dist/types';
import {
Button,
Flex,
Group,
Input,
Modal,
Paper,
Radio,
Stack,
Text,
TextInput,
UnstyledButton,
} from '@mantine/core';
import SourceSchemaPreview from './components/SourceSchemaPreview';
import { SourceSelectControlled } from './components/SourceSelect';
import { SQLInlineEditorControlled } from './components/SQLInlineEditor';
import { useSource } from './source';
import { getMetricTableName } from './utils';
interface DashboardFilterEditFormProps {
filter: DashboardFilter;
onSaveFilter: (definition: DashboardFilter) => void;
onRemoveFilter: (id: string) => void;
parentRef?: HTMLElement | null;
}
const DashboardFilterEditForm = ({
filter,
onSaveFilter,
onRemoveFilter,
parentRef,
}: DashboardFilterEditFormProps) => {
const { handleSubmit, register, formState, control, watch, reset } =
useForm<DashboardFilter>({
defaultValues: filter,
});
useEffect(() => {
reset(filter);
}, [filter, reset]);
const sourceId = watch('source');
const { data: source } = useSource({ id: sourceId });
const metricType = watch('sourceMetricType');
const tableName = source && getMetricTableName(source, metricType);
const tableConnection: TableConnection | undefined = tableName
? {
connectionId: source.connection,
databaseName: source.from.databaseName,
tableName,
}
: undefined;
const sourceIsMetric = source?.kind === SourceKind.Metric;
const metricTypes = Object.values(MetricsDataType).filter(
type => source?.metricTables?.[type],
);
return (
<form onSubmit={handleSubmit(onSaveFilter)}>
<Stack>
<TextInput
label="Name"
placeholder="Name"
required
error={formState.errors.name?.message}
{...register('name', { required: true, minLength: 1 })}
/>
<Input.Wrapper
label="Data Source"
description="The data source that the filter values are queried from"
required
>
<Group>
<span className="flex-grow-1">
<SourceSelectControlled
control={control}
name="source"
data-testid="source-selector"
rules={{ required: true }}
comboboxProps={{ withinPortal: true }}
sourceSchemaPreview={
<SourceSchemaPreview source={source} variant="text" />
}
/>
</span>
</Group>
</Input.Wrapper>
{sourceIsMetric && (
<Input.Wrapper label="Metric Type" required>
<Controller
control={control}
name="sourceMetricType"
rules={{ required: true }}
render={({ field: { onChange, value } }) => (
<Radio.Group
value={value}
onChange={v => onChange(v)}
withAsterisk
>
<Group>
{metricTypes.map(type => (
<Radio key={type} value={type} label={type} />
))}
</Group>
</Radio.Group>
)}
/>
</Input.Wrapper>
)}
<Input.Wrapper
label="Filter Expression"
description="SQL column or expression to filter on"
required
>
<SQLInlineEditorControlled
tableConnections={tableConnection}
control={control}
name="expression"
placeholder="SQL column or expression"
language="sql"
enableHotkey
rules={{ required: true }}
parentRef={parentRef}
/>
</Input.Wrapper>
<Group justify="space-between" mt="md">
<Button
variant="outline"
color="red"
onClick={() => onRemoveFilter(filter.id)}
>
Delete
</Button>
<Button type="submit" className="align-self-end">
Save
</Button>
</Group>
</Stack>
</form>
);
};
interface EmptyStateProps {
onCreateFilter: () => void;
}
const EmptyState = ({ onCreateFilter }: EmptyStateProps) => {
return (
<Stack align="center" justify="center" py="xl">
<Text size="md" maw={300} ta="center">
Dashboard filters allow users of this dashboard to quickly filter on
important columns. Filters are saved in the dashboard.
</Text>
<Button variant="outline" onClick={onCreateFilter}>
Create Filter
</Button>
</Stack>
);
};
interface DashboardFiltersEditModalProps {
opened: boolean;
onClose: () => void;
filters: DashboardFilter[];
onSaveFilter: (filter: DashboardFilter) => void;
onRemoveFilter: (id: string) => void;
parentRef?: HTMLElement | null;
}
const NEW_FILTER_ID = 'new';
const DashboardFiltersEditModal = ({
opened,
onClose,
filters,
onSaveFilter,
onRemoveFilter,
}: DashboardFiltersEditModalProps) => {
const [selectedFilter, setSelectedFilter] = useState<
DashboardFilter | undefined
>(filters[0]);
useEffect(() => {
if (opened) {
setSelectedFilter(filters[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened]);
const handleRemoveFilter = (id: string) => {
if (id === selectedFilter?.id) {
setSelectedFilter(filters.find(f => f.id !== id));
}
onRemoveFilter(id);
};
const handleAddNewFilter = () => {
setSelectedFilter({
id: NEW_FILTER_ID,
type: 'QUERY_EXPRESSION',
name: '',
expression: '',
source: '',
});
};
const handleSaveFilter = (filter: DashboardFilter) => {
if (filter.id === NEW_FILTER_ID) {
const filterWithRealId = { ...filter, id: crypto.randomUUID() };
onSaveFilter(filterWithRealId);
setSelectedFilter(filterWithRealId);
} else {
onSaveFilter(filter);
}
};
const [modalContentRef, setModalContentRef] = useState<HTMLElement | null>(
null,
);
return (
<Modal opened={opened} onClose={onClose} title="Filters" size="xl">
{!selectedFilter && filters.length === 0 ? (
<EmptyState onCreateFilter={handleAddNewFilter} />
) : (
<div ref={setModalContentRef}>
<Flex direction="row" gap="0">
<Paper withBorder flex={0} miw={200} pt="sm">
<Stack gap="0">
{filters.map(filter => (
<UnstyledButton
key={filter.id}
className={`px-2 py-1 ${filter.id === selectedFilter?.id ? 'bg-light-grey' : 'bg-default-dark-grey-hover'}`}
onClick={() => setSelectedFilter(filter)}
>
<Text>{filter.name}</Text>
</UnstyledButton>
))}
<Button
variant="subtle"
color="gray"
onClick={handleAddNewFilter}
>
Add Filter
</Button>
</Stack>
</Paper>
<Paper withBorder p="md" flex={1}>
{selectedFilter && (
<DashboardFilterEditForm
filter={selectedFilter}
onSaveFilter={handleSaveFilter}
onRemoveFilter={handleRemoveFilter}
parentRef={modalContentRef}
/>
)}
</Paper>
</Flex>
</div>
)}
</Modal>
);
};
export default DashboardFiltersEditModal;

View file

@ -0,0 +1,392 @@
import { useEffect, useState } from 'react';
import { Controller, FieldError, useForm } from 'react-hook-form';
import { TableConnection } from '@hyperdx/common-utils/dist/metadata';
import {
DashboardFilter,
MetricsDataType,
SourceKind,
} from '@hyperdx/common-utils/dist/types';
import {
Button,
Group,
Input,
Modal,
Paper,
Radio,
Stack,
Text,
TextInput,
Title,
Tooltip,
UnstyledButton,
} from '@mantine/core';
import { IconFilter, IconPencil, IconTrash } from '@tabler/icons-react';
import SourceSchemaPreview from './components/SourceSchemaPreview';
import { SourceSelectControlled } from './components/SourceSelect';
import { SQLInlineEditorControlled } from './components/SQLInlineEditor';
import { useSource, useSources } from './source';
import { getMetricTableName } from './utils';
import styles from '../styles/DashboardFiltersModal.module.scss';
const MODAL_SIZE = 'sm';
interface CustomInputWrapperProps {
children: React.ReactNode;
label: string;
tooltipText?: string;
error?: FieldError;
}
const CustomInputWrapper = ({
children,
label,
tooltipText,
error,
}: CustomInputWrapperProps) => {
const errorMessage =
error &&
(error.message ||
(error?.type === 'required' ? 'This field is required' : 'Error'));
return (
<div>
<Input.Label>{label}</Input.Label>
{tooltipText && (
<Tooltip label={tooltipText}>
<i className="bi bi-info-circle ms-2" />
</Tooltip>
)}
{errorMessage && (
<Input.Error color="red" size="sm">
{errorMessage}
</Input.Error>
)}
<div className="mt-1">{children}</div>
</div>
);
};
interface DashboardFilterEditFormProps {
filter: DashboardFilter;
isNew: boolean;
onSave: (definition: DashboardFilter) => void;
onClose: () => void;
onCancel: () => void;
}
const DashboardFilterEditForm = ({
filter,
isNew,
onSave,
onClose,
onCancel,
}: DashboardFilterEditFormProps) => {
const { handleSubmit, register, formState, control, watch, reset } =
useForm<DashboardFilter>({
defaultValues: filter,
});
useEffect(() => {
reset(filter);
}, [filter, reset]);
const sourceId = watch('source');
const { data: source } = useSource({ id: sourceId });
const metricType = watch('sourceMetricType');
const tableName = source && getMetricTableName(source, metricType);
const tableConnection: TableConnection | undefined = tableName
? {
connectionId: source.connection,
databaseName: source.from.databaseName,
tableName,
}
: undefined;
const sourceIsMetric = source?.kind === SourceKind.Metric;
const metricTypes = Object.values(MetricsDataType).filter(
type => source?.metricTables?.[type],
);
const [modalContentRef, setModalContentRef] = useState<HTMLElement | null>(
null,
);
return (
<Modal
title={isNew ? 'Add filter' : 'Edit filter'}
opened
onClose={onClose}
size={MODAL_SIZE}
>
<div ref={setModalContentRef}>
<form onSubmit={handleSubmit(onSave)}>
<Stack>
<CustomInputWrapper label="Name" error={formState.errors.name}>
<TextInput
placeholder="Name"
{...register('name', { required: true, minLength: 1 })}
/>
</CustomInputWrapper>
<CustomInputWrapper
label="Data source"
tooltipText="The data source that the filter values are queried from"
error={formState.errors.source}
>
<SourceSelectControlled
control={control}
name="source"
data-testid="source-selector"
rules={{ required: true }}
comboboxProps={{ withinPortal: true }}
sourceSchemaPreview={
<SourceSchemaPreview source={source} variant="text" />
}
/>
</CustomInputWrapper>
{sourceIsMetric && (
<CustomInputWrapper
label="Metric type"
tooltipText="The metric table that the filter values are queried from"
error={formState.errors.sourceMetricType}
>
<Controller
control={control}
name="sourceMetricType"
rules={{ required: true }}
render={({ field: { onChange, value } }) => (
<Radio.Group
value={value}
onChange={v => onChange(v)}
withAsterisk
>
<Group>
{metricTypes.map(type => (
<Radio key={type} value={type} label={type} />
))}
</Group>
</Radio.Group>
)}
/>
</CustomInputWrapper>
)}
<CustomInputWrapper
label="Filter expression"
tooltipText="The SQL column or expression to filter on"
error={formState.errors.expression}
>
<SQLInlineEditorControlled
tableConnections={tableConnection}
control={control}
name="expression"
placeholder="SQL column or expression"
language="sql"
enableHotkey
rules={{ required: true }}
parentRef={modalContentRef}
/>
</CustomInputWrapper>
<Group justify="flex-end" my="xs">
<Button variant="outline" color="gray.2" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Save filter</Button>
</Group>
</Stack>
</form>
</div>
</Modal>
);
};
interface EmptyStateProps {
onCreateFilter: () => void;
onClose: () => void;
}
const EmptyState = ({ onCreateFilter, onClose }: EmptyStateProps) => {
return (
<Modal opened onClose={onClose} size={MODAL_SIZE}>
<Stack align="center" justify="center" pt="lg" pb="xl">
<IconFilter />
<Title order={4}>No filters yet.</Title>
<Text size="sm" ta="center" px="xl">
Add filters to let users quickly narrow data on key columns. Saved
filters will stay with this dashboard.
</Text>
<Button variant="outline" color="gray.2" onClick={onCreateFilter}>
Add new filter
</Button>
</Stack>
</Modal>
);
};
interface DashboardFiltersListProps {
filters: DashboardFilter[];
isLoading?: boolean;
onEdit: (filter: DashboardFilter) => void;
onRemove: (id: string) => void;
onClose: () => void;
onAddNew: () => void;
}
const DashboardFiltersList = ({
filters,
isLoading,
onEdit,
onRemove,
onClose,
onAddNew,
}: DashboardFiltersListProps) => {
const { data: sources } = useSources();
return (
<Modal
opened
onClose={onClose}
title="Filters"
size={MODAL_SIZE}
className={styles.modal}
>
<Stack className={styles.filtersContainer} gap="xs">
{filters.map(filter => (
<Paper
key={filter.id}
withBorder
bg={'dark.5'}
className={styles.filterPaper}
p="xs"
>
<Group justify="space-between" className={styles.filterHeader}>
<Text size="xs">{filter.name}</Text>
<Group>
<UnstyledButton
onClick={() => onEdit(filter)}
className={styles.filterActionButton}
>
<IconPencil size={16} />
</UnstyledButton>
<UnstyledButton
onClick={() => onRemove(filter.id)}
className={`${styles.filterActionButton} ${styles.deleteButton}`}
>
<IconTrash size={16} />
</UnstyledButton>
</Group>
</Group>
<Group gap="xs">
<i className="bi bi-collection"></i>
<Text size="xs">
{sources?.find(s => s.id === filter.source)?.name}
</Text>
</Group>
</Paper>
))}
{isLoading && (
<div
className="spinner-border mx-auto"
style={{ width: 14, height: 14 }}
/>
)}
</Stack>
<Group justify="center" my="sm">
<Button variant="outline" color="gray.2" onClick={onAddNew}>
Add new filter
</Button>
</Group>
</Modal>
);
};
interface DashboardFiltersEditModalProps {
opened: boolean;
filters: DashboardFilter[];
isLoading?: boolean;
onClose: () => void;
onSaveFilter: (filter: DashboardFilter) => void;
onRemoveFilter: (id: string) => void;
}
const NEW_FILTER_ID = 'new';
const DashboardFiltersModal = ({
opened,
filters,
isLoading,
onClose,
onSaveFilter,
onRemoveFilter,
}: DashboardFiltersEditModalProps) => {
const [selectedFilter, setSelectedFilter] = useState<DashboardFilter>();
useEffect(() => {
if (opened) {
setSelectedFilter(undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened]);
const handleRemoveFilter = (id: string) => {
if (id === selectedFilter?.id) {
setSelectedFilter(filters.find(f => f.id !== id));
}
onRemoveFilter(id);
};
const handleAddNewFilter = () => {
setSelectedFilter({
id: NEW_FILTER_ID,
type: 'QUERY_EXPRESSION',
name: '',
expression: '',
source: '',
});
};
const handleSaveFilter = (filter: DashboardFilter) => {
setSelectedFilter(undefined);
if (filter.id === NEW_FILTER_ID) {
const filterWithRealId = { ...filter, id: crypto.randomUUID() };
onSaveFilter(filterWithRealId);
} else {
onSaveFilter(filter);
}
};
const isEmpty = !selectedFilter && filters.length === 0;
if (!opened) {
return null;
} else if (isEmpty) {
return <EmptyState onCreateFilter={handleAddNewFilter} onClose={onClose} />;
} else if (selectedFilter) {
return (
<DashboardFilterEditForm
filter={selectedFilter}
onSave={handleSaveFilter}
onCancel={() => setSelectedFilter(undefined)}
onClose={onClose}
isNew={selectedFilter.id === NEW_FILTER_ID}
/>
);
} else {
return (
<DashboardFiltersList
filters={filters}
onEdit={setSelectedFilter}
onRemove={handleRemoveFilter}
onClose={onClose}
onAddNew={handleAddNewFilter}
isLoading={isLoading}
/>
);
}
};
export default DashboardFiltersModal;

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
import {
DashboardFilter,
@ -101,16 +101,19 @@ export function useDashboard({
const updateDashboard = useUpdateDashboard();
const { data: remoteDashboard } = useQuery({
queryKey: ['dashboards'],
queryFn: () => {
return hdxServer('dashboards').json<Dashboard[]>();
},
select: data => {
return data.find(d => d.id === dashboardId);
},
enabled: dashboardId != null,
});
const { data: remoteDashboard, isFetching: isFetchingRemoteDashboard } =
useQuery({
queryKey: ['dashboards'],
queryFn: () => {
return hdxServer('dashboards').json<Dashboard[]>();
},
select: data => {
return data.find(d => d.id === dashboardId);
},
enabled: dashboardId != null,
});
const [isSetting, setIsSettingDashboard] = useState(false);
const isLocalDashboard = dashboardId == null;
@ -131,11 +134,14 @@ export function useDashboard({
setLocalDashboard(newDashboard);
onSuccess?.();
} else {
setIsSettingDashboard(true);
return updateDashboard.mutate(newDashboard, {
onSuccess: () => {
setIsSettingDashboard(false);
onSuccess?.();
},
onError: e => {
setIsSettingDashboard(false);
notifications.show({
color: 'red',
title: 'Unable to save dashboard',
@ -161,6 +167,8 @@ export function useDashboard({
dashboardHash,
isLocalDashboard,
isLocalDashboardEmpty: localDashboard == null,
isFetching: isFetchingRemoteDashboard,
isSetting,
};
}

View file

@ -0,0 +1,23 @@
.filtersContainer {
max-height: 400px;
overflow-y: auto;
border-top: 1px solid var(--mantine-color-dark-4);
border-bottom: 1px solid var(--mantine-color-dark-4);
padding: var(--mantine-spacing-sm) 0;
margin-bottom: var(--mantine-spacing-xs) 0;
}
.filterActionButton {
opacity: 0.7;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
.deleteButton {
&:hover {
color: var(--mantine-color-red-4);
}
}