refactor: Introduce api resource hooks, fetch owners for chart errors (#13218)

* refactor: introduce api resource hooks, fix error owners

* comment

* vestigial comment

* not a function

* clarification

* vestigial comment

* filter the api for a leaner response

* reorganize code, add resource hook catalog

* better names

* implement state management in redux

* Revert "implement state management in redux"

This reverts commit d64d50a99f70e85e010fc6160d93fe74a6fe01eb.

* add tests for hooks
diff --git a/superset-frontend/src/chart/Chart.jsx b/superset-frontend/src/chart/Chart.jsx
index 520378a..d13a6e5 100644
--- a/superset-frontend/src/chart/Chart.jsx
+++ b/superset-frontend/src/chart/Chart.jsx
@@ -25,9 +25,9 @@
 import { Logger, LOG_ACTIONS_RENDER_CHART } from '../logger/LogUtils';
 import Loading from '../components/Loading';
 import RefreshChartOverlay from '../components/RefreshChartOverlay';
-import ErrorMessageWithStackTrace from '../components/ErrorMessage/ErrorMessageWithStackTrace';
 import ErrorBoundary from '../components/ErrorBoundary';
 import ChartRenderer from './ChartRenderer';
+import { ChartErrorMessage } from './ChartErrorMessage';
 
 const propTypes = {
   annotationData: PropTypes.object,
@@ -49,9 +49,6 @@
   timeout: PropTypes.number,
   vizType: PropTypes.string.isRequired,
   triggerRender: PropTypes.bool,
-  owners: PropTypes.arrayOf(
-    PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
-  ),
   // state
   chartAlert: PropTypes.string,
   chartStatus: PropTypes.string,
@@ -152,17 +149,13 @@
   }
 
   renderErrorMessage(queryResponse) {
-    const { chartAlert, chartStackTrace, dashboardId, owners } = this.props;
+    const { chartId, chartAlert, chartStackTrace, dashboardId } = this.props;
 
     const error = queryResponse?.errors?.[0];
-    if (error) {
-      const extra = error.extra || {};
-      extra.owners = owners;
-      error.extra = extra;
-    }
     const message = chartAlert || queryResponse?.message;
     return (
-      <ErrorMessageWithStackTrace
+      <ChartErrorMessage
+        chartId={chartId}
         error={error}
         subtitle={message}
         copyText={message}
diff --git a/superset-frontend/src/chart/ChartErrorMessage.tsx b/superset-frontend/src/chart/ChartErrorMessage.tsx
new file mode 100644
index 0000000..f6c44ad
--- /dev/null
+++ b/superset-frontend/src/chart/ChartErrorMessage.tsx
@@ -0,0 +1,47 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React from 'react';
+import { useChartOwnerNames } from 'src/common/hooks/apiResources';
+import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
+import { SupersetError } from 'src/components/ErrorMessage/types';
+
+interface Props {
+  chartId: string;
+  error?: SupersetError;
+}
+
+/**
+ * fetches the chart owners and adds them to the extra data of the error message
+ */
+export const ChartErrorMessage: React.FC<Props> = ({
+  chartId,
+  error,
+  ...props
+}) => {
+  const { result: owners } = useChartOwnerNames(chartId);
+
+  // don't mutate props
+  const ownedError = error && {
+    ...error,
+    extra: { ...error.extra, owners },
+  };
+
+  return <ErrorMessageWithStackTrace {...props} error={ownedError} />;
+};
diff --git a/superset-frontend/src/common/hooks/apiResources/apiResources.test.ts b/superset-frontend/src/common/hooks/apiResources/apiResources.test.ts
new file mode 100644
index 0000000..6a5478e
--- /dev/null
+++ b/superset-frontend/src/common/hooks/apiResources/apiResources.test.ts
@@ -0,0 +1,165 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { makeApi } from '@superset-ui/core';
+import { act, renderHook } from '@testing-library/react-hooks';
+import {
+  ResourceStatus,
+  useApiResourceFullBody,
+  useApiV1Resource,
+  useTransformedResource,
+} from './apiResources';
+
+const fakeApiResult = {
+  id: 1,
+  name: 'fake api result',
+};
+
+const nameToAllCaps = (thing: any) => ({
+  ...thing,
+  name: thing.name.toUpperCase(),
+});
+
+jest.mock('@superset-ui/core', () => ({
+  ...jest.requireActual<any>('@superset-ui/core'),
+  makeApi: jest
+    .fn()
+    .mockReturnValue(jest.fn().mockResolvedValue(fakeApiResult)),
+}));
+
+describe('apiResource hooks', () => {
+  beforeAll(() => {
+    jest.useFakeTimers();
+  });
+
+  afterAll(() => {
+    jest.useRealTimers();
+  });
+
+  describe('useApiResourceFullBody', () => {
+    it('returns a loading state at the start', async () => {
+      const { result } = renderHook(() =>
+        useApiResourceFullBody('/test/endpoint'),
+      );
+      expect(result.current).toEqual({
+        status: ResourceStatus.LOADING,
+        result: null,
+        error: null,
+      });
+      await act(async () => {
+        jest.runAllTimers();
+      });
+    });
+
+    it('resolves to the value from the api', async () => {
+      const { result } = renderHook(() =>
+        useApiResourceFullBody('/test/endpoint'),
+      );
+      await act(async () => {
+        jest.runAllTimers();
+      });
+      expect(result.current).toEqual({
+        status: ResourceStatus.COMPLETE,
+        result: fakeApiResult,
+        error: null,
+      });
+    });
+
+    it('handles api errors', async () => {
+      const fakeError = new Error('fake api error');
+      (makeApi as any).mockReturnValue(jest.fn().mockRejectedValue(fakeError));
+      const { result } = renderHook(() =>
+        useApiResourceFullBody('/test/endpoint'),
+      );
+      await act(async () => {
+        jest.runAllTimers();
+      });
+      expect(result.current).toEqual({
+        status: ResourceStatus.ERROR,
+        result: null,
+        error: fakeError,
+      });
+    });
+  });
+
+  describe('useTransformedResource', () => {
+    it('applies a transformation to the resource', () => {
+      const { result } = renderHook(() =>
+        useTransformedResource(
+          {
+            status: ResourceStatus.COMPLETE,
+            result: fakeApiResult,
+            error: null,
+          },
+          nameToAllCaps,
+        ),
+      );
+      expect(result.current).toEqual({
+        status: ResourceStatus.COMPLETE,
+        result: {
+          id: 1,
+          name: 'FAKE API RESULT',
+        },
+        error: null,
+      });
+    });
+
+    it('works while loading', () => {
+      const nameToAllCaps = (thing: any) => ({
+        ...thing,
+        name: thing.name.toUpperCase(),
+      });
+      const { result } = renderHook(() =>
+        useTransformedResource(
+          {
+            status: ResourceStatus.LOADING,
+            result: null,
+            error: null,
+          },
+          nameToAllCaps,
+        ),
+      );
+      expect(result.current).toEqual({
+        status: ResourceStatus.LOADING,
+        result: null,
+        error: null,
+      });
+    });
+  });
+
+  describe('useApiV1Endpoint', () => {
+    it('resolves to the value from the api', async () => {
+      (makeApi as any).mockReturnValue(
+        jest.fn().mockResolvedValue({
+          meta: 'data',
+          count: 1,
+          result: fakeApiResult,
+        }),
+      );
+      const { result } = renderHook(() => useApiV1Resource('/test/endpoint'));
+      await act(async () => {
+        jest.runAllTimers();
+      });
+      expect(result.current).toEqual({
+        status: ResourceStatus.COMPLETE,
+        result: fakeApiResult,
+        error: null,
+      });
+    });
+  });
+});
diff --git a/superset-frontend/src/common/hooks/apiResources/apiResources.ts b/superset-frontend/src/common/hooks/apiResources/apiResources.ts
new file mode 100644
index 0000000..99504c7
--- /dev/null
+++ b/superset-frontend/src/common/hooks/apiResources/apiResources.ts
@@ -0,0 +1,181 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { makeApi } from '@superset-ui/core';
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+export enum ResourceStatus {
+  LOADING = 'loading',
+  COMPLETE = 'complete',
+  ERROR = 'error',
+}
+
+/**
+ * An object containing the data fetched from the API,
+ * as well as loading and error info
+ */
+export type Resource<T> = LoadingState | CompleteState<T> | ErrorState;
+
+// Trying out something a little different: a separate type per status.
+// This should let Typescript know whether a Resource has a result or error.
+// It's possible that I'm expecting too much from Typescript here.
+// If this ends up causing problems, we can change the type to:
+//
+// export type Resource<T> = {
+//   status: ResourceStatus;
+//   result: null | T;
+//   error: null | Error;
+// }
+
+type LoadingState = {
+  status: ResourceStatus.LOADING;
+  result: null;
+  error: null;
+};
+
+type CompleteState<T> = {
+  status: ResourceStatus.COMPLETE;
+  result: T;
+  error: null;
+};
+
+type ErrorState = {
+  status: ResourceStatus.ERROR;
+  result: null;
+  error: Error;
+};
+
+const initialState: LoadingState = {
+  status: ResourceStatus.LOADING,
+  result: null,
+  error: null,
+};
+
+/**
+ * A general-purpose hook to fetch the response from an endpoint.
+ * Returns the full response body from the API, including metadata.
+ *
+ * Note: You likely want {useApiV1Resource} instead of this!
+ *
+ * TODO Store the state in redux or something, share state between hook instances.
+ *
+ * TODO Include a function in the returned resource object to refresh the data.
+ *
+ * A core design decision here is composition > configuration,
+ * and every hook should only have one job.
+ * Please address new needs with new hooks if possible,
+ * rather than adding config options to this hook.
+ *
+ * @param endpoint The url where the resource is located.
+ */
+export function useApiResourceFullBody<RESULT>(
+  endpoint: string,
+): Resource<RESULT> {
+  const [resource, setResource] = useState<Resource<RESULT>>(initialState);
+  const cancelRef = useRef<() => void>(() => {});
+
+  useEffect(() => {
+    // If refresh is implemented, this will need to change.
+    // The previous values should stay during refresh.
+    setResource(initialState);
+
+    // when this effect runs, the endpoint has changed.
+    // cancel any current calls so that state doesn't get messed up.
+    cancelRef.current();
+    let cancelled = false;
+    cancelRef.current = () => {
+      cancelled = true;
+    };
+
+    const fetchResource = makeApi<{}, RESULT>({
+      method: 'GET',
+      endpoint,
+    });
+
+    fetchResource({})
+      .then(result => {
+        if (!cancelled) {
+          setResource({
+            status: ResourceStatus.COMPLETE,
+            result,
+            error: null,
+          });
+        }
+      })
+      .catch(error => {
+        if (!cancelled) {
+          setResource({
+            status: ResourceStatus.ERROR,
+            result: null,
+            error,
+          });
+        }
+      });
+
+    // Cancel the request when the component un-mounts
+    return () => {
+      cancelled = true;
+    };
+  }, [endpoint]);
+
+  return resource;
+}
+
+/**
+ * For when you want to transform the result of an api resource hook before using it.
+ *
+ * @param resource the Resource object returned from useApiV1Resource
+ * @param transformFn a callback that transforms the result object into the shape you want.
+ * Make sure to use a persistent function for this so it doesn't constantly recalculate!
+ */
+export function useTransformedResource<IN, OUT>(
+  resource: Resource<IN>,
+  transformFn: (result: IN) => OUT,
+): Resource<OUT> {
+  return useMemo(() => {
+    if (resource.status !== ResourceStatus.COMPLETE) {
+      // While incomplete, there is no result - no need to transform.
+      return resource;
+    }
+    return {
+      ...resource,
+      result: transformFn(resource.result),
+    };
+  }, [resource, transformFn]);
+}
+
+// returns the "result" field from a fetched API v1 endpoint
+const extractInnerResult = <T>(responseBody: { result: T }) =>
+  responseBody.result;
+
+/**
+ * A general-purpose hook to fetch a Superset resource from a v1 API endpoint.
+ * Handles request lifecycle and async logic so you don't have to.
+ *
+ * This returns the data under the "result" field in the API response body.
+ * If you need the full response body, use {useFullApiResource} instead.
+ *
+ * @param endpoint The url where the resource is located.
+ */
+export function useApiV1Resource<RESULT>(endpoint: string): Resource<RESULT> {
+  return useTransformedResource(
+    useApiResourceFullBody<{ result: RESULT }>(endpoint),
+    extractInnerResult,
+  );
+}
diff --git a/superset-frontend/src/common/hooks/apiResources/charts.ts b/superset-frontend/src/common/hooks/apiResources/charts.ts
new file mode 100644
index 0000000..8b5f109
--- /dev/null
+++ b/superset-frontend/src/common/hooks/apiResources/charts.ts
@@ -0,0 +1,39 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import rison from 'rison';
+import Chart from 'src/types/Chart';
+import { useApiV1Resource, useTransformedResource } from './apiResources';
+
+function extractOwnerNames({ owners }: Chart) {
+  if (!owners) return null;
+  return owners.map(owner => `${owner.first_name} ${owner.last_name}`);
+}
+
+const ownerNamesQuery = rison.encode({
+  columns: ['owners.first_name', 'owners.last_name'],
+  keys: ['none'],
+});
+
+export function useChartOwnerNames(chartId: string) {
+  return useTransformedResource(
+    useApiV1Resource<Chart>(`/api/v1/chart/${chartId}?q=${ownerNamesQuery}`),
+    extractOwnerNames,
+  );
+}
diff --git a/superset-frontend/src/common/hooks/apiResources/index.ts b/superset-frontend/src/common/hooks/apiResources/index.ts
new file mode 100644
index 0000000..8befc73
--- /dev/null
+++ b/superset-frontend/src/common/hooks/apiResources/index.ts
@@ -0,0 +1,29 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export {
+  useApiResourceFullBody,
+  useApiV1Resource,
+  useTransformedResource,
+} from './apiResources';
+
+// A central catalog of API Resource hooks.
+// Add new API hooks here, organized under
+// different files for different resource types.
+export { useChartOwnerNames } from './charts';
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
index b028977..8229994 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
@@ -364,7 +364,6 @@
             timeout={timeout}
             triggerQuery={chart.triggerQuery}
             vizType={slice.viz_type}
-            owners={slice.owners}
           />
         </div>
       </div>
diff --git a/superset-frontend/src/explore/components/ExploreChartPanel.jsx b/superset-frontend/src/explore/components/ExploreChartPanel.jsx
index 12fb784..e84b5c4 100644
--- a/superset-frontend/src/explore/components/ExploreChartPanel.jsx
+++ b/superset-frontend/src/explore/components/ExploreChartPanel.jsx
@@ -199,7 +199,6 @@
           errorMessage={props.errorMessage}
           formData={props.form_data}
           onQuery={props.onQuery}
-          owners={props?.slice?.owners}
           queriesResponse={chart.queriesResponse}
           refreshOverlayVisible={props.refreshOverlayVisible}
           setControlValue={props.actions.setControlValue}