You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/08/13 17:26:07 UTC

(superset) 01/02: feat(chart): publish data-chart-status readiness beacon on Chart.tsx

This is an automated email from the ASF dual-hosted git repository.

sadpandajoe pushed a commit to branch feat-new-alert-review-model
in repository https://gitbox.apache.org/repos/asf/superset.git

commit d0a8ecd73efa8a1edc28f69ba8f5dd74ca87b5c8
Author: Joe Li <jo...@preset.io>
AuthorDate: Thu Aug 13 10:24:59 2026 -0700

    feat(chart): publish data-chart-status readiness beacon on Chart.tsx
    
    Every render branch of Chart.tsx now sets a data-chart-status attribute
    (pending/loading/rendered/failed/stopped) on its root DOM node, mapped
    from Redux chartStatus. This gives a screenshot/PDF pipeline a
    definitive DOM signal for render completion instead of relying on CSS
    class heuristics. The failed branch additionally distinguishes a
    still-loading datasource from a true failure. No CSS class names are
    introduced or changed.
    
    Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
---
 .../src/components/Chart/Chart.test.tsx            | 292 ++++++++++++++++++---
 superset-frontend/src/components/Chart/Chart.tsx   | 173 +++++++-----
 superset-frontend/src/utils/screenshotContract.ts  |  95 +++++++
 3 files changed, 467 insertions(+), 93 deletions(-)

diff --git a/superset-frontend/src/components/Chart/Chart.test.tsx b/superset-frontend/src/components/Chart/Chart.test.tsx
index ac154642764..7b563b3f506 100644
--- a/superset-frontend/src/components/Chart/Chart.test.tsx
+++ b/superset-frontend/src/components/Chart/Chart.test.tsx
@@ -18,11 +18,34 @@
  */
 import { render, screen, fireEvent } from 'spec/helpers/testing-library';
 import '@testing-library/jest-dom';
+import type { JsonObject } from '@superset-ui/core';
 import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
 import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
+import type { ChartStatus } from 'src/explore/types';
+import {
+  CHART_STATUS_ATTR,
+  CHART_STATUS_ATTR_VALUES,
+} from 'src/utils/screenshotContract';
 import Chart from './Chart';
 import type { Actions } from './Chart';
 
