mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: people infinite scroll (#11326)
* feat: people infinite scroll * add infinite scroll to show & hide modal * update unit tests * show total people count instead of currently loaded * update personsearchdto
This commit is contained in:
parent
152421e288
commit
8e6bc13540
17 changed files with 276 additions and 67 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsNotEmpty, IsString, MaxDate, ValidateNested } from 'class-validator';
|
||||
import { IsArray, IsInt, IsNotEmpty, IsString, Max, MaxDate, Min, ValidateNested } from 'class-validator';
|
||||
import { PropertyLifecycle } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
|
|
@ -63,6 +63,21 @@ export class MergePersonDto {
|
|||
export class PersonSearchDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
withHidden?: boolean;
|
||||
|
||||
/** Page number for pagination */
|
||||
@ApiPropertyOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
page: number = 1;
|
||||
|
||||
/** Number of items per page */
|
||||
@ApiPropertyOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
@Type(() => Number)
|
||||
size: number = 500;
|
||||
}
|
||||
|
||||
export class PersonResponseDto {
|
||||
|
|
@ -132,6 +147,10 @@ export class PeopleResponseDto {
|
|||
@ApiProperty({ type: 'integer' })
|
||||
hidden!: number;
|
||||
people!: PersonResponseDto[];
|
||||
|
||||
// TODO: make required after a few versions
|
||||
@PropertyLifecycle({ addedAt: 'v1.110.0' })
|
||||
hasNextPage?: boolean;
|
||||
}
|
||||
|
||||
export function mapPerson(person: PersonEntity): PersonResponseDto {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export interface PeopleStatistics {
|
|||
|
||||
export interface IPersonRepository {
|
||||
getAll(pagination: PaginationOptions, options?: FindManyOptions<PersonEntity>): Paginated<PersonEntity>;
|
||||
getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
|
||||
getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated<PersonEntity>;
|
||||
getAllWithoutFaces(): Promise<PersonEntity[]>;
|
||||
getById(personId: string): Promise<PersonEntity | null>;
|
||||
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
|
||||
|
|
|
|||
|
|
@ -37,9 +37,12 @@ ORDER BY
|
|||
"person"."isHidden" ASC,
|
||||
NULLIF("person"."name", '') IS NULL ASC,
|
||||
COUNT("face"."assetId") DESC,
|
||||
NULLIF("person"."name", '') ASC NULLS LAST
|
||||
NULLIF("person"."name", '') ASC NULLS LAST,
|
||||
"person"."createdAt" ASC
|
||||
LIMIT
|
||||
500
|
||||
11
|
||||
OFFSET
|
||||
10
|
||||
|
||||
-- PersonRepository.getAllWithoutFaces
|
||||
SELECT
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
UpdateFacesData,
|
||||
} from 'src/interfaces/person.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Paginated, PaginationOptions, paginate } from 'src/utils/pagination';
|
||||
import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
|
||||
import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
|
|
@ -64,8 +64,8 @@ export class PersonRepository implements IPersonRepository {
|
|||
return paginate(this.personRepository, pagination, options);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getAllForUser(userId: string, options?: PersonSearchOptions): Promise<PersonEntity[]> {
|
||||
@GenerateSql({ params: [{ take: 10, skip: 10 }, DummyValue.UUID] })
|
||||
getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions): Paginated<PersonEntity> {
|
||||
const queryBuilder = this.personRepository
|
||||
.createQueryBuilder('person')
|
||||
.leftJoin('person.faces', 'face')
|
||||
|
|
@ -76,15 +76,18 @@ export class PersonRepository implements IPersonRepository {
|
|||
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
|
||||
.addOrderBy('COUNT(face.assetId)', 'DESC')
|
||||
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
|
||||
.addOrderBy('person.createdAt')
|
||||
.andWhere("person.thumbnailPath != ''")
|
||||
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
|
||||
.groupBy('person.id')
|
||||
.limit(500);
|
||||
.groupBy('person.id');
|
||||
if (!options?.withHidden) {
|
||||
queryBuilder.andWhere('person.isHidden = false');
|
||||
}
|
||||
|
||||
return queryBuilder.getMany();
|
||||
return paginatedBuilder(queryBuilder, {
|
||||
mode: PaginationMode.LIMIT_OFFSET,
|
||||
...pagination,
|
||||
});
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
|
|
|
|||
|
|
@ -115,9 +115,13 @@ describe(PersonService.name, () => {
|
|||
|
||||
describe('getAll', () => {
|
||||
it('should get all hidden and visible people with thumbnails', async () => {
|
||||
personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]);
|
||||
personMock.getAllForUser.mockResolvedValue({
|
||||
items: [personStub.withName, personStub.hidden],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
|
||||
await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({
|
||||
await expect(sut.getAll(authStub.admin, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({
|
||||
hasNextPage: false,
|
||||
total: 2,
|
||||
hidden: 1,
|
||||
people: [
|
||||
|
|
@ -132,7 +136,7 @@ describe(PersonService.name, () => {
|
|||
},
|
||||
],
|
||||
});
|
||||
expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, {
|
||||
expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
|
||||
minimumFaceCount: 3,
|
||||
withHidden: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -91,15 +91,22 @@ export class PersonService {
|
|||
}
|
||||
|
||||
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
|
||||
const { withHidden = false, page, size } = dto;
|
||||
const pagination = {
|
||||
take: size,
|
||||
skip: (page - 1) * size,
|
||||
};
|
||||
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: false });
|
||||
const people = await this.repository.getAllForUser(auth.user.id, {
|
||||
const { items, hasNextPage } = await this.repository.getAllForUser(pagination, auth.user.id, {
|
||||
minimumFaceCount: machineLearning.facialRecognition.minFaces,
|
||||
withHidden: dto.withHidden || false,
|
||||
withHidden,
|
||||
});
|
||||
const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id);
|
||||
|
||||
return {
|
||||
people: people.map((person) => mapPerson(person)),
|
||||
people: items.map((person) => mapPerson(person)),
|
||||
hasNextPage,
|
||||
total,
|
||||
hidden,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue