diff --git a/.changeset/wicked-ads-admire.md b/.changeset/wicked-ads-admire.md
new file mode 100644
index 0000000000..b51a78d76a
--- /dev/null
+++ b/.changeset/wicked-ads-admire.md
@@ -0,0 +1,5 @@
+---
+"@hyperdx/app": patch
+---
+
+feat: Allow creation and editing of static list filters in the UI
diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts
index 8e0fd7fc4c..ca3adee7f4 100644
--- a/packages/app/playwright.config.ts
+++ b/packages/app/playwright.config.ts
@@ -106,7 +106,7 @@ export default defineConfig({
// Full UI: Alerts + Dashboards. Not local mode; Alerts enabled;
command: USE_DEV
? `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true next dev --webpack`
- : `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true yarn build && SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_DASHBOARD_VARIABLES=true NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true yarn start`,
+ : `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true yarn build && SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true yarn start`,
port: parseInt(APP_PORT, 10),
reuseExistingServer: !process.env.CI,
timeout: APP_SERVER_STARTUP_TIMEOUT_MS,
diff --git a/packages/app/src/ServicesDashboardPage/ServicesDashboardPage.tsx b/packages/app/src/ServicesDashboardPage/ServicesDashboardPage.tsx
index cf2877c325..b1f13b5bf7 100644
--- a/packages/app/src/ServicesDashboardPage/ServicesDashboardPage.tsx
+++ b/packages/app/src/ServicesDashboardPage/ServicesDashboardPage.tsx
@@ -377,6 +377,7 @@ function ServicesDashboardPage() {
variant="secondary"
onClick={() => setShowFiltersModal(true)}
size="lg"
+ data-testid="edit-filters-button"
>
diff --git a/packages/app/src/__tests__/DashboardFilters.test.ts b/packages/app/src/__tests__/DashboardFilters.test.ts
index dcaebce988..1284060dbd 100644
--- a/packages/app/src/__tests__/DashboardFilters.test.ts
+++ b/packages/app/src/__tests__/DashboardFilters.test.ts
@@ -88,6 +88,23 @@ describe('getFilterEffect', () => {
).toEqual('Filters 2 sources');
});
+ it('describes a static filter as a variable only', () => {
+ expect(
+ getFilterEffect({
+ id: 'filter2',
+ type: 'STATIC_LIST',
+ name: 'Environment',
+ options: ['prod', 'staging', 'dev'],
+ isBroadcastEnabled: false,
+ isVariableEnabled: true,
+ variableName: 'env',
+ }),
+ ).toEqual({
+ hasEffect: true,
+ tooltip: 'Available as variable ($env)',
+ });
+ });
+
it('ignores the stored scope while broadcasting is off', () => {
expect(
getFilterEffect({
diff --git a/packages/app/src/components/DashboardFiltersModal/CustomInputWrapper.tsx b/packages/app/src/components/DashboardFiltersModal/CustomInputWrapper.tsx
index bfe8ba5e6c..31c97c6895 100644
--- a/packages/app/src/components/DashboardFiltersModal/CustomInputWrapper.tsx
+++ b/packages/app/src/components/DashboardFiltersModal/CustomInputWrapper.tsx
@@ -1,4 +1,4 @@
-import { FieldError } from 'react-hook-form';
+import { FieldError, Merge } from 'react-hook-form';
import { Input, Tooltip } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
@@ -6,7 +6,7 @@ interface CustomInputWrapperProps {
children: React.ReactNode;
label: string;
tooltipText?: string;
- error?: FieldError;
+ error?: FieldError | Merge;
}
export const CustomInputWrapper = ({
diff --git a/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx b/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx
index bae781ffde..a79f776c2b 100644
--- a/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx
+++ b/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx
@@ -1,91 +1,66 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Controller, useForm, useWatch } from 'react-hook-form';
-import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
-import {
- deriveVariableName,
- hasFilterEffect,
- isFilterBroadcastEnabled,
- isFilterVariableEnabled,
- validateVariableName,
-} from '@hyperdx/common-utils/dist/filters';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import { useForm, useWatch } from 'react-hook-form';
+import { deriveVariableName } from '@hyperdx/common-utils/dist/filters';
import {
+ ChartVariable,
DashboardFilter,
- MetricsDataType,
- QueryExpressionDashboardFilter,
- SourceKind,
+ DashboardFilterType,
TSource,
} from '@hyperdx/common-utils/dist/types';
-import {
- Alert,
- Box,
- Button,
- Divider,
- Group,
- Modal,
- Radio,
- Stack,
- TextInput,
-} from '@mantine/core';
+import { Alert, Button, Group, Modal, Stack, TextInput } from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
-import { CheckBoxControlled } from '@/components/InputControlled';
-import SearchWhereInput, {
- getStoredLanguage,
-} from '@/components/SearchInput/SearchWhereInput';
-import { SourceMultiSelectControlled } from '@/components/SourceMultiSelect';
-import SourceSchemaPreview, {
- isSourceSchemaPreviewEnabled,
-} from '@/components/SourceSchemaPreview';
-import { SourceSelectControlled } from '@/components/SourceSelect';
-import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
-import { useSource } from '@/source';
+import SelectControlled from '@/components/SelectControlled';
+import { SqlVariablesProvider } from '@/components/SQLEditor/variableCompletions';
import { useConfirm } from '@/useConfirm';
-import { getMetricTableName } from '@/utils';
import { useZIndex } from '@/zIndex';
import { MODAL_SIZE } from './constants';
import { CustomInputWrapper } from './CustomInputWrapper';
+import {
+ FilterFormValues,
+ toFormValues,
+ toSavedFilter,
+} from './filterFormState';
+import { QueryExpressionFilterEditForm } from './QueryExpressionFilterEditForm';
+import { StaticListFilterEditForm } from './StaticListFilterEditForm';
+
+const FILTER_TYPE_OPTIONS = [
+ {
+ value: DashboardFilterType.enum.QUERY_EXPRESSION,
+ label: 'Queried values',
+ },
+ { value: DashboardFilterType.enum.STATIC_LIST, label: 'Static values' },
+];
interface DashboardFilterEditFormProps {
- /** TODO: Support editing static list filters. */
- filter: QueryExpressionDashboardFilter;
- isNew: boolean;
- source: TSource | undefined;
- /** Filters that already exist on the dashboard, used to keep variable names unique. */
+ /** The stored filter this session started from; unset for one that does not exist yet. */
+ filter?: DashboardFilter;
+ /** Filters that already exist on the dashboard */
filters: DashboardFilter[];
+ /** The source to pre-fill for a new filter, if any */
+ source?: TSource;
/** Whether the broadcast / variable controls are available. */
showVariableOptions: boolean;
- onSave: (definition: DashboardFilter) => void;
+ /** The dashboard's current variable state, if any */
+ variables?: ChartVariable[];
+ onSave: (filter: DashboardFilter) => void;
onClose: () => void;
onCancel: () => void;
}
/**
- * The modal body scrolls, so an autocomplete popup rendered inside it is
- * clipped at the modal's edge. Portal it to the document body instead.
+ * The editor for a single filter, of either type. One form covers both, so
+ * switching type keeps the fields they share. Which of the fields are actually
+ * stored is settled by `toSavedFilter`, not by which editor happens to be
+ * mounted.
*/
-const TOOLTIP_PORTAL_TARGET =
- typeof document !== 'undefined' ? document.body : null;
-
-/** Normalize a stored filter into form values. */
-const toFormValues = (
- filter: QueryExpressionDashboardFilter,
-): QueryExpressionDashboardFilter => ({
- ...filter,
- where: filter.where ?? '',
- whereLanguage: filter.whereLanguage ?? getStoredLanguage() ?? 'sql',
- appliesToSourceIds: filter.appliesToSourceIds ?? [],
- isBroadcastEnabled: isFilterBroadcastEnabled(filter),
- isVariableEnabled: isFilterVariableEnabled(filter),
- variableName: filter.variableName ?? '',
-});
-
export const DashboardFilterEditForm = ({
filter,
- isNew,
source: presetSource,
filters,
showVariableOptions,
+ variables,
onSave,
onClose,
onCancel,
@@ -95,11 +70,11 @@ export const DashboardFilterEditForm = ({
register,
formState,
control,
- reset,
setValue,
+ setError,
trigger,
- } = useForm({
- defaultValues: toFormValues(filter),
+ } = useForm({
+ defaultValues: toFormValues(filter, presetSource?.id),
});
const confirm = useConfirm();
@@ -134,50 +109,22 @@ export const DashboardFilterEditForm = ({
});
}, [confirm, isDirty, onClose]);
- // Gates the auto-fill of Variable Name from Name below. Seeded true for a filter
- // that already has a stored name, because renaming an existing filter must not
- // silently break the tiles referencing its old token.
- const [hasEditedVariableName, setHasEditedVariableName] = useState(
- !isNew && !!filter.variableName,
- );
-
- useEffect(() => {
- reset(toFormValues(filter));
- setHasEditedVariableName(!isNew && !!filter.variableName);
- }, [filter, isNew, reset]);
-
- const otherFilters = useMemo(
- () => filters.filter(f => f.id !== filter.id),
- [filters, filter.id],
- );
+ // Gates the auto-fill of Variable Name from Name below. The auto-fill itself
+ // uses `setValue` without `shouldDirty`, so the field is dirty only once the
+ // user has typed in it. Also gated for a filter that already has a stored
+ // name, because renaming an existing filter must not silently break the tiles
+ // referencing its old token.
+ const hasEditedVariableName =
+ !!filter?.variableName || !!formState.dirtyFields.variableName;
- const [
- formFilterName,
- formIsBroadCastEnabled,
- formIsVariableEnabled,
- formAppliesToSourceIds,
- ] = useWatch({
+ const [formFilterType, formFilterName] = useWatch({
control,
- name: [
- 'name',
- 'isBroadcastEnabled',
- 'isVariableEnabled',
- 'appliesToSourceIds',
- ],
+ name: ['type', 'name'],
});
-
- // Both modes on with an unrestricted broadcast is almost always a mistake:
- // broadcast already reaches every tile, so the variable adds nothing and the
- // tiles that reference it get filtered twice over. Scoping the broadcast is
- // what makes the pair meaningful, so nudge toward "Applies to sources".
- const showUnscopedBroadcastWarning =
- showVariableOptions &&
- formIsBroadCastEnabled !== false &&
- formIsVariableEnabled === true &&
- !formAppliesToSourceIds?.some(id => !!id?.length);
-
const derivedVariableName = deriveVariableName(formFilterName ?? '');
+ // Keep the variable name in sync with the display
+ // name, unless the user has already edited it
useEffect(() => {
if (!showVariableOptions || hasEditedVariableName) return;
setValue('variableName', derivedVariableName);
@@ -188,64 +135,14 @@ export const DashboardFilterEditForm = ({
setValue,
]);
- const validateVariableNameField = (value: string | undefined) => {
- if (!showVariableOptions || !formIsVariableEnabled) return true;
- return validateVariableName({ value, otherFilters }) ?? true;
- };
-
- /**
- * Registered on the variable checkbox — the lower of the pair — so the
- * message lands under both controls rather than between them.
- *
- * Skipped when the controls are hidden: the form cannot express the invalid
- * state there (broadcast defaults on, and neither box is reachable), so all
- * an error could do is block a save the user has no way to fix.
- */
- const validateFilterModes = () => {
- if (!showVariableOptions) return true;
- return (
- hasFilterEffect({
- isBroadcastEnabled: formIsBroadCastEnabled,
- isVariableEnabled: formIsVariableEnabled,
- }) ||
- 'A filter must broadcast its value, be available as a variable, or both'
- );
- };
-
- // The rule spans two checkboxes but its error lives on one, so react-hook-form
- // won't re-run it when the *other* box changes. Re-trigger on both.
- useEffect(() => {
- if (!showVariableOptions) return;
- void trigger('isVariableEnabled');
- }, [
- formIsBroadCastEnabled,
- formIsVariableEnabled,
- showVariableOptions,
- trigger,
- ]);
-
- const sourceId = useWatch({ control, name: 'source' });
- const { data: source } = useSource({
- id: sourceId,
- });
-
- const metricType = useWatch({ control, name: '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?.kind === SourceKind.Metric ? source.metricTables?.[type] : false,
+ const otherFilters = useMemo(
+ () => filters.filter(f => f.id !== filter?.id),
+ [filters, filter?.id],
);
- const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] =
- useState(false);
+ const isNew = !filter;
+ const isStaticListTypeAvailable = !!showVariableOptions;
+ const showTypeInput = isStaticListTypeAvailable;
return (