Browse Source

feat(query-builder): Add basic parameter editing for aggregate keys (#75270)

This PR adds the ability to edit the function parameters (with no
validation or suggestions).
Malachi Willey 7 months ago
parent
commit
9c64da6109

+ 77 - 1
static/app/components/searchQueryBuilder/tokens/filter/aggregateKey.tsx

@@ -1,9 +1,13 @@
+import {useRef, useState} from 'react';
 import styled from '@emotion/styled';
+import {useFocusWithin} from '@react-aria/interactions';
+import {mergeProps} from '@react-aria/utils';
 import type {ListState} from '@react-stately/list';
 import type {Node} from '@react-types/shared';
 
 import InteractionStateLayer from 'sentry/components/interactionStateLayer';
 import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
+import {SearchQueryBuilderParametersCombobox} from 'sentry/components/searchQueryBuilder/tokens/filter/parametersCombobox';
 import {UnstyledButton} from 'sentry/components/searchQueryBuilder/tokens/filter/unstyledButton';
 import {useFilterButtonProps} from 'sentry/components/searchQueryBuilder/tokens/filter/useFilterButtonProps';
 import type {
@@ -22,17 +26,68 @@ type AggregateKeyProps = {
   token: AggregateFilter;
 };
 
-export function AggregateKey({item, state, token}: AggregateKeyProps) {
+export function AggregateKey({
+  item,
+  state,
+  token,
+  onActiveChange,
+  filterRef,
+}: AggregateKeyProps) {
+  const ref = useRef<HTMLDivElement>(null);
   const {disabled} = useSearchQueryBuilder();
 
+  const [isEditing, setIsEditing] = useState(false);
+
+  const {focusWithinProps} = useFocusWithin({
+    onBlurWithin: () => {
+      setIsEditing(false);
+    },
+  });
+
   const filterButtonProps = useFilterButtonProps({state, item});
 
   const fnName = getKeyName(token.key);
   const fnParams = token.key.args?.text ?? '';
 
+  if (isEditing) {
+    return (
+      <KeyEditing ref={ref} {...mergeProps(focusWithinProps, filterButtonProps)}>
+        <UnfocusedText>
+          {fnName}
+          {'('}
+        </UnfocusedText>
+        <Parameters>
+          <SearchQueryBuilderParametersCombobox
+            token={token}
+            onDelete={() => {
+              filterRef.current?.focus();
+              state.selectionManager.setFocusedKey(item.key);
+              setIsEditing(false);
+              onActiveChange(false);
+            }}
+            onCommit={() => {
+              setIsEditing(false);
+              onActiveChange(false);
+              if (state.collection.getKeyAfter(item.key)) {
+                state.selectionManager.setFocusedKey(
+                  state.collection.getKeyAfter(item.key)
+                );
+              }
+            }}
+          />
+        </Parameters>
+        <UnfocusedText>{')'}</UnfocusedText>
+      </KeyEditing>
+    );
+  }
+
   return (
     <KeyButton
       aria-label={t('Edit parameters for filter: %s', fnName)}
+      onClick={() => {
+        setIsEditing(true);
+        onActiveChange(true);
+      }}
       disabled={disabled}
       {...filterButtonProps}
     >
@@ -63,6 +118,10 @@ const FnName = styled('span')`
   color: ${p => p.theme.green400};
 `;
 
+const UnfocusedText = styled('span')`
+  color: ${p => p.theme.subText};
+`;
+
 const Parameters = styled('span')`
   height: 100%;
 
@@ -70,3 +129,20 @@ const Parameters = styled('span')`
     padding: 0 ${space(0.25)};
   }
 `;
+
+const KeyEditing = styled('div')`
+  padding: 0 ${space(0.25)} 0 ${space(0.5)};
+  max-width: 100%;
+  display: flex;
+  align-items: center;
+
+  border-left: 1px solid transparent;
+  border-right: 1px solid transparent;
+
+  :focus-within {
+    ${Parameters} {
+      background-color: ${p => p.theme.purple100};
+      height: 100%;
+    }
+  }
+`;

+ 107 - 0
static/app/components/searchQueryBuilder/tokens/filter/parametersCombobox.tsx

@@ -0,0 +1,107 @@
+import {type ReactNode, useCallback, useMemo, useRef, useState} from 'react';
+import {Item} from '@react-stately/collections';
+
+import type {SelectOptionWithKey} from 'sentry/components/compactSelect/types';
+import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
+import {SearchQueryBuilderCombobox} from 'sentry/components/searchQueryBuilder/tokens/combobox';
+import type {AggregateFilter} from 'sentry/components/searchSyntax/parser';
+import {t} from 'sentry/locale';
+
+type ParametersComboboxProps = {
+  onCommit: () => void;
+  onDelete: () => void;
+  token: AggregateFilter;
+};
+
+type SuggestionItem = {
+  value: string;
+  description?: ReactNode;
+  label?: ReactNode;
+};
+
+function getInitialInputValue(token: AggregateFilter) {
+  if ('args' in token.key) {
+    return token.key.args?.text ?? '';
+  }
+
+  return '';
+}
+
+// TODO(malwilley): Implement parameter suggestions
+function useParameterSuggestions(): SelectOptionWithKey<string>[] {
+  const parameterSuggestions = useMemo<SuggestionItem[]>(() => {
+    return [];
+  }, []);
+
+  const createItem = useCallback(
+    (suggestion: SuggestionItem): SelectOptionWithKey<string> => {
+      return {
+        key: suggestion.value,
+        label: suggestion.label ?? suggestion.value,
+        value: suggestion.value,
+        details: suggestion.description,
+        textValue: suggestion.value,
+        hideCheck: true,
+      };
+    },
+    []
+  );
+
+  const items = useMemo(() => {
+    return parameterSuggestions.map(createItem);
+  }, [createItem, parameterSuggestions]);
+
+  return items;
+}
+
+export function SearchQueryBuilderParametersCombobox({
+  token,
+  onCommit,
+}: ParametersComboboxProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const {dispatch} = useSearchQueryBuilder();
+  const [inputValue, setInputValue] = useState(() => getInitialInputValue(token));
+
+  const items = useParameterSuggestions();
+
+  const handleInputValueConfirmed = useCallback(
+    (value: string) => {
+      if (!token.key.args) {
+        return;
+      }
+
+      dispatch({type: 'UPDATE_AGGREGATE_ARGS', token: token.key.args, value});
+      onCommit();
+    },
+    [dispatch, onCommit, token]
+  );
+
+  const handleOptionSelected = useCallback(() => {
+    // TODO: Replace value at cursor position
+  }, []);
+
+  return (
+    <SearchQueryBuilderCombobox
+      ref={inputRef}
+      items={items}
+      onOptionSelected={handleOptionSelected}
+      onCustomValueBlurred={handleInputValueConfirmed}
+      onCustomValueCommitted={handleInputValueConfirmed}
+      onExit={onCommit}
+      inputValue={inputValue}
+      filterValue=""
+      token={token}
+      inputLabel={t('Edit function parameters')}
+      onInputChange={e => setInputValue(e.target.value)}
+      autoFocus
+      maxOptions={50}
+      openOnFocus
+    >
+      {items.map(item => (
+        <Item {...item} key={item.key}>
+          {item.label}
+        </Item>
+      ))}
+    </SearchQueryBuilderCombobox>
+  );
+}