From e10ef2241d951b1d9a399ed0ee09a336234df4bb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Torkel=20=C3=96degaard?= <torkel@grafana.com>
Date: Fri, 7 Apr 2023 08:31:37 +0200
Subject: [PATCH] Transformations: Improve UX and fix refId issues (#65982)

* Transformations: Improve UX and fix refId issues

* Show query names and frame names in description

* move to main grafan UI component

* Added unit test

* Fix lint error

---------

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
---
 .../FieldsByFrameRefIdMatcher.test.tsx        |  54 +++++++
 .../MatchersUI/FieldsByFrameRefIdMatcher.tsx  | 153 ++++++++++++------
 .../TransformationFilter.tsx                  |  19 +--
 .../geomap/editor/FrameSelectionEditor.tsx    |  79 +--------
 4 files changed, 179 insertions(+), 126 deletions(-)
 create mode 100644 packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx

diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx
new file mode 100644
index 00000000000..c8ab4606ea7
--- /dev/null
+++ b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx
@@ -0,0 +1,54 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import React from 'react';
+
+import { toDataFrame, FieldType } from '@grafana/data';
+
+import { RefIDPicker, Props } from './FieldsByFrameRefIdMatcher';
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+const frame1 = toDataFrame({
+  refId: 'A',
+  name: 'Series A',
+  fields: [],
+});
+
+const frame2 = toDataFrame({
+  refId: 'A',
+  fields: [{ name: 'Value', type: FieldType.number, values: [10, 200], config: { displayName: 'Second series' } }],
+});
+
+const frame3 = toDataFrame({
+  refId: 'B',
+  name: 'Series B',
+  fields: [],
+});
+
+const mockOnChange = jest.fn();
+
+const props: Props = {
+  data: [frame1, frame2, frame3],
+  onChange: mockOnChange,
+};
+
+const setup = (testProps?: Partial<Props>) => {
+  const editorProps = { ...props, ...testProps };
+  return render(<RefIDPicker {...editorProps} />);
+};
+
+describe('RefIDPicker', () => {
+  it('Should be able to select frame', async () => {
+    setup();
+
+    const select = await screen.findByRole('combobox');
+    fireEvent.keyDown(select, { keyCode: 40 });
+
+    const selectOptions = screen.getAllByLabelText('Select option');
+
+    expect(selectOptions).toHaveLength(2);
+    expect(selectOptions[0]).toHaveTextContent('Query: AFrames (2): Series A, Second series');
+    expect(selectOptions[1]).toHaveTextContent('Query: BFrames (1): Series B');
+  });
+});
diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx
index e452fcde5bb..65617bfacb4 100644
--- a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx
+++ b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx
@@ -1,35 +1,117 @@
-import React, { memo, useMemo, useCallback } from 'react';
+import React, { useMemo, useState, useCallback } from 'react';
 
-import { FieldMatcherID, fieldMatchers, SelectableValue, DataFrame } from '@grafana/data';
+import { DataFrame, getFrameDisplayName, FieldMatcherID, fieldMatchers, SelectableValue } from '@grafana/data';
 
 import { Select } from '../Select/Select';
 
-import { MatcherUIProps, FieldMatcherUIRegistryItem } from './types';
+import { FieldMatcherUIRegistryItem, MatcherUIProps } from './types';
 
-/**
- * UI to configure "fields by frame refId"-matcher.
- * @public
- */
-export const FieldsByFrameRefIdMatcher = memo<MatcherUIProps<string>>((props) => {
-  const { data, options, onChange: onChangeFromProps } = props;
-  const referenceIDs = useFrameRefIds(data);
-  const selectOptions = useSelectOptions(referenceIDs);
+const recoverRefIdMissing = (
+  newRefIds: SelectableValue[],
+  oldRefIds: SelectableValue[],
+  previousValue: string | undefined
+): SelectableValue | undefined => {
+  if (!previousValue) {
+    return;
+  }
+  // Previously selected value is missing from the new list.
+  // Find the value that is in the new list but isn't in the old list
+  let changedTo = newRefIds.find((refId) => {
+    return !oldRefIds.some((refId2) => {
+      return refId === refId2;
+    });
+  });
+  if (changedTo) {
+    // Found the new value, we assume the old value changed to this one, so we'll use it
+    return changedTo;
+  }
+  return;
+};
 
-  const onChange = useCallback(
-    (selection: SelectableValue<string>) => {
-      if (!selection.value || !referenceIDs.has(selection.value)) {
-        return;
-      }
-      return onChangeFromProps(selection.value);
+export interface Props {
+  value?: string; // refID
+  data: DataFrame[];
+  onChange: (value: string) => void;
+  placeholder?: string;
+}
+
+// Not exported globally... but used in grafana core
+export function RefIDPicker({ value, data, onChange, placeholder }: Props) {
+  const listOfRefIds = useMemo(() => getListOfQueryRefIds(data), [data]);
+
+  const [priorSelectionState, updatePriorSelectionState] = useState<{
+    refIds: SelectableValue[];
+    value: string | undefined;
+  }>({
+    refIds: [],
+    value: undefined,
+  });
+
+  const currentValue = useMemo(() => {
+    return (
+      listOfRefIds.find((refId) => refId.value === value) ??
+      recoverRefIdMissing(listOfRefIds, priorSelectionState.refIds, priorSelectionState.value)
+    );
+  }, [value, listOfRefIds, priorSelectionState]);
+
+  const onFilterChange = useCallback(
+    (v: SelectableValue<string>) => {
+      onChange(v.value!);
     },
-    [referenceIDs, onChangeFromProps]
+    [onChange]
   );
 
-  const selectedOption = selectOptions.find((v) => v.value === options);
-  return <Select value={selectedOption} options={selectOptions} onChange={onChange} />;
-});
+  if (listOfRefIds !== priorSelectionState.refIds || currentValue?.value !== priorSelectionState.value) {
+    updatePriorSelectionState({
+      refIds: listOfRefIds,
+      value: currentValue?.value,
+    });
+  }
+  return (
+    <Select
+      options={listOfRefIds}
+      onChange={onFilterChange}
+      isClearable={true}
+      placeholder={placeholder ?? 'Select query refId'}
+      value={currentValue}
+    />
+  );
+}
 
-FieldsByFrameRefIdMatcher.displayName = 'FieldsByFrameRefIdMatcher';
+function getListOfQueryRefIds(data: DataFrame[]): Array<SelectableValue<string>> {
+  const queries = new Map<string, DataFrame[]>();
+
+  for (const frame of data) {
+    const refId = frame.refId ?? '';
+    const frames = queries.get(refId) ?? [];
+
+    if (frames.length === 0) {
+      queries.set(refId, frames);
+    }
+
+    frames.push(frame);
+  }
+
+  const values: Array<SelectableValue<string>> = [];
+
+  for (const [refId, frames] of queries.entries()) {
+    values.push({
+      value: refId,
+      label: `Query: ${refId ?? '(missing refId)'}`,
+      description: getFramesDescription(frames),
+    });
+  }
+
+  return values;
+}
+
+function getFramesDescription(frames: DataFrame[]): string {
+  return `Frames (${frames.length}):
+    ${frames
+      .slice(0, Math.min(3, frames.length))
+      .map((x) => getFrameDisplayName(x))
+      .join(', ')} ${frames.length > 3 ? '...' : ''}`;
+}
 
 /**
  * Registry item for UI to configure "fields by frame refId"-matcher.
@@ -37,32 +119,11 @@ FieldsByFrameRefIdMatcher.displayName = 'FieldsByFrameRefIdMatcher';
  */
 export const fieldsByFrameRefIdItem: FieldMatcherUIRegistryItem<string> = {
   id: FieldMatcherID.byFrameRefID,
-  component: FieldsByFrameRefIdMatcher,
+  component: (props: MatcherUIProps<string>) => {
+    return <RefIDPicker value={props.options} data={props.data} onChange={props.onChange} />;
+  },
   matcher: fieldMatchers.get(FieldMatcherID.byFrameRefID),
   name: 'Fields returned by query',
   description: 'Set properties for fields from a specific query',
   optionsToLabel: (options) => options,
 };
-
-const useFrameRefIds = (data: DataFrame[]): Set<string> => {
-  return useMemo(() => {
-    const refIds: Set<string> = new Set();
-
-    for (const frame of data) {
-      if (frame.refId) {
-        refIds.add(frame.refId);
-      }
-    }
-
-    return refIds;
-  }, [data]);
-};
-
-const useSelectOptions = (displayNames: Set<string>): Array<SelectableValue<string>> => {
-  return useMemo(() => {
-    return Array.from(displayNames).map((n) => ({
-      value: n,
-      label: n,
-    }));
-  }, [displayNames]);
-};
diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx
index ebbccbab512..7de44c7d3a6 100644
--- a/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx
+++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx
@@ -8,7 +8,7 @@ import {
   StandardEditorContext,
   StandardEditorsRegistryItem,
 } from '@grafana/data';
-import { useStyles2 } from '@grafana/ui';
+import { Field, useStyles2 } from '@grafana/ui';
 import { FrameSelectionEditor } from 'app/plugins/panel/geomap/editor/FrameSelectionEditor';
 
 interface TransformationFilterProps {
@@ -27,14 +27,15 @@ export const TransformationFilter = ({ index, data, config, onChange }: Transfor
 
   return (
     <div className={styles.wrapper}>
-      <h5>Apply tranformation to</h5>
-      <FrameSelectionEditor
-        value={config.filter!}
-        context={context}
-        // eslint-disable-next-line
-        item={{} as StandardEditorsRegistryItem}
-        onChange={(filter) => onChange(index, { ...config, filter })}
-      />
+      <Field label="Apply tranformation to">
+        <FrameSelectionEditor
+          value={config.filter!}
+          context={context}
+          // eslint-disable-next-line
+          item={{} as StandardEditorsRegistryItem}
+          onChange={(filter) => onChange(index, { ...config, filter })}
+        />
+      </Field>
     </div>
   );
 };
diff --git a/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx b/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx
index 98bcac91b8b..26bf727d8b9 100644
--- a/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx
+++ b/public/app/plugins/panel/geomap/editor/FrameSelectionEditor.tsx
@@ -1,69 +1,18 @@
-import React, { useCallback, useMemo, useState } from 'react';
+import React, { useCallback } from 'react';
 
-import {
-  FrameMatcherID,
-  getFieldDisplayName,
-  MatcherConfig,
-  SelectableValue,
-  StandardEditorProps,
-} from '@grafana/data';
-import { Select } from '@grafana/ui';
-
-const recoverRefIdMissing = (
-  newRefIds: SelectableValue[],
-  oldRefIds: SelectableValue[],
-  previousValue: string | undefined
-): SelectableValue | undefined => {
-  if (!previousValue) {
-    return;
-  }
-  // Previously selected value is missing from the new list.
-  // Find the value that is in the new list but isn't in the old list
-  let changedTo = newRefIds.find((refId) => {
-    return !oldRefIds.some((refId2) => {
-      return refId === refId2;
-    });
-  });
-  if (changedTo) {
-    // Found the new value, we assume the old value changed to this one, so we'll use it
-    return changedTo;
-  }
-  return;
-};
+import { FrameMatcherID, MatcherConfig, StandardEditorProps } from '@grafana/data';
+import { RefIDPicker } from '@grafana/ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher';
 
 type Props = StandardEditorProps<MatcherConfig>;
 
-export const FrameSelectionEditor = ({ value, context, onChange, item }: Props) => {
-  const listOfRefId = useMemo(() => {
-    return context.data.map((f) => ({
-      value: f.refId,
-      label: `Query: ${f.refId} (size: ${f.length})`,
-      description: f.fields.map((f) => getFieldDisplayName(f)).join(', '),
-    }));
-  }, [context.data]);
-
-  const [priorSelectionState, updatePriorSelectionState] = useState<{
-    refIds: SelectableValue[];
-    value: string | undefined;
-  }>({
-    refIds: [],
-    value: undefined,
-  });
-
-  const currentValue = useMemo(() => {
-    return (
-      listOfRefId.find((refId) => refId.value === value?.options) ??
-      recoverRefIdMissing(listOfRefId, priorSelectionState.refIds, priorSelectionState.value)
-    );
-  }, [value, listOfRefId, priorSelectionState]);
-
+export const FrameSelectionEditor = ({ value, context, onChange }: Props) => {
   const onFilterChange = useCallback(
-    (v: SelectableValue<string>) => {
+    (v: string) => {
       onChange(
-        v?.value
+        v?.length
           ? {
               id: FrameMatcherID.byRefId,
-              options: v.value,
+              options: v,
             }
           : undefined
       );
@@ -71,19 +20,7 @@ export const FrameSelectionEditor = ({ value, context, onChange, item }: Props)
     [onChange]
   );
 
-  if (listOfRefId !== priorSelectionState.refIds || currentValue?.value !== priorSelectionState.value) {
-    updatePriorSelectionState({
-      refIds: listOfRefId,
-      value: currentValue?.value,
-    });
-  }
   return (
-    <Select
-      options={listOfRefId}
-      onChange={onFilterChange}
-      isClearable={true}
-      placeholder="Change filter"
-      value={currentValue}
-    />
+    <RefIDPicker value={value.options} onChange={onFilterChange} data={context.data} placeholder="Change filter" />
   );
 };