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..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,8 +17,10 @@ * under the License. */ import fetchMock from 'fetch-mock'; +import { logging } from '@apache-superset/core/utils'; -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 +65,65 @@ describe('ImageLoader', () => { 'src', '/fallback', ); - expect(fetchMock.callHistory.calls(/thumbnail/)).toHaveLength(1); - expect(global.URL.createObjectURL).toHaveBeenCalled(); expect(await screen.findByTestId('image-loader')).toHaveAttribute( 'src', '/local_url', ); + expect(fetchMock.callHistory.calls(/thumbnail/)).toHaveLength(1); + expect(global.URL.createObjectURL).toHaveBeenCalled(); + }); + + 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' }, + ); + 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 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 () => { @@ -79,7 +134,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..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 @@ -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,10 @@ export function ImageLoader({ useEffect(() => { if (src) { - fetch(src) + // 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)) { 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', diff --git a/superset-frontend/src/features/dashboards/DashboardCard.test.tsx b/superset-frontend/src/features/dashboards/DashboardCard.test.tsx index ff82715a1fc7..e3c5d4b668a5 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'; @@ -240,7 +241,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: '', @@ -252,12 +253,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: '', @@ -269,8 +275,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' }), + ), ); });