+// The success/rendered/pending branches render down through ChartRenderer
+// into SuperChart, which needs a registered viz plugin. Mock it the same way
+// ChartRenderer.test.tsx does, so these tests can exercise the beacon
+// attribute on Chart's own root node without depending on the chart plugin
+// registry.
+jest.mock('@superset-ui/core', () => ({
+  ...jest.requireActual('@superset-ui/core'),
+  SuperChart: (props: JsonObject) => (
+    <div data-test="mock-super-chart">{JSON.stringify(props.formData)}</div>
+  ),
+}));
+
+jest.mock(
+  'src/components/Chart/ChartContextMenu/ChartContextMenu',
+  () => () => <div data-test="mock-chart-context-menu" />,
+);
+
 const mockActions: Actions = {
   logEvent: jest.fn() as unknown as Actions['logEvent'],
   chartRenderingFailed: jest.fn() as unknown as Actions['chartRenderingFailed'],
@@ -41,57 +64,134 @@ const baseProps = {
   setControlValue: jest.fn(),
 };
 
-test('shows backend error instead of loading spinner when datasource is still a placeholder', () => {
-  render(
-    <Chart
-      {...baseProps}
-      chartStatus="failed"
-      chartAlert="Your default credentials were not found."
-      datasource={PLACEHOLDER_DATASOURCE}
-      datasetsStatus={ResourceStatus.Loading}
-      queriesResponse={[
+// Finds the root DOM node carrying the data-chart-status beacon attribute,
+// regardless of which Chart.tsx render branch produced it.
+const getStatusNode = (container: HTMLElement) =>
+  container.querySelector(`[${CHART_STATUS_ATTR}]`);
+
+// dashboardId is intentionally varied across these cases (rather than only
+// tested once via statusCases below) because it changes which sub-tree
+// actually renders for "failed" (spinner size, ChartSource passed into
+// ChartErrorMessage) and for "stopped" (EmptyState sizing) — the published
+// beacon value must stay identical across both render paths regardless.
+const failedBackendErrorProps = {
+  chartStatus: 'failed' as const,
+  chartAlert: 'Your default credentials were not found.',
+  datasource: PLACEHOLDER_DATASOURCE,
+  datasetsStatus: ResourceStatus.Loading,
+  queriesResponse: [
+    {
+      errors: [
         {
-          errors: [
-            {
-              error_type: 'GENERIC_BACKEND_ERROR',
-              message: 'Your default credentials were not found.',
-              extra: {
-                issue_codes: [{ code: 1011, message: 'Issue 1011' }],
-              },
-              level: 'error',
-            },
-          ],
+          error_type: 'GENERIC_BACKEND_ERROR',
+          message: 'Your default credentials were not found.',
+          extra: {
+            issue_codes: [{ code: 1011, message: 'Issue 1011' }],
+          },
+          level: 'error',
         },
-      ]}
-    />,
+      ],
+    },
+  ],
+};
+
+test('shows backend error instead of loading spinner when datasource is still a placeholder (standalone)', () => {
+  const { container } = render(
+    <Chart {...baseProps} {...failedBackendErrorProps} />,
+  );
+
+  expect(
+    screen.getByText(/Your default credentials were not found/),
+  ).toBeInTheDocument();
+  // A real backend error is a terminal failure, so the beacon must say
+  // "failed", not "loading" — even though the datasource is still a
+  // placeholder, the errors array takes precedence.
+  expect(getStatusNode(container)).toHaveAttribute(
+    CHART_STATUS_ATTR,
+    CHART_STATUS_ATTR_VALUES.FAILED,
+  );
+});
+
+test('shows backend error instead of loading spinner when datasource is still a placeholder (dashboard)', () => {
+  const { container } = render(
+    <Chart {...baseProps} {...failedBackendErrorProps} dashboardId={7} />,
   );
 
   expect(
     screen.getByText(/Your default credentials were not found/),
   ).toBeInTheDocument();
+  expect(getStatusNode(container)).toHaveAttribute(
+    CHART_STATUS_ATTR,
+    CHART_STATUS_ATTR_VALUES.FAILED,
+  );
 });
 
-test('shows loading spinner for client-side errors without errors array when datasource is still a placeholder', () => {
-  render(
+const failedDatasourceLoadingProps = {
+  chartStatus: 'failed' as const,
+  chartAlert: 'Some client-side error',
+  datasource: PLACEHOLDER_DATASOURCE,
+  datasetsStatus: ResourceStatus.Loading,
+  queriesResponse: [{}],
+};
+
+test('shows loading spinner for client-side errors without errors array when datasource is still a placeholder (standalone)', () => {
+  const { container } = render(
+    <Chart {...baseProps} {...failedDatasourceLoadingProps} />,
+  );
+
+  expect(screen.getByRole('status')).toBeInTheDocument();
+  expect(screen.queryByText(/Some client-side error/)).not.toBeInTheDocument();
+  // Datasource-still-loading sub-case of the failed branch: publishing the
+  // terminal "failed" value here would lie to beacon consumers about a
+  // spinner state that is still in progress.
+  expect(getStatusNode(container)).toHaveAttribute(
+    CHART_STATUS_ATTR,
+    CHART_STATUS_ATTR_VALUES.LOADING,
+  );
+});
+
+test('shows loading spinner for client-side errors without errors array when datasource is still a placeholder (dashboard)', () => {
+  const { container } = render(
+    <Chart {...baseProps} {...failedDatasourceLoadingProps} dashboardId={7} />,
+  );
+
+  expect(screen.getByRole('status')).toBeInTheDocument();
+  expect(screen.queryByText(/Some client-side error/)).not.toBeInTheDocument();
+  expect(getStatusNode(container)).toHaveAttribute(
+    CHART_STATUS_ATTR,
+    CHART_STATUS_ATTR_VALUES.LOADING,
+  );
+});
+
+test('shows the stop message and a re-run affordance when the query was stopped (standalone)', () => {
+  const onQuery = jest.fn();
+  const { container } = render(
     <Chart
       {...baseProps}
-      chartStatus="failed"
-      chartAlert="Some client-side error"
-      datasource={PLACEHOLDER_DATASOURCE}
-      datasetsStatus={ResourceStatus.Loading}
-      queriesResponse={[{}]}
+      chartStatus="stopped"
+      chartAlert="Updating chart was stopped"
+      onQuery={onQuery}
     />,
   );
 
-  expect(screen.getByRole('status')).toBeInTheDocument();
-  expect(screen.queryByText(/Some client-side error/)).not.toBeInTheDocument();
+  expect(screen.getByText('Updating chart was stopped')).toBeInTheDocument();
+
+  const rerun = screen.getByText('click here');
+  expect(rerun.tagName).toBe('BUTTON');
+  fireEvent.click(rerun);
+  expect(onQuery).toHaveBeenCalledTimes(1);
+  expect(getStatusNode(container)).toHaveAttribute(
+    CHART_STATUS_ATTR,
+    CHART_STATUS_ATTR_VALUES.STOPPED,
+  );
 });
 
-test('shows the stop message and a re-run affordance when the query was stopped', () => {
+test('shows the stop message and a re-run affordance when the query was stopped (dashboard)', () => {
   const onQuery = jest.fn();
-  render(
+  const { container } = render(
     <Chart
       {...baseProps}
+      dashboardId={7}
       chartStatus="stopped"
       chartAlert="Updating chart was stopped"
       onQuery={onQuery}
@@ -104,4 +204,132 @@ test('shows the stop message and a re-run affordance when the query was stopped'
   expect(rerun.tagName).toBe('BUTTON');
   fireEvent.click(rerun);
   expect(onQuery).toHaveBeenCalledTimes(1);
+  expect(getStatusNode(container)).toHaveAttribute(
+    CHART_STATUS_ATTR,
+    CHART_STATUS_ATTR_VALUES.STOPPED,
+  );
+});
+
+// Every reducer-set chartStatus value, plus the pre-fetch `null` that Redux
+// starts with, mapped to the beacon value Chart.tsx must publish on its
+// shared Styles root — for both the standalone (Explore) and dashboard
+// render paths, since dashboardId changes which sub-tree renders (spinner
+// size, muted styling) but must never change the published status.
+const statusCases: Array<{
+  chartStatus: ChartStatus | '' | null | undefined;
+  expected: string;
+  label: string;
+}> = [
+  {
+    chartStatus: null,
+    expected: CHART_STATUS_ATTR_VALUES.PENDING,
+    label: 'null',
+  },
+  {
+    chartStatus: '',
+    expected: CHART_STATUS_ATTR_VALUES.PENDING,
+    label: '"" (datasource-retry reset)',
+  },
+  {
+    chartStatus: 'loading',
+    expected: CHART_STATUS_ATTR_VALUES.LOADING,
+    label: '"loading"',
+  },
+  {
+    chartStatus: 'success',
+    expected: CHART_STATUS_ATTR_VALUES.LOADING,
+    label: '"success" (pre-paint, non-terminal)',
+  },
+  {
+    chartStatus: 'rendered',
+    expected: CHART_STATUS_ATTR_VALUES.RENDERED,
+    label: '"rendered"',
+  },
+];
+
+statusCases.forEach(({ chartStatus, expected, label }) => {
+  test(`publishes data-chart-status="${expected}" for chartStatus ${label} (standalone)`, () => {
+    const { container } = render(
+      <Chart
+        {...baseProps}
+        chartStatus={chartStatus as ChartStatus}
+        queriesResponse={[]}
+      />,
+    );
+    expect(screen.getByTestId('chart-container')).toHaveAttribute(
+      CHART_STATUS_ATTR,
+      expected,
+    );
+    expect(getStatusNode(container)).toBe(
+      screen.getByTestId('chart-container'),
+    );
+  });
+
+  test(`publishes data-chart-status="${expected}" for chartStatus ${label} (dashboard)`, () => {
+    const { container } = render(
+      <Chart
+        {...baseProps}
+        dashboardId={7}
+        chartStatus={chartStatus as ChartStatus}
+        queriesResponse={[]}
+      />,
+    );
+    expect(screen.getByTestId('chart-container')).toHaveAttribute(
+      CHART_STATUS_ATTR,
+      expected,
+    );
+    expect(getStatusNode(container)).toBe(
+      screen.getByTestId('chart-container'),
+    );
+  });
+});
+
+test('falls back to data-chart-status="pending" for an unrecognized/future chartStatus value', () => {
+  const { container } = render(
+    <Chart
+      {...baseProps}
+      chartStatus={'not-a-real-status' as unknown as ChartStatus}
+      queriesResponse={[]}
+    />,
+  );
+  expect(getStatusNode(container)).toHaveAttribute(
+    CHART_STATUS_ATTR,
+    CHART_STATUS_ATTR_VALUES.PENDING,
+  );
+});
+
+test('publishes data-chart-status="stopped" for the missing-controls EmptyState (Explore preview, not a chartStatus)', () => {
+  const { container } = render(
+    <Chart {...baseProps} errorMessage="Add required control values" />,
+  );
+  expect(
+    screen.getByText('Add required control values to preview chart'),
+  ).toBeInTheDocument();
+  expect(getStatusNode(container)).toHaveAttribute(
+    CHART_STATUS_ATTR,
+    CHART_STATUS_ATTR_VALUES.STOPPED,
+  );
+});
+
+test('publishes data-chart-status="stopped" for the "ready to go" EmptyState (Explore preview, not a chartStatus)', () => {
+  const { container } = render(
+    <Chart {...baseProps} chartIsStale queriesResponse={[]} />,
+  );
+  expect(screen.getByText('Your chart is ready to go!')).toBeInTheDocument();
+  expect(getStatusNode(container)).toHaveAttribute(
+    CHART_STATUS_ATTR,
+    CHART_STATUS_ATTR_VALUES.STOPPED,
+  );
+});
+
+test('screenshotContract exports no CSS class name strings', () => {
+  // Guard against scope creep: this contract is attribute-only, and must
+  // never grow a styling hook such as a CSS class name.
+  expect(CHART_STATUS_ATTR).toBe('data-chart-status');
+  Object.values(CHART_STATUS_ATTR_VALUES).forEach(value => {
+    expect(typeof value).toBe('string');
+    expect(value).not.toMatch(/[.#]/); // no CSS selector punctuation
+    expect(value.toLowerCase()).not.toContain('chart-container');
+    expect(value.toLowerCase()).not.toContain('class');
+  });
 });
diff --git a/superset-frontend/src/components/Chart/Chart.tsx b/superset-frontend/src/components/Chart/Chart.tsx
index 6114088a7ed..f8c345e8fbc 100644
--- a/superset-frontend/src/components/Chart/Chart.tsx
+++ b/superset-frontend/src/components/Chart/Chart.tsx
@@ -46,6 +46,12 @@ import { Dispatch } from 'redux';
 import ChartRenderer from './ChartRenderer';
 import { ChartErrorMessage } from './ChartErrorMessage';
 import { getChartRequiredFieldsMissingMessage } from '../../utils/getChartRequiredFieldsMissingMessage';
+import {
+  CHART_STATUS_ATTR,
+  CHART_STATUS_ATTR_VALUES,
+  getChartStatusAttrValue,
+  type ChartStatusAttrValue,
+} from '../../utils/screenshotContract';
 
 export type ChartErrorType = Partial<ClientErrorObject>;
 export interface ChartProps {
@@ -126,6 +132,13 @@ const NONEXISTENT_DATASET = t(
   'The dataset associated with this chart no longer exists',
 );
 
+// JSX prop names must be static identifiers, so the CHART_STATUS_ATTR
+// constant (the single source of truth for the attribute name) is bound via
+// a computed-key spread rather than a literal `data-chart-status=` prop.
+const chartStatusAttrProps = (value: ChartStatusAttrValue) => ({
+  [CHART_STATUS_ATTR]: value,
+});
+
 const Styles = styled.div<{ height: number; width?: number }>`
   min-height: ${p => p.height}px;
   position: relative;
@@ -430,8 +443,30 @@ function Chart({
   const showSpinner = isLoading && !suppressLoadingSpinner;
 
   if (chartStatus === 'failed') {
+    // If none of the query responses carry a backend error, renderErrorMessage
+    // falls back to a "datasource still loading" spinner instead of an error
+    // message (see the condition mirrored below). In that sub-case, the
+    // terminal "failed" value would misrepresent an in-progress state to
+    // consumers of this attribute, so publish "loading" instead.
+    const hasQueryErrors = ensureIsArray(queriesResponse).some(
+      item => (item as ChartErrorType)?.errors?.[0],
+    );
+    const isDatasourceStillLoading =
+      !hasQueryErrors &&
+      chartAlert !== undefined &&
+      chartAlert !== NONEXISTENT_DATASET &&
+      datasource === PLACEHOLDER_DATASOURCE &&
+      datasetsStatus !== ResourceStatus.Error;
+
     return (
-      <ErrorContainer height={height}>
+      <ErrorContainer
+        height={height}
+        {...chartStatusAttrProps(
+          isDatasourceStillLoading
+            ? CHART_STATUS_ATTR_VALUES.LOADING
+            : CHART_STATUS_ATTR_VALUES.FAILED,
+        )}
+      >
         {queriesResponse?.map(item =>
           renderErrorMessage(item as ChartErrorType),
         )}
@@ -441,43 +476,53 @@ function Chart({
 
   if (chartStatus === 'stopped') {
     return (
-      <EmptyState
-        size="large"
-        title={chartAlert || t('Updating chart was stopped')}
-        description={
-          <span>
-            {t('Run a new query using the "Update chart" button or')}{' '}
-            <button
-              type="button"
-              onClick={onQuery}
-              css={css`
-                appearance: none;
-                border: none;
-                background: none;
-                padding: 0;
-                font: inherit;
-                cursor: pointer;
-                text-decoration: underline;
-              `}
-            >
-              {t('click here')}
-            </button>
-            .
-          </span>
-        }
-        image="chart.svg"
-      />
+      <div
+        {...chartStatusAttrProps(CHART_STATUS_ATTR_VALUES.STOPPED)}
+        css={{ width: '100%', height: '100%' }}
+      >
+        <EmptyState
+          size="large"
+          title={chartAlert || t('Updating chart was stopped')}
+          description={
+            <span>
+              {t('Run a new query using the "Update chart" button or')}{' '}
+              <button
+                type="button"
+                onClick={onQuery}
+                css={css`
+                  appearance: none;
+                  border: none;
+                  background: none;
+                  padding: 0;
+                  font: inherit;
+                  cursor: pointer;
+                  text-decoration: underline;
+                `}
+              >
+                {t('click here')}
+              </button>
+              .
+            </span>
+          }
+          image="chart.svg"
+        />
+      </div>
     );
   }
 
   if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
     return (
-      <EmptyState
-        size="large"
-        title={t('Add required control values to preview chart')}
-        description={getChartRequiredFieldsMissingMessage(true)}
-        image="chart.svg"
-      />
+      <div
+        {...chartStatusAttrProps(CHART_STATUS_ATTR_VALUES.STOPPED)}
+        css={{ width: '100%', height: '100%' }}
+      >
+        <EmptyState
+          size="large"
+          title={t('Add required control values to preview chart')}
+          description={getChartRequiredFieldsMissingMessage(true)}
+          image="chart.svg"
+        />
+      </div>
     );
   }
   if (
@@ -488,34 +533,39 @@ function Chart({
     ensureIsArray(queriesResponse).length === 0
   ) {
     return (
-      <EmptyState
-        size="large"
-        title={t('Your chart is ready to go!')}
-        description={
-          <span>
-            {t(
-              'Click on "Create chart" button in the control panel on the left to preview a visualization or',
-            )}{' '}
-            <button
-              type="button"
-              onClick={onQuery}
-              css={css`
-                appearance: none;
-                border: none;
-                background: none;
-                padding: 0;
-                font: inherit;
-                cursor: pointer;
-                text-decoration: underline;
-              `}
-            >
-              {t('click here')}
-            </button>
-            .
-          </span>
-        }
-        image="chart.svg"
-      />
+      <div
+        {...chartStatusAttrProps(CHART_STATUS_ATTR_VALUES.STOPPED)}
+        css={{ width: '100%', height: '100%' }}
+      >
+        <EmptyState
+          size="large"
+          title={t('Your chart is ready to go!')}
+          description={
+            <span>
+              {t(
+                'Click on "Create chart" button in the control panel on the left to preview a visualization or',
+              )}{' '}
+              <button
+                type="button"
+                onClick={onQuery}
+                css={css`
+                  appearance: none;
+                  border: none;
+                  background: none;
+                  padding: 0;
+                  font: inherit;
+                  cursor: pointer;
+                  text-decoration: underline;
+                `}
+              >
+                {t('click here')}
+              </button>
+              .
+            </span>
+          }
+          image="chart.svg"
+        />
+      </div>
     );
   }
 
@@ -525,6 +575,7 @@ function Chart({
         data-ui-anchor="chart"
         className="chart-container"
         data-test="chart-container"
+        {...chartStatusAttrProps(getChartStatusAttrValue(chartStatus))}
         height={height}
         width={width}
       >
diff --git a/superset-frontend/src/utils/screenshotContract.ts b/superset-frontend/src/utils/screenshotContract.ts
new file mode 100644
index 00000000000..37da3988b74
--- /dev/null
+++ b/superset-frontend/src/utils/screenshotContract.ts
@@ -0,0 +1,95 @@
+/**
+ * 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.
+ */
+
+/**
+ * Single source of truth for the per-chart "screenshot readiness beacon"
+ * contract published on each chart's root DOM node.
+ *
+ * A backend screenshot/PDF pipeline (and a later frontend MutationObserver
+ * beacon) observes this attribute to decide when a chart has settled into a
+ * stable, paint-complete state. Every render branch of `Chart.tsx` must set
+ * `CHART_STATUS_ATTR` on its root DOM node to one of `CHART_STATUS_ATTR_VALUES`.
+ *
+ * This module intentionally holds only the attribute name, its allowed
+ * values, and the mapping from a Redux `chartStatus` to an attribute value.
+ * It must never reference CSS class names: `data-*` attributes have no
+ * styling hooks in this codebase, and this contract is not the place to
+ * introduce one.
+ */
+import type { ChartStatus } from 'src/explore/types';
+
+/** The DOM attribute name published on each chart's root node. */
+export const CHART_STATUS_ATTR = 'data-chart-status';
+
+/**
+ * Allowed values for `CHART_STATUS_ATTR`.
+ *
+ * - `pending`: no chart status yet, or a non-terminal/unrecognized status.
+ * - `loading`: a query is in flight, or data has arrived but the
+ *   visualization has not yet committed to the DOM.
+ * - `rendered`: the visualization has committed to the DOM (terminal).
+ * - `failed`: the query or render failed (terminal).
+ * - `stopped`: the query was stopped, or the chart is in a non-error
+ *   Explore-preview state (missing controls, ready-to-run) (terminal).
+ */
+export const CHART_STATUS_ATTR_VALUES = {
+  PENDING: 'pending',
+  LOADING: 'loading',
+  RENDERED: 'rendered',
+  FAILED: 'failed',
+  STOPPED: 'stopped',
+} as const;
+
+export type ChartStatusAttrValue =
+  (typeof CHART_STATUS_ATTR_VALUES)[keyof typeof CHART_STATUS_ATTR_VALUES];
+
+/**
+ * Maps a Redux `chartStatus` value to the `CHART_STATUS_ATTR` value for the
+ * shared success/loading/pending render branch of `Chart.tsx`.
+ *
+ * The `failed` and `stopped` chart statuses are handled by their own render
+ * branches (which may need additional local context, e.g. whether the
+ * datasource is still loading) and are included here only for completeness.
+ */
+export function getChartStatusAttrValue(
+  chartStatus: ChartStatus | '' | null | undefined,
+): ChartStatusAttrValue {
+  switch (chartStatus) {
+    case 'loading':
+      return CHART_STATUS_ATTR_VALUES.LOADING;
+    case 'success':
+      // Data has arrived but the visualization hasn't committed to the DOM
+      // yet. This is non-terminal; the terminal "rendered" value is
+      // published later, on render commit, via chartRenderingSucceeded.
+      return CHART_STATUS_ATTR_VALUES.LOADING;
+    case 'rendered':
+      return CHART_STATUS_ATTR_VALUES.RENDERED;
+    case 'failed':
+      return CHART_STATUS_ATTR_VALUES.FAILED;
+    case 'stopped':
+      return CHART_STATUS_ATTR_VALUES.STOPPED;
+    case null:
+    case undefined:
+    case '':
+    default:
+      // Fail-safe catch-all: pre-fetch, a failed-chart datasource-retry
+      // reset, or any other/future status not enumerated above.
+      return CHART_STATUS_ATTR_VALUES.PENDING;
+  }
+}