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:
parent
42dfd30d94
commit
28f2471bb5
|
@ -2,4 +2,4 @@
|
|||
"eslint.options": {
|
||||
"rulePaths": ["./eslint-local-rules"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
Loading…
Reference in New Issue