From 2f26a7edae1db2fa8be5b25e5e96742dcc56b6dc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 5 Aug 2023 22:43:26 -0400 Subject: [PATCH] feat(server/web): album description (#3558) * feat(server): add album description * chore: open api * fix: tests * show and edit description on the web * fix test * remove unused code * type event * format fix --------- Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 18 ++++++++ mobile/openapi/doc/AlbumResponseDto.md | 1 + mobile/openapi/doc/CreateAlbumDto.md | 1 + mobile/openapi/doc/UpdateAlbumDto.md | 1 + .../openapi/lib/model/album_response_dto.dart | 10 +++- .../openapi/lib/model/create_album_dto.dart | 19 +++++++- .../openapi/lib/model/update_album_dto.dart | 23 ++++++++-- .../openapi/test/album_response_dto_test.dart | 5 ++ .../openapi/test/create_album_dto_test.dart | 5 ++ .../openapi/test/update_album_dto_test.dart | 5 ++ server/immich-openapi-specs.json | 10 ++++ server/src/domain/album/album-response.dto.ts | 32 +++---------- server/src/domain/album/album.service.spec.ts | 1 + server/src/domain/album/album.service.ts | 2 + .../src/domain/album/dto/album-create.dto.ts | 6 ++- .../src/domain/album/dto/album-update.dto.ts | 9 ++-- .../immich/controllers/album.controller.ts | 8 ++-- server/src/infra/entities/album.entity.ts | 3 ++ .../1691209138541-AddAlbumDescription.ts | 13 ++++++ .../repositories/typesense.repository.ts | 2 +- .../infra/typesense-schemas/album.schema.ts | 3 +- server/test/e2e/album.e2e-spec.ts | 31 ++++++++++++- server/test/fixtures/album.stub.ts | 10 ++++ server/test/fixtures/shared-link.stub.ts | 2 + web/src/api/open-api/api.ts | 18 ++++++++ .../components/album-page/album-viewer.svelte | 46 +++++++++++++++++++ .../album-page/edit-description-modal.svelte | 43 +++++++++++++++++ web/src/test-data/factories/album-factory.ts | 1 + 28 files changed, 287 insertions(+), 41 deletions(-) create mode 100644 server/src/infra/migrations/1691209138541-AddAlbumDescription.ts create mode 100644 web/src/lib/components/album-page/edit-description-modal.svelte diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index ba920d6d13..4476442134 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -210,6 +210,12 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'createdAt': string; + /** + * + * @type {string} + * @memberof AlbumResponseDto + */ + 'description': string; /** * * @type {string} @@ -865,6 +871,12 @@ export interface CreateAlbumDto { * @memberof CreateAlbumDto */ 'assetIds'?: Array; + /** + * + * @type {string} + * @memberof CreateAlbumDto + */ + 'description'?: string; /** * * @type {Array} @@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto { * @memberof UpdateAlbumDto */ 'albumThumbnailAssetId'?: string; + /** + * + * @type {string} + * @memberof UpdateAlbumDto + */ + 'description'?: string; } /** * diff --git a/mobile/openapi/doc/AlbumResponseDto.md b/mobile/openapi/doc/AlbumResponseDto.md index 0f2f203819..9a806a9588 100644 --- a/mobile/openapi/doc/AlbumResponseDto.md +++ b/mobile/openapi/doc/AlbumResponseDto.md @@ -13,6 +13,7 @@ Name | Type | Description | Notes **assetCount** | **int** | | **assets** | [**List**](AssetResponseDto.md) | | [default to const []] **createdAt** | [**DateTime**](DateTime.md) | | +**description** | **String** | | **id** | **String** | | **lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional] **owner** | [**UserResponseDto**](UserResponseDto.md) | | diff --git a/mobile/openapi/doc/CreateAlbumDto.md b/mobile/openapi/doc/CreateAlbumDto.md index 557a2499ba..0a472725e4 100644 --- a/mobile/openapi/doc/CreateAlbumDto.md +++ b/mobile/openapi/doc/CreateAlbumDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **albumName** | **String** | | **assetIds** | **List** | | [optional] [default to const []] +**description** | **String** | | [optional] **sharedWithUserIds** | **List** | | [optional] [default to const []] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/UpdateAlbumDto.md b/mobile/openapi/doc/UpdateAlbumDto.md index 27ce6b365b..283b8bc29a 100644 --- a/mobile/openapi/doc/UpdateAlbumDto.md +++ b/mobile/openapi/doc/UpdateAlbumDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **albumName** | **String** | | [optional] **albumThumbnailAssetId** | **String** | | [optional] +**description** | **String** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index e8a07c26dc..3f9492c73d 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -18,6 +18,7 @@ class AlbumResponseDto { required this.assetCount, this.assets = const [], required this.createdAt, + required this.description, required this.id, this.lastModifiedAssetTimestamp, required this.owner, @@ -37,6 +38,8 @@ class AlbumResponseDto { DateTime createdAt; + String description; + String id; /// @@ -64,6 +67,7 @@ class AlbumResponseDto { other.assetCount == assetCount && other.assets == assets && other.createdAt == createdAt && + other.description == description && other.id == id && other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && other.owner == owner && @@ -80,6 +84,7 @@ class AlbumResponseDto { (assetCount.hashCode) + (assets.hashCode) + (createdAt.hashCode) + + (description.hashCode) + (id.hashCode) + (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + (owner.hashCode) + @@ -89,7 +94,7 @@ class AlbumResponseDto { (updatedAt.hashCode); @override - String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, updatedAt=$updatedAt]'; + String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -102,6 +107,7 @@ class AlbumResponseDto { json[r'assetCount'] = this.assetCount; json[r'assets'] = this.assets; json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + json[r'description'] = this.description; json[r'id'] = this.id; if (this.lastModifiedAssetTimestamp != null) { json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); @@ -129,6 +135,7 @@ class AlbumResponseDto { assetCount: mapValueOfType(json, r'assetCount')!, assets: AssetResponseDto.listFromJson(json[r'assets']), createdAt: mapDateTime(json, r'createdAt', r'')!, + description: mapValueOfType(json, r'description')!, id: mapValueOfType(json, r'id')!, lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''), owner: UserResponseDto.fromJson(json[r'owner'])!, @@ -188,6 +195,7 @@ class AlbumResponseDto { 'assetCount', 'assets', 'createdAt', + 'description', 'id', 'owner', 'ownerId', diff --git a/mobile/openapi/lib/model/create_album_dto.dart b/mobile/openapi/lib/model/create_album_dto.dart index d25a59c135..738df05a59 100644 --- a/mobile/openapi/lib/model/create_album_dto.dart +++ b/mobile/openapi/lib/model/create_album_dto.dart @@ -15,6 +15,7 @@ class CreateAlbumDto { CreateAlbumDto({ required this.albumName, this.assetIds = const [], + this.description, this.sharedWithUserIds = const [], }); @@ -22,12 +23,21 @@ class CreateAlbumDto { List assetIds; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + List sharedWithUserIds; @override bool operator ==(Object other) => identical(this, other) || other is CreateAlbumDto && other.albumName == albumName && other.assetIds == assetIds && + other.description == description && other.sharedWithUserIds == sharedWithUserIds; @override @@ -35,15 +45,21 @@ class CreateAlbumDto { // ignore: unnecessary_parenthesis (albumName.hashCode) + (assetIds.hashCode) + + (description == null ? 0 : description!.hashCode) + (sharedWithUserIds.hashCode); @override - String toString() => 'CreateAlbumDto[albumName=$albumName, assetIds=$assetIds, sharedWithUserIds=$sharedWithUserIds]'; + String toString() => 'CreateAlbumDto[albumName=$albumName, assetIds=$assetIds, description=$description, sharedWithUserIds=$sharedWithUserIds]'; Map toJson() { final json = {}; json[r'albumName'] = this.albumName; json[r'assetIds'] = this.assetIds; + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } json[r'sharedWithUserIds'] = this.sharedWithUserIds; return json; } @@ -60,6 +76,7 @@ class CreateAlbumDto { assetIds: json[r'assetIds'] is Iterable ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) : const [], + description: mapValueOfType(json, r'description'), sharedWithUserIds: json[r'sharedWithUserIds'] is Iterable ? (json[r'sharedWithUserIds'] as Iterable).cast().toList(growable: false) : const [], diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index 8fdd75b2d5..6c0bf3eca6 100644 --- a/mobile/openapi/lib/model/update_album_dto.dart +++ b/mobile/openapi/lib/model/update_album_dto.dart @@ -15,6 +15,7 @@ class UpdateAlbumDto { UpdateAlbumDto({ this.albumName, this.albumThumbnailAssetId, + this.description, }); /// @@ -33,19 +34,29 @@ class UpdateAlbumDto { /// String? albumThumbnailAssetId; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + @override bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto && other.albumName == albumName && - other.albumThumbnailAssetId == albumThumbnailAssetId; + other.albumThumbnailAssetId == albumThumbnailAssetId && + other.description == description; @override int get hashCode => // ignore: unnecessary_parenthesis (albumName == null ? 0 : albumName!.hashCode) + - (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode); + (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) + + (description == null ? 0 : description!.hashCode); @override - String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId]'; + String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description]'; Map toJson() { final json = {}; @@ -59,6 +70,11 @@ class UpdateAlbumDto { } else { // json[r'albumThumbnailAssetId'] = null; } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } return json; } @@ -72,6 +88,7 @@ class UpdateAlbumDto { return UpdateAlbumDto( albumName: mapValueOfType(json, r'albumName'), albumThumbnailAssetId: mapValueOfType(json, r'albumThumbnailAssetId'), + description: mapValueOfType(json, r'description'), ); } return null; diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index da80f49920..2c01a043a3 100644 --- a/mobile/openapi/test/album_response_dto_test.dart +++ b/mobile/openapi/test/album_response_dto_test.dart @@ -41,6 +41,11 @@ void main() { // TODO }); + // String description + test('to test the property `description`', () async { + // TODO + }); + // String id test('to test the property `id`', () async { // TODO diff --git a/mobile/openapi/test/create_album_dto_test.dart b/mobile/openapi/test/create_album_dto_test.dart index c727c20062..d23e66cf7e 100644 --- a/mobile/openapi/test/create_album_dto_test.dart +++ b/mobile/openapi/test/create_album_dto_test.dart @@ -26,6 +26,11 @@ void main() { // TODO }); + // String description + test('to test the property `description`', () async { + // TODO + }); + // List sharedWithUserIds (default value: const []) test('to test the property `sharedWithUserIds`', () async { // TODO diff --git a/mobile/openapi/test/update_album_dto_test.dart b/mobile/openapi/test/update_album_dto_test.dart index 8ac32dc594..7b8472ad3e 100644 --- a/mobile/openapi/test/update_album_dto_test.dart +++ b/mobile/openapi/test/update_album_dto_test.dart @@ -26,6 +26,11 @@ void main() { // TODO }); + // String description + test('to test the property `description`', () async { + // TODO + }); + }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index b94c554dde..43dc2ea406 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4754,6 +4754,9 @@ "format": "date-time", "type": "string" }, + "description": { + "type": "string" + }, "id": { "type": "string" }, @@ -4786,6 +4789,7 @@ "id", "ownerId", "albumName", + "description", "createdAt", "updatedAt", "albumThumbnailAssetId", @@ -5264,6 +5268,9 @@ }, "type": "array" }, + "description": { + "type": "string" + }, "sharedWithUserIds": { "items": { "format": "uuid", @@ -6903,6 +6910,9 @@ "albumThumbnailAssetId": { "format": "uuid", "type": "string" + }, + "description": { + "type": "string" } }, "type": "object" diff --git a/server/src/domain/album/album-response.dto.ts b/server/src/domain/album/album-response.dto.ts index e50c8aa161..5fde07c57a 100644 --- a/server/src/domain/album/album-response.dto.ts +++ b/server/src/domain/album/album-response.dto.ts @@ -7,6 +7,7 @@ export class AlbumResponseDto { id!: string; ownerId!: string; albumName!: string; + description!: string; createdAt!: Date; updatedAt!: Date; albumThumbnailAssetId!: string | null; @@ -19,7 +20,7 @@ export class AlbumResponseDto { lastModifiedAssetTimestamp?: Date; } -export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { +const _map = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => { const sharedUsers: UserResponseDto[] = []; entity.sharedUsers?.forEach((user) => { @@ -29,6 +30,7 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { return { albumName: entity.albumName, + description: entity.description, albumThumbnailAssetId: entity.albumThumbnailAssetId, createdAt: entity.createdAt, updatedAt: entity.updatedAt, @@ -37,33 +39,13 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { owner: mapUser(entity.owner), sharedUsers, shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, - assets: entity.assets?.map((asset) => mapAsset(asset)) || [], + assets: withAssets ? entity.assets?.map((asset) => mapAsset(asset)) || [] : [], assetCount: entity.assets?.length || 0, }; -} +}; -export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto { - const sharedUsers: UserResponseDto[] = []; - - entity.sharedUsers?.forEach((user) => { - const userDto = mapUser(user); - sharedUsers.push(userDto); - }); - - return { - albumName: entity.albumName, - albumThumbnailAssetId: entity.albumThumbnailAssetId, - createdAt: entity.createdAt, - updatedAt: entity.updatedAt, - id: entity.id, - ownerId: entity.ownerId, - owner: mapUser(entity.owner), - sharedUsers, - shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, - assets: [], - assetCount: entity.assets?.length || 0, - }; -} +export const mapAlbum = (entity: AlbumEntity) => _map(entity, true); +export const mapAlbumExcludeAssetInfo = (entity: AlbumEntity) => _map(entity, false); export class AlbumCountResponseDto { @ApiProperty({ type: 'integer' }) diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index b6c6204215..50eed510d6 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -156,6 +156,7 @@ describe(AlbumService.name, () => { await expect(sut.create(authStub.admin, { albumName: 'Empty album' })).resolves.toEqual({ albumName: 'Empty album', + description: '', albumThumbnailAssetId: null, assetCount: 0, assets: [], diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 246a53047a..f98cdfb1f1 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -94,6 +94,7 @@ export class AlbumService { const album = await this.albumRepository.create({ ownerId: authUser.id, albumName: dto.albumName, + description: dto.description, sharedUsers: dto.sharedWithUserIds?.map((value) => ({ id: value } as UserEntity)) ?? [], assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)), albumThumbnailAssetId: dto.assetIds?.[0] || null, @@ -118,6 +119,7 @@ export class AlbumService { const updatedAlbum = await this.albumRepository.update({ id: album.id, albumName: dto.albumName, + description: dto.description, albumThumbnailAssetId: dto.albumThumbnailAssetId, }); diff --git a/server/src/domain/album/dto/album-create.dto.ts b/server/src/domain/album/dto/album-create.dto.ts index 3d99683319..586cde2c64 100644 --- a/server/src/domain/album/dto/album-create.dto.ts +++ b/server/src/domain/album/dto/album-create.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { ValidateUUID } from '../../domain.util'; export class CreateAlbumDto { @@ -8,6 +8,10 @@ export class CreateAlbumDto { @ApiProperty() albumName!: string; + @IsString() + @IsOptional() + description?: string; + @ValidateUUID({ optional: true, each: true }) sharedWithUserIds?: string[]; diff --git a/server/src/domain/album/dto/album-update.dto.ts b/server/src/domain/album/dto/album-update.dto.ts index 9bbe16e3ba..8270777e2b 100644 --- a/server/src/domain/album/dto/album-update.dto.ts +++ b/server/src/domain/album/dto/album-update.dto.ts @@ -1,12 +1,15 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional } from 'class-validator'; +import { IsOptional, IsString } from 'class-validator'; import { ValidateUUID } from '../../domain.util'; export class UpdateAlbumDto { @IsOptional() - @ApiProperty() + @IsString() albumName?: string; + @IsOptional() + @IsString() + description?: string; + @ValidateUUID({ optional: true }) albumThumbnailAssetId?: string; } diff --git a/server/src/immich/controllers/album.controller.ts b/server/src/immich/controllers/album.controller.ts index 889a025a4c..6f6ad2132e 100644 --- a/server/src/immich/controllers/album.controller.ts +++ b/server/src/immich/controllers/album.controller.ts @@ -5,8 +5,8 @@ import { AuthUserDto, BulkIdResponseDto, BulkIdsDto, - CreateAlbumDto, - UpdateAlbumDto, + CreateAlbumDto as CreateDto, + UpdateAlbumDto as UpdateDto, } from '@app/domain'; import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; @@ -34,7 +34,7 @@ export class AlbumController { } @Post() - createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumDto) { + createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto) { return this.service.create(authUser, dto); } @@ -45,7 +45,7 @@ export class AlbumController { } @Patch(':id') - updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) { + updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto) { return this.service.update(authUser, id, dto); } diff --git a/server/src/infra/entities/album.entity.ts b/server/src/infra/entities/album.entity.ts index 5cf2ebd31e..06cd7fa690 100644 --- a/server/src/infra/entities/album.entity.ts +++ b/server/src/infra/entities/album.entity.ts @@ -27,6 +27,9 @@ export class AlbumEntity { @Column({ default: 'Untitled Album' }) albumName!: string; + @Column({ type: 'text', default: '' }) + description!: string; + @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; diff --git a/server/src/infra/migrations/1691209138541-AddAlbumDescription.ts b/server/src/infra/migrations/1691209138541-AddAlbumDescription.ts new file mode 100644 index 0000000000..f4167598af --- /dev/null +++ b/server/src/infra/migrations/1691209138541-AddAlbumDescription.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAlbumDescription1691209138541 implements MigrationInterface { + name = 'AddAlbumDescription1691209138541'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums" ADD "description" text NOT NULL DEFAULT ''`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "description"`); + } +} diff --git a/server/src/infra/repositories/typesense.repository.ts b/server/src/infra/repositories/typesense.repository.ts index 2ca81f19c4..bb78a0995c 100644 --- a/server/src/infra/repositories/typesense.repository.ts +++ b/server/src/infra/repositories/typesense.repository.ts @@ -234,7 +234,7 @@ export class TypesenseRepository implements ISearchRepository { .documents() .search({ q: query, - query_by: 'albumName', + query_by: ['albumName', 'description'].join(','), filter_by: this.getAlbumFilters(filters), }); diff --git a/server/src/infra/typesense-schemas/album.schema.ts b/server/src/infra/typesense-schemas/album.schema.ts index bc01aca0c7..7a7506a863 100644 --- a/server/src/infra/typesense-schemas/album.schema.ts +++ b/server/src/infra/typesense-schemas/album.schema.ts @@ -1,11 +1,12 @@ import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; -export const albumSchemaVersion = 1; +export const albumSchemaVersion = 2; export const albumSchema: CollectionCreateSchema = { name: `albums-v${albumSchemaVersion}`, fields: [ { name: 'ownerId', type: 'string', facet: false }, { name: 'albumName', type: 'string', facet: false, sort: true }, + { name: 'description', type: 'string', facet: false }, { name: 'createdAt', type: 'string', facet: false, sort: true }, { name: 'updatedAt', type: 'string', facet: false, sort: true }, ], diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts index 8b10823292..ffde674f13 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/test/e2e/album.e2e-spec.ts @@ -4,7 +4,7 @@ import { SharedLinkType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; -import { errorStub } from '../fixtures'; +import { errorStub, uuidStub } from '../fixtures'; import { api, db } from '../test-utils'; const user1SharedUser = 'user1SharedUser'; @@ -193,6 +193,7 @@ describe(`${AlbumController.name} (e2e)`, () => { updatedAt: expect.any(String), ownerId: user1.userId, albumName: 'New album', + description: '', albumThumbnailAssetId: null, shared: false, sharedUsers: [], @@ -202,4 +203,32 @@ describe(`${AlbumController.name} (e2e)`, () => { }); }); }); + + describe('PATCH /album/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server) + .patch(`/album/${uuidStub.notFound}`) + .send({ albumName: 'New album name' }); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should update an album', async () => { + const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' }); + const { status, body } = await request(server) + .patch(`/album/${album.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ + albumName: 'New album name', + description: 'An album description', + }); + expect(status).toBe(200); + expect(body).toEqual({ + ...album, + updatedAt: expect.any(String), + albumName: 'New album name', + description: 'An album description', + }); + }); + }); }); diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index e86d949768..8da88aa2d6 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -7,6 +7,7 @@ export const albumStub = { empty: Object.freeze({ id: 'album-1', albumName: 'Empty album', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [], @@ -20,6 +21,7 @@ export const albumStub = { sharedWithUser: Object.freeze({ id: 'album-2', albumName: 'Empty album shared with user', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [], @@ -33,6 +35,7 @@ export const albumStub = { sharedWithMultiple: Object.freeze({ id: 'album-3', albumName: 'Empty album shared with users', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [], @@ -46,6 +49,7 @@ export const albumStub = { sharedWithAdmin: Object.freeze({ id: 'album-3', albumName: 'Empty album shared with admin', + description: '', ownerId: authStub.user1.id, owner: userStub.user1, assets: [], @@ -59,6 +63,7 @@ export const albumStub = { oneAsset: Object.freeze({ id: 'album-4', albumName: 'Album with one asset', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [assetStub.image], @@ -72,6 +77,7 @@ export const albumStub = { twoAssets: Object.freeze({ id: 'album-4a', albumName: 'Album with two assets', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [assetStub.image, assetStub.withLocation], @@ -85,6 +91,7 @@ export const albumStub = { emptyWithInvalidThumbnail: Object.freeze({ id: 'album-5', albumName: 'Empty album with invalid thumbnail', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [], @@ -98,6 +105,7 @@ export const albumStub = { emptyWithValidThumbnail: Object.freeze({ id: 'album-5', albumName: 'Empty album with invalid thumbnail', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [], @@ -111,6 +119,7 @@ export const albumStub = { oneAssetInvalidThumbnail: Object.freeze({ id: 'album-6', albumName: 'Album with one asset and invalid thumbnail', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [assetStub.image], @@ -124,6 +133,7 @@ export const albumStub = { oneAssetValidThumbnail: Object.freeze({ id: 'album-6', albumName: 'Album with one asset and invalid thumbnail', + description: '', ownerId: authStub.admin.id, owner: userStub.admin, assets: [assetStub.image], diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 777e136cea..004df894da 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -68,6 +68,7 @@ const assetResponse: AssetResponseDto = { const albumResponse: AlbumResponseDto = { albumName: 'Test Album', + description: '', albumThumbnailAssetId: null, createdAt: today, updatedAt: today, @@ -146,6 +147,7 @@ export const sharedLinkStub = { ownerId: authStub.admin.id, owner: userStub.admin, albumName: 'Test Album', + description: '', createdAt: today, updatedAt: today, albumThumbnailAsset: null, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 7d83aa12da..8af3bc4ff7 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -210,6 +210,12 @@ export interface AlbumResponseDto { * @memberof AlbumResponseDto */ 'createdAt': string; + /** + * + * @type {string} + * @memberof AlbumResponseDto + */ + 'description': string; /** * * @type {string} @@ -865,6 +871,12 @@ export interface CreateAlbumDto { * @memberof CreateAlbumDto */ 'assetIds'?: Array; + /** + * + * @type {string} + * @memberof CreateAlbumDto + */ + 'description'?: string; /** * * @type {Array} @@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto { * @memberof UpdateAlbumDto */ 'albumThumbnailAssetId'?: string; + /** + * + * @type {string} + * @memberof UpdateAlbumDto + */ + 'description'?: string; } /** * diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 8661b836d8..c81f3462d0 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -44,6 +44,7 @@ import { handleError } from '../../utils/handle-error'; import { downloadArchive } from '../../utils/asset-utils'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import EditDescriptionModal from './edit-description-modal.svelte'; export let album: AlbumResponseDto; export let sharedLink: SharedLinkResponseDto | undefined = undefined; @@ -73,6 +74,7 @@ let isShowAlbumOptions = false; let isShowThumbnailSelection = false; let isShowDeleteConfirmation = false; + let isEditingDescription = false; let backUrl = '/albums'; let currentAlbumName = ''; @@ -298,6 +300,27 @@ const handleSelectAll = () => { multiSelectAsset = new Set(album.assets); }; + + const descriptionUpdatedHandler = (description: string) => { + try { + api.albumApi.updateAlbumInfo({ + id: album.id, + updateAlbumDto: { + description, + }, + }); + + album.description = description; + } catch (e) { + console.error('Error [descriptionUpdatedHandler] ', e); + notificationController.show({ + type: NotificationType.Error, + message: 'Error setting album description, check console for more details', + }); + } + + isEditingDescription = false; + };
@@ -405,6 +428,7 @@ {/if}
+ { if (e.key == 'Enter') { @@ -421,8 +445,10 @@ bind:value={album.albumName} disabled={!isOwned} bind:this={titleInput} + title="Edit Title" /> + {#if album.assetCount > 0}

{getDateRange()}

@@ -448,6 +474,17 @@ {/if} + + + {#if album.assetCount > 0 && !isShowAssetSelection} {:else} @@ -490,6 +527,7 @@ {#if isShowShareLinkModal} (isShowShareLinkModal = false)} shareType={SharedLinkType.Album} {album} /> {/if} + {#if isShowShareInfoModal} (isShowShareInfoModal = false)} {album} on:user-deleted={sharedUserDeletedHandler} /> {/if} @@ -515,3 +553,11 @@ {/if} + +{#if isEditingDescription} + (isEditingDescription = false)} + on:updated={({ detail: description }) => descriptionUpdatedHandler(description)} + /> +{/if} diff --git a/web/src/lib/components/album-page/edit-description-modal.svelte b/web/src/lib/components/album-page/edit-description-modal.svelte new file mode 100644 index 0000000000..90f339f8be --- /dev/null +++ b/web/src/lib/components/album-page/edit-description-modal.svelte @@ -0,0 +1,43 @@ + + + dispatch('close')}> +
+
+

Edit description

+
+ +
+
+ + + +
+ +
+ + +
+
+
+
diff --git a/web/src/test-data/factories/album-factory.ts b/web/src/test-data/factories/album-factory.ts index 1ea7284448..dfa5e530ea 100644 --- a/web/src/test-data/factories/album-factory.ts +++ b/web/src/test-data/factories/album-factory.ts @@ -5,6 +5,7 @@ import { userFactory } from './user-factory'; export const albumFactory = Sync.makeFactory({ albumName: Sync.each(() => faker.commerce.product()), + description: '', albumThumbnailAssetId: null, assetCount: Sync.each((i) => i % 5), assets: [],