mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
730325a5cc
commit
bd940f300f
6 changed files with 448 additions and 294 deletions
5
.changeset/lucky-pugs-turn.md
Normal file
5
.changeset/lucky-pugs-turn.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
style: Improve dashboard filter modal UX
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
392
packages/app/src/DashboardFiltersModal.tsx
Normal file
392
packages/app/src/DashboardFiltersModal.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
23
packages/app/styles/DashboardFiltersModal.module.scss
Normal file
23
packages/app/styles/DashboardFiltersModal.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue