Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gallery view #1081 #1100

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from 7 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
76 changes: 76 additions & 0 deletions cypress/e2e/with_mock_data/items.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,82 @@ describe('Items', () => {

cy.findByText('Upload failed').should('exist');
});

it('falls back to placeholder thumbnail', () => {
cy.window().its('msw').should('not.equal', undefined);
cy.window().then((window) => {
const { worker, http } = window.msw;

worker.use(
http.get('/images', async () => {
return HttpResponse.json(
[
{
id: '1',
thumbnail_base64: 'test',
file_name: 'test.png',
},
],
{ status: 200 }
);
})
);
});

cy.findByText('5YUQDDjKpz2z').click();
cy.findByText(
'High-resolution cameras for beam characterization. 1'
).should('exist');

cy.findByText('Gallery').click();

cy.findByRole('progressbar', { timeout: 10000 }).should('not.exist');

cy.findByRole('img', { timeout: 10000 }).should(
'have.attr',
'src',
'http://localhost:3000/images/thumbnail-not-available.png'
);
});

it('opens full-size image when thumbnail is clicked and navigates to the next image', () => {
cy.findByText('5YUQDDjKpz2z').click();
cy.findByText(
'High-resolution cameras for beam characterization. 1'
).should('exist');

cy.findByText('Gallery').click();

cy.findAllByAltText('Image: tetstw').first().click();

cy.findByText('File Name: stfc-logo-blue-text.png').should('exist');
cy.findByText('Title: tetstw').should('exist');
cy.findByText('test').should('exist');

cy.findByRole('dialog').within(() => {
cy.findByRole('img').should('exist');
});

cy.findByRole('button', { name: 'Next' }).click();

cy.findByText('File Name: logo1.png').should('exist');
cy.findByText('Title: tetstw').should('exist');
cy.findByText('test').should('exist');

cy.findByRole('dialog').within(() => {
cy.findByRole('img').should('exist');
});

cy.findByRole('button', { name: 'Next' }).click();
// Failed to render image
cy.findByText('File Name: stfc-logo-blue-text.png').should('exist');
cy.findByText('Title: tetstw').should('exist');
cy.findByText('test').should('exist');

cy.findByRole('dialog').within(() => {
cy.findByRole('img', { timeout: 10000 }).should('exist');
});
});
});

