feat(ws): add fallback mechanism to broken images (#448)

Signed-off-by: Guilherme Caponetto <638737+caponetto@users.noreply.github.com>
This commit is contained in:
Guilherme Caponetto 2025-06-27 10:46:17 -03:00 committed by GitHub
parent 42dfd30d94
commit 28f2471bb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 178 additions and 30 deletions

View File

@ -7,7 +7,6 @@ import {
PaginationVariant, PaginationVariant,
Pagination, Pagination,
Content, Content,
Brand,
Tooltip, Tooltip,
Bullseye, Bullseye,
Button, Button,
@ -28,7 +27,6 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
TimesCircleIcon, TimesCircleIcon,
QuestionCircleIcon, QuestionCircleIcon,
CodeIcon,
} from '@patternfly/react-icons'; } from '@patternfly/react-icons';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { Workspace, WorkspaceState } from '~/shared/api/backendApiTypes'; import { Workspace, WorkspaceState } from '~/shared/api/backendApiTypes';
@ -48,10 +46,12 @@ import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds';
import { WorkspaceConnectAction } from '~/app/pages/Workspaces/WorkspaceConnectAction'; import { WorkspaceConnectAction } from '~/app/pages/Workspaces/WorkspaceConnectAction';
import CustomEmptyState from '~/shared/components/CustomEmptyState'; import CustomEmptyState from '~/shared/components/CustomEmptyState';
import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter'; import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter';
import WithValidImage from '~/shared/components/WithValidImage';
import { import {
formatResourceFromWorkspace, formatResourceFromWorkspace,
formatWorkspaceIdleState, formatWorkspaceIdleState,
} from '~/shared/utilities/WorkspaceUtils'; } from '~/shared/utilities/WorkspaceUtils';
import ImageFallback from '~/shared/components/ImageFallback';
const { const {
fields: wsTableColumns, fields: wsTableColumns,
@ -436,19 +436,25 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
case 'kind': case 'kind':
return ( return (
<Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}> <Td key={columnKey} dataLabel={wsTableColumns[columnKey].label}>
{kindLogoDict[workspace.workspaceKind.name] ? ( <WithValidImage
<Tooltip content={workspace.workspaceKind.name}> imageSrc={kindLogoDict[workspace.workspaceKind.name]}
<Brand skeletonWidth="20px"
src={kindLogoDict[workspace.workspaceKind.name]} fallback={
alt={workspace.workspaceKind.name} <ImageFallback
style={{ width: '20px', height: '20px', cursor: 'pointer' }} imageSrc={kindLogoDict[workspace.workspaceKind.name]}
/> />
</Tooltip> }
) : ( >
<Tooltip content={workspace.workspaceKind.name}> {(validSrc) => (
<CodeIcon /> <Tooltip content={workspace.workspaceKind.name}>
</Tooltip> <img
)} src={validSrc}
alt={workspace.workspaceKind.name}
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
/>
</Tooltip>
)}
</WithValidImage>
</Td> </Td>
); );
case 'namespace': case 'namespace':

View File

@ -5,7 +5,6 @@ import {
DrawerContentBody, DrawerContentBody,
PageSection, PageSection,
Content, Content,
Brand,
Tooltip, Tooltip,
Label, Label,
Toolbar, Toolbar,
@ -34,13 +33,15 @@ import {
ActionsColumn, ActionsColumn,
IActions, IActions,
} from '@patternfly/react-table'; } from '@patternfly/react-table';
import { CodeIcon, FilterIcon } from '@patternfly/react-icons'; import { FilterIcon } from '@patternfly/react-icons';
import { WorkspaceKind } from '~/shared/api/backendApiTypes'; import { WorkspaceKind } from '~/shared/api/backendApiTypes';
import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds'; import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds';
import { useWorkspaceCountPerKind } from '~/app/hooks/useWorkspaceCountPerKind'; import { useWorkspaceCountPerKind } from '~/app/hooks/useWorkspaceCountPerKind';
import { WorkspaceKindsColumns } from '~/app/types'; import { WorkspaceKindsColumns } from '~/app/types';
import ThemeAwareSearchInput from '~/app/components/ThemeAwareSearchInput'; import ThemeAwareSearchInput from '~/app/components/ThemeAwareSearchInput';
import CustomEmptyState from '~/shared/components/CustomEmptyState'; import CustomEmptyState from '~/shared/components/CustomEmptyState';
import WithValidImage from '~/shared/components/WithValidImage';
import ImageFallback from '~/shared/components/ImageFallback';
import { useTypedNavigate } from '~/app/routerHelper'; import { useTypedNavigate } from '~/app/routerHelper';
import { WorkspaceKindDetails } from './details/WorkspaceKindDetails'; import { WorkspaceKindDetails } from './details/WorkspaceKindDetails';
@ -555,15 +556,19 @@ export const WorkspaceKinds: React.FunctionComponent = () => {
<Tbody id="workspace-kind-table-content" key={rowIndex} data-testid="table-body"> <Tbody id="workspace-kind-table-content" key={rowIndex} data-testid="table-body">
<Tr id={`workspace-kind-table-row-${rowIndex + 1}`}> <Tr id={`workspace-kind-table-row-${rowIndex + 1}`}>
<Td dataLabel={columns.icon.name} style={{ width: '50px' }}> <Td dataLabel={columns.icon.name} style={{ width: '50px' }}>
{workspaceKind.icon.url ? ( <WithValidImage
<Brand imageSrc={workspaceKind.icon.url}
src={workspaceKind.icon.url} skeletonWidth="20px"
alt={workspaceKind.name} fallback={<ImageFallback imageSrc={workspaceKind.icon.url} />}
style={{ width: '20px', height: '20px', cursor: 'pointer' }} >
/> {(validSrc) => (
) : ( <img
<CodeIcon /> src={validSrc}
)} alt={workspaceKind.name}
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
/>
)}
</WithValidImage>
</Td> </Td>
<Td dataLabel={columns.name.name}>{workspaceKind.name}</Td> <Td dataLabel={columns.name.name}>{workspaceKind.name}</Td>
<Td <Td

View File

@ -5,9 +5,10 @@ import {
DescriptionListGroup, DescriptionListGroup,
DescriptionListDescription, DescriptionListDescription,
Divider, Divider,
Brand,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { WorkspaceKind } from '~/shared/api/backendApiTypes'; import { WorkspaceKind } from '~/shared/api/backendApiTypes';
import ImageFallback from '~/shared/components/ImageFallback';
import WithValidImage from '~/shared/components/WithValidImage';
type WorkspaceDetailsOverviewProps = { type WorkspaceDetailsOverviewProps = {
workspaceKind: WorkspaceKind; workspaceKind: WorkspaceKind;
@ -48,7 +49,19 @@ export const WorkspaceKindDetailsOverview: React.FunctionComponent<
<DescriptionListGroup> <DescriptionListGroup>
<DescriptionListTerm style={{ alignSelf: 'center' }}>Icon</DescriptionListTerm> <DescriptionListTerm style={{ alignSelf: 'center' }}>Icon</DescriptionListTerm>
<DescriptionListDescription> <DescriptionListDescription>
<Brand src={workspaceKind.icon.url} alt={workspaceKind.name} style={{ width: '40px' }} /> <WithValidImage
imageSrc={workspaceKind.icon.url}
skeletonWidth="40px"
fallback={
<ImageFallback
imageSrc={workspaceKind.icon.url}
extended
message="Cannot load icon image"
/>
}
>
{(validSrc) => <img src={validSrc} alt={workspaceKind.name} style={{ width: '40px' }} />}
</WithValidImage>
</DescriptionListDescription> </DescriptionListDescription>
<DescriptionListTerm style={{ alignSelf: 'center' }}>Icon URL</DescriptionListTerm> <DescriptionListTerm style={{ alignSelf: 'center' }}>Icon URL</DescriptionListTerm>
<DescriptionListDescription> <DescriptionListDescription>
@ -61,7 +74,19 @@ export const WorkspaceKindDetailsOverview: React.FunctionComponent<
<DescriptionListGroup> <DescriptionListGroup>
<DescriptionListTerm style={{ alignSelf: 'center' }}>Logo</DescriptionListTerm> <DescriptionListTerm style={{ alignSelf: 'center' }}>Logo</DescriptionListTerm>
<DescriptionListDescription> <DescriptionListDescription>
<Brand src={workspaceKind.logo.url} alt={workspaceKind.name} style={{ width: '40px' }} /> <WithValidImage
imageSrc={workspaceKind.logo.url}
skeletonWidth="40px"
fallback={
<ImageFallback
imageSrc={workspaceKind.logo.url}
extended
message="Cannot load logo image"
/>
}
>
{(validSrc) => <img src={validSrc} alt={workspaceKind.name} style={{ width: '40px' }} />}
</WithValidImage>
</DescriptionListDescription> </DescriptionListDescription>
<DescriptionListTerm style={{ alignSelf: 'center' }}>Logo URL</DescriptionListTerm> <DescriptionListTerm style={{ alignSelf: 'center' }}>Logo URL</DescriptionListTerm>
<DescriptionListDescription> <DescriptionListDescription>

View File

@ -12,6 +12,8 @@ import {
import { WorkspaceKind } from '~/shared/api/backendApiTypes'; import { WorkspaceKind } from '~/shared/api/backendApiTypes';
import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter'; import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter';
import CustomEmptyState from '~/shared/components/CustomEmptyState'; import CustomEmptyState from '~/shared/components/CustomEmptyState';
import ImageFallback from '~/shared/components/ImageFallback';
import WithValidImage from '~/shared/components/WithValidImage';
import { defineDataFields, FilterableDataFieldKey } from '~/app/filterableDataHelper'; import { defineDataFields, FilterableDataFieldKey } from '~/app/filterableDataHelper';
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -111,7 +113,21 @@ export const WorkspaceFormKindList: React.FunctionComponent<WorkspaceFormKindLis
onChange, onChange,
}} }}
> >
<img src={kind.logo.url} alt={`${kind.name} logo`} style={{ maxWidth: '60px' }} /> <WithValidImage
imageSrc={kind.logo.url}
skeletonWidth="60px"
fallback={
<ImageFallback
imageSrc={kind.logo.url}
extended
message="Cannot load logo image"
/>
}
>
{(validSrc) => (
<img src={validSrc} alt={`${kind.name} logo`} style={{ maxWidth: '60px' }} />
)}
</WithValidImage>
</CardHeader> </CardHeader>
<CardTitle>{kind.displayName}</CardTitle> <CardTitle>{kind.displayName}</CardTitle>
<CardBody>{kind.description}</CardBody> <CardBody>{kind.description}</CardBody>

View File

@ -0,0 +1,38 @@
import React from 'react';
import { ExclamationCircleIcon } from '@patternfly/react-icons';
import { Content, ContentVariants, Flex, FlexItem, Tooltip } from '@patternfly/react-core';
type ImageFallbackProps = {
extended?: boolean;
imageSrc: string | undefined | null;
message?: string;
};
const ImageFallback: React.FC<ImageFallbackProps> = ({
extended = false,
imageSrc,
message = `Cannot load image: ${imageSrc || 'no image source provided'}`,
}) => {
if (extended) {
return (
<Flex alignItems={{ default: 'alignItemsCenter' }} spaceItems={{ default: 'spaceItemsSm' }}>
<FlexItem>
<ExclamationCircleIcon />
</FlexItem>
<FlexItem>
<Content component={ContentVariants.small}>
<i>{message}</i>
</Content>
</FlexItem>
</Flex>
);
}
return (
<Tooltip content={message} position="top">
<ExclamationCircleIcon />
</Tooltip>
);
};
export default ImageFallback;

View File

@ -0,0 +1,58 @@
import React, { useEffect, useState } from 'react';
import { Skeleton, SkeletonProps } from '@patternfly/react-core';
type WithValidImageProps = {
imageSrc: string | undefined | null;
fallback: React.ReactNode;
children: (validImageSrc: string) => React.ReactNode;
skeletonWidth?: SkeletonProps['width'];
skeletonShape?: SkeletonProps['shape'];
};
const DEFAULT_SKELETON_WIDTH = '32px';
const DEFAULT_SKELETON_SHAPE: SkeletonProps['shape'] = 'square';
type LoadState = 'loading' | 'valid' | 'invalid';
const WithValidImage: React.FC<WithValidImageProps> = ({
imageSrc,
fallback,
children,
skeletonWidth = DEFAULT_SKELETON_WIDTH,
skeletonShape = DEFAULT_SKELETON_SHAPE,
}) => {
const [status, setStatus] = useState<LoadState>('loading');
const [resolvedSrc, setResolvedSrc] = useState<string>('');
useEffect(() => {
let cancelled = false;
if (!imageSrc) {
setStatus('invalid');
return;
}
const img = new Image();
img.onload = () => !cancelled && (setResolvedSrc(imageSrc), setStatus('valid'));
img.onerror = () => !cancelled && setStatus('invalid');
img.src = imageSrc;
return () => {
cancelled = true;
};
}, [imageSrc]);
if (status === 'loading') {
return (
<Skeleton shape={skeletonShape} width={skeletonWidth} screenreaderText="Loading image" />
);
}
if (status === 'invalid') {
return <>{fallback}</>;
}
return <>{children(resolvedSrc)}</>;
};
export default WithValidImage;