From d0b3d841b29b1acf42d8505a3c029e3bafe26d5c Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 3 Nov 2026 16:29:44 +0100 Subject: [PATCH] feat(photos): filter the photos by device --- src/app/i18n/locales/de.json | 3 + src/app/i18n/locales/en.json | 3 + src/app/i18n/locales/es.json | 3 + src/app/i18n/locales/fr.json | 3 + src/app/i18n/locales/it.json | 3 + src/app/i18n/locales/pt.json | 3 + src/app/i18n/locales/ru.json | 3 + src/app/i18n/locales/tw.json | 3 + src/app/i18n/locales/zh.json | 3 + src/views/Photos/PhotosView.test.tsx | 46 +++++++++++ src/views/Photos/PhotosView.tsx | 48 +++++++----- .../Photos/components/PhotosDeviceFilter.tsx | 29 +++++++ .../Photos/components/PhotosFilterMenu.tsx | 77 +++++++++++++++++++ src/views/Photos/components/PhotosLayout.tsx | 27 +++++++ 14 files changed, 234 insertions(+), 20 deletions(-) create mode 100644 src/views/Photos/components/PhotosDeviceFilter.tsx create mode 100644 src/views/Photos/components/PhotosFilterMenu.tsx create mode 100644 src/views/Photos/components/PhotosLayout.tsx diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index a4a1ad24b..41178d24a 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -466,6 +466,9 @@ "title": "Deine Fotos konnten nicht geladen werden", "description": "Überprüfe deine Verbindung und versuche es erneut", "retry": "Erneut versuchen" + }, + "filters": { + "allDevices": "Alle Geräte" } }, "favorites": { diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 4fd602211..38f1ef5d7 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -487,6 +487,9 @@ "title": "We couldn't load your photos", "description": "Check your connection and try again", "retry": "Try again" + }, + "filters": { + "allDevices": "All devices" } }, "favorites": { diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 784e67f7d..80d879403 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -466,6 +466,9 @@ "title": "No hemos podido cargar tus fotos", "description": "Comprueba tu conexión e inténtalo de nuevo", "retry": "Reintentar" + }, + "filters": { + "allDevices": "Todos los dispositivos" } }, "favorites": { diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 8269024d5..8ad7967e6 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -466,6 +466,9 @@ "title": "Impossible de charger vos photos", "description": "Vérifiez votre connexion et réessayez", "retry": "Réessayer" + }, + "filters": { + "allDevices": "Tous les appareils" } }, "favorites": { diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 5d5555d93..c5e71d952 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -487,6 +487,9 @@ "title": "Impossibile caricare le tue foto", "description": "Controlla la connessione e riprova", "retry": "Riprova" + }, + "filters": { + "allDevices": "Tutti i dispositivi" } }, "favorites": { diff --git a/src/app/i18n/locales/pt.json b/src/app/i18n/locales/pt.json index 629e0d8fa..440ee9100 100644 --- a/src/app/i18n/locales/pt.json +++ b/src/app/i18n/locales/pt.json @@ -487,6 +487,9 @@ "title": "Não foi possível carregar suas fotos", "description": "Verifique sua conexão e tente novamente", "retry": "Tentar novamente" + }, + "filters": { + "allDevices": "Todos os dispositivos" } }, "favorites": { diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index ddaeb1570..b4229cad9 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -466,6 +466,9 @@ "title": "Не удалось загрузить фото", "description": "Проверьте подключение и попробуйте снова", "retry": "Повторить" + }, + "filters": { + "allDevices": "Все устройства" } }, "favorites": { diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index bad2eff6a..85f961306 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -487,6 +487,9 @@ "title": "無法載入照片", "description": "請檢查網路連線後再試一次", "retry": "重試" + }, + "filters": { + "allDevices": "所有裝置" } }, "favorites": { diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index dabffa2af..e5c91f25b 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -487,6 +487,9 @@ "title": "无法加载照片", "description": "请检查网络连接后重试", "retry": "重试" + }, + "filters": { + "allDevices": "所有设备" } }, "favorites": { diff --git a/src/views/Photos/PhotosView.test.tsx b/src/views/Photos/PhotosView.test.tsx index aa2fcb495..b59e6df15 100644 --- a/src/views/Photos/PhotosView.test.tsx +++ b/src/views/Photos/PhotosView.test.tsx @@ -87,6 +87,52 @@ describe('Photos view', () => { expect(photosClient.listDevices).toHaveBeenCalledTimes(2); }); + describe('Filtering by device', () => { + const openDeviceFilter = async () => { + fireEvent.click(await screen.findByRole('button', { name: /views.photos.filters.allDevices/ })); + }; + + test('When the user has a single device, then there is no device filter', async () => { + photosClient.listDevices.mockResolvedValue([device('iPhone', 'EXISTS')]); + + render(); + + await waitFor(() => expect(screen.queryByTestId('photos-grid-skeleton')).not.toBeInTheDocument()); + 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')]); + + render(); + await openDeviceFilter(); + + expect((await screen.findAllByRole('menuitem')).map((option) => option.textContent)).toStrictEqual([ + 'views.photos.filters.allDevices', + 'iPhone', + 'Pixel', + ]); + expect(screen.getByRole('menuitem', { name: /views.photos.filters.allDevices/ })).toHaveAttribute( + 'aria-current', + 'true', + ); + }); + + 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')]); + + render(); + await openDeviceFilter(); + fireEvent.click(await screen.findByRole('menuitem', { name: /Pixel/ })); + + fireEvent.click(await screen.findByRole('button', { name: /Pixel/ })); + expect(await screen.findByRole('menuitem', { name: /Pixel/ })).toHaveAttribute('aria-current', 'true'); + + fireEvent.click(await screen.findByRole('menuitem', { name: /views.photos.filters.allDevices/ })); + expect(await screen.findByRole('button', { name: /views.photos.filters.allDevices/ })).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); diff --git a/src/views/Photos/PhotosView.tsx b/src/views/Photos/PhotosView.tsx index 75588da04..4239de437 100644 --- a/src/views/Photos/PhotosView.tsx +++ b/src/views/Photos/PhotosView.tsx @@ -1,31 +1,39 @@ -import { Helmet } from 'react-helmet-async'; -import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { useState } from 'react'; +import PhotosDeviceFilter from './components/PhotosDeviceFilter'; import PhotosEmptyState from './components/PhotosEmptyState'; import PhotosErrorState from './components/PhotosErrorState'; import PhotosGridSkeleton from './components/PhotosGridSkeleton'; -import { PhotoDevicesState, usePhotoDevices } from './hooks/usePhotoDevices'; - -const PhotosContent = ({ state, onRetry }: { state: PhotoDevicesState; onRetry: () => void }) => { - if (state.status === 'loading') return ; - if (state.status === 'error') return ; - if (state.status === 'loaded' && state.devices.length === 0) return ; - return null; -}; +import PhotosLayout from './components/PhotosLayout'; +import { usePhotoDevices } from './hooks/usePhotoDevices'; const PhotosView = (): JSX.Element => { - const { translate } = useTranslationContext(); const { state, retry } = usePhotoDevices(); + const [selectedDeviceUuid, setSelectedDeviceUuid] = useState(null); + + const devices = state.status === 'loaded' ? state.devices : []; + const hasSeveralDevices = devices.length > 1; + + const content = { + loading: , + error: , + // TODO: show the photos of the chosen devices when there are devices, once the gallery is added + loaded: devices.length === 0 && , + }; return ( -
- - {translate('sideNav.photos')} - Internxt Drive - -
-

{translate('views.photos.head')}

-
- -
+ + ) + } + > + {content[state.status]} + ); }; diff --git a/src/views/Photos/components/PhotosDeviceFilter.tsx b/src/views/Photos/components/PhotosDeviceFilter.tsx new file mode 100644 index 000000000..f6c1cc383 --- /dev/null +++ b/src/views/Photos/components/PhotosDeviceFilter.tsx @@ -0,0 +1,29 @@ +import { DeviceMobileIcon } from '@phosphor-icons/react'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { PhotoDevice } from '../types'; +import PhotosFilterMenu from './PhotosFilterMenu'; + +interface PhotosDeviceFilterProps { + devices: PhotoDevice[]; + selectedDeviceUuid: string | null; + onSelect: (deviceUuid: string | null) => void; +} + +const PhotosDeviceFilter = ({ devices, selectedDeviceUuid, onSelect }: PhotosDeviceFilterProps): JSX.Element => { + const { translate } = useTranslationContext(); + + return ( + ({ + id: device.uuid, + label: device.name, + icon: , + }))} + selectedId={selectedDeviceUuid} + onSelect={onSelect} + /> + ); +}; + +export default PhotosDeviceFilter; diff --git a/src/views/Photos/components/PhotosFilterMenu.tsx b/src/views/Photos/components/PhotosFilterMenu.tsx new file mode 100644 index 000000000..605e23b77 --- /dev/null +++ b/src/views/Photos/components/PhotosFilterMenu.tsx @@ -0,0 +1,77 @@ +import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; +import { CheckIcon } from '@phosphor-icons/react'; +import { ReactNode } from 'react'; +import SearchFilterPill from 'views/Home/components/SearchFilterPill'; + +export interface PhotosFilterOption { + id: T; + label: string; + icon?: ReactNode; +} + +interface PhotosFilterMenuProps { + allLabel: string; + options: PhotosFilterOption[]; + selectedId: T | null; + onSelect: (id: T | null) => void; +} + +const PhotosFilterMenuRow = ({ + label, + icon, + isSelected, + onClick, +}: { + label: string; + icon?: ReactNode; + isSelected: boolean; + onClick: () => void; +}) => ( + + + +); + +/** Single choice filter shown as a pill, with an "all" option that clears it. */ +const PhotosFilterMenu = ({ + allLabel, + options, + selectedId, + onSelect, +}: PhotosFilterMenuProps): JSX.Element => { + const selectedOption = options.find((option) => option.id === selectedId); + + return ( + + + + onSelect(null)} /> +
+ {options.map((option) => ( + onSelect(option.id)} + /> + ))} + +
+ ); +}; + +export default PhotosFilterMenu; diff --git a/src/views/Photos/components/PhotosLayout.tsx b/src/views/Photos/components/PhotosLayout.tsx new file mode 100644 index 000000000..e11598f55 --- /dev/null +++ b/src/views/Photos/components/PhotosLayout.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; +import { Helmet } from 'react-helmet-async'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; + +interface PhotosLayoutProps { + filters?: ReactNode; + children?: ReactNode; +} + +const PhotosLayout = ({ filters, children }: PhotosLayoutProps): JSX.Element => { + const { translate } = useTranslationContext(); + + return ( +
+ + {translate('sideNav.photos')} - Internxt Drive + +
+

{translate('views.photos.head')}

+ {filters &&
{filters}
} +
+ {children} +
+ ); +}; + +export default PhotosLayout;