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 (
{ - const trimmedWhere = values.where?.trim() ?? ''; - const appliesTo = values.appliesToSourceIds?.filter( - id => !!id?.length, - ); - const isVariableEnabled = values.isVariableEnabled === true; - const isBroadcastEnabled = values.isBroadcastEnabled !== false; - const trimmedVariableName = values.variableName?.trim() ?? ''; - onSave({ - ...values, - where: trimmedWhere || undefined, - whereLanguage: trimmedWhere - ? (values.whereLanguage ?? 'sql') - : undefined, - appliesToSourceIds: appliesTo?.length ? appliesTo : undefined, - isBroadcastEnabled, - isVariableEnabled, - // Dropped when the variable is disabled, so the set of variable - // names is exactly the set of enabled filters. - variableName: isVariableEnabled - ? trimmedVariableName || - deriveVariableName(values.name) || - undefined - : undefined, - }); + try { + const validated = toSavedFilter(values); + onSave(validated); + } catch { + // The field rules should make this unreachable + setError('root', { + message: 'This filter is incomplete and could not be saved.', + }); + } })} > + {showTypeInput && ( + + + + )} - - setIsSourceSchemaPreviewOpen(true)} - isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(source)} - disabled={!!presetSource} - allowedSourceKinds={[ - SourceKind.Log, - SourceKind.Trace, - SourceKind.Session, - SourceKind.Metric, - ]} - /> - setIsSourceSchemaPreviewOpen(false)} - /> - - {sourceIsMetric && ( - - ( - onChange(v)} - withAsterisk - > - - {metricTypes.map(type => ( - - ))} - - - )} - /> - - )} - - - - - - - - - {showVariableOptions && ( - <> - - + - + )} - {/** - * Not available for filters on preset dashboards, always shown when showVariableOptions is disabled, - * and shown only when the broadcast condition is enabled if showVariableOptions is enabled. - **/} - {!presetSource && - (!showVariableOptions || formIsBroadCastEnabled) && ( - - - - - - )} - {showVariableOptions && ( - <> - - - {!!formIsVariableEnabled && ( - - - setHasEditedVariableName(true), - validate: validateVariableNameField, - })} - /> - - - )} - {showUnscopedBroadcastWarning && ( - } - data-testid="filter-unscoped-broadcast-warning" - > - Broadcast already applies this filter to every tile, including - the ones that reference the variable. Set “Applies to sources” - to limit which tiles the broadcast reaches, or turn off - broadcast so only tiles that reference the variable are - filtered. - - )} - + + {formState.errors.root && ( + } + data-testid="filter-save-error" + > + {formState.errors.root.message} + )} diff --git a/packages/app/src/components/DashboardFiltersModal/DashboardFiltersList.tsx b/packages/app/src/components/DashboardFiltersModal/DashboardFiltersList.tsx index e199b34bda..a6e12c4d7d 100644 --- a/packages/app/src/components/DashboardFiltersModal/DashboardFiltersList.tsx +++ b/packages/app/src/components/DashboardFiltersModal/DashboardFiltersList.tsx @@ -2,7 +2,6 @@ import { getFilterBroadcastTarget, getFilterVariableName, isFilterVariableEnabled, - isQueryExpressionFilter, } from '@hyperdx/common-utils/dist/filters'; import { DashboardFilter } from '@hyperdx/common-utils/dist/types'; import { @@ -13,12 +12,22 @@ import { Stack, UnstyledButton, } from '@mantine/core'; -import { IconPencil, IconRefresh, IconTrash } from '@tabler/icons-react'; +import { + IconBuildingBroadcastTower, + IconList, + IconPencil, + IconRefresh, + IconSearch, + IconTrash, +} from '@tabler/icons-react'; import { useSources } from '@/source'; import { MODAL_SIZE } from './constants'; -import { DashboardFilterListItem } from './DashboardFiltersListItem'; +import { + DashboardFilterAttribute, + DashboardFilterListItem, +} from './DashboardFiltersListItem'; import styles from '@styles/DashboardFiltersModal.module.scss'; @@ -34,15 +43,29 @@ interface DashboardFiltersListProps { onAddNew: () => void; } -function getValuesSourceName( +/** Where the dropdown's values come from: the queried source, or the authored list. */ +function getValuesAttribute( filter: DashboardFilter, sources?: { id: string; name: string }[], -) { - if (isQueryExpressionFilter(filter)) { - return sources?.find(s => s.id === filter?.source)?.name; +): DashboardFilterAttribute { + switch (filter.type) { + case 'QUERY_EXPRESSION': + return { + icon: , + tooltip: 'Source the dropdown values are queried from', + label: sources?.find(s => s.id === filter.source)?.name ?? '', + }; + case 'STATIC_LIST': { + const count = filter.options.length; + return { + icon: , + label: `${count} custom option${count === 1 ? '' : 's'}`, + }; + } + default: + filter satisfies never; // exhaustive check + return { icon: , label: '' }; } - - return 'Custom values'; } function getBroadcastTargetDisplay( @@ -89,8 +112,17 @@ export const DashboardFiltersList = ({ data-testid="dashboard-filters-list" > {filters.map(filter => { - const valuesSourceName = getValuesSourceName(filter, sources); + const attributes = [getValuesAttribute(filter, sources)]; + const broadcastTarget = getBroadcastTargetDisplay(filter, sources); + if (!hideAppliesTo && broadcastTarget != null) { + attributes.push({ + icon: , + tooltip: 'Sources this filter broadcasts to', + label: broadcastTarget, + testId: `dashboard-filter-applies-to-${filter.name}`, + }); + } const variableName = showVariableOptions && isFilterVariableEnabled(filter) @@ -101,9 +133,7 @@ export const DashboardFiltersList = ({ key={filter.id} name={filter.name} nameSuffix={variableName ? ` ($${variableName})` : undefined} - queriedFrom={valuesSourceName ?? ''} - queriedFromTooltip="Source of the available filter values" - appliedTo={!hideAppliesTo ? broadcastTarget : undefined} + attributes={attributes} actions={ <> ( {actions != null && {actions}} - - - - - - {queriedFrom} - - - {appliedTo != null && ( - - { + return ( + - - - - {appliedTo} - - - )} + {tooltip ? ( + + {icon} + + ) : ( + {icon} + )} + + {label} + + + ); + })} ); diff --git a/packages/app/src/components/DashboardFiltersModal/QueryExpressionFilterEditForm.tsx b/packages/app/src/components/DashboardFiltersModal/QueryExpressionFilterEditForm.tsx new file mode 100644 index 0000000000..1bff9d85e0 --- /dev/null +++ b/packages/app/src/components/DashboardFiltersModal/QueryExpressionFilterEditForm.tsx @@ -0,0 +1,293 @@ +import { useEffect, useState } from 'react'; +import { + Controller, + useFormState, + UseFormTrigger, + useWatch, +} from 'react-hook-form'; +import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; +import { hasFilterEffect } from '@hyperdx/common-utils/dist/filters'; +import { + DashboardFilter, + MetricsDataType, + SourceKind, + TSource, +} from '@hyperdx/common-utils/dist/types'; +import { Alert, Box, Divider, Group, Radio } from '@mantine/core'; +import { IconAlertTriangle } from '@tabler/icons-react'; + +import { CheckBoxControlled } from '@/components/InputControlled'; +import SearchWhereInput 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 { getMetricTableName } from '@/utils'; + +import { CustomInputWrapper } from './CustomInputWrapper'; +import { FilterFormControl, FilterFormValues } from './filterFormState'; +import { VariableNameInput } from './VariableNameInput'; + +interface QueryExpressionFilterEditFormProps { + control: FilterFormControl; + trigger: UseFormTrigger; + /** Set when the dashboard pins the filter to one source. */ + pinnedSource: TSource | undefined; + /** Filters other than the one being edited, used to keep variable names unique. */ + otherFilters: DashboardFilter[]; + /** Whether the broadcast / variable controls are available. */ + showVariableOptions: boolean; +} + +/** + * 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. + */ +const TOOLTIP_PORTAL_TARGET = + typeof document !== 'undefined' ? document.body : null; + +/** + * The fields describing where a filter's dropdown values are queried from. + * `StaticListFilterEditForm` covers the other type; everything the two share + * lives in the modal above them. + */ +export const QueryExpressionFilterEditForm = ({ + control, + trigger, + pinnedSource: presetSource, + otherFilters, + showVariableOptions, +}: QueryExpressionFilterEditFormProps) => { + 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 [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = + useState(false); + + const [isBroadcastEnabled, isVariableEnabled, appliesToSourceIds] = useWatch({ + control, + name: ['isBroadcastEnabled', 'isVariableEnabled', 'appliesToSourceIds'], + }); + + // 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 && + isBroadcastEnabled !== false && + isVariableEnabled === true && + !appliesToSourceIds?.some(id => !!id?.length); + + /** + * 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, isVariableEnabled }) || + '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'); + }, [isBroadcastEnabled, isVariableEnabled, showVariableOptions, trigger]); + + const { errors } = useFormState({ control }); + + return ( + <> + + setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled(source)} + disabled={!!presetSource} + allowedSourceKinds={[ + SourceKind.Log, + SourceKind.Trace, + SourceKind.Session, + SourceKind.Metric, + ]} + /> + setIsSourceSchemaPreviewOpen(false)} + /> + + {sourceIsMetric && ( + + ( + onChange(v)} + withAsterisk + > + + {metricTypes.map(type => ( + + ))} + + + )} + /> + + )} + + + + + + + + + + {showVariableOptions && ( + <> + + + + )} + {/** + * Not available for filters on preset dashboards, always shown when showVariableOptions is disabled, + * and shown only when the broadcast condition is enabled if showVariableOptions is enabled. + **/} + {!presetSource && (!showVariableOptions || isBroadcastEnabled) && ( + + + + + + )} + {showVariableOptions && ( + <> + + + {!!isVariableEnabled && ( + + + + )} + {showUnscopedBroadcastWarning && ( + } + data-testid="filter-unscoped-broadcast-warning" + > + Broadcast already applies this filter to every tile, including the + ones that reference the variable. Consider setting “Applies to + sources” to limit which tiles the broadcast reaches, or turn off + broadcast so only tiles that reference the variable are filtered. + + )} + + )} + + ); +}; diff --git a/packages/app/src/components/DashboardFiltersModal/StaticListFilterEditForm.tsx b/packages/app/src/components/DashboardFiltersModal/StaticListFilterEditForm.tsx new file mode 100644 index 0000000000..9261ed3048 --- /dev/null +++ b/packages/app/src/components/DashboardFiltersModal/StaticListFilterEditForm.tsx @@ -0,0 +1,53 @@ +import { Controller, useFormState } from 'react-hook-form'; +import { DashboardFilter } from '@hyperdx/common-utils/dist/types'; +import { TagsInput } from '@mantine/core'; + +import { CustomInputWrapper } from './CustomInputWrapper'; +import { FilterFormControl } from './filterFormState'; +import { VariableNameInput } from './VariableNameInput'; + +interface StaticListFilterEditFormProps { + control: FilterFormControl; + /** Filters other than the one being edited, used to keep variable names unique. */ + otherFilters: DashboardFilter[]; +} + +/** Form for editing a filter whose dropdown offers a hand-authored list. */ +export const StaticListFilterEditForm = ({ + control, + otherFilters, +}: StaticListFilterEditFormProps) => { + const { errors } = useFormState({ control }); + const validateAtLeastOneOption = (value: string[]) => + (value?.length ?? 0) > 0 || 'Add at least one option'; + + return ( + <> + + ( + + )} + /> + + + + + ); +}; diff --git a/packages/app/src/components/DashboardFiltersModal/VariableNameInput.tsx b/packages/app/src/components/DashboardFiltersModal/VariableNameInput.tsx new file mode 100644 index 0000000000..72f8dbdf93 --- /dev/null +++ b/packages/app/src/components/DashboardFiltersModal/VariableNameInput.tsx @@ -0,0 +1,41 @@ +import { useFormState, useWatch } from 'react-hook-form'; +import { + deriveVariableName, + validateVariableName, +} from '@hyperdx/common-utils/dist/filters'; +import { DashboardFilter } from '@hyperdx/common-utils/dist/types'; +import { TextInput } from '@mantine/core'; + +import { CustomInputWrapper } from './CustomInputWrapper'; +import { FilterFormControl } from './filterFormState'; + +interface VariableNameInputProps { + control: FilterFormControl; + otherFilters: DashboardFilter[]; +} + +export const VariableNameInput = ({ + control, + otherFilters, +}: VariableNameInputProps) => { + const { errors } = useFormState({ control }); + const filterName = useWatch({ control, name: 'name' }); + const derivedVariableName = deriveVariableName(filterName ?? ''); + + return ( + + + validateVariableName({ value, otherFilters }) ?? true, + })} + /> + + ); +}; diff --git a/packages/app/src/components/DashboardFiltersModal/__tests__/DashboardFiltersModal.test.tsx b/packages/app/src/components/DashboardFiltersModal/__tests__/DashboardFiltersModal.test.tsx new file mode 100644 index 0000000000..e9bae13ab5 --- /dev/null +++ b/packages/app/src/components/DashboardFiltersModal/__tests__/DashboardFiltersModal.test.tsx @@ -0,0 +1,314 @@ +import { DashboardFilter } from '@hyperdx/common-utils/dist/types'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import DashboardFiltersModal from '@/components/DashboardFiltersModal'; +import { FilterFormControl } from '@/components/DashboardFiltersModal/filterFormState'; + +// ConfirmProvider pulls in next/router, which jsdom has no answer for. +jest.mock('@/useConfirm', () => ({ useConfirm: () => jest.fn() })); + +jest.mock('@/source', () => ({ useSources: () => ({ data: [] }) })); + +// Mantine's Combobox calls scrollIntoView when its dropdown opens; jsdom lacks it. +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +/** + * Stands in for the queried editor, which would drag CodeMirror and the source + * metadata queries into jsdom. It registers the same required fields as the + * real form, so the tests still cover what those rules do to a save once the + * user has switched to the other type. + */ +jest.mock( + '@/components/DashboardFiltersModal/QueryExpressionFilterEditForm', + () => ({ + QueryExpressionFilterEditForm: ({ + control, + }: { + control: FilterFormControl; + }) => ( +
+ + +
+ ), + }), +); + +const EXISTING_FILTER: DashboardFilter = { + id: 'other', + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + source: 'logs', + isVariableEnabled: true, + variableName: 'env', +}; + +const EXISTING_STATIC_FILTER: DashboardFilter = { + id: 'static', + type: 'STATIC_LIST', + name: 'Environment', + options: ['prod'], + isBroadcastEnabled: false, + isVariableEnabled: true, + variableName: 'env', +}; + +const renderModal = ( + props: Partial> = {}, +) => { + const onSaveFilter = jest.fn(); + renderWithMantine( + , + ); + return { onSaveFilter, user: userEvent.setup() }; +}; + +const selectFilterType = async ( + user: ReturnType, + label: 'Queried values' | 'Static values', +) => { + await user.click(screen.getByTestId('filter-type-picker')); + // The dropdown's options are matched by text: jsdom has none of Mantine's + // stylesheet, so it reports them as inaccessible and `*ByRole` skips them. + await user.click(await screen.findByText(label)); +}; + +describe('DashboardFiltersModal', () => { + it('saves a variable-only static filter carrying the authored options in order', async () => { + const { onSaveFilter, user } = renderModal(); + + await user.click(screen.getByTestId('add-filter-button')); + await selectFilterType(user, 'Static values'); + await user.type(screen.getByTestId('filter-name-input'), 'Environment'); + const options = screen.getByTestId('filter-options-input'); + for (const option of ['prod', 'staging', 'dev']) { + await user.type(options, `${option}{Enter}`); + } + await user.click(screen.getByTestId('save-filter-button')); + + await waitFor(() => expect(onSaveFilter).toHaveBeenCalledTimes(1)); + expect(onSaveFilter).toHaveBeenCalledWith({ + id: expect.any(String), + type: 'STATIC_LIST', + name: 'Environment', + options: ['prod', 'staging', 'dev'], + isBroadcastEnabled: false, + isVariableEnabled: true, + // Derived from the display name, since it was never edited by hand. + variableName: 'Environment', + }); + }); + + it('keeps the fields both types share when the type changes', async () => { + const { user } = renderModal(); + + await user.click(screen.getByTestId('add-filter-button')); + await user.type(screen.getByTestId('filter-name-input'), 'Environment'); + await user.type(screen.getByTestId('filter-expression-input'), 'Env'); + + await selectFilterType(user, 'Static values'); + + expect(screen.getByTestId('filter-name-input')).toHaveValue('Environment'); + + await user.clear(screen.getByTestId('filter-variable-name-input')); + await user.type(screen.getByTestId('filter-variable-name-input'), 'env'); + await user.type(screen.getByTestId('filter-options-input'), 'prod{Enter}'); + + await selectFilterType(user, 'Queried values'); + + expect(screen.getByTestId('filter-name-input')).toHaveValue('Environment'); + expect(screen.getByTestId('filter-expression-input')).toHaveValue('Env'); + + await selectFilterType(user, 'Static values'); + + expect(screen.getByTestId('filter-variable-name-input')).toHaveValue('env'); + expect(screen.getByText('prod')).toBeInTheDocument(); + }); + + it('does not let the queried fields block a static save', async () => { + const { onSaveFilter, user } = renderModal(); + + // The queried editor mounts first, registering its required fields; the + // static filter has no answer for them and must save anyway. + await user.click(screen.getByTestId('add-filter-button')); + await selectFilterType(user, 'Static values'); + await user.type(screen.getByTestId('filter-name-input'), 'Environment'); + await user.type(screen.getByTestId('filter-options-input'), 'prod{Enter}'); + await user.click(screen.getByTestId('save-filter-button')); + + await waitFor(() => expect(onSaveFilter).toHaveBeenCalledTimes(1)); + expect(onSaveFilter).toHaveBeenCalledWith( + expect.objectContaining({ type: 'STATIC_LIST', options: ['prod'] }), + ); + }); + + it('drops the fields belonging to the type the user did not settle on', async () => { + const { onSaveFilter, user } = renderModal(); + + await user.click(screen.getByTestId('add-filter-button')); + await user.type(screen.getByTestId('filter-expression-input'), 'Env'); + await selectFilterType(user, 'Static values'); + await user.type(screen.getByTestId('filter-name-input'), 'Environment'); + await user.type(screen.getByTestId('filter-options-input'), 'prod{Enter}'); + await user.click(screen.getByTestId('save-filter-button')); + + await waitFor(() => expect(onSaveFilter).toHaveBeenCalledTimes(1)); + expect(onSaveFilter.mock.calls[0][0]).not.toHaveProperty('expression'); + expect(onSaveFilter.mock.calls[0][0]).not.toHaveProperty('source'); + }); + + it('refuses to save a static filter without an option', async () => { + const { onSaveFilter, user } = renderModal(); + + await user.click(screen.getByTestId('add-filter-button')); + await selectFilterType(user, 'Static values'); + await user.type(screen.getByTestId('filter-name-input'), 'Environment'); + await user.click(screen.getByTestId('save-filter-button')); + + expect(await screen.findByText('Add at least one option')).toBeVisible(); + expect(onSaveFilter).not.toHaveBeenCalled(); + }); + + it('rejects a variable name already taken by another filter', async () => { + const { onSaveFilter, user } = renderModal({ filters: [EXISTING_FILTER] }); + + await user.click(screen.getByTestId('add-filter-button')); + await selectFilterType(user, 'Static values'); + await user.type(screen.getByTestId('filter-name-input'), 'Environment'); + await user.type(screen.getByTestId('filter-options-input'), 'prod{Enter}'); + await user.clear(screen.getByTestId('filter-variable-name-input')); + await user.type(screen.getByTestId('filter-variable-name-input'), 'env'); + await user.click(screen.getByTestId('save-filter-button')); + + expect( + await screen.findByText(/used by another filter on this dashboard/), + ).toBeVisible(); + expect(onSaveFilter).not.toHaveBeenCalled(); + }); + + it('stops deriving the variable name once the user edits it', async () => { + const { user } = renderModal(); + + await user.click(screen.getByTestId('add-filter-button')); + await selectFilterType(user, 'Static values'); + await user.type(screen.getByTestId('filter-name-input'), 'Env'); + expect(screen.getByTestId('filter-variable-name-input')).toHaveValue('Env'); + + await user.clear(screen.getByTestId('filter-variable-name-input')); + await user.type( + screen.getByTestId('filter-variable-name-input'), + 'custom_env', + ); + await user.type(screen.getByTestId('filter-name-input'), 'ironment'); + + expect(screen.getByTestId('filter-variable-name-input')).toHaveValue( + 'custom_env', + ); + }); + + it('leaves a stored variable name alone when the display name changes', async () => { + const { onSaveFilter, user } = renderModal({ + filters: [EXISTING_STATIC_FILTER], + }); + + await user.click(screen.getByTestId('edit-filter-button-Environment')); + await user.clear(screen.getByTestId('filter-name-input')); + await user.type(screen.getByTestId('filter-name-input'), 'Deploy target'); + + expect(screen.getByTestId('filter-variable-name-input')).toHaveValue('env'); + + await user.click(screen.getByTestId('save-filter-button')); + + await waitFor(() => expect(onSaveFilter).toHaveBeenCalledTimes(1)); + expect(onSaveFilter).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Deploy target', variableName: 'env' }), + ); + }); + + it('offers the type picker while the filter is unsaved', async () => { + const { user } = renderModal(); + + await user.click(screen.getByTestId('add-filter-button')); + + expect(screen.getByTestId('filter-type-picker')).toBeVisible(); + }); + + it('offers the type picker for a saved filter', async () => { + const { user } = renderModal({ filters: [EXISTING_FILTER] }); + + await user.click(screen.getByTestId('edit-filter-button-Service')); + + expect(screen.getByTestId('filter-name-input')).toHaveValue('Service'); + expect(screen.getByTestId('filter-type-picker')).toBeVisible(); + }); + + it('converts a saved queried filter to a static one', async () => { + const { onSaveFilter, user } = renderModal({ filters: [EXISTING_FILTER] }); + + await user.click(screen.getByTestId('edit-filter-button-Service')); + await selectFilterType(user, 'Static values'); + await user.type(screen.getByTestId('filter-options-input'), 'api{Enter}'); + await user.click(screen.getByTestId('save-filter-button')); + + await waitFor(() => expect(onSaveFilter).toHaveBeenCalledTimes(1)); + expect(onSaveFilter).toHaveBeenCalledWith({ + // The same filter, so the tiles referencing it keep working. + id: EXISTING_FILTER.id, + type: 'STATIC_LIST', + name: 'Service', + options: ['api'], + isBroadcastEnabled: false, + isVariableEnabled: true, + // Kept, so the tiles referencing $env keep working. + variableName: 'env', + }); + }); + + it('converts a saved static filter to a queried one', async () => { + const { onSaveFilter, user } = renderModal({ + filters: [EXISTING_STATIC_FILTER], + }); + + await user.click(screen.getByTestId('edit-filter-button-Environment')); + await selectFilterType(user, 'Queried values'); + await user.type(screen.getByTestId('filter-expression-input'), 'Env'); + await user.type(screen.getByTestId('source-selector'), 'logs'); + await user.click(screen.getByTestId('save-filter-button')); + + await waitFor(() => expect(onSaveFilter).toHaveBeenCalledTimes(1)); + expect(onSaveFilter).toHaveBeenCalledWith( + expect.objectContaining({ + id: EXISTING_STATIC_FILTER.id, + type: 'QUERY_EXPRESSION', + expression: 'Env', + source: 'logs', + variableName: 'env', + }), + ); + expect(onSaveFilter.mock.calls[0][0]).not.toHaveProperty('options'); + }); + + it('hides the type picker where variables are unavailable', async () => { + const { user } = renderModal({ showVariableOptions: false }); + + await user.click(screen.getByTestId('add-filter-button')); + + expect(screen.queryByTestId('filter-type-picker')).toBeNull(); + }); +}); diff --git a/packages/app/src/components/DashboardFiltersModal/__tests__/filterFormState.test.ts b/packages/app/src/components/DashboardFiltersModal/__tests__/filterFormState.test.ts new file mode 100644 index 0000000000..912d314725 --- /dev/null +++ b/packages/app/src/components/DashboardFiltersModal/__tests__/filterFormState.test.ts @@ -0,0 +1,110 @@ +import { + FilterFormValues, + toFormValues, + toSavedFilter, +} from '@/components/DashboardFiltersModal/filterFormState'; + +/** Form values as the editor holds them: every field, whatever the type. */ +const formValues = ( + overrides: Partial, +): FilterFormValues => ({ + ...toFormValues(), + id: 'a', + ...overrides, +}); + +describe('toFormValues', () => { + it('seeds both types fields for a new filter', () => { + const values = toFormValues(undefined, 'logs'); + + expect(values).toMatchObject({ + type: 'QUERY_EXPRESSION', + name: '', + source: 'logs', + expression: '', + options: [], + isBroadcastEnabled: true, + isVariableEnabled: false, + }); + expect(values.id).toEqual(expect.any(String)); + }); + + it('carries a stored static filter into the queried fields as blanks', () => { + const values = toFormValues({ + id: 'a', + type: 'STATIC_LIST', + name: 'Environment', + options: ['prod'], + isBroadcastEnabled: false, + isVariableEnabled: true, + variableName: 'env', + }); + + expect(values).toMatchObject({ + id: 'a', + type: 'STATIC_LIST', + options: ['prod'], + variableName: 'env', + expression: '', + source: '', + isBroadcastEnabled: false, + isVariableEnabled: true, + }); + }); +}); + +describe('toSavedFilter', () => { + it('drops the queried fields from a static filter', () => { + const saved = toSavedFilter( + formValues({ + type: 'STATIC_LIST', + name: 'Environment', + options: [' prod ', 'staging'], + // Left behind by the queried editor before the type was switched. + expression: 'Env', + source: 'logs', + where: 'x = 1', + }), + ); + + expect(saved).toEqual({ + id: 'a', + type: 'STATIC_LIST', + name: 'Environment', + options: ['prod', 'staging'], + isBroadcastEnabled: false, + isVariableEnabled: true, + variableName: 'Environment', + }); + }); + + it('drops the static options from a queried filter', () => { + const saved = toSavedFilter( + formValues({ + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + source: 'logs', + where: ' ', + appliesToSourceIds: [], + options: ['prod'], + }), + ); + + expect(saved).toEqual({ + id: 'a', + type: 'QUERY_EXPRESSION', + name: 'Service', + expression: 'ServiceName', + source: 'logs', + isBroadcastEnabled: true, + isVariableEnabled: false, + }); + }); + + it('rejects values the schema cannot accept', () => { + expect(() => + toSavedFilter(formValues({ type: 'STATIC_LIST', options: [] })), + ).toThrow(); + }); +}); diff --git a/packages/app/src/components/DashboardFiltersModal/filterFormState.ts b/packages/app/src/components/DashboardFiltersModal/filterFormState.ts new file mode 100644 index 0000000000..da49f20c47 --- /dev/null +++ b/packages/app/src/components/DashboardFiltersModal/filterFormState.ts @@ -0,0 +1,84 @@ +import { Control } from 'react-hook-form'; +import { + getFilterVariableName, + isFilterBroadcastEnabled, + isFilterVariableEnabled, +} from '@hyperdx/common-utils/dist/filters'; +import { + DashboardFilter, + DashboardFilterSchema, + QueryExpressionDashboardFilter, + StaticListDashboardFilter, +} from '@hyperdx/common-utils/dist/types'; + +import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput'; + +/** + * Flattened intersection of available dashboard filter types, representing the type + * of a dashboard filter form which can be switched between filter type. Deliberately + * not a `DashboardFilter`, since a mid-edit form may not be a valid DashboardFilter value. + */ +export type FilterFormValues = { + type: DashboardFilter['type']; +} & Omit & + Omit< + StaticListDashboardFilter, + 'type' | 'isBroadcastEnabled' | 'isVariableEnabled' + >; + +export type FilterFormControl = Control; + +export const toFormValues = ( + filter?: DashboardFilter, + presetSourceId?: string, +): FilterFormValues => { + const queried = filter?.type === 'QUERY_EXPRESSION' ? filter : undefined; + const staticList = filter?.type === 'STATIC_LIST' ? filter : undefined; + + return { + id: filter?.id ?? crypto.randomUUID(), + type: filter?.type ?? 'QUERY_EXPRESSION', + name: filter?.name ?? '', + variableName: filter?.variableName ?? '', + isBroadcastEnabled: filter ? isFilterBroadcastEnabled(filter) : true, + isVariableEnabled: filter ? isFilterVariableEnabled(filter) : false, + + // QUERY_EXPRESSION fields + expression: queried?.expression ?? '', + source: queried?.source ?? presetSourceId ?? '', + sourceMetricType: queried?.sourceMetricType, + where: queried?.where ?? '', + whereLanguage: queried?.whereLanguage ?? getStoredLanguage() ?? 'sql', + appliesToSourceIds: queried?.appliesToSourceIds ?? [], + + // STATIC_LIST fields + options: staticList?.options ?? [], + }; +}; + +/** Normalizes the form values into the filter that gets stored. */ +export const toSavedFilter = (values: FilterFormValues): DashboardFilter => { + if (values.type === 'STATIC_LIST') { + return DashboardFilterSchema.parse({ + ...values, + options: values.options.map(option => option.trim()), + isBroadcastEnabled: false, + isVariableEnabled: true, + variableName: getFilterVariableName(values), + }); + } + + const trimmedWhere = values.where?.trim() ?? ''; + const appliesTo = values.appliesToSourceIds?.filter(id => !!id?.length); + const isVariableEnabled = isFilterVariableEnabled(values); + + return DashboardFilterSchema.parse({ + ...values, + where: trimmedWhere || undefined, + whereLanguage: trimmedWhere ? (values.whereLanguage ?? 'sql') : undefined, + appliesToSourceIds: appliesTo?.length ? appliesTo : undefined, + isBroadcastEnabled: isFilterBroadcastEnabled(values), + isVariableEnabled, + variableName: isVariableEnabled ? getFilterVariableName(values) : undefined, + }); +}; diff --git a/packages/app/src/components/DashboardFiltersModal/index.tsx b/packages/app/src/components/DashboardFiltersModal/index.tsx index ad3fd43bd0..ae2566dee6 100644 --- a/packages/app/src/components/DashboardFiltersModal/index.tsx +++ b/packages/app/src/components/DashboardFiltersModal/index.tsx @@ -1,14 +1,10 @@ -import { useEffect, useState } from 'react'; -import { isQueryExpressionFilter } from '@hyperdx/common-utils/dist/filters'; +import { useCallback, useEffect, useState } from 'react'; import { ChartVariable, DashboardFilter, TSource, } from '@hyperdx/common-utils/dist/types'; -import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput'; -import { SqlVariablesProvider } from '@/components/SQLEditor/variableCompletions'; - import { DashboardFilterEditForm } from './DashboardFilterEditForm'; import { DashboardFiltersEmptyState } from './DashboardFiltersEmptyState'; import { DashboardFiltersList } from './DashboardFiltersList'; @@ -27,8 +23,6 @@ interface DashboardFiltersEditModalProps { onRemoveFilter: (id: string) => void; } -const NEW_FILTER_ID = 'new'; - const DashboardFiltersModal = ({ opened, filters, @@ -40,77 +34,60 @@ const DashboardFiltersModal = ({ onSaveFilter, onRemoveFilter, }: DashboardFiltersEditModalProps) => { - const [selectedFilter, setSelectedFilter] = useState(); + // Undefined when not editing, set to the filter being edited when editing. + // The filter is undefined when creating a new one. + const [editState, setEditState] = useState<{ filter?: DashboardFilter }>(); + + const startEditing = useCallback( + (filter?: DashboardFilter) => setEditState({ filter }), + [], + ); + const stopEditing = useCallback(() => setEditState(undefined), []); useEffect(() => { if (opened) { - setSelectedFilter(undefined); + setEditState(undefined); } }, [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: source?.id ?? '', - where: '', - whereLanguage: getStoredLanguage() ?? 'sql', - isBroadcastEnabled: true, - isVariableEnabled: false, - }); - }; + const handleAddNewFilter = useCallback(() => setEditState({}), []); - const handleSaveFilter = (filter: DashboardFilter) => { - setSelectedFilter(undefined); - if (filter.id === NEW_FILTER_ID) { - const filterWithRealId = { ...filter, id: crypto.randomUUID() }; - onSaveFilter(filterWithRealId); - } else { + const handleSaveFilter = useCallback( + (filter: DashboardFilter) => { + setEditState(undefined); onSaveFilter(filter); - } - }; - - const isEmpty = !selectedFilter && filters.length === 0; + }, + [onSaveFilter], + ); if (!opened) { return null; - } else if (isEmpty) { + } else if (editState) { return ( - ); - } else if (selectedFilter && isQueryExpressionFilter(selectedFilter)) { + } else if (filters.length === 0) { return ( - - setSelectedFilter(undefined)} - onClose={onClose} - isNew={selectedFilter.id === NEW_FILTER_ID} - source={source} - filters={filters} - showVariableOptions={showVariableOptions} - /> - + ); } else { return ( { ); }); + it('withholds the one-argument filter macro for an expressionless variable', () => { + // A static-list filter declares no expression, so $__filter($env) has + // nothing to filter on and throws at expansion time. Every other reference + // form still works. + const env: ChartVariable = { name: 'env', values: ['prod'] }; + expect(labels([env])).not.toContain('$__filter($env)'); + expect(labels([env])).toEqual( + expect.arrayContaining([ + '$__filter', + '$__conditionalAll', + '$env', + '${env}', + '${env:sqlstring}', + '${env:csv}', + '${env:regex}', + '${env:lucene}', + ]), + ); + }); + it('shows the empty-selection expansion when nothing is selected', () => { const unselected: ChartVariable = { ...SERVICE, values: [] }; expect(footnoteOf([unselected], '$service')).toBe('Expands to: NULL'); diff --git a/packages/app/tests/e2e/features/dashboard-filter-value-format.spec.ts b/packages/app/tests/e2e/features/dashboard-filter-value-format.spec.ts index f34c2876a4..e6fe10b81f 100644 --- a/packages/app/tests/e2e/features/dashboard-filter-value-format.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-filter-value-format.spec.ts @@ -18,24 +18,7 @@ import type { Page } from '@playwright/test'; import { DashboardPage } from '../page-objects/DashboardPage'; import { expect, test } from '../utils/base-test'; import { DEFAULT_LOGS_SOURCE_NAME } from '../utils/constants'; - -type FilterEntry = - | { type: 'sql'; condition: string } - | { type: 'variable'; name: string; values: string[] }; - -/** The `filters=` param, decoded. `null` when the param is absent. */ -const filtersParam = (page: Page): FilterEntry[] | null => { - const raw = new URL(page.url()).searchParams.get('filters'); - if (raw === null) return null; - return JSON.parse(decodeURIComponent(raw)); -}; - -/** Wait for the param to settle on `expected` — nuqs writes it asynchronously. */ -const expectFiltersParam = async (page: Page, expected: FilterEntry[]) => { - await expect(async () => { - expect(filtersParam(page)).toEqual(expected); - }).toPass({ timeout: 10000 }); -}; +import { expectFiltersParam, type FilterEntry } from '../utils/filters-param'; /** Navigate to a dashboard with a hand-written `filters=` param. */ const gotoWithFilters = async ( diff --git a/packages/app/tests/e2e/features/dashboard-static-filters.spec.ts b/packages/app/tests/e2e/features/dashboard-static-filters.spec.ts index a33e514b74..fec1e3e1ea 100644 --- a/packages/app/tests/e2e/features/dashboard-static-filters.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-static-filters.spec.ts @@ -1,111 +1,305 @@ /** - * STATIC_LIST dashboard filters render as dropdowns offering their authored - * options, and the selection reaches tiles as `$variableName`. - * - * The filter editor can't author static filters yet, so the dashboard is - * seeded through the external API — which also makes this `@full-stack`, along - * with the tile's real count query. + * A `STATIC_LIST` dashboard filter offers a hand-authored list of values rather + * than one queried from ClickHouse. With no expression there is nothing to + * broadcast into a tile's `WHERE` clause, so the filter is a dashboard variable + * and nothing else — and it is offered only where variables are. */ +import { DisplayType } from '@hyperdx/common-utils/dist/types'; + import { DashboardPage } from '../page-objects/DashboardPage'; -import { getApiUrl, getSources, getUserAccessKey } from '../utils/api-helpers'; +import { ServicesDashboardPage } from '../page-objects/ServicesDashboardPage'; import { expect, test } from '../utils/base-test'; +import { + DEFAULT_LOGS_SOURCE_NAME, + DEFAULT_TRACES_SOURCE_NAME, +} from '../utils/constants'; +import { expectFiltersParam } from '../utils/filters-param'; + +/** + * Authored in an order that is not their sorted order, so "the dropdown lists + * them as written" is distinguishable from "the dropdown sorts them". + */ +const ENVIRONMENTS = ['prod', 'staging', 'dev']; test.describe( 'Static list dashboard filters', { tag: ['@dashboard', '@full-stack'] }, () => { - const BASE_URL = `${getApiUrl()}/api/v2/dashboards`; + let dashboardPage: DashboardPage; + + test.beforeEach(async ({ page }) => { + dashboardPage = new DashboardPage(page); + await dashboardPage.goto(); + }); + + test('authors a filter with no source, expression or broadcast controls', async () => { + test.setTimeout(90000); + + await test.step('Open the add-filter form and pick the static type', async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.openAddFilterForm(); + await expect(dashboardPage.getFilterTypePicker()).toBeVisible(); + // Filled before the switch: the display name belongs to both types, so + // changing type must not discard it. + await dashboardPage.getFilterNameInput().fill('Environment'); + await dashboardPage.selectFilterType('Static values'); + await expect(dashboardPage.getFilterNameInput()).toHaveValue( + 'Environment', + ); + }); + + await test.step('Only the fields a static filter has are rendered', async () => { + const form = dashboardPage.getFilterForm(); + await expect(dashboardPage.getFilterNameInput()).toBeVisible(); + await expect(dashboardPage.getFilterOptionsInput()).toBeVisible(); + // Always a variable, so the name is asked for rather than unlocked by + // a checkbox. + await expect(dashboardPage.variableNameInput).toBeVisible(); + + // Scoped to the form: these test ids are shared with the tile editor. + await expect(form.getByTestId('source-selector')).toHaveCount(0); + await expect( + form.getByTestId('applies-to-source-selector'), + ).toHaveCount(0); + await expect(form.getByTestId('filter-broadcast-checkbox')).toHaveCount( + 0, + ); + await expect( + form.getByTestId('filter-variable-enabled-checkbox'), + ).toHaveCount(0); + // Covers both the filter expression and the dropdown values filter: + // each is a CodeMirror editor, and the static form has neither. + await expect(form.locator('div.cm-editor')).toHaveCount(0); + }); + + await test.step('The saved filter is summarized by its option count', async () => { + await dashboardPage.fillFilterOptions(ENVIRONMENTS); + await dashboardPage.variableNameInput.fill('env'); + await dashboardPage.page.getByTestId('save-filter-button').click(); - // Deliberately not alphabetical: the dropdown must keep this order. - const OPTIONS = ['warn', 'error', 'debug', 'info']; + const item = dashboardPage.getFilterItemByName('Environment'); + await expect(item).toBeVisible(); + await expect(item).toContainText('3 custom options'); + await expect(item).toContainText('($env)'); + }); + }); - test('offers options in definition order and feeds the selection to tiles', async ({ + test('lists the options in author order and stores the selection by variable name', async ({ page, }) => { test.setTimeout(120000); - const dashboardPage = new DashboardPage(page); - await dashboardPage.goto(); + await test.step('Create a static Environment filter', async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.addNumberTile( + 'Count tile', + DEFAULT_LOGS_SOURCE_NAME, + ); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addStaticListFilterToDashboard( + 'Environment', + ENVIRONMENTS, + { variableName: 'env' }, + ); + await dashboardPage.closeFiltersModal(); + }); - const accessKey = await getUserAccessKey(page); - const [logSource] = await getSources(page, 'log'); - - await test.step('Seed a dashboard with a static filter via the external API', async () => { - const createResponse = await page.request.post(BASE_URL, { - headers: { - Authorization: `Bearer ${accessKey}`, - 'Content-Type': 'application/json', - }, - data: { - name: `Static filter test ${Date.now()}`, - tiles: [ - { - name: 'Log count', - x: 0, - y: 0, - w: 6, - h: 3, - config: { - displayType: 'number', - sourceId: logSource._id, - select: [ - { - aggFn: 'count', - where: '$__filter(SeverityText, $sev)', - whereLanguage: 'sql', - }, - ], - }, - }, - ], - filters: [ - { - type: 'STATIC_LIST', - name: 'Severity', - options: OPTIONS, - variableName: 'sev', - }, - ], - }, - }); - expect(createResponse.ok()).toBeTruthy(); - const dashboardId = (await createResponse.json()).data.id; - await dashboardPage.gotoDashboard(dashboardId); + await test.step('The dropdown offers the options as written, not sorted', async () => { + await dashboardPage.openFilterDropdown('Environment'); + await expect(async () => { + expect(await dashboardPage.getOpenFilterDropdownOptions()).toEqual( + ENVIRONMENTS, + ); + }).toPass({ timeout: 15000 }); + await page.keyboard.press('Escape'); }); - let unfilteredCount: string; - await test.step('The tile renders the unfiltered count ($__filter is a no-op unselected)', async () => { - await expect(dashboardPage.getNumberTileValue()).toHaveText(/\d/, { - timeout: 30000, - }); - unfilteredCount = await dashboardPage.getNumberTileValue().innerText(); + await test.step('Selecting a value writes a variable-keyed entry', async () => { + await dashboardPage.toggleFilterValue('Environment', 'prod'); + await expectFiltersParam(page, [ + { type: 'variable', name: 'env', values: ['prod'] }, + ]); }); - await test.step('The dropdown offers the options in definition order', async () => { - await dashboardPage.openFilterDropdown('Severity'); - await expect(page.getByRole('option')).toHaveText(OPTIONS); - await page.keyboard.press('Escape'); + await test.step('The selection survives a reload', async () => { + await page.reload(); + await dashboardPage.waitForLoaded(); + await expect( + dashboardPage.getFilterPill('Environment', 'prod'), + ).toBeVisible({ timeout: 20000 }); }); - await test.step('Selecting a value narrows the tile through $sev', async () => { - await dashboardPage.toggleFilterValue('Severity', 'error'); - // Seed logs spread severities evenly, so the error-only count is - // strictly below the total. - await expect(dashboardPage.getNumberTileValue()).not.toHaveText( - unfilteredCount, - { timeout: 30000 }, + await test.step('A freeform value that is not on the list is accepted', async () => { + await dashboardPage.typeFilterSearchValue('Environment', 'qa'); + await expect(dashboardPage.getFilterEmptyDropdownState()).toBeVisible(); + await dashboardPage.submitFilterSearchValue('Environment'); + + await expectFiltersParam(page, [ + { type: 'variable', name: 'env', values: ['prod', 'qa'] }, + ]); + }); + }); + + test('narrows a raw SQL tile by the selected value', async () => { + test.setTimeout(120000); + const chartName = `E2E Static Filter Tile ${Date.now()}`; + // The options double as real ServiceName values so the selection can be + // seen to narrow a query, in an order that is not their sorted order. + const services = ['frontend', 'accounting', 'ad']; + const sql = `SELECT ServiceName, count() AS count FROM default.e2e_otel_logs WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND Timestamp <= fromUnixTimestamp64Milli({endDateMilliseconds:Int64}) AND $__conditionalAll(ServiceName IN $svc, $svc) GROUP BY ServiceName LIMIT 200`; + + await test.step('Create a static Service filter and select one value', async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addStaticListFilterToDashboard( + 'Service', + services, + { variableName: 'svc' }, ); + await dashboardPage.closeFiltersModal(); + await dashboardPage.toggleFilterValue('Service', 'accounting'); + }); + + await test.step('Add a raw SQL tile that references the variable', async () => { + await dashboardPage.addTile(); + await expect(dashboardPage.chartEditor.nameInput).toBeVisible(); + await dashboardPage.chartEditor.waitForDataToLoad(); + await dashboardPage.chartEditor.setChartType(DisplayType.Table); + await dashboardPage.chartEditor.setChartName(chartName); + await dashboardPage.chartEditor.switchToSqlMode(); + await dashboardPage.chartEditor.typeSqlQuery(sql); + await dashboardPage.chartEditor.runQuery(false); + await dashboardPage.saveTile(); + }); + + await test.step('The tile shows only the selected service', async () => { + const tile = dashboardPage.getTiles().filter({ hasText: chartName }); await expect( - dashboardPage.getFilterPill('Severity', 'error'), - ).toBeVisible(); + tile.getByTitle('accounting', { exact: true }), + ).toBeVisible({ timeout: 20000 }); + await expect(tile.getByTitle('frontend', { exact: true })).toHaveCount( + 0, + ); }); - await test.step('The selection survives a reload via the URL state', async () => { - await page.reload(); + await test.step('Changing the selection re-renders the tile', async () => { + await dashboardPage.toggleFilterValue('Service', 'accounting'); + await dashboardPage.toggleFilterValue('Service', 'frontend'); + + const tile = dashboardPage.getTiles().filter({ hasText: chartName }); + await expect(tile.getByTitle('frontend', { exact: true })).toBeVisible({ + timeout: 20000, + }); + await expect( + tile.getByTitle('accounting', { exact: true }), + ).toHaveCount(0); + }); + }); + + test('round-trips the options and variable name through an edit', async ({ + page, + }) => { + test.setTimeout(120000); + + await test.step('Create a static Environment filter', async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addStaticListFilterToDashboard( + 'Environment', + ENVIRONMENTS, + { variableName: 'env' }, + ); + }); + + await test.step('Reopening the form shows what was saved', async () => { + await dashboardPage.openEditFilterForm('Environment'); + await expect(async () => { + expect(await dashboardPage.getFilterOptionValues()).toEqual( + ENVIRONMENTS, + ); + }).toPass({ timeout: 10000 }); + await expect(dashboardPage.variableNameInput).toHaveValue('env'); + await expect(dashboardPage.getFilterTypePicker()).toBeVisible(); + }); + + await test.step('An added option is saved', async () => { + await dashboardPage.fillFilterOptions(['qa']); + await dashboardPage.page.getByTestId('save-filter-button').click(); await expect( - dashboardPage.getFilterPill('Severity', 'error'), - ).toBeVisible({ timeout: 30000 }); + dashboardPage.getFilterItemByName('Environment'), + ).toContainText('4 custom options'); + await dashboardPage.closeFiltersModal(); + }); + + await test.step('It survives a reload, still in author order', async () => { + await page.reload(); + await dashboardPage.waitForLoaded(); + await dashboardPage.openFilterDropdown('Environment'); + await expect(async () => { + expect(await dashboardPage.getOpenFilterDropdownOptions()).toEqual([ + ...ENVIRONMENTS, + 'qa', + ]); + }).toPass({ timeout: 15000 }); }); }); + + test('converts a saved filter between the two types', async ({ page }) => { + test.setTimeout(120000); + + await test.step('Create a static Environment filter', async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addStaticListFilterToDashboard( + 'Environment', + ENVIRONMENTS, + { variableName: 'env' }, + ); + }); + + await test.step('Switching to queried values swaps in the queried fields', async () => { + await dashboardPage.openEditFilterForm('Environment'); + await dashboardPage.selectFilterType('Queried values'); + await expect(dashboardPage.getFilterOptionsInput()).toHaveCount(0); + + await dashboardPage.selectFilterSource(DEFAULT_LOGS_SOURCE_NAME); + await dashboardPage.fillFilterExpression('ServiceName'); + await page.getByTestId('save-filter-button').click(); + }); + + await test.step('The conversion survives a reload, keeping $env', async () => { + await dashboardPage.closeFiltersModal(); + await page.reload(); + await dashboardPage.waitForLoaded(); + await dashboardPage.openEditFiltersModal(); + + const item = dashboardPage.getFilterItemByName('Environment'); + await expect(item).toContainText(DEFAULT_LOGS_SOURCE_NAME); + await expect(item).toContainText('($env)'); + await expect(item).not.toContainText('custom options'); + }); + }); + + test('is not offered on a dashboard that has no variables', async ({ + page, + }) => { + test.setTimeout(90000); + + // The services dashboard is the one preset dashboard, and preset filters + // are broadcast-only: it renders the modal with `showVariableOptions` + // off, which is also how every dashboard looks with the variables flag + // disabled. + const servicesPage = new ServicesDashboardPage(page); + await servicesPage.goto(); + await servicesPage.selectSource(DEFAULT_TRACES_SOURCE_NAME); + await servicesPage.openEditFiltersModal(); + await dashboardPage.openAddFilterForm(); + + await expect(dashboardPage.getFilterNameInput()).toBeVisible(); + const form = dashboardPage.getFilterForm(); + await expect(form.getByTestId('filter-type-picker')).toHaveCount(0); + await expect(form.getByTestId('filter-options-input')).toHaveCount(0); + }); }, ); diff --git a/packages/app/tests/e2e/features/filter-key-edge-cases.spec.ts b/packages/app/tests/e2e/features/filter-key-edge-cases.spec.ts index 6e4de97459..a90916cff4 100644 --- a/packages/app/tests/e2e/features/filter-key-edge-cases.spec.ts +++ b/packages/app/tests/e2e/features/filter-key-edge-cases.spec.ts @@ -44,6 +44,7 @@ import { INTERESTING_FILTER_KEYS_SOURCE_NAME, METADATA_MV_LOGS_SOURCE_NAME, } from '../utils/constants'; +import { expectFiltersParam, rawFiltersParam } from '../utils/filters-param'; const [ROW1, ROW2, ROW3] = INTERESTING_FILTER_KEYS_ROWS; const [MV_ROW1, MV_ROW2, MV_ROW3] = METADATA_MV_ROWS; @@ -550,19 +551,15 @@ test.describe( }); await expect(dashboardPage.getTileError()).toHaveCount(0); - await expect(async () => { - const raw = new URL(page.url()).searchParams.get('filters'); - expect(raw).not.toBeNull(); - expect(JSON.parse(decodeURIComponent(raw!))).toEqual([ - { - type: 'variable', - name: 'mapkey', - values: [ROW1.resourceAttrValue], - }, - ]); - // No bracket- or quote-bearing key anywhere in the param. - expect(raw).not.toContain('ResourceAttributes'); - }).toPass({ timeout: 10000 }); + await expectFiltersParam(page, [ + { + type: 'variable', + name: 'mapkey', + values: [ROW1.resourceAttrValue], + }, + ]); + // No bracket- or quote-bearing key anywhere in the param. + expect(rawFiltersParam(page)).not.toContain('ResourceAttributes'); }); await test.step('the selection survives a reload', async () => { diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index ccb44ed2cd..a2c0b4ddb2 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -862,6 +862,22 @@ export class DashboardPage { await this.page.getByTestId(`edit-filter-button-${filterName}`).click(); } + /** + * Replace the filter expression in the open edit form, then blur the SQL + * editor so its autocomplete tooltip closes — left open it overlaps the save + * button and makes the click flake on "element is not stable". + */ + async fillFilterExpression(expression: string) { + const editor = getSqlEditor(this.page, 'expression'); + await editor.click(); + await this.page.keyboard.press( + process.platform === 'darwin' ? 'Meta+A' : 'Control+A', + ); + await this.page.keyboard.press('Backspace'); + await this.page.keyboard.insertText(expression); + await this.getFilterNameInput().click(); + } + /** Pick the data source in the filter edit form. */ async selectFilterSource(sourceName: string) { await this.filtersSourceSelector.click(); @@ -1257,6 +1273,101 @@ export class DashboardPage { }); } + /** + * The type dropdown at the top of the filter edit form. Only rendered where + * dashboard variables are on. It is a Mantine `Select`, whose test id lands + * on the underlying text input. + */ + getFilterTypePicker(): Locator { + return this.page.getByTestId('filter-type-picker'); + } + + /** Switch the add-filter form between the queried and static value types. */ + async selectFilterType(label: 'Queried values' | 'Static values') { + await this.getFilterTypePicker().click(); + await this.getFilterOption(label).click(); + } + + /** + * The static filter form's options field — a Mantine `TagsInput`, whose test + * id lands on the underlying text input. + */ + getFilterOptionsInput(): Locator { + return this.page.getByTestId('filter-options-input'); + } + + /** + * The authored options currently held by the options field, in order. Each + * is a Mantine `Pill`, matched by class because the pills carry no role or + * test id of their own and the filter form has no other pills. + * + * `allTextContents` does not auto-wait, so the field is waited for first — + * a read taken before the form has rendered would otherwise return `[]`. + */ + async getFilterOptionValues(): Promise { + await this.getFilterOptionsInput().waitFor({ + state: 'visible', + timeout: 10000, + }); + const texts = await this.getFilterForm() + .locator('[class*="Pill-label"]') + .allTextContents(); + return texts.map(text => text.trim()); + } + + /** Append `options` to the static filter form's options field, in order. */ + async fillFilterOptions(options: string[]) { + const input = this.getFilterOptionsInput(); + await input.click(); + for (const option of options) { + await input.fill(option); + await input.press('Enter'); + } + } + + /** + * Add a dashboard filter whose dropdown offers a hand-authored list. Kept + * separate from `fillFilterForm` because a static filter shares only the + * display name and variable name with a queried one — it has no source, + * expression, or broadcast mode to configure. + * + * Assumes the Edit Filters modal is already open; leaves it open on the + * filters list, having waited for the new filter to land there so a slow + * save cannot race the next add. + */ + async addStaticListFilterToDashboard( + name: string, + options: string[], + variableOptions?: { variableName?: string }, + ) { + await this.addFiltersButton.click(); + await this.selectFilterType('Static values'); + const nameInput = this.getFilterNameInput(); + await nameInput.waitFor({ state: 'visible', timeout: 10000 }); + await nameInput.fill(name); + await this.fillFilterOptions(options); + if (variableOptions?.variableName !== undefined) { + await this.variableNameInput.fill(variableOptions.variableName); + } + await this.page.getByTestId('save-filter-button').click(); + await this.getFilterItemByName(name).waitFor({ + state: 'visible', + timeout: 10000, + }); + } + + /** + * The options offered by the dashboard filter dropdown that is currently + * open, in render order. Scoped to visible nodes: a just-closed dropdown's + * portal can linger in the DOM. + */ + async getOpenFilterDropdownOptions(): Promise { + const texts = await this.page + .locator('[role="option"]:visible') + .allTextContents(); + return texts.map(text => text.trim()); + } + /** * Toggle a value in a dashboard filter's multi-select. Selecting an unselected * value applies it; calling again with the same value clears it. Closes the diff --git a/packages/app/tests/e2e/page-objects/ServicesDashboardPage.ts b/packages/app/tests/e2e/page-objects/ServicesDashboardPage.ts index e489f13000..518ec835ce 100644 --- a/packages/app/tests/e2e/page-objects/ServicesDashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/ServicesDashboardPage.ts @@ -20,6 +20,11 @@ export class ServicesDashboardPage { await this.page.waitForLoadState('networkidle'); } + /** Open the filters modal. Preset-dashboard filters are broadcast-only. */ + async openEditFiltersModal() { + await this.page.getByTestId('edit-filters-button').click(); + } + /** The WHERE input's language switch (SQL / Lucene). */ get whereLanguageSwitch(): Locator { return this.page.getByTestId('where-language-switch'); diff --git a/packages/app/tests/e2e/utils/filters-param.ts b/packages/app/tests/e2e/utils/filters-param.ts new file mode 100644 index 0000000000..7d09cb1d9e --- /dev/null +++ b/packages/app/tests/e2e/utils/filters-param.ts @@ -0,0 +1,31 @@ +/** + * The `filters=` URL param carries dashboard filter selections in one of two + * shapes: legacy, keyed by SQL expression, or variable-keyed by variable name. + * These helpers are the one definition of that contract for the E2E suite. + */ +import { expect, type Page } from '@playwright/test'; + +export type FilterEntry = + | { type: 'sql'; condition: string } + | { type: 'variable'; name: string; values: string[] }; + +/** The raw, still-encoded `filters=` param. `null` when the param is absent. */ +export const rawFiltersParam = (page: Page): string | null => + new URL(page.url()).searchParams.get('filters'); + +/** The `filters=` param, decoded. `null` when the param is absent. */ +export const filtersParam = (page: Page): FilterEntry[] | null => { + const raw = rawFiltersParam(page); + if (raw === null) return null; + return JSON.parse(decodeURIComponent(raw)); +}; + +/** Wait for the param to settle on `expected` — nuqs writes it asynchronously. */ +export const expectFiltersParam = async ( + page: Page, + expected: FilterEntry[], +) => { + await expect(async () => { + expect(filtersParam(page)).toEqual(expected); + }).toPass({ timeout: 10000 }); +};