Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/four-geckos-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@alfalab/core-components-icon-view': minor
'@alfalab/core-components-file-upload-item': patch
---

- В `icon-view` добавлен callback `onImageBrokenChange` и улучшена обработка битых изображений в `BaseShape`.
- В `file-upload-item` для битого `imageUrl` добавлено отображение fallback-иконки `DocumentImageOff`.
3 changes: 3 additions & 0 deletions packages/file-upload-item/src/Component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const FileUploadItemComponent: React.FC<FileUploadItemProps> = ({
backgroundColor,
}) => {
const [actionsPresent, setActionsPresent] = useState(false);
const [isBrokenImage, setIsBrokenImage] = useState(false);

return (
<div
Expand Down Expand Up @@ -77,6 +78,8 @@ export const FileUploadItemComponent: React.FC<FileUploadItemProps> = ({
customContent,
truncate,
imageUrl,
isBrokenImage,
setIsBrokenImage,
backgroundColor,
actionsPresent,
setActionsPresent,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react';
import { render, screen } from '@testing-library/react';

import { FileUploadItemContext } from '../../../context/file-upload-item-context';

import { ExtensionIcon } from '.';

jest.mock('@alfalab/icons-glyph/DocumentImageOffMIcon', () => ({
DocumentImageOffMIcon: () => <div data-test-id='broken-image-icon' />,
}));

describe('ExtensionIcon', () => {
it('should hide icon when image is valid', () => {
const CustomIcon = () => <div data-test-id='custom-icon' />;

render(
<FileUploadItemContext.Provider
value={{
imageUrl: 'https://test-image',
isBrokenImage: false,
customIcon: CustomIcon,
}}
>
<ExtensionIcon />
</FileUploadItemContext.Provider>,
);

expect(screen.queryByTestId('custom-icon')).not.toBeInTheDocument();
});

it('should show fallback icon when image is broken', () => {
render(
<FileUploadItemContext.Provider
value={{
imageUrl: 'https://broken-image',
isBrokenImage: true,
}}
>
<ExtensionIcon />
</FileUploadItemContext.Provider>,
);

expect(screen.getByTestId('broken-image-icon')).toBeInTheDocument();
});

it('should keep custom icon priority for broken image', () => {
const CustomIcon = () => <div data-test-id='custom-icon' />;

render(
<FileUploadItemContext.Provider
value={{
imageUrl: 'https://broken-image',
isBrokenImage: true,
customIcon: CustomIcon,
}}
>
<ExtensionIcon />
</FileUploadItemContext.Provider>,
);

expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Document1CMIcon } from '@alfalab/icons-glyph/Document1CMIcon';
import { DocumentDocMIcon } from '@alfalab/icons-glyph/DocumentDocMIcon';
import { DocumentExcelMIcon } from '@alfalab/icons-glyph/DocumentExcelMIcon';
import { DocumentImageMIcon } from '@alfalab/icons-glyph/DocumentImageMIcon';
import { DocumentImageOffMIcon } from '@alfalab/icons-glyph/DocumentImageOffMIcon';
import { DocumentMIcon } from '@alfalab/icons-glyph/DocumentMIcon';
import { DocumentOffMIcon } from '@alfalab/icons-glyph/DocumentOffMIcon';
import { DocumentPdfMIcon } from '@alfalab/icons-glyph/DocumentPdfMIcon';
Expand All @@ -15,33 +16,7 @@ import { getExtension, isInitialStatus } from '../../../utils';

import styles from './index.module.css';

export const ExtensionIcon = () => {
const {
title = '',
uploadStatus,
iconStyle,
customIcon: CustomIcon,
imageUrl,
showRestore,
} = useContext(FileUploadItemContext);

if (imageUrl) {
return null;
}

if (CustomIcon) {
return <CustomIcon />;
}

if (isInitialStatus(uploadStatus)) {
return <PaperclipMIcon />;
}

if (showRestore) {
return <DocumentOffMIcon />;
}

const isColoredIcon = iconStyle === 'colored';
const getDefaultFileIcon = (title: string, isColoredIcon: boolean) => {
const fileType = getExtension(title);

switch (fileType) {
Expand Down Expand Up @@ -72,3 +47,39 @@ export const ExtensionIcon = () => {
);
}
};

export const ExtensionIcon = () => {
const {
title = '',
uploadStatus,
iconStyle,
customIcon: CustomIcon,
imageUrl,
isBrokenImage,
showRestore,
} = useContext(FileUploadItemContext);

if (imageUrl && !isBrokenImage) {
return null;
}

if (imageUrl && isBrokenImage && !CustomIcon) {
return <DocumentImageOffMIcon />;
}

if (CustomIcon) {
return <CustomIcon />;
}

if (isInitialStatus(uploadStatus)) {
return <PaperclipMIcon />;
}

if (showRestore) {
return <DocumentOffMIcon />;
}

const isColoredIcon = iconStyle === 'colored';

return getDefaultFileIcon(title, isColoredIcon);
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const StatusControl = () => {
progressBar = 0,
progressBarAvailable = true,
imageUrl,
setIsBrokenImage,
backgroundColor,
actionsPresent,
isClickable,
Expand All @@ -37,6 +38,7 @@ export const StatusControl = () => {
<SuperEllipse
backgroundColor={backgroundColor}
size={48}
onImageBrokenChange={setIsBrokenImage}
{...(imageUrl && { imageUrl })}
>
<ExtensionIcon />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type TFileUploadItemContext = {
customContent?: ElementType;
truncate?: boolean;
imageUrl?: string;
isBrokenImage?: boolean;
setIsBrokenImage?: (isBroken: boolean) => void;
backgroundColor?: SuperEllipseProps['backgroundColor'];
actionsPresent?: boolean;
setActionsPresent?: (present: boolean) => void;
Expand Down Expand Up @@ -64,6 +66,8 @@ export const FileUploadItemContext = createContext<TFileUploadItemContext>({
customContent: undefined,
truncate: false,
imageUrl: undefined,
isBrokenImage: false,
setIsBrokenImage: undefined,
backgroundColor: undefined,
actionsPresent: false,
setActionsPresent: undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { checkImageIsBroken } from './check-image-is-broken';

type EventType = 'load' | 'error';

type MockImageConstructorParams = {
eventType: EventType;
decode: () => Promise<void>;
};

const getMockImageConstructor = ({ eventType, decode }: MockImageConstructorParams) => {
function MockImage(this: {
onload: null | (() => void);
onerror: null | (() => void);
decode: () => Promise<void>;
}) {
this.onload = null;
this.onerror = null;
this.decode = decode;
}

Object.defineProperty(MockImage.prototype, 'src', {
set(this: { onload: null | (() => void); onerror: null | (() => void) }) {
if (eventType === 'load') {
this.onload?.();
} else {
this.onerror?.();
}
},
});

return MockImage as unknown as typeof Image;
};

describe('checkImageIsBroken', () => {
const originalCreateImageBitmap = global.createImageBitmap;
const originalImage = global.Image;

afterEach(() => {
global.createImageBitmap = originalCreateImageBitmap;
global.Image = originalImage;
});

it('should return false for valid image via createImageBitmap', async () => {
const close = jest.fn();

global.createImageBitmap = jest.fn().mockResolvedValue({
close,
} as unknown as ImageBitmap);
global.Image = getMockImageConstructor({
eventType: 'load',
decode: jest.fn().mockResolvedValue(undefined),
});

await new Promise<void>((resolve) => {
checkImageIsBroken({
imageUrl: 'https://test-url',
onResolve: (isBroken) => {
expect(isBroken).toBe(false);
expect(close).toHaveBeenCalled();
resolve();
},
});
});
});

it('should use decode fallback when createImageBitmap fails', async () => {
global.createImageBitmap = jest.fn().mockRejectedValue(new Error('bitmap failed'));
global.Image = getMockImageConstructor({
eventType: 'load',
decode: jest.fn().mockResolvedValue(undefined),
});

await new Promise<void>((resolve) => {
checkImageIsBroken({
imageUrl: 'https://test-url',
onResolve: (isBroken) => {
expect(isBroken).toBe(false);
resolve();
},
});
});
});

it('should return true when decode fallback also fails', async () => {
global.createImageBitmap = jest.fn().mockRejectedValue(new Error('bitmap failed'));
global.Image = getMockImageConstructor({
eventType: 'load',
decode: jest.fn().mockRejectedValue(new Error('decode failed')),
});

await new Promise<void>((resolve) => {
checkImageIsBroken({
imageUrl: 'https://test-url',
onResolve: (isBroken) => {
expect(isBroken).toBe(true);
resolve();
},
});
});
});

it('should return true on image onerror', async () => {
global.createImageBitmap = jest.fn();
global.Image = getMockImageConstructor({
eventType: 'error',
decode: jest.fn().mockResolvedValue(undefined),
});

await new Promise<void>((resolve) => {
checkImageIsBroken({
imageUrl: 'https://test-url',
onResolve: (isBroken) => {
expect(isBroken).toBe(true);
resolve();
},
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
type CheckImageIsBrokenParams = {
imageUrl: string;
onResolve: (isBroken: boolean) => void;
};

export const checkImageIsBroken = ({ imageUrl, onResolve }: CheckImageIsBrokenParams) => {
const image = new Image();

const resolveWithDecode = () => {
Promise.resolve()
.then(() => image.decode())
.then(() => {
onResolve(false);
})
.catch(() => {
onResolve(true);
});
};

image.onload = () => {
Promise.resolve()
.then(() => createImageBitmap(image))
.then((imageBitmap) => {
imageBitmap.close();
onResolve(false);
})
.catch(() => {
resolveWithDecode();
});
};

image.onerror = () => {
onResolve(true);
};

image.src = imageUrl;
};
Loading