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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"@iconscout/react-unicons": "^2.2.5",
"@internxt/css-config": "1.1.1",
"@internxt/lib": "=1.5.1",
"@internxt/sdk": "=1.25.0",
"@internxt/sdk": "=1.26.0",
"@internxt/ui": "=0.1.21",
"@marsidev/react-turnstile": "^1.6.1",
"@phosphor-icons/react": "^2.1.10",
Expand Down
3 changes: 2 additions & 1 deletion src/app/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,8 @@
"retry": "Erneut versuchen"
},
"filters": {
"allDevices": "Alle Geräte"
"allDevices": "Alle Geräte",
"allDates": "Alle Daten"
}
},
"favorites": {
Expand Down
3 changes: 2 additions & 1 deletion src/app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,8 @@
"retry": "Try again"
},
"filters": {
"allDevices": "All devices"
"allDevices": "All devices",
"allDates": "All dates"
}
},
"favorites": {
Expand Down
3 changes: 2 additions & 1 deletion src/app/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,8 @@
"retry": "Reintentar"
},
"filters": {
"allDevices": "Todos los dispositivos"
"allDevices": "Todos los dispositivos",
"allDates": "Todas las fechas"
}
},
"favorites": {
Expand Down
3 changes: 2 additions & 1 deletion src/app/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,8 @@
"retry": "Réessayer"
},
"filters": {
"allDevices": "Tous les appareils"
"allDevices": "Tous les appareils",
"allDates": "Toutes les dates"
}
},
"favorites": {
Expand Down
3 changes: 2 additions & 1 deletion src/app/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,8 @@
"retry": "Riprova"
},
"filters": {
"allDevices": "Tutti i dispositivi"
"allDevices": "Tutti i dispositivi",
"allDates": "Tutte le date"
}
},
"favorites": {
Expand Down
3 changes: 2 additions & 1 deletion src/app/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,8 @@
"retry": "Tentar novamente"
},
"filters": {
"allDevices": "Todos os dispositivos"
"allDevices": "Todos os dispositivos",
"allDates": "Todas as datas"
}
},
"favorites": {
Expand Down
3 changes: 2 additions & 1 deletion src/app/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,8 @@
"retry": "Повторить"
},
"filters": {
"allDevices": "Все устройства"
"allDevices": "Все устройства",
"allDates": "Все даты"
}
},
"favorites": {
Expand Down
3 changes: 2 additions & 1 deletion src/app/i18n/locales/tw.json
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,8 @@
"retry": "重試"
},
"filters": {
"allDevices": "所有裝置"
"allDevices": "所有裝置",
"allDates": "所有日期"
}
},
"favorites": {
Expand Down
3 changes: 2 additions & 1 deletion src/app/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,8 @@
"retry": "重试"
},
"filters": {
"allDevices": "所有设备"
"allDevices": "所有设备",
"allDates": "所有日期"
}
},
"favorites": {
Expand Down
35 changes: 35 additions & 0 deletions src/testUtils/fixtures/photos.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { PhotoDevice as SdkPhotoDevice } from '@internxt/sdk/dist/drive/photos';
import { FetchFolderFoldersCursorResponse } from '@internxt/sdk/dist/drive/storage/types';

type Folder = FetchFolderFoldersCursorResponse['folders'][number];

export const getSdkPhotoDevice = (overrides: Partial<SdkPhotoDevice> = {}): SdkPhotoDevice => ({
uuid: 'iphone-uuid',
plainName: 'iPhone',
bucket: 'photos-bucket',
status: 'EXISTS',
...overrides,
});

export const getPhotoFolder = (overrides: Partial<Folder> = {}): Folder =>
({
type: 'folder',
id: 1,
parentId: 0,
parentUuid: 'parent-uuid',
name: 'encrypted-name',
bucket: 'photos-bucket',
userId: 1,
encryptVersion: '03-aes',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
uuid: 'folder-uuid',
plainName: 'folder',
size: 0,
creationTime: '2026-01-01T00:00:00.000Z',
modificationTime: '2026-01-01T00:00:00.000Z',
status: 'EXISTS',
removed: false,
deleted: false,
...overrides,
}) as Folder;
158 changes: 141 additions & 17 deletions src/views/Photos/PhotosView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import errorService from 'services/error.service';
import { PhotoDevice as SdkPhotoDevice } from '@internxt/sdk/dist/drive/photos';
import { getPhotoFolder, getSdkPhotoDevice } from 'testUtils/fixtures/photos.fixtures';
import PhotosView from './PhotosView';

const { photosClient } = vi.hoisted(() => ({
const { photosClient, storageClient } = vi.hoisted(() => ({
photosClient: { listDevices: vi.fn() },
storageClient: { getFolderFoldersByUuidWithCursor: vi.fn() },
}));

vi.mock('app/core/factory/sdk', () => ({
SdkFactory: { getNewApiInstance: () => ({ createPhotosClient: () => photosClient }) },
SdkFactory: {
getNewApiInstance: () => ({
createPhotosClient: () => photosClient,
createNewStorageClient: () => storageClient,
}),
},
}));
vi.mock('app/i18n/provider/TranslationProvider', () => ({
useTranslationContext: () => ({ translate: (key: string) => key }),
Expand All @@ -20,16 +28,37 @@ vi.mock('services/error.service', () => ({
default: { reportError: vi.fn() },
}));

const device = (uuid: string, status: 'EXISTS' | 'TRASHED' | 'DELETED') => ({
uuid,
plainName: uuid,
bucket: 'photos-bucket',
status,
});
type Folder = ReturnType<typeof getPhotoFolder>;

const givenDevices = (devices: SdkPhotoDevice[]) => photosClient.listDevices.mockResolvedValue(devices);

const givenSubfolders = (subfoldersByFolder: Record<string, Folder[]>) => {
storageClient.getFolderFoldersByUuidWithCursor.mockImplementation((folderUuid: string) => [
Promise.resolve({ folders: subfoldersByFolder[folderUuid] ?? [], nextCursor: null }),
{ cancel: vi.fn() },
]);
};

const iphone = getSdkPhotoDevice({ uuid: 'iphone', plainName: 'iPhone' });
const pixel = getSdkPhotoDevice({ uuid: 'pixel', plainName: 'Pixel' });

const iphonePhotosOfNovember2026 = {
iphone: [getPhotoFolder({ uuid: 'iphone-2026', plainName: '2026' })],
'iphone-2026': [getPhotoFolder({ uuid: 'iphone-2026-11', plainName: '11' })],
};

const pixelPhotosOfOctober2026 = {
pixel: [getPhotoFolder({ uuid: 'pixel-2026', plainName: '2026' })],
'pixel-2026': [getPhotoFolder({ uuid: 'pixel-2026-10', plainName: '10' })],
};

const waitForPhotosToLoad = () =>
waitFor(() => expect(screen.queryByTestId('photos-grid-skeleton')).not.toBeInTheDocument());

describe('Photos view', () => {
beforeEach(() => {
vi.clearAllMocks();
givenSubfolders({});
});

test('When the devices are loading, then the photos grid placeholder is shown under the title', () => {
Expand Down Expand Up @@ -59,19 +88,23 @@ describe('Photos view', () => {
});

test('When every device was deleted, then the user is treated as having no devices', async () => {
photosClient.listDevices.mockResolvedValue([device('old-phone', 'TRASHED'), device('tablet', 'DELETED')]);
givenDevices([
getSdkPhotoDevice({ uuid: 'old-phone', status: 'TRASHED' }),
getSdkPhotoDevice({ uuid: 'tablet', status: 'DELETED' }),
]);

render(<PhotosView />);

expect(await screen.findByText('views.photos.empty.title')).toBeInTheDocument();
});

test('When the user has devices, then the empty state is not shown', async () => {
photosClient.listDevices.mockResolvedValue([device('iphone', 'EXISTS')]);
test('When the user has photos, then the empty state is not shown', async () => {
givenDevices([iphone]);
givenSubfolders(iphonePhotosOfNovember2026);

render(<PhotosView />);

await waitFor(() => expect(screen.queryByTestId('photos-grid-skeleton')).not.toBeInTheDocument());
await waitForPhotosToLoad();
expect(screen.queryByText('views.photos.empty.title')).not.toBeInTheDocument();
expect(screen.queryByText('views.photos.error.title')).not.toBeInTheDocument();
});
Expand All @@ -93,16 +126,18 @@ describe('Photos view', () => {
};

test('When the user has a single device, then there is no device filter', async () => {
photosClient.listDevices.mockResolvedValue([device('iPhone', 'EXISTS')]);
givenDevices([iphone]);
givenSubfolders(iphonePhotosOfNovember2026);

render(<PhotosView />);

await waitFor(() => expect(screen.queryByTestId('photos-grid-skeleton')).not.toBeInTheDocument());
await waitForPhotosToLoad();
expect(screen.queryByRole('button', { name: /views.photos.filters.allDevices/ })).not.toBeInTheDocument();
});

test('When the user has several devices, then they can choose one of them', async () => {
photosClient.listDevices.mockResolvedValue([device('iPhone', 'EXISTS'), device('Pixel', 'EXISTS')]);
givenDevices([iphone, pixel]);
givenSubfolders({ ...iphonePhotosOfNovember2026, ...pixelPhotosOfOctober2026 });

render(<PhotosView />);
await openDeviceFilter();
Expand All @@ -119,7 +154,8 @@ describe('Photos view', () => {
});

test('When the user chooses a device, then the filter shows it until they go back to all devices', async () => {
photosClient.listDevices.mockResolvedValue([device('iPhone', 'EXISTS'), device('Pixel', 'EXISTS')]);
givenDevices([iphone, pixel]);
givenSubfolders({ ...iphonePhotosOfNovember2026, ...pixelPhotosOfOctober2026 });

render(<PhotosView />);
await openDeviceFilter();
Expand All @@ -133,6 +169,94 @@ describe('Photos view', () => {
});
});

describe('Filtering by month', () => {
const openMonthFilter = async () => {
fireEvent.click(await screen.findByRole('button', { name: /views.photos.filters.allDates/ }));
};

const offeredMonths = async () => {
const monthMenu = await screen.findByRole('menu', { name: /views.photos.filters.allDates/ });
return within(monthMenu)
.getAllByRole('menuitem')
.map((option) => option.textContent);
};

test('When the user has photos in several months, then the months are offered newest first', async () => {
givenDevices([iphone, pixel]);
givenSubfolders({
iphone: [
getPhotoFolder({ uuid: 'iphone-2025', plainName: '2025' }),
getPhotoFolder({ uuid: 'iphone-2026', plainName: '2026' }),
],
'iphone-2025': [getPhotoFolder({ uuid: 'iphone-2025-12', plainName: '12' })],
'iphone-2026': [getPhotoFolder({ uuid: 'iphone-2026-11', plainName: '11' })],
pixel: [getPhotoFolder({ uuid: 'pixel-2026', plainName: '2026' })],
'pixel-2026': [
getPhotoFolder({ uuid: 'pixel-2026-11', plainName: '11' }),
getPhotoFolder({ uuid: 'pixel-2026-01', plainName: '01' }),
],
});

render(<PhotosView />);
await openMonthFilter();

expect(await offeredMonths()).toStrictEqual([
'views.photos.filters.allDates',
'November 2026',
'January 2026',
'December 2025',
]);
});

test('When a device is chosen, then only the months with photos on that device are offered', async () => {
givenDevices([iphone, pixel]);
givenSubfolders({ ...iphonePhotosOfNovember2026, ...pixelPhotosOfOctober2026 });

render(<PhotosView />);
fireEvent.click(await screen.findByRole('button', { name: /views.photos.filters.allDevices/ }));
fireEvent.click(await screen.findByRole('menuitem', { name: /Pixel/ }));
await openMonthFilter();

expect(await offeredMonths()).toStrictEqual(['views.photos.filters.allDates', 'October 2026']);
});

test('When the chosen month has no photos on the newly chosen device, then all dates are shown again', async () => {
givenDevices([iphone, pixel]);
givenSubfolders({ ...iphonePhotosOfNovember2026, ...pixelPhotosOfOctober2026 });

render(<PhotosView />);
await openMonthFilter();
fireEvent.click(await screen.findByRole('menuitem', { name: /November 2026/ }));
fireEvent.click(await screen.findByRole('button', { name: /views.photos.filters.allDevices/ }));
fireEvent.click(await screen.findByRole('menuitem', { name: /Pixel/ }));

expect(await screen.findByRole('button', { name: /views.photos.filters.allDates/ })).toBeInTheDocument();
});

test('When the devices have no photos yet, then the empty state is shown without filters', async () => {
givenDevices([iphone]);

render(<PhotosView />);

expect(await screen.findByText('views.photos.empty.title')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /views.photos.filters.allDates/ })).not.toBeInTheDocument();
});

test('When the months cannot be found, then the user can try again', async () => {
givenDevices([iphone]);
givenSubfolders(iphonePhotosOfNovember2026);
storageClient.getFolderFoldersByUuidWithCursor.mockImplementationOnce(() => [
Promise.reject(new Error('Network error')),
{ cancel: vi.fn() },
]);

render(<PhotosView />);
fireEvent.click(await screen.findByRole('button', { name: /views.photos.error.retry/ }));

expect(await screen.findByRole('button', { name: /views.photos.filters.allDates/ })).toBeInTheDocument();
});
});

test('When the devices cannot be loaded, then the error is reported and no empty state is shown', async () => {
const networkError = new Error('Network error');
photosClient.listDevices.mockRejectedValue(networkError);
Expand Down
Loading
Loading