From 28f2471bb5f1032701fdef93246c22946bc0f6eb Mon Sep 17 00:00:00 2001 From: Guilherme Caponetto <638737+caponetto@users.noreply.github.com> Date: Fri, 27 Jun 2025 10:46:17 -0300 Subject: [PATCH] feat(ws): add fallback mechanism to broken images (#448) Signed-off-by: Guilherme Caponetto <638737+caponetto@users.noreply.github.com> --- workspaces/frontend/.vscode/settings.json | 2 +- .../src/app/components/WorkspaceTable.tsx | 34 ++++++----- .../pages/WorkspaceKinds/WorkspaceKinds.tsx | 27 +++++---- .../details/WorkspaceKindDetailsOverview.tsx | 31 +++++++++- .../Form/kind/WorkspaceFormKindList.tsx | 18 +++++- .../src/shared/components/ImageFallback.tsx | 38 ++++++++++++ .../src/shared/components/WithValidImage.tsx | 58 +++++++++++++++++++ 7 files changed, 178 insertions(+), 30 deletions(-) create mode 100644 workspaces/frontend/src/shared/components/ImageFallback.tsx create mode 100644 workspaces/frontend/src/shared/components/WithValidImage.tsx diff --git a/workspaces/frontend/.vscode/settings.json b/workspaces/frontend/.vscode/settings.json index 1b09158c..cccc87eb 100644 --- a/workspaces/frontend/.vscode/settings.json +++ b/workspaces/frontend/.vscode/settings.json @@ -2,4 +2,4 @@ "eslint.options": { "rulePaths": ["./eslint-local-rules"] } -} \ No newline at end of file +} diff --git a/workspaces/frontend/src/app/components/WorkspaceTable.tsx b/workspaces/frontend/src/app/components/WorkspaceTable.tsx index 20e20c6e..a2c11866 100644 --- a/workspaces/frontend/src/app/components/WorkspaceTable.tsx +++ b/workspaces/frontend/src/app/components/WorkspaceTable.tsx @@ -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( case 'kind': return ( - {kindLogoDict[workspace.workspaceKind.name] ? ( - - - - ) : ( - - - - )} + } + > + {(validSrc) => ( + + {workspace.workspaceKind.name} + + )} + ); case 'namespace': diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx index b16ecb65..f1927d1f 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx @@ -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 = () => { - {workspaceKind.icon.url ? ( - - ) : ( - - )} + } + > + {(validSrc) => ( + {workspaceKind.name} + )} + {workspaceKind.name} Icon - + + } + > + {(validSrc) => {workspaceKind.name}} + Icon URL @@ -61,7 +74,19 @@ export const WorkspaceKindDetailsOverview: React.FunctionComponent< Logo - + + } + > + {(validSrc) => {workspaceKind.name}} + Logo URL diff --git a/workspaces/frontend/src/app/pages/Workspaces/Form/kind/WorkspaceFormKindList.tsx b/workspaces/frontend/src/app/pages/Workspaces/Form/kind/WorkspaceFormKindList.tsx index bf50e094..30240e62 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Form/kind/WorkspaceFormKindList.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Form/kind/WorkspaceFormKindList.tsx @@ -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 - {`${kind.name} + + } + > + {(validSrc) => ( + {`${kind.name} + )} + {kind.displayName} {kind.description} diff --git a/workspaces/frontend/src/shared/components/ImageFallback.tsx b/workspaces/frontend/src/shared/components/ImageFallback.tsx new file mode 100644 index 00000000..2002cfa9 --- /dev/null +++ b/workspaces/frontend/src/shared/components/ImageFallback.tsx @@ -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 = ({ + extended = false, + imageSrc, + message = `Cannot load image: ${imageSrc || 'no image source provided'}`, +}) => { + if (extended) { + return ( + + + + + + + {message} + + + + ); + } + + return ( + + + + ); +}; + +export default ImageFallback; diff --git a/workspaces/frontend/src/shared/components/WithValidImage.tsx b/workspaces/frontend/src/shared/components/WithValidImage.tsx new file mode 100644 index 00000000..3743b53b --- /dev/null +++ b/workspaces/frontend/src/shared/components/WithValidImage.tsx @@ -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 = ({ + imageSrc, + fallback, + children, + skeletonWidth = DEFAULT_SKELETON_WIDTH, + skeletonShape = DEFAULT_SKELETON_SHAPE, +}) => { + const [status, setStatus] = useState('loading'); + const [resolvedSrc, setResolvedSrc] = useState(''); + + 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 ( + + ); + } + + if (status === 'invalid') { + return <>{fallback}; + } + + return <>{children(resolvedSrc)}; +}; + +export default WithValidImage;