mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 15:02:32 -04:00
fix(web): migrate people management component to page, enabling tooltips (#26971)
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
import { page } from '$app/stores';
|
||||
import { scrollMemory } from '$lib/actions/scroll-memory';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ManagePeopleVisibility from './ManagePeopleVisibility.svelte';
|
||||
import PeopleCard from './PeopleCard.svelte';
|
||||
import PeopleInfiniteScroll from './PeopleInfiniteScroll.svelte';
|
||||
import SearchPeople from '$lib/components/faces-page/PeopleSearch.svelte';
|
||||
@@ -22,8 +21,6 @@
|
||||
import { mdiAccountOff, mdiEyeOutline } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
@@ -32,7 +29,6 @@
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let selectHidden = $state(false);
|
||||
let searchName = $state('');
|
||||
let newName = $state('');
|
||||
let currentPage = $state(1);
|
||||
@@ -331,7 +327,7 @@
|
||||
</div>
|
||||
<Button
|
||||
leadingIcon={mdiEyeOutline}
|
||||
onclick={() => (selectHidden = !selectHidden)}
|
||||
onclick={() => goto('/people/manage')}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary">{$t('show_and_hide_people')}</Button
|
||||
@@ -377,21 +373,3 @@
|
||||
</div>
|
||||
{/if}
|
||||
</UserPageLayout>
|
||||
|
||||
{#if selectHidden}
|
||||
<dialog
|
||||
transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }}
|
||||
class="fixed inset-0 size-full max-h-none max-w-none bg-light"
|
||||
aria-labelledby="manage-visibility-title"
|
||||
{@attach (dialog) => dialog.showModal()}
|
||||
>
|
||||
<ManagePeopleVisibility
|
||||
{people}
|
||||
totalPeopleCount={data.people.total}
|
||||
titleId="manage-visibility-title"
|
||||
onClose={() => (selectHidden = false)}
|
||||
onUpdate={(updatedPeople) => (people = updatedPeople.slice())}
|
||||
{loadNextPage}
|
||||
/>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PersonResponseDto } from '@immich/sdk';
|
||||
import { TooltipProvider } from '@immich/ui';
|
||||
import ManagePeopleVisibility from './ManagePeopleVisibility.svelte';
|
||||
|
||||
interface Props {
|
||||
people: PersonResponseDto[];
|
||||
totalPeopleCount: number;
|
||||
titleId?: string | undefined;
|
||||
onClose: () => void;
|
||||
onUpdate: (people: PersonResponseDto[]) => void;
|
||||
loadNextPage: () => void;
|
||||
}
|
||||
|
||||
let props: Props = $props();
|
||||
</script>
|
||||
|
||||
<TooltipProvider>
|
||||
<ManagePeopleVisibility {...props} />
|
||||
</TooltipProvider>
|
||||
+37
-35
@@ -1,28 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
||||
import PeopleInfiniteScroll from './PeopleInfiniteScroll.svelte';
|
||||
import PeopleInfiniteScroll from '../PeopleInfiniteScroll.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||
import { ToggleVisibility } from '$lib/constants';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updatePeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { getAllPeople, updatePeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, IconButton, toastManager } from '@immich/ui';
|
||||
import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
people: PersonResponseDto[];
|
||||
totalPeopleCount: number;
|
||||
titleId?: string | undefined;
|
||||
onClose: () => void;
|
||||
onUpdate: (people: PersonResponseDto[]) => void;
|
||||
loadNextPage: () => void;
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { people, totalPeopleCount, titleId = undefined, onClose, onUpdate, loadNextPage }: Props = $props();
|
||||
const { data }: Props = $props();
|
||||
|
||||
let people = $derived(data.people.people);
|
||||
const totalPeopleCount = $derived(data.people.total);
|
||||
let nextPage = $state(data.people.hasNextPage ? 2 : null);
|
||||
let toggleVisibility = $state(ToggleVisibility.SHOW_ALL);
|
||||
let showLoadingSpinner = $state(false);
|
||||
const overrides = new SvelteMap<string, boolean>();
|
||||
@@ -78,8 +78,7 @@
|
||||
}
|
||||
overrides.clear();
|
||||
|
||||
onUpdate(people);
|
||||
onClose();
|
||||
await goto('/people');
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_visibility', { values: { count: changed.length } }));
|
||||
} finally {
|
||||
@@ -95,6 +94,19 @@
|
||||
overrides.set(person.id, isHidden);
|
||||
};
|
||||
|
||||
const loadNextPage = async () => {
|
||||
if (!nextPage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage });
|
||||
people = people.concat(newPeople);
|
||||
nextPage = hasNextPage ? nextPage + 1 : null;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_load_people'));
|
||||
}
|
||||
};
|
||||
|
||||
let toggleButtonOptions: Record<ToggleVisibility, { icon: string; label: string }> = $derived({
|
||||
[ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') },
|
||||
[ToggleVisibility.HIDE_UNNANEMD]: { icon: mdiEyeSettings, label: $t('hide_unnamed_people') },
|
||||
@@ -103,28 +115,18 @@
|
||||
let toggleButton = $derived(toggleButtonOptions[getNextVisibility(toggleVisibility)]);
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
|
||||
|
||||
<div class="h-full overflow-y-auto">
|
||||
<div
|
||||
class="sticky top-0 z-1 flex h-16 w-full items-center justify-between border-b bg-white p-1 md:p-8 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('close')}
|
||||
icon={mdiClose}
|
||||
onclick={onClose}
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<p id={titleId} class="ms-2">{$t('show_and_hide_people')}</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-600">({totalPeopleCount.toLocaleString($locale)})</p>
|
||||
</div>
|
||||
</div>
|
||||
<UserPageLayout title={$t('show_and_hide_people')} description={`(${totalPeopleCount.toLocaleString($locale)})`}>
|
||||
{#snippet buttons()}
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center md:me-4">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('close')}
|
||||
icon={mdiClose}
|
||||
onclick={() => goto('/people')}
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
@@ -144,10 +146,10 @@
|
||||
</div>
|
||||
<Button loading={showLoadingSpinner} onclick={handleSaveVisibility} size="small">{$t('done')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-wrap gap-1 p-2 pb-8 md:px-8">
|
||||
<PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}>
|
||||
<PeopleInfiniteScroll {people} hasNextPage={nextPage !== null} {loadNextPage}>
|
||||
{#snippet children({ person })}
|
||||
{@const hidden = overrides.get(person.id) ?? person.isHidden}
|
||||
<button
|
||||
@@ -175,4 +177,4 @@
|
||||
{/snippet}
|
||||
</PeopleInfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getAllPeople } from '@immich/sdk';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
|
||||
const people = await getAllPeople({ withHidden: true });
|
||||
|
||||
return {
|
||||
people,
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
+33
-42
@@ -1,35 +1,50 @@
|
||||
import { render } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { vi } from 'vitest';
|
||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||
import { personFactory } from '@test-data/factories/person-factory';
|
||||
import ManagePeopleVisibilityWrapper from './ManagePeopleVisibility.test-wrapper.svelte';
|
||||
import ManagePeoplePage from './+page.svelte';
|
||||
import ManagePeoplePageTestWrapper from './ManagePeopleVisibility.test-wrapper.svelte';
|
||||
|
||||
describe('ManagePeopleVisibility component', () => {
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
|
||||
return {
|
||||
featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/components/layouts/UserPageLayout.svelte', async () => {
|
||||
return await import('@test-data/mocks/UserPageLayout.mock.svelte');
|
||||
});
|
||||
|
||||
const getData = (
|
||||
people: ReturnType<typeof personFactory.build>[],
|
||||
hasNextPage = false,
|
||||
): ComponentProps<typeof ManagePeoplePage>['data'] => ({
|
||||
error: undefined,
|
||||
meta: { title: 'Manage people visibility' },
|
||||
asset: undefined,
|
||||
people: {
|
||||
people,
|
||||
total: people.length,
|
||||
hidden: people.filter((person) => person.isHidden).length,
|
||||
hasNextPage,
|
||||
},
|
||||
});
|
||||
|
||||
describe('People manage page', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||
});
|
||||
|
||||
it('keeps toggled hidden state when loading more people', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUpdate = vi.fn();
|
||||
const loadNextPage = vi.fn();
|
||||
|
||||
const [personA, personB, personC] = [
|
||||
personFactory.build({ id: 'a', isHidden: false }),
|
||||
personFactory.build({ id: 'b', isHidden: false }),
|
||||
personFactory.build({ id: 'c', isHidden: true }),
|
||||
];
|
||||
|
||||
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
|
||||
props: {
|
||||
people: [personA, personB],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
},
|
||||
});
|
||||
const { container, rerender } = render(ManagePeoplePageTestWrapper, { data: getData([personA, personB], true) });
|
||||
const user = userEvent.setup();
|
||||
|
||||
let personButtons = container.querySelectorAll('button[aria-pressed]');
|
||||
@@ -38,13 +53,7 @@ describe('ManagePeopleVisibility component', () => {
|
||||
await user.click(personButtons[0]);
|
||||
expect(personButtons[0].getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
await rerender({
|
||||
people: [personA, personB, personC],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
});
|
||||
await rerender({ data: getData([personA, personB, personC], false) });
|
||||
|
||||
personButtons = container.querySelectorAll('button[aria-pressed]');
|
||||
expect(personButtons).toHaveLength(3);
|
||||
@@ -53,33 +62,15 @@ describe('ManagePeopleVisibility component', () => {
|
||||
});
|
||||
|
||||
it('shows newly loaded hidden people as hidden', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUpdate = vi.fn();
|
||||
const loadNextPage = vi.fn();
|
||||
|
||||
const [personA, personB, personC] = [
|
||||
personFactory.build({ id: 'a', isHidden: false }),
|
||||
personFactory.build({ id: 'b', isHidden: false }),
|
||||
personFactory.build({ id: 'c', isHidden: true }),
|
||||
];
|
||||
|
||||
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
|
||||
props: {
|
||||
people: [personA, personB],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
},
|
||||
});
|
||||
const { container, rerender } = render(ManagePeoplePageTestWrapper, { data: getData([personA, personB], true) });
|
||||
|
||||
await rerender({
|
||||
people: [personA, personB, personC],
|
||||
totalPeopleCount: 3,
|
||||
onClose,
|
||||
onUpdate,
|
||||
loadNextPage,
|
||||
});
|
||||
await rerender({ data: getData([personA, personB, personC], false) });
|
||||
|
||||
const personButtons = container.querySelectorAll('button[aria-pressed]');
|
||||
expect(personButtons).toHaveLength(3);
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { TooltipProvider } from '@immich/ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import ManagePeoplePage from './+page.svelte';
|
||||
|
||||
interface Props {
|
||||
data: ComponentProps<typeof ManagePeoplePage>['data'];
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
</script>
|
||||
|
||||
<TooltipProvider>
|
||||
<ManagePeoplePage {data} />
|
||||
</TooltipProvider>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
buttons?: import('svelte').Snippet;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { buttons, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section>
|
||||
{@render buttons?.()}
|
||||
{@render children?.()}
|
||||
</section>
|
||||
Reference in New Issue
Block a user