Copilot commented on code in PR #39461:
URL: https://github.com/apache/superset/pull/39461#discussion_r3191497194


##########
superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx:
##########
@@ -46,6 +46,8 @@ 
jest.mock('src/components/Datasource/components/DatasourceEditor', () => ({
     ),
 }));
 
+const SupersetClientGet = jest.spyOn(SupersetClient, 'get');

Review Comment:
   The `SupersetClient.get` spy is created at module scope and is no longer 
restored in `afterEach`. `jest.clearAllMocks()` clears call history but does 
not restore spied methods, which can leak mocked behavior into other test files 
running in the same Jest worker. Restore the spy in `afterEach`/`afterAll` 
(e.g., `SupersetClientGet.mockRestore()` or `jest.restoreAllMocks()`) to avoid 
cross-test pollution.



##########
superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx:
##########
@@ -54,19 +56,8 @@ beforeEach(() => {
 
 afterEach(() => {
   window.location = originalLocation;
-
-  try {
-    const unmatched = fetchMock.callHistory.calls('unmatched');
-    if (unmatched.length > 0) {
-      const urls = unmatched.map(call => call.url).join(', ');
-      throw new Error(
-        `fetchMock: ${unmatched.length} unmatched call(s): ${urls}`,
-      );
-    }
-  } finally {
-    fetchMock.clearHistory().removeRoutes();
-    jest.restoreAllMocks();
-  }
+  fetchMock.clearHistory().removeRoutes();
+  jest.clearAllMocks(); // Clears mock history but keeps spy in place
 });
 

Review Comment:
   The `SupersetClient.get` spy is created at module scope and is no longer 
restored in `afterEach`. `jest.clearAllMocks()` clears call history but does 
not restore spied methods, which can leak mocked behavior into other test files 
running in the same Jest worker. Restore the spy in `afterEach`/`afterAll` 
(e.g., `SupersetClientGet.mockRestore()` or `jest.restoreAllMocks()`) to avoid 
cross-test pollution.
   



##########
superset-frontend/src/components/CopyToClipboard/index.tsx:
##########
@@ -16,126 +16,137 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Component, cloneElement, ReactElement } from 'react';
+import { cloneElement, isValidElement, ReactElement, useCallback } from 
'react';
 import { t } from '@apache-superset/core/translation';
 import { css, SupersetTheme } from '@apache-superset/core/theme';
 import copyTextToClipboard from 'src/utils/copy';
 import { Tooltip } from '@superset-ui/core/components';
 import withToasts from '../MessageToasts/withToasts';
 import type { CopyToClipboardProps } from './types';
 
-const defaultProps: Partial<CopyToClipboardProps> = {
-  copyNode: <span>{t('Copy')}</span>,
-  onCopyEnd: () => {},
-  shouldShowText: true,
-  wrapped: true,
-  tooltipText: t('Copy to clipboard'),
-  hideTooltip: false,
-};
+function CopyToClip({
+  copyNode = <span>{t('Copy')}</span>,
+  onCopyEnd = () => {},
+  shouldShowText = true,
+  wrapped = true,
+  tooltipText = t('Copy to clipboard'),
+  hideTooltip = false,
+  disabled,
+  getText,
+  text,
+  addSuccessToast,
+  addDangerToast,
+}: CopyToClipboardProps) {
+  const copyToClipboard = useCallback(
+    (textToCopy: Promise<string>) => {
+      copyTextToClipboard(() => textToCopy)
+        .then(() => {
+          addSuccessToast(t('Copied to clipboard!'));
+        })
+        .catch(() => {
+          addDangerToast(
+            t(
+              'Sorry, your browser does not support copying. Use Ctrl / Cmd + 
C!',
+            ),
+          );
+        })
+        .finally(() => {
+          if (onCopyEnd) onCopyEnd();
+        });
+    },
+    [addSuccessToast, addDangerToast, onCopyEnd],
+  );
 
-class CopyToClip extends Component<CopyToClipboardProps> {
-  static defaultProps = defaultProps;
-
-  constructor(props: CopyToClipboardProps) {
-    super(props);
-    this.copyToClipboard = this.copyToClipboard.bind(this);
-    this.onClick = this.onClick.bind(this);
-  }
-
-  onClick() {
-    if (this.props.disabled) {
+  const onClick = useCallback(() => {
+    if (disabled) {
       return;
     }
-    if (this.props.getText) {
-      this.props.getText((d: string) => {
-        this.copyToClipboard(Promise.resolve(d));
+    if (getText) {
+      getText((d: string) => {
+        copyToClipboard(Promise.resolve(d));
       });
     } else {
-      this.copyToClipboard(Promise.resolve(this.props.text || ''));
+      copyToClipboard(Promise.resolve(text || ''));
     }
-  }
-
-  getDecoratedCopyNode() {
-    const copyNode = this.props.copyNode as ReactElement;
-    const { disabled } = this.props;
-    return cloneElement(copyNode, {
-      style: {
-        ...copyNode.props.style,
-        cursor: disabled ? 'not-allowed' : 'pointer',
-      },
-      onClick: disabled ? undefined : this.onClick,
-      'aria-disabled': disabled || undefined,
-      tabIndex: disabled ? -1 : copyNode.props.tabIndex,
-    });
-  }
+  }, [disabled, getText, text, copyToClipboard]);
 
-  copyToClipboard(textToCopy: Promise<string>) {
-    copyTextToClipboard(() => textToCopy)
-      .then(() => {
-        this.props.addSuccessToast(t('Copied to clipboard!'));
-      })
-      .catch(() => {
-        this.props.addDangerToast(
-          t(
-            'Sorry, your browser does not support copying. Use Ctrl / Cmd + 
C!',
-          ),
-        );
-      })
-      .finally(() => {
-        if (this.props.onCopyEnd) this.props.onCopyEnd();
+  const getDecoratedCopyNode = useCallback(() => {
+    const cursor = disabled ? 'not-allowed' : 'pointer';
+    if (isValidElement(copyNode)) {
+      const node = copyNode as ReactElement;
+      return cloneElement(node, {
+        style: {
+          ...node.props.style,
+          cursor,
+        },
+        onClick: disabled ? undefined : onClick,
+        'aria-disabled': disabled || undefined,
+        tabIndex: disabled ? -1 : node.props.tabIndex,
       });
-  }
-
-  renderTooltip(cursor: string) {
+    }
     return (
+      <span
+        style={{ cursor }}
+        onClick={disabled ? undefined : onClick}
+        role="button"
+        aria-disabled={disabled || undefined}
+        tabIndex={disabled ? -1 : undefined}
+      >
+        {copyNode}
+      </span>
+    );

Review Comment:
   When `copyNode` is not a valid React element, the fallback wrapper renders a 
`role=\"button\"` element but does not support keyboard activation (e.g., 
Enter/Space via `onKeyDown`). Add keyboard handlers (and ideally `aria-label` 
when needed) so non-mouse users can trigger copy consistently.



##########
superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx:
##########
@@ -407,103 +431,101 @@ export default class CRUDCollection extends 
PureComponent<
       });
     }
 
-    return antdColumns as ColumnsType<CollectionItem>;
-  }
-
-  render() {
-    const {
-      stickyHeader,
-      emptyMessage = t('No items'),
-      expandFieldset,
-      pagination = false,
-      filterTerm,
-      filterFields,
-    } = this.props;
-
-    const displayData =
-      filterTerm && filterFields?.length
-        ? this.state.collectionArray.filter(item =>
-            filterFields.some(field =>
-              String(item[field] ?? '')
-                .toLowerCase()
-                .includes(filterTerm.toLowerCase()),
-            ),
-          )
-        : this.state.collectionArray;
-
-    const tableColumns = this.buildTableColumns();
-    const expandedRowKeys = Object.keys(this.state.expandedColumns).filter(
-      id => this.state.expandedColumns[id],
-    );
-
-    const expandableConfig = expandFieldset
-      ? {
-          expandedRowRender: (record: CollectionItem) =>
-            this.renderExpandableSection(record),
-          rowExpandable: () => true,
-          expandedRowKeys,
-          onExpand: (expanded: boolean, record: CollectionItem) => {
-            this.toggleExpand(record.id);
-          },
-        }
-      : undefined;
-
-    // Build controlled pagination config, clamping currentPage to valid range
-    // based on displayData (filtered) length, not the full collection
-    const { pageSize, currentPage: statePage } = this.state;
-    const totalItems = displayData.length;
-    const maxPage = totalItems > 0 ? Math.ceil(totalItems / pageSize) : 1;
-    const currentPage = Math.min(statePage, maxPage);
-    const paginationConfig: false | TablePaginationConfig | undefined =
-      pagination === false || pagination === undefined
-        ? pagination
-        : {
-            ...(typeof pagination === 'object' ? pagination : {}),
-            current: currentPage,
-            pageSize,
-            total: totalItems,
-          };
+    return columns;
+  }, [
+    tableColumns,
+    getLabel,
+    getTooltip,
+    sortColumns,
+    sortColumn,
+    sort,
+    renderCell,
+    itemCellProps,
+    allowDeletes,
+    deleteItem,
+  ]);
+
+  const displayData = useMemo(() => {
+    if (filterTerm && filterFields?.length) {
+      return collectionArray.filter(item =>
+        filterFields.some(field =>
+          String(item[field] ?? '')
+            .toLowerCase()
+            .includes(filterTerm.toLowerCase()),
+        ),
+      );
+    }
+    return collectionArray;
+  }, [collectionArray, filterTerm, filterFields]);
 
-    return (
-      <>
-        <CrudButtonWrapper>
-          {this.props.allowAddItem && (
-            <StyledButtonWrapper>
-              <Button
-                buttonSize="small"
-                buttonStyle="secondary"
-                onClick={this.onAddItem}
-                data-test="add-item-button"
-              >
-                <Icons.PlusOutlined
-                  iconSize="m"
-                  data-test="crud-add-table-item"
-                />
-                {t('Add item')}
-              </Button>
-            </StyledButtonWrapper>
-          )}
-        </CrudButtonWrapper>
-        <Table<CollectionItem>
-          data-test="crud-table"
-          columns={tableColumns}
-          data={displayData as CollectionItem[]}
-          rowKey={(record: CollectionItem) => String(record.id)}
-          sticky={stickyHeader}
-          pagination={paginationConfig}
-          onChange={this.handleTableChange}
-          locale={{ emptyText: emptyMessage }}
-          css={
-            stickyHeader &&
-            css`
-              overflow: auto;
-            `
+  const paginationConfig = useMemo((): false | TablePaginationConfig => {
+    if (pagination === false || pagination === undefined) {
+      return false;
+    }
+    return typeof pagination === 'object' ? pagination : {};
+  }, [pagination]);

Review Comment:
   The previous implementation controlled pagination state and explicitly 
clamped `currentPage` based on the (possibly filtered) dataset size to avoid 
landing on an empty page after filtering/shrinking the collection. This 
refactor delegates pagination fully to the table without preserving that 
clamping behavior, which can regress UX (e.g., filtered results appear empty 
until the user changes page). Consider reinstating controlled 
`current/pageSize` (or clamping logic) when `filterTerm/filterFields` are 
applied.



##########
superset-frontend/src/explore/components/SaveModal.test.tsx:
##########
@@ -330,139 +329,43 @@ test('renders InfoTooltip icon next to Dataset Name 
label when datasource type i
   expect(labelContainer).toContainElement(infoTooltip);
 });
 
-test('make sure slice_id in the URLSearchParams before the redirect', () => {
-  const myProps = {
-    ...defaultProps,
-    slice: { slice_id: 1, slice_name: 'title', owners: [1] },
-    actions: {
-      setFormData: jest.fn(),
-      updateSlice: jest.fn(() => Promise.resolve({ id: 1 })),
-      getSliceDashboards: jest.fn(),
-    },
-    user: { userId: 1 },
-    history: {
-      replace: jest.fn(),
-    },
-    dispatch: jest.fn(),
-  };
-
-  const saveModal = new TestSaveModal(myProps);
-  const result = saveModal.handleRedirect(
-    'https://example.com/?name=John&age=30',
+test('createRedirectParams sets slice_id in the URLSearchParams', () => {
+  const result = createRedirectParams(
+    '?name=John&age=30',
     { id: 1 },
+    'overwrite',
   );
   expect(result.get('slice_id')).toEqual('1');
+  expect(result.get('save_action')).toEqual('overwrite');
 });
 
-test('removes form_data_key from URL parameters after save', () => {
-  const myProps = {
-    ...defaultProps,
-    slice: { slice_id: 1, slice_name: 'title', owners: [1] },
-    actions: {
-      setFormData: jest.fn(),
-      updateSlice: jest.fn(() => Promise.resolve({ id: 1 })),
-      getSliceDashboards: jest.fn(),
-    },
-    user: { userId: 1 },
-    history: {
-      replace: jest.fn(),
-    },
-    dispatch: jest.fn(),
-  };
-
-  const saveModal = new TestSaveModal(myProps);
-
+test('createRedirectParams removes form_data_key from URL parameters', () => {
   // Test with form_data_key in the URL
   const urlWithFormDataKey = '?form_data_key=12345&other_param=value';
-  const result = saveModal.handleRedirect(urlWithFormDataKey, { id: 1 });
+  const result = createRedirectParams(
+    urlWithFormDataKey,
+    { id: 1 },
+    'overwrite',
+  );
 
   // form_data_key should be removed
   expect(result.has('form_data_key')).toBe(false);
   // other parameters should remain
   expect(result.get('other_param')).toEqual('value');
   expect(result.get('slice_id')).toEqual('1');
-  expect(result.has('save_action')).toBe(false);
+  expect(result.get('save_action')).toEqual('overwrite');
 });
 
-test('dispatches removeChartState when saving and going to dashboard', async 
() => {
-  // Spy on the removeChartState action creator
-  const removeChartStateSpy = jest.spyOn(
-    dashboardStateActions,
-    'removeChartState',
-  );
-
-  // Mock the dashboard API response
-  const dashboardId = 123;
-  const dashboardUrl = '/superset/dashboard/test-dashboard/';
-  fetchMock.get(`glob:*/api/v1/dashboard/${dashboardId}*`, {
-    result: {
-      id: dashboardId,
-      dashboard_title: 'Test Dashboard',
-      url: dashboardUrl,
-    },
-  });
-
-  const mockDispatch = jest.fn();
-  const mockHistory = {
-    push: jest.fn(),
-    replace: jest.fn(),
-  };
-  const chartId = 42;
-  const mockUpdateSlice = jest.fn(() => Promise.resolve({ id: chartId }));
-  const mockSetFormData = jest.fn();
-
-  const myProps = {
-    ...defaultProps,
-    slice: { slice_id: 1, slice_name: 'title', owners: [1] },
-    actions: {
-      setFormData: mockSetFormData,
-      updateSlice: mockUpdateSlice,
-      getSliceDashboards: jest.fn(() => Promise.resolve([])),
-      saveSliceFailed: jest.fn(),
-    },
-    user: { userId: 1 },
-    history: mockHistory,
-    dispatch: mockDispatch,
-  };
-
-  const saveModal = new TestSaveModal(myProps);
-  saveModal.state = {
-    action: 'overwrite',
-    newSliceName: 'test chart',
-    datasetName: 'test dataset',
-    dashboard: { label: 'Test Dashboard', value: dashboardId },
-    saveStatus: null,
-    isLoading: false,
-    tabsData: [],
-  };
-
-  // Mock onHide to prevent errors
-  saveModal.onHide = jest.fn();
-
-  // Trigger save and go to dashboard (gotodash = true)
-  await saveModal.saveOrOverwrite(true);
-
-  // Wait for async operations
-  await waitFor(() => {
-    expect(mockUpdateSlice).toHaveBeenCalled();
-    expect(mockSetFormData).toHaveBeenCalled();
-  });
-
-  // Verify removeChartState was called with the correct chart ID
-  expect(removeChartStateSpy).toHaveBeenCalledWith(chartId);
-
-  // Verify the action was dispatched (check the action object directly)
-  expect(mockDispatch).toHaveBeenCalled();
-  expect(mockDispatch).toHaveBeenCalledWith({
-    type: 'REMOVE_CHART_STATE',
-    chartId,
-  });
-
-  // Verify navigation happened
-  expect(mockHistory.push).toHaveBeenCalled();
-
-  // Clean up
-  removeChartStateSpy.mockRestore();
+/**
+ * TODO: This test was written for the class component version of SaveModal.
+ * Since SaveModal has been converted to a function component, this test
+ * needs to be rewritten to test through component rendering and user 
interaction.
+ * The test should verify that clicking "Save & go to dashboard" dispatches
+ * removeChartState with the correct chart ID.
+ */
+test('dispatches removeChartState when saving and going to dashboard - 
placeholder', () => {
+  // See TODO comment above
+  expect(true).toBe(true);
 });
 

Review Comment:
   These placeholder tests no longer validate any behavior and will pass 
unconditionally, which effectively removes coverage for the SaveModal flows 
described in the TODOs. Rewrite them as interaction tests against the function 
component (render + user events + assert dispatched actions / network calls) or 
remove them so coverage expectations remain meaningful.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to