Skip to content
Merged
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
3 changes: 3 additions & 0 deletions src/app/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,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": {
Expand Down
3 changes: 3 additions & 0 deletions src/app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@
"title": "We couldn't load your photos",
"description": "Check your connection and try again",
"retry": "Try again"
},
"filters": {
"allDevices": "All devices"
}
},
"favorites": {
Expand Down
3 changes: 3 additions & 0 deletions src/app/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,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": {
Expand Down
3 changes: 3 additions & 0 deletions src/app/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,9 @@
"title": "Impossible de charger vos photos",
"description": "Vérifiez votre connexion et réessayez",
"retry": "Réessayer"
},
"filters": {
"allDevices": "Tous les appareils"
}
},
"favorites": {
Expand Down
3 changes: 3 additions & 0 deletions src/app/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@
"title": "Impossibile caricare le tue foto",
"description": "Controlla la connessione e riprova",
"retry": "Riprova"
},
"filters": {
"allDevices": "Tutti i dispositivi"
}
},
"favorites": {
Expand Down
3 changes: 3 additions & 0 deletions src/app/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,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": {
Expand Down
3 changes: 3 additions & 0 deletions src/app/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,9 @@
"title": "Не удалось загрузить фото",
"description": "Проверьте подключение и попробуйте снова",
"retry": "Повторить"
},
"filters": {
"allDevices": "Все устройства"
}
},
"favorites": {
Expand Down
3 changes: 3 additions & 0 deletions src/app/i18n/locales/tw.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@
"title": "無法載入照片",
"description": "請檢查網路連線後再試一次",
"retry": "重試"
},
"filters": {
"allDevices": "所有裝置"
}
},
"favorites": {
Expand Down
3 changes: 3 additions & 0 deletions src/app/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@
"title": "无法加载照片",
"description": "请检查网络连接后重试",
"retry": "重试"
},
"filters": {
"allDevices": "所有设备"
}
},
"favorites": {
Expand Down
46 changes: 46 additions & 0 deletions src/views/Photos/PhotosView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PhotosView />);

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(<PhotosView />);
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(<PhotosView />);
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);
Expand Down
48 changes: 28 additions & 20 deletions src/views/Photos/PhotosView.tsx
Original file line number Diff line number Diff line change
@@ -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 <PhotosGridSkeleton />;
if (state.status === 'error') return <PhotosErrorState onRetry={onRetry} />;
if (state.status === 'loaded' && state.devices.length === 0) return <PhotosEmptyState />;
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<string | null>(null);

const devices = state.status === 'loaded' ? state.devices : [];
const hasSeveralDevices = devices.length > 1;

const content = {
loading: <PhotosGridSkeleton />,
error: <PhotosErrorState onRetry={retry} />,
// TODO: show the photos of the chosen devices when there are devices, once the gallery is added

Check warning on line 19 in src/views/Photos/PhotosView.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-web&issues=AaGnkVaNHWFvLt2OoNdo&open=AaGnkVaNHWFvLt2OoNdo&pullRequest=2282
loaded: devices.length === 0 && <PhotosEmptyState />,
};

return (
<div className="flex w-full shrink-0 grow flex-col">
<Helmet>
<title>{translate('sideNav.photos')} - Internxt Drive</title>
</Helmet>
<header className="flex h-14 shrink-0 items-center px-5">
<h1 className="text-lg">{translate('views.photos.head')}</h1>
</header>
<PhotosContent state={state} onRetry={retry} />
</div>
<PhotosLayout
filters={
hasSeveralDevices && (
<PhotosDeviceFilter
devices={devices}
selectedDeviceUuid={selectedDeviceUuid}
onSelect={setSelectedDeviceUuid}
/>
)
}
>
{content[state.status]}
</PhotosLayout>
);
};

Expand Down
29 changes: 29 additions & 0 deletions src/views/Photos/components/PhotosDeviceFilter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PhotosFilterMenu
allLabel={translate('views.photos.filters.allDevices')}
options={devices.map((device) => ({
id: device.uuid,
label: device.name,
icon: <DeviceMobileIcon size={20} className="shrink-0 text-gray-60" />,
}))}
selectedId={selectedDeviceUuid}
onSelect={onSelect}
/>
);
};

export default PhotosDeviceFilter;
77 changes: 77 additions & 0 deletions src/views/Photos/components/PhotosFilterMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends string> {
id: T;
label: string;
icon?: ReactNode;
}

interface PhotosFilterMenuProps<T extends string> {
allLabel: string;
options: PhotosFilterOption<T>[];
selectedId: T | null;
onSelect: (id: T | null) => void;
}

const PhotosFilterMenuRow = ({
label,
icon,
isSelected,
onClick,
}: {
label: string;
icon?: ReactNode;
isSelected: boolean;
onClick: () => void;
}) => (
<MenuItem>
<button
type="button"
aria-current={isSelected}
onClick={onClick}
className="flex w-full items-center gap-2 px-4 py-2 text-left text-gray-100 data-[focus]:bg-gray-5 dark:data-[focus]:bg-gray-10"
>
{icon}
<span className="grow truncate">{label}</span>
<CheckIcon size={16} className={isSelected ? 'shrink-0 text-primary' : 'invisible shrink-0'} />
</button>
</MenuItem>
);

/** Single choice filter shown as a pill, with an "all" option that clears it. */
const PhotosFilterMenu = <T extends string>({
allLabel,
options,
selectedId,
onSelect,
}: PhotosFilterMenuProps<T>): JSX.Element => {
const selectedOption = options.find((option) => option.id === selectedId);

return (
<Menu as="div" className="relative min-w-0">
<MenuButton as={SearchFilterPill} label={selectedOption?.label ?? allLabel} active={!!selectedOption} />
Comment thread
xabg2 marked this conversation as resolved.
Resolved
<MenuItems
transition
modal={false}
className="absolute right-0 z-20 mt-1 flex max-h-80 min-w-[240px] origin-top-right flex-col overflow-y-auto rounded-lg border border-gray-10 bg-surface py-1.5 shadow-subtle-hard outline-none transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 dark:bg-gray-5"
>
<PhotosFilterMenuRow label={allLabel} isSelected={!selectedOption} onClick={() => onSelect(null)} />
<div className="mx-4 my-1 border-t border-gray-10" />
{options.map((option) => (
<PhotosFilterMenuRow
key={option.id}
label={option.label}
icon={option.icon}
isSelected={option.id === selectedOption?.id}
onClick={() => onSelect(option.id)}
/>
))}
</MenuItems>
</Menu>
);
};

export default PhotosFilterMenu;
27 changes: 27 additions & 0 deletions src/views/Photos/components/PhotosLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-0 w-full shrink-0 grow flex-col">
<Helmet>
<title>{translate('sideNav.photos')} - Internxt Drive</title>
</Helmet>
<header className="flex h-14 shrink-0 items-center justify-between gap-4 px-5">
<h1 className="text-lg">{translate('views.photos.head')}</h1>
{filters && <div className="flex min-w-0 items-center gap-2">{filters}</div>}
</header>
{children}
</div>
);
};

export default PhotosLayout;
Loading