it('delete an item', () => {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@
"lz-string": "^1.5.0",
"material-react-table": "^2.13.0",
"msw": "2.4.11",
"photoswipe": "^5.4.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.0",
"react-photoswipe-gallery": "^3.0.2",
"react-redux": "^9.1.2",
"react-router-dom": "^6.23.1",
"single-spa-react": "5.1.4",
Expand Down
Binary file added public/images/image-not-available.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/thumbnail-not-available.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/api/api.types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,7 @@ export interface Image
primary: boolean;
thumbnail_base64: string;
}

export interface ImageGet extends Image {
download_url: string;
}
35 changes: 35 additions & 0 deletions src/api/images.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { renderHook, waitFor } from '@testing-library/react';
import { hooksWrapperWithProviders } from '../testUtils';
import { useGetImages } from './images';

describe('images api functions', () => {
afterEach(() => {
vi.clearAllMocks();
});

describe('useGetImages', () => {
it('sends request to fetch image data and returns successful response', async () => {
const { result } = renderHook(() => useGetImages('1'), {
wrapper: hooksWrapperWithProviders(),
});

await waitFor(() => {
expect(result.current.isSuccess).toBeTruthy();
});

expect(result.current.data?.length).toEqual(20);
});

it('sends request to fetch primary image data and returns successful response', async () => {
const { result } = renderHook(() => useGetImages('1', true), {
wrapper: hooksWrapperWithProviders(),
});

await waitFor(() => {
expect(result.current.isSuccess).toBeTruthy();
});

expect(result.current.data?.length).toEqual(1);
});
});
});
36 changes: 36 additions & 0 deletions src/api/images.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { storageApi } from './api';
import { Image, ImageGet } from './api.types';

export const getImage = async (id: string): Promise<ImageGet> => {
return storageApi.get(`/images/${id}`).then((response) => {
return response.data;
});
};

const getImages = async (
entityId: string,
primary?: boolean
): Promise<Image[]> => {
const queryParams = new URLSearchParams();
queryParams.append('entity_id', entityId);

if (primary !== undefined) queryParams.append('primary', String(primary));
return storageApi
.get(`/images`, {
params: queryParams,
})
.then((response) => response.data);
};

export const useGetImages = (
entityId?: string,
primary?: boolean
): UseQueryResult<Image[], AxiosError> => {
return useQuery({
queryKey: ['Images', entityId, primary],
queryFn: () => getImages(entityId ?? '', primary),
enabled: !!entityId,
});
};
1 change: 0 additions & 1 deletion src/common/actionMenu.component.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import '@testing-library/jest-dom';
import { screen, waitFor } from '@testing-library/react';
import userEvent, { UserEvent } from '@testing-library/user-event';
import { vi } from 'vitest';
Expand Down
1,856 changes: 1,856 additions & 0 deletions src/common/images/__snapshots__/imageGallery.component.test.tsx.snap

Large diffs are not rendered by default.

218 changes: 218 additions & 0 deletions src/common/images/imageGallery.component.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent, { UserEvent } from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { act } from 'react';
import { MockInstance } from 'vitest';
import { storageApi } from '../../api/api';
import ImageJSON from '../../mocks/image.json';
import { server } from '../../mocks/server';
import { renderComponentWithRouterProvider } from '../../testUtils';
import ImageGallery, { ImageGalleryProps } from './imageGallery.component';

describe('Image Gallery', () => {
let props: ImageGalleryProps;
let user: UserEvent;
let axiosGetSpy: MockInstance;

const createView = () => {
return renderComponentWithRouterProvider(<ImageGallery {...props} />);
};

beforeAll(() => {
let _src: string;

Object.defineProperty(global.Image.prototype, 'src', {
set(value) {
_src = value;

// Check for an invalid base64 thumbnail or URL and call onError
if (value.includes('test')) {
setTimeout(() => {
if (typeof this.onerror === 'function') {
this.onerror(new Event('error'));
}
}, 0);
} else {
setTimeout(() => {
if (typeof this.onload === 'function') {
this.onload();
}
}, 0);
}
},
get() {
return _src;
},
});

Object.defineProperty(global.Image.prototype, 'naturalWidth', {
get() {
return 100;
},
});

Object.defineProperty(global.Image.prototype, 'naturalHeight', {
get() {
return 100;
},
});
});

beforeEach(() => {
props = {
entityId: '1',
};
user = userEvent.setup();
axiosGetSpy = vi.spyOn(storageApi, 'get');

Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});

afterEach(() => {
vi.clearAllMocks();
});

it('renders correctly', async () => {
let baseElement;
await act(async () => {
baseElement = createView().baseElement;
});

await waitFor(() =>
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
);

expect(screen.getAllByText('logo1.png').length).toEqual(10);
expect(baseElement).toMatchSnapshot();
});

it('renders no results page correctly', async () => {
server.use(
http.get('/images', async () => {
return HttpResponse.json([], { status: 200 });
})
);
let baseElement;
await act(async () => {
baseElement = createView().baseElement;
});

await waitFor(() =>
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
);

expect(screen.queryByText('logo1.png')).not.toBeInTheDocument();
expect(baseElement).toMatchSnapshot();
});

it('falls back to placeholder thumbnail', async () => {
server.use(
http.get('/images', async () => {
return HttpResponse.json(
[
{
...ImageJSON,
id: '1',
thumbnail_base64: 'test',
file_name: 'test.png',
},
],
{ status: 200 }
);
})
);
createView();

await waitFor(() =>
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
);

const image = screen.getByRole('img') as HTMLImageElement; // Replace with actual alt text or selector

expect(image).toHaveAttribute('src', 'data:image/webp;base64,test');
fireEvent.error(image);

await waitFor(() => {
expect(image.src).toEqual('/images/thumbnail-not-available.png');
});
});

it('opens full-size image when thumbnail is clicked, navigates to the next image, and then navigates to a third image that failed to upload, falling back to a placeholder', async () => {
createView();

await waitFor(() =>
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
);
const thumbnail = await screen.findAllByAltText('Image: tetstw');
await user.click(thumbnail[0]);

expect(axiosGetSpy).toHaveBeenCalledWith('/images/1');
await waitFor(() => {
expect(
screen.getByText('File Name: stfc-logo-blue-text.png')
).toBeInTheDocument();
});
expect(screen.getByText('Title: tetstw')).toBeInTheDocument();
expect(screen.getByText('test')).toBeInTheDocument();

await waitFor(
() => {
expect(
within(screen.getByRole('dialog')).getByRole('img')
).toBeInTheDocument();
},
{ timeout: 5000 }
);

await user.click(screen.getByRole('button', { name: 'Next' }));

expect(axiosGetSpy).toHaveBeenCalledWith('/images/2');
await waitFor(() => {
expect(screen.getByText('File Name: logo1.png')).toBeInTheDocument();
});
expect(screen.getByText('Title: tetstw')).toBeInTheDocument();
expect(screen.getByText('test')).toBeInTheDocument();

await waitFor(
() => {
expect(
within(screen.getByRole('dialog')).getByRole('img')
).toBeInTheDocument();
},
{ timeout: 5000 }
);
await user.click(screen.getByRole('button', { name: 'Next' }));

// Failed to render image
expect(axiosGetSpy).toHaveBeenCalledWith('/images/3');

await waitFor(() => {
expect(
screen.getByText('File Name: stfc-logo-blue-text.png')
).toBeInTheDocument();
});
expect(screen.getByText('Title: tetstw')).toBeInTheDocument();
expect(screen.getByText('test')).toBeInTheDocument();

await waitFor(
() => {
expect(
within(screen.getByRole('dialog')).getByRole('img')
).toBeInTheDocument();
},
{ timeout: 5000 }
);
}, 15000);
});
Loading