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
|
@ -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':
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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