mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
fix: autocomplete for lucene column values (#720)
 Added autocomplete for potential search values for lucene where clauses. Added testing for useAutoCompleteOptions and useGetKeyValues. Ref: HDX-1509
This commit is contained in:
parent
56e39dc484
commit
092a292b60
13 changed files with 672 additions and 45 deletions
6
.changeset/fifty-pugs-nail.md
Normal file
6
.changeset/fifty-pugs-nail.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
fix: autocomplete for key-values complete for v2 lucene
|
||||
|
|
@ -278,7 +278,7 @@ export default function NamespaceDetailsSidePanel({
|
|||
|
||||
const { data: logServiceNames } = useGetKeyValues(
|
||||
{
|
||||
chartConfig: {
|
||||
chartConfigs: {
|
||||
from: logSource.from,
|
||||
where: `${logSource?.resourceAttributesExpression}.k8s.namespace.name:"${namespaceName}"`,
|
||||
whereLanguage: 'lucene',
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ export default function NodeDetailsSidePanel({
|
|||
|
||||
const { data: logServiceNames } = useGetKeyValues(
|
||||
{
|
||||
chartConfig: {
|
||||
chartConfigs: {
|
||||
from: logSource.from,
|
||||
where: `${logSource?.resourceAttributesExpression}.k8s.node.name:"${nodeName}"`,
|
||||
whereLanguage: 'lucene',
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ export default function PodDetailsSidePanel({
|
|||
|
||||
const { data: logServiceNames } = useGetKeyValues(
|
||||
{
|
||||
chartConfig: {
|
||||
chartConfigs: {
|
||||
from: logSource.from,
|
||||
where: `${logSource?.resourceAttributesExpression}.k8s.pod.name:"${podName}"`,
|
||||
whereLanguage: 'lucene',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,27 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { TableConnection } from '@hyperdx/common-utils/dist/metadata';
|
||||
import { Field, TableConnection } from '@hyperdx/common-utils/dist/metadata';
|
||||
import { genEnglishExplanation } from '@hyperdx/common-utils/dist/queryParser';
|
||||
|
||||
import AutocompleteInput from '@/AutocompleteInput';
|
||||
import { useAllFields } from '@/hooks/useMetadata';
|
||||
|
||||
import {
|
||||
ILanguageFormatter,
|
||||
useAutoCompleteOptions,
|
||||
} from './hooks/useAutoCompleteOptions';
|
||||
|
||||
export class LuceneLanguageFormatter implements ILanguageFormatter {
|
||||
formatFieldValue(f: Field): string {
|
||||
return f.path.join('.');
|
||||
}
|
||||
formatFieldLabel(f: Field): string {
|
||||
return `${f.path.join('.')} (${f.jsType})`;
|
||||
}
|
||||
formatKeyValPair(key: string, value: string): string {
|
||||
return `${key}:"${value}"`;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SearchInputV2({
|
||||
tableConnections,
|
||||
|
|
@ -34,31 +50,17 @@ export default function SearchInputV2({
|
|||
} = useController(props);
|
||||
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: fields } = useAllFields(tableConnections ?? [], {
|
||||
enabled:
|
||||
!!tableConnections &&
|
||||
(Array.isArray(tableConnections) ? tableConnections.length > 0 : true),
|
||||
});
|
||||
|
||||
const autoCompleteOptions = useMemo(() => {
|
||||
const _columns = (fields ?? []).filter(c => c.jsType !== null);
|
||||
const baseOptions = _columns.map(c => ({
|
||||
value: c.path.join('.'),
|
||||
label: `${c.path.join('.')} (${c.jsType})`,
|
||||
}));
|
||||
|
||||
const suggestionOptions =
|
||||
additionalSuggestions?.map(column => ({
|
||||
value: column,
|
||||
label: column,
|
||||
})) ?? [];
|
||||
|
||||
return [...baseOptions, ...suggestionOptions];
|
||||
}, [fields, additionalSuggestions]);
|
||||
|
||||
const [parsedEnglishQuery, setParsedEnglishQuery] = useState<string>('');
|
||||
|
||||
const autoCompleteOptions = useAutoCompleteOptions(
|
||||
new LuceneLanguageFormatter(),
|
||||
value,
|
||||
{
|
||||
tableConnections,
|
||||
additionalSuggestions,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
genEnglishExplanation(value).then(q => {
|
||||
setParsedEnglishQuery(q);
|
||||
|
|
|
|||
|
|
@ -380,7 +380,7 @@ export const DBSearchPageFilters = ({
|
|||
isLoading: isFacetsLoading,
|
||||
isFetching: isFacetsFetching,
|
||||
} = useGetKeyValues({
|
||||
chartConfig: { ...chartConfig, dateRange },
|
||||
chartConfigs: { ...chartConfig, dateRange },
|
||||
keys: datum,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -42,19 +42,19 @@ function useMetricNames(metricSource: TSource) {
|
|||
}, [metricSource]);
|
||||
|
||||
const { data: gaugeMetrics } = useGetKeyValues({
|
||||
chartConfig: gaugeConfig,
|
||||
chartConfigs: gaugeConfig,
|
||||
keys: ['MetricName'],
|
||||
limit: MAX_METRIC_NAME_OPTIONS,
|
||||
disableRowLimit: true,
|
||||
});
|
||||
// const { data: histogramMetrics } = useGetKeyValues({
|
||||
// chartConfig: histogramConfig,
|
||||
// chartConfigs: histogramConfig,
|
||||
// keys: ['MetricName'],
|
||||
// limit: MAX_METRIC_NAME_OPTIONS,
|
||||
// disableRowLimit: true,
|
||||
// });
|
||||
const { data: sumMetrics } = useGetKeyValues({
|
||||
chartConfig: sumConfig,
|
||||
chartConfigs: sumConfig,
|
||||
keys: ['MetricName'],
|
||||
limit: MAX_METRIC_NAME_OPTIONS,
|
||||
disableRowLimit: true,
|
||||
|
|
|
|||
210
packages/app/src/hooks/__tests__/useAutoCompleteOptions.test.tsx
Normal file
210
packages/app/src/hooks/__tests__/useAutoCompleteOptions.test.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { JSDataType } from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { Field } from '@hyperdx/common-utils/dist/metadata';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { LuceneLanguageFormatter } from '../../SearchInputV2';
|
||||
import { useAutoCompleteOptions } from '../useAutoCompleteOptions';
|
||||
import { useAllFields, useGetKeyValues } from '../useMetadata';
|
||||
|
||||
if (!globalThis.structuredClone) {
|
||||
globalThis.structuredClone = (obj: any) => {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
};
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../useMetadata', () => ({
|
||||
...jest.requireActual('../useMetadata.tsx'),
|
||||
useAllFields: jest.fn(),
|
||||
useGetKeyValues: jest.fn(),
|
||||
}));
|
||||
|
||||
const luceneFormatter = new LuceneLanguageFormatter();
|
||||
|
||||
const mockFields: Field[] = [
|
||||
{
|
||||
path: ['ResourceAttributes'],
|
||||
jsType: JSDataType.Map,
|
||||
type: 'map',
|
||||
},
|
||||
{
|
||||
path: ['ResourceAttributes', 'service.name'],
|
||||
jsType: JSDataType.String,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
path: ['TraceAttributes', 'trace.id'],
|
||||
jsType: JSDataType.String,
|
||||
type: 'string',
|
||||
},
|
||||
];
|
||||
|
||||
const mockTableConnections = [
|
||||
{
|
||||
databaseName: 'test_db',
|
||||
tableName: 'traces',
|
||||
connectionId: 'conn1',
|
||||
},
|
||||
];
|
||||
|
||||
describe('useAutoCompleteOptions', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup default mock implementations
|
||||
(useAllFields as jest.Mock).mockReturnValue({
|
||||
data: mockFields,
|
||||
});
|
||||
|
||||
(useGetKeyValues as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return field options with correct lucene formatting', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAutoCompleteOptions(luceneFormatter, 'ResourceAttributes', {
|
||||
tableConnections: mockTableConnections,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current).toEqual([
|
||||
{
|
||||
value: 'ResourceAttributes',
|
||||
label: 'ResourceAttributes (map)',
|
||||
},
|
||||
{
|
||||
value: 'ResourceAttributes.service.name',
|
||||
label: 'ResourceAttributes.service.name (string)',
|
||||
},
|
||||
{
|
||||
value: 'TraceAttributes.trace.id',
|
||||
label: 'TraceAttributes.trace.id (string)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return key value options with correct lucene formatting', () => {
|
||||
const mockKeyValues = [
|
||||
{
|
||||
key: 'ResourceAttributes.service.name',
|
||||
value: ['frontend', 'backend'],
|
||||
},
|
||||
];
|
||||
|
||||
(useGetKeyValues as jest.Mock).mockReturnValue({
|
||||
data: mockKeyValues,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoCompleteOptions(
|
||||
luceneFormatter,
|
||||
'ResourceAttributes.service.name',
|
||||
{
|
||||
tableConnections: mockTableConnections,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current).toEqual([
|
||||
{
|
||||
value: 'ResourceAttributes',
|
||||
label: 'ResourceAttributes (map)',
|
||||
},
|
||||
{
|
||||
value: 'ResourceAttributes.service.name',
|
||||
label: 'ResourceAttributes.service.name (string)',
|
||||
},
|
||||
{
|
||||
value: 'TraceAttributes.trace.id',
|
||||
label: 'TraceAttributes.trace.id (string)',
|
||||
},
|
||||
{
|
||||
value: 'ResourceAttributes.service.name:"frontend"',
|
||||
label: 'ResourceAttributes.service.name:"frontend"',
|
||||
},
|
||||
{
|
||||
value: 'ResourceAttributes.service.name:"backend"',
|
||||
label: 'ResourceAttributes.service.name:"backend"',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// TODO: Does this test case need to be removed after HDX-1548?
|
||||
it('should handle nested key value options', () => {
|
||||
const mockKeyValues = [
|
||||
{
|
||||
key: 'ResourceAttributes',
|
||||
value: [
|
||||
{
|
||||
'service.name': 'frontend',
|
||||
'deployment.environment': 'production',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
(useGetKeyValues as jest.Mock).mockReturnValue({
|
||||
data: mockKeyValues,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoCompleteOptions(luceneFormatter, 'ResourceAttributes', {
|
||||
tableConnections: mockTableConnections,
|
||||
}),
|
||||
);
|
||||
|
||||
//console.log(result.current);
|
||||
expect(result.current).toEqual([
|
||||
{
|
||||
value: 'ResourceAttributes',
|
||||
label: 'ResourceAttributes (map)',
|
||||
},
|
||||
{
|
||||
value: 'ResourceAttributes.service.name',
|
||||
label: 'ResourceAttributes.service.name (string)',
|
||||
},
|
||||
{
|
||||
value: 'TraceAttributes.trace.id',
|
||||
label: 'TraceAttributes.trace.id (string)',
|
||||
},
|
||||
{
|
||||
value: 'ResourceAttributes.service.name:"frontend"',
|
||||
label: 'ResourceAttributes.service.name:"frontend"',
|
||||
},
|
||||
{
|
||||
value: 'ResourceAttributes.deployment.environment:"production"',
|
||||
label: 'ResourceAttributes.deployment.environment:"production"',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle additional suggestions', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAutoCompleteOptions(luceneFormatter, 'ResourceAttributes', {
|
||||
tableConnections: mockTableConnections,
|
||||
additionalSuggestions: ['custom.field'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current).toEqual([
|
||||
{
|
||||
value: 'ResourceAttributes',
|
||||
label: 'ResourceAttributes (map)',
|
||||
},
|
||||
{
|
||||
value: 'ResourceAttributes.service.name',
|
||||
label: 'ResourceAttributes.service.name (string)',
|
||||
},
|
||||
{
|
||||
value: 'TraceAttributes.trace.id',
|
||||
label: 'TraceAttributes.trace.id (string)',
|
||||
},
|
||||
{
|
||||
value: 'custom.field',
|
||||
label: 'custom.field',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,224 @@
|
|||
import { deduplicate2dArray } from '../useMetadata';
|
||||
import React from 'react';
|
||||
import * as metadataModule from '@hyperdx/app/src/metadata';
|
||||
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { Metadata, MetadataCache } from '@hyperdx/common-utils/dist/metadata';
|
||||
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { deduplicate2dArray, useGetKeyValues } from '../useMetadata';
|
||||
|
||||
// Create a mock ChartConfig based on the Zod schema
|
||||
const createMockChartConfig = (
|
||||
overrides: Partial<ChartConfigWithDateRange> = {},
|
||||
): ChartConfigWithDateRange =>
|
||||
({
|
||||
timestampValueExpression: '',
|
||||
connection: 'foo',
|
||||
from: {
|
||||
databaseName: 'telemetry',
|
||||
tableName: 'traces',
|
||||
},
|
||||
...overrides,
|
||||
}) as ChartConfigWithDateRange;
|
||||
|
||||
describe('useGetKeyValues', () => {
|
||||
let queryClient: QueryClient;
|
||||
let wrapper: React.ComponentType<{ children: any }>;
|
||||
let mockMetadata: Metadata;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// initialize metadata object
|
||||
mockMetadata = new Metadata({} as ClickhouseClient, {} as MetadataCache);
|
||||
jest.spyOn(metadataModule, 'getMetadata').mockReturnValue(mockMetadata);
|
||||
|
||||
// Create a new QueryClient for each test
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create a wrapper component with QueryClientProvider
|
||||
wrapper = ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
});
|
||||
|
||||
// Test case: Basic functionality with single chart config
|
||||
it('should fetch key values for a single chart config', async () => {
|
||||
// Arrange
|
||||
const mockChartConfig = createMockChartConfig();
|
||||
const mockKeys = ["ResourceAttributes['service.name']"];
|
||||
|
||||
const mockKeyValues = [
|
||||
{
|
||||
key: "ResourceAttributes['service.name']",
|
||||
value: ['frontend', 'backend', 'database'],
|
||||
},
|
||||
];
|
||||
|
||||
jest.spyOn(mockMetadata, 'getKeyValues').mockResolvedValue(mockKeyValues);
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useGetKeyValues({
|
||||
chartConfigs: mockChartConfig,
|
||||
keys: mockKeys,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
//console.log(result.current.data);
|
||||
expect(result.current.data).toEqual(mockKeyValues);
|
||||
});
|
||||
|
||||
// Test case: Multiple chart configs with different configurations
|
||||
it('should fetch key values for multiple chart configs', async () => {
|
||||
// Arrange
|
||||
const mockChartConfigs = [
|
||||
createMockChartConfig({
|
||||
from: { databaseName: 'telemetry', tableName: 'traces' },
|
||||
groupBy: "ResourceAttributes['service.name']",
|
||||
}),
|
||||
createMockChartConfig({
|
||||
from: { databaseName: 'logs', tableName: 'application_logs' },
|
||||
orderBy: '"timestamp" DESC',
|
||||
}),
|
||||
];
|
||||
const mockKeys = [
|
||||
'ResourceAttributes.service.name',
|
||||
'ResourceAttributes.environment',
|
||||
];
|
||||
|
||||
jest
|
||||
.spyOn(mockMetadata, 'getKeyValues')
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
key: "ResourceAttributes['service.name']",
|
||||
value: ['frontend', 'backend'],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
key: "ResourceAttributes['environment']",
|
||||
value: ['production', 'staging'],
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useGetKeyValues({
|
||||
chartConfigs: mockChartConfigs,
|
||||
keys: mockKeys,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data).toEqual([
|
||||
{
|
||||
key: "ResourceAttributes['service.name']",
|
||||
value: ['frontend', 'backend'],
|
||||
},
|
||||
{
|
||||
key: "ResourceAttributes['environment']",
|
||||
value: ['production', 'staging'],
|
||||
},
|
||||
]);
|
||||
expect(jest.spyOn(mockMetadata, 'getKeyValues')).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Test case: Handling empty keys
|
||||
it('should not fetch when keys array is empty', () => {
|
||||
// Arrange
|
||||
const mockChartConfig = createMockChartConfig();
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useGetKeyValues({
|
||||
chartConfigs: mockChartConfig,
|
||||
keys: [],
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.current.isFetched).toBe(false);
|
||||
expect(jest.spyOn(mockMetadata, 'getKeyValues')).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test case: Custom limit and disableRowLimit
|
||||
it('should pass custom limit and disableRowLimit', async () => {
|
||||
// Arrange
|
||||
const mockChartConfig = createMockChartConfig();
|
||||
const mockKeys = ['ResourceAttributes.service.name'];
|
||||
|
||||
const mockKeyValues = [
|
||||
{
|
||||
key: "ResourceAttributes['service.name']",
|
||||
value: ['frontend', 'backend'],
|
||||
},
|
||||
];
|
||||
|
||||
jest.spyOn(mockMetadata, 'getKeyValues').mockResolvedValue(mockKeyValues);
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useGetKeyValues({
|
||||
chartConfigs: mockChartConfig,
|
||||
keys: mockKeys,
|
||||
limit: 50,
|
||||
disableRowLimit: true,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
});
|
||||
|
||||
// Test case: Error handling
|
||||
it('should handle errors when fetching key values', async () => {
|
||||
// Arrange
|
||||
const mockChartConfig = createMockChartConfig();
|
||||
const mockKeys = ['ResourceAttributes.service.name'];
|
||||
|
||||
jest
|
||||
.spyOn(mockMetadata, 'getKeyValues')
|
||||
.mockRejectedValue(new Error('Fetch failed'));
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useGetKeyValues({
|
||||
chartConfigs: mockChartConfig,
|
||||
keys: mockKeys,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(result.current.error).toEqual(expect.any(Error));
|
||||
expect(result.current.error!.message).toBe('Fetch failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deduplicate2dArray', () => {
|
||||
// Test basic deduplication
|
||||
|
|
|
|||
165
packages/app/src/hooks/useAutoCompleteOptions.tsx
Normal file
165
packages/app/src/hooks/useAutoCompleteOptions.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Field, TableConnection } from '@hyperdx/common-utils/dist/metadata';
|
||||
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
|
||||
|
||||
import {
|
||||
deduplicate2dArray,
|
||||
useAllFields,
|
||||
useGetKeyValues,
|
||||
} from '@/hooks/useMetadata';
|
||||
import { toArray } from '@/utils';
|
||||
|
||||
export interface ILanguageFormatter {
|
||||
formatFieldValue: (f: Field) => string;
|
||||
formatFieldLabel: (f: Field) => string;
|
||||
formatKeyValPair: (key: string, value: string) => string;
|
||||
}
|
||||
|
||||
export function useAutoCompleteOptions(
|
||||
formatter: ILanguageFormatter,
|
||||
value: string,
|
||||
{
|
||||
tableConnections,
|
||||
additionalSuggestions,
|
||||
}: {
|
||||
tableConnections?: TableConnection | TableConnection[];
|
||||
additionalSuggestions?: string[];
|
||||
},
|
||||
) {
|
||||
// Fetch and gather all field options
|
||||
const { data: fields } = useAllFields(tableConnections ?? [], {
|
||||
enabled:
|
||||
!!tableConnections &&
|
||||
(Array.isArray(tableConnections) ? tableConnections.length > 0 : true),
|
||||
});
|
||||
const { fieldCompleteOptions, fieldCompleteMap } = useMemo(() => {
|
||||
const _columns = (fields ?? []).filter(c => c.jsType !== null);
|
||||
|
||||
const fieldCompleteMap = new Map<string, Field>();
|
||||
const baseOptions = _columns.map(c => {
|
||||
const val = {
|
||||
value: formatter.formatFieldValue(c),
|
||||
label: formatter.formatFieldLabel(c),
|
||||
};
|
||||
fieldCompleteMap.set(val.value, c);
|
||||
return val;
|
||||
});
|
||||
|
||||
const suggestionOptions =
|
||||
additionalSuggestions?.map(column => ({
|
||||
value: column,
|
||||
label: column,
|
||||
})) ?? [];
|
||||
|
||||
const fieldCompleteOptions = [...baseOptions, ...suggestionOptions];
|
||||
|
||||
return { fieldCompleteOptions, fieldCompleteMap };
|
||||
}, [formatter, fields, additionalSuggestions]);
|
||||
|
||||
// searchField is used for the purpose of checking if a key is valid and key values should be fetched
|
||||
const [searchField, setSearchField] = useState<Field | null>(null);
|
||||
// check if any search field matches
|
||||
useEffect(() => {
|
||||
const v = fieldCompleteMap.get(value);
|
||||
if (v) {
|
||||
setSearchField(v);
|
||||
}
|
||||
}, [fieldCompleteMap, value]);
|
||||
// clear search field if no key matches anymore
|
||||
useEffect(() => {
|
||||
if (!searchField) return;
|
||||
if (
|
||||
!(value as string).startsWith(formatter.formatFieldValue(searchField))
|
||||
) {
|
||||
setSearchField(null);
|
||||
}
|
||||
}, [searchField, setSearchField, value]);
|
||||
const searchKeys = useMemo(
|
||||
() =>
|
||||
searchField
|
||||
? [
|
||||
searchField.path.length > 1
|
||||
? `${searchField.path[0]}['${searchField.path[1]}']`
|
||||
: searchField.path[0],
|
||||
]
|
||||
: [],
|
||||
[searchField],
|
||||
);
|
||||
|
||||
// hooks to get key values
|
||||
const chartConfigs = toArray(tableConnections).map(
|
||||
({ databaseName, tableName, connectionId }) =>
|
||||
({
|
||||
connection: connectionId,
|
||||
from: {
|
||||
databaseName,
|
||||
tableName,
|
||||
},
|
||||
timestampValueExpression: '',
|
||||
select: '',
|
||||
where: '',
|
||||
}) as ChartConfigWithDateRange,
|
||||
);
|
||||
const { data: keyVals } = useGetKeyValues({
|
||||
chartConfigs,
|
||||
keys: searchKeys,
|
||||
});
|
||||
const keyValCompleteOptions = useMemo<
|
||||
{ value: string; label: string }[]
|
||||
>(() => {
|
||||
if (!keyVals || !searchField) return fieldCompleteOptions;
|
||||
const output = // TODO: Fix this hacky type assertion caused by bug in HDX-1548
|
||||
(
|
||||
keyVals as unknown as {
|
||||
key: string;
|
||||
value: (string | { [key: string]: string })[];
|
||||
}[]
|
||||
).flatMap(kv => {
|
||||
return kv.value.flatMap(v => {
|
||||
if (typeof v === 'string') {
|
||||
const value = formatter.formatKeyValPair(
|
||||
formatter.formatFieldValue(searchField),
|
||||
v,
|
||||
);
|
||||
return [
|
||||
{
|
||||
value,
|
||||
label: value,
|
||||
},
|
||||
];
|
||||
} else if (typeof v === 'object') {
|
||||
// TODO: Fix type issues mentioned in HDX-1548
|
||||
const output: {
|
||||
value: string;
|
||||
label: string;
|
||||
}[] = [];
|
||||
for (const [key, val] of Object.entries(v)) {
|
||||
if (typeof key !== 'string' || typeof val !== 'string') {
|
||||
console.error('unknown type for autocomplete object ', v);
|
||||
return [];
|
||||
}
|
||||
const field = structuredClone(searchField);
|
||||
field.path.push(key);
|
||||
const value = formatter.formatKeyValPair(
|
||||
formatter.formatFieldValue(field),
|
||||
val,
|
||||
);
|
||||
output.push({
|
||||
value,
|
||||
label: value,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
});
|
||||
return output;
|
||||
}, [fieldCompleteOptions, keyVals, searchField]);
|
||||
|
||||
// combine all autocomplete options
|
||||
return useMemo(() => {
|
||||
return deduplicate2dArray([fieldCompleteOptions, keyValCompleteOptions]);
|
||||
}, [fieldCompleteOptions, keyValCompleteOptions]);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from '@tanstack/react-query';
|
||||
|
||||
import { getMetadata } from '@/metadata';
|
||||
import { toArray } from '@/utils';
|
||||
|
||||
export function useColumns(
|
||||
{
|
||||
|
|
@ -96,12 +97,12 @@ export function useTableMetadata(
|
|||
|
||||
export function useGetKeyValues(
|
||||
{
|
||||
chartConfig,
|
||||
chartConfigs,
|
||||
keys,
|
||||
limit,
|
||||
disableRowLimit,
|
||||
}: {
|
||||
chartConfig: ChartConfigWithDateRange;
|
||||
chartConfigs: ChartConfigWithDateRange | ChartConfigWithDateRange[];
|
||||
keys: string[];
|
||||
limit?: number;
|
||||
disableRowLimit?: boolean;
|
||||
|
|
@ -109,16 +110,26 @@ export function useGetKeyValues(
|
|||
options?: Omit<UseQueryOptions<any, Error>, 'queryKey'>,
|
||||
) {
|
||||
const metadata = getMetadata();
|
||||
const chartConfigsArr = toArray(chartConfigs);
|
||||
return useQuery<{ key: string; value: string[] }[]>({
|
||||
queryKey: ['useMetadata.useGetKeyValues', { chartConfig, keys }],
|
||||
queryFn: async () => {
|
||||
return metadata.getKeyValues({
|
||||
chartConfig,
|
||||
keys: keys.slice(0, 20), // Limit to 20 keys for now, otherwise request fails (max header size)
|
||||
limit,
|
||||
disableRowLimit,
|
||||
});
|
||||
},
|
||||
queryKey: [
|
||||
'useMetadata.useGetKeyValues',
|
||||
...chartConfigsArr.map(cc => ({ ...cc })),
|
||||
...keys,
|
||||
],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await Promise.all(
|
||||
chartConfigsArr.map(chartConfig =>
|
||||
metadata.getKeyValues({
|
||||
chartConfig,
|
||||
keys: keys.slice(0, 20), // Limit to 20 keys for now, otherwise request fails (max header size)
|
||||
limit,
|
||||
disableRowLimit,
|
||||
}),
|
||||
),
|
||||
)
|
||||
).flatMap(v => v),
|
||||
staleTime: 1000 * 60 * 5, // Cache every 5 min
|
||||
enabled: !!keys.length,
|
||||
placeholderData: keepPreviousData,
|
||||
|
|
@ -126,6 +137,10 @@ export function useGetKeyValues(
|
|||
});
|
||||
}
|
||||
|
||||
export function deduplicateArray<T extends object>(array: T[]): T[] {
|
||||
return deduplicate2dArray([array]);
|
||||
}
|
||||
|
||||
export function deduplicate2dArray<T extends object>(array2d: T[][]): T[] {
|
||||
// deduplicate common fields
|
||||
const array: T[] = [];
|
||||
|
|
|
|||
|
|
@ -649,3 +649,10 @@ export function getMetricTableName(
|
|||
metricType.toLowerCase() as keyof typeof source.metricTables
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts (T | T[]) to T[]. If undefined, empty array
|
||||
*/
|
||||
export function toArray<T>(obj?: T | T[]): T[] {
|
||||
return !obj ? [] : Array.isArray(obj) ? obj : [obj];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -457,6 +457,8 @@ export class Metadata {
|
|||
})
|
||||
.then(res => res.json<any>());
|
||||
|
||||
// TODO: Fix type issues mentioned in HDX-1548. value is not acually a
|
||||
// string[], sometimes it's { [key: string]: string; }
|
||||
return Object.entries(json.data[0]).map(([key, value]) => ({
|
||||
key: keys[parseInt(key.replace('param', ''))],
|
||||
value: (value as string[])?.filter(Boolean), // remove nulls
|
||||
|
|
|
|||
Loading…
Reference in a new issue