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

@ -2,4 +2,4 @@
"eslint.options": {
"rulePaths": ["./eslint-local-rules"]
}
}
}

View File

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

View File

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

View File

@ -5,9 +5,10 @@ import {
DescriptionListGroup,
DescriptionListDescription,
Divider,
Brand,
} from '@patternfly/react-core';
import { WorkspaceKind } from '~/shared/api/backendApiTypes';
import ImageFallback from '~/shared/components/ImageFallback';
import WithValidImage from '~/shared/components/WithValidImage';
type WorkspaceDetailsOverviewProps = {
workspaceKind: WorkspaceKind;
@ -48,7 +49,19 @@ export const WorkspaceKindDetailsOverview: React.FunctionComponent<
<DescriptionListGroup>
<DescriptionListTerm style={{ alignSelf: 'center' }}>Icon</DescriptionListTerm>
<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>
<DescriptionListTerm style={{ alignSelf: 'center' }}>Icon URL</DescriptionListTerm>
<DescriptionListDescription>
@ -61,7 +74,19 @@ export const WorkspaceKindDetailsOverview: React.FunctionComponent<
<DescriptionListGroup>
<DescriptionListTerm style={{ alignSelf: 'center' }}>Logo</DescriptionListTerm>
<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>
<DescriptionListTerm style={{ alignSelf: 'center' }}>Logo URL</DescriptionListTerm>
<DescriptionListDescription>

View File

@ -12,6 +12,8 @@ import {
import { WorkspaceKind } from '~/shared/api/backendApiTypes';
import Filter, { FilteredColumn, FilterRef } from '~/shared/components/Filter';
import CustomEmptyState from '~/shared/components/CustomEmptyState';
import ImageFallback from '~/shared/components/ImageFallback';
import WithValidImage from '~/shared/components/WithValidImage';
import { defineDataFields, FilterableDataFieldKey } from '~/app/filterableDataHelper';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -111,7 +113,21 @@ export const WorkspaceFormKindList: React.FunctionComponent<WorkspaceFormKindLis
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>
<CardTitle>{kind.displayName}</CardTitle>
<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;