From d9e1c50fb188d698e584813fc948b9d0dbf82447 Mon Sep 17 00:00:00 2001 From: Greg Campion Date: Thu, 22 Oct 2026 11:03:28 -0700 Subject: [PATCH 1/4] fix(frontend): use SupersetClient in ImageLoader to prepend SUPERSET_APP_ROOT ImageLoader fetched thumbnails with a bare fetch, so root-relative thumbnail_url paths didn't prepend SUPERSET_APP_ROOT. Moved the ListViewCard story images to imgFallbackURL since Storybook has no configured client. --- .../ListViewCard/ImageLoader.test.tsx | 21 +++++++++++++++++-- .../components/ListViewCard/ImageLoader.tsx | 3 ++- .../ListViewCard/ListViewCard.stories.tsx | 16 ++++++++++---- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx index 039054ce34a8..b5eecea83706 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx @@ -18,7 +18,8 @@ */ import fetchMock from 'fetch-mock'; -import { render, screen } from '@superset-ui/core/spec'; +import { render, screen, waitFor } from '@superset-ui/core/spec'; +import { SupersetClient } from '../../connection'; import { ImageLoader, type BackgroundPosition } from './ImageLoader'; global.URL.createObjectURL = jest.fn(() => '/local_url'); @@ -63,12 +64,26 @@ describe('ImageLoader', () => { 'src', '/fallback', ); + expect(await screen.findByTestId('image-loader')).toHaveAttribute( + 'src', + '/local_url', + ); expect(fetchMock.callHistory.calls(/thumbnail/)).toHaveLength(1); expect(global.URL.createObjectURL).toHaveBeenCalled(); + }); + + test('requests src through SupersetClient', async () => { + const getSpy = jest.spyOn(SupersetClient, 'get'); + setup(); + expect(getSpy).toHaveBeenCalledWith({ + endpoint: '/thumbnail', + parseMethod: 'raw', + }); expect(await screen.findByTestId('image-loader')).toHaveAttribute( 'src', '/local_url', ); + getSpy.mockRestore(); }); test('displays fallback image when response is not an image', async () => { @@ -79,7 +94,9 @@ describe('ImageLoader', () => { 'src', '/fallback', ); - expect(fetchMock.callHistory.calls(/thumbnail2/)).toHaveLength(1); + await waitFor(() => + expect(fetchMock.callHistory.calls(/thumbnail2/)).toHaveLength(1), + ); expect(await screen.findByTestId('image-loader')).toHaveAttribute( 'src', '/fallback', diff --git a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.tsx b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.tsx index fec42902cab0..9fc13d82b62f 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.tsx @@ -20,6 +20,7 @@ import { useEffect, useState, DetailedHTMLProps, HTMLAttributes } from 'react'; import { logging } from '@apache-superset/core/utils'; import { styled } from '@apache-superset/core/theme'; +import { SupersetClient } from '../../connection'; export type BackgroundPosition = 'top' | 'bottom'; interface ImageContainerProps { @@ -57,7 +58,7 @@ export function ImageLoader({ useEffect(() => { if (src) { - fetch(src) + SupersetClient.get({ endpoint: src, parseMethod: 'raw' }) .then(response => response.blob()) .then(blob => { if (/image/.test(blob.type)) { diff --git a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ListViewCard.stories.tsx b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ListViewCard.stories.tsx index 8e1aefadc70f..6edd5e58c5c6 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ListViewCard.stories.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ListViewCard.stories.tsx @@ -39,7 +39,8 @@ export const InteractiveListViewCard: Story = { title: 'Superset Card Title', loading: false, url: '/superset/dashboard/births/', - imgURL: 'https://picsum.photos/seed/superset/300/200', + imgURL: '', + imgFallbackURL: 'https://picsum.photos/seed/superset/300/200', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit...', coverLeft: 'Left Section', coverRight: 'Right Section', @@ -61,7 +62,14 @@ export const InteractiveListViewCard: Story = { imgURL: { name: 'imgURL', control: { type: 'text' }, - description: 'Primary image URL for the card.', + description: + 'Superset endpoint for the card image, such as a chart or dashboard thumbnail URL. The fallback image shows while it loads or when it is empty.', + }, + imgFallbackURL: { + name: 'imgFallbackURL', + control: { type: 'text' }, + description: + 'Image URL shown while imgURL loads, or when imgURL is empty or fails.', }, description: { control: { type: 'text' }, @@ -88,7 +96,7 @@ export const InteractiveListViewCard: Story = { title="My Dashboard" description="A sample dashboard card" url="/dashboard/1" - imgURL="https://picsum.photos/seed/demo/300/200" + imgFallbackURL="https://picsum.photos/seed/demo/300/200" coverLeft="Created: Jan 2024" coverRight="Views: 1,234" /> @@ -104,7 +112,7 @@ export const SupersetListViewCard: Story = { title: 'Superset Card Title', loading: false, url: '/superset/dashboard/births/', - imgURL: 'https://picsum.photos/seed/superset2/300/200', + imgFallbackURL: 'https://picsum.photos/seed/superset2/300/200', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit...', coverLeft: 'Left Section', coverRight: 'Right Section', From f9f88e7499024f81ee4f1b60f985db58c1d5f675 Mon Sep 17 00:00:00 2001 From: Greg Campion Date: Tue, 27 Oct 2026 08:36:24 -0700 Subject: [PATCH 2/4] test(dashboards): adapt thumbnail tests for SupersetClient ImageLoader fetches thumbnails via SupersetClient.get, which awaits ensureAuth() before fetch. Test now waits for the request and matches the URL by its path and method option --- .../dashboards/DashboardCard.test.tsx | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/superset-frontend/src/features/dashboards/DashboardCard.test.tsx b/superset-frontend/src/features/dashboards/DashboardCard.test.tsx index e814e09457e6..05d081073a9f 100644 --- a/superset-frontend/src/features/dashboards/DashboardCard.test.tsx +++ b/superset-frontend/src/features/dashboards/DashboardCard.test.tsx @@ -26,6 +26,7 @@ import { render, screen, fireEvent, + waitFor, within, } from 'spec/helpers/testing-library'; import { SubjectType } from 'src/types/Subject'; @@ -223,7 +224,7 @@ describe('thumbnail URL construction', () => { , ); - test('constructs thumbnail URL from dashboard id and changed_on_utc', () => { + test('constructs thumbnail URL from dashboard id and changed_on_utc', async () => { renderCard({ id: 2, changed_by_name: '', @@ -235,12 +236,17 @@ describe('thumbnail URL construction', () => { changed_on_utc: '2024-01-01T00:00:00', }); - expect(fetchSpy).toHaveBeenCalledWith( - '/api/v1/dashboard/2/thumbnail/2024-01-01T00%3A00%3A00/', + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringMatching( + /\/api\/v1\/dashboard\/2\/thumbnail\/2024-01-01T00%3A00%3A00\/$/, + ), + expect.objectContaining({ method: 'GET' }), + ), ); }); - test('falls back to changed_on when changed_on_utc is absent', () => { + test('falls back to changed_on when changed_on_utc is absent', async () => { renderCard({ id: 3, changed_by_name: '', @@ -252,8 +258,13 @@ describe('thumbnail URL construction', () => { changed_on: '2024-06-01T12:00:00', }); - expect(fetchSpy).toHaveBeenCalledWith( - '/api/v1/dashboard/3/thumbnail/2024-06-01T12%3A00%3A00/', + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringMatching( + /\/api\/v1\/dashboard\/3\/thumbnail\/2024-06-01T12%3A00%3A00\/$/, + ), + expect.objectContaining({ method: 'GET' }), + ), ); }); From bf5b953f91e14f704d430cd36733aa2bc44f1c76 Mon Sep 17 00:00:00 2001 From: Greg Campion Date: Fri, 30 Oct 2026 14:49:50 -0700 Subject: [PATCH 3/4] test(ImageLoader): update test to verify src prefixing with app root configuration --- .../ListViewCard/ImageLoader.test.tsx | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx index b5eecea83706..a8e89225d712 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx @@ -72,18 +72,36 @@ describe('ImageLoader', () => { expect(global.URL.createObjectURL).toHaveBeenCalled(); }); - test('requests src through SupersetClient', async () => { - const getSpy = jest.spyOn(SupersetClient, 'get'); - setup(); - expect(getSpy).toHaveBeenCalledWith({ - endpoint: '/thumbnail', - parseMethod: 'raw', - }); - expect(await screen.findByTestId('image-loader')).toHaveAttribute( - 'src', - '/local_url', + test('prefixes src with the configured app root', async () => { + const prefixedUrl = 'http://localhost/analytics/app-root-thumbnail'; + fetchMock.get( + prefixedUrl, + { body: blob, headers: { 'Content-Type': 'image/png' } }, + { name: 'app-root-thumbnail' }, ); - getSpy.mockRestore(); + await SupersetClient.configure({ + protocol: 'http:', + host: 'localhost', + appRoot: '/analytics', + }).init(); + + try { + setup({ src: '/app-root-thumbnail' }); + expect(await screen.findByTestId('image-loader')).toHaveAttribute( + 'src', + '/local_url', + ); + expect(fetchMock.callHistory.called(prefixedUrl)).toBe(true); + expect( + fetchMock.callHistory.calls(/app-root-thumbnail/).map(({ url }) => url), + ).toEqual([prefixedUrl]); + } finally { + fetchMock.removeRoute('app-root-thumbnail'); + await SupersetClient.configure({ + protocol: 'http:', + host: 'localhost', + }).init(); + } }); test('displays fallback image when response is not an image', async () => { From ffb7ded915c6960b412e363a9011333cdc9c6be3 Mon Sep 17 00:00:00 2001 From: Greg Campion Date: Sat, 31 Oct 2026 11:24:03 -0700 Subject: [PATCH 4/4] fix(frontend): start inside a promise chain so unconfigured clients fall back --- .../ListViewCard/ImageLoader.test.tsx | 22 +++++++++++++++++++ .../components/ListViewCard/ImageLoader.tsx | 5 ++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx index a8e89225d712..6feec8250417 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx @@ -17,6 +17,7 @@ * under the License. */ import fetchMock from 'fetch-mock'; +import { logging } from '@apache-superset/core/utils'; import { render, screen, waitFor } from '@superset-ui/core/spec'; import { SupersetClient } from '../../connection'; @@ -104,6 +105,27 @@ describe('ImageLoader', () => { } }); + test('displays fallback image when SupersetClient is not configured', async () => { + SupersetClient.reset(); + const loggingSpy = jest.spyOn(logging, 'error').mockImplementation(); + + try { + setup({ src: 'https://picsum.photos/seed/superset/300/200' }); + await waitFor(() => expect(loggingSpy).toHaveBeenCalled()); + expect(screen.getByTestId('image-loader')).toHaveAttribute( + 'src', + '/fallback', + ); + expect(fetchMock.callHistory.calls(/picsum/)).toHaveLength(0); + } finally { + loggingSpy.mockRestore(); + await SupersetClient.configure({ + protocol: 'http:', + host: 'localhost', + }).init(); + } + }); + test('displays fallback image when response is not an image', async () => { fetchMock.once('glob:*/thumbnail2', {}, { name: 'thumbnail2' }); diff --git a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.tsx b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.tsx index 9fc13d82b62f..37acaee3352a 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.tsx @@ -58,7 +58,10 @@ export function ImageLoader({ useEffect(() => { if (src) { - SupersetClient.get({ endpoint: src, parseMethod: 'raw' }) + // Start the request inside the chain so an unconfigured client + // (e.g. Storybook) rejects into the fallback instead of throwing. + Promise.resolve() + .then(() => SupersetClient.get({ endpoint: src, parseMethod: 'raw' })) .then(response => response.blob()) .then(blob => { if (/image/.test(blob.type)) {