1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +00:00

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 <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2023-08-05 22:43:26 -04:00 committed by GitHub
parent deaf81e2a4
commit 2f26a7edae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 222 additions and 36 deletions

View file

@ -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<string>;
/**
*
* @type {string}
* @memberof CreateAlbumDto
*/
'description'?: string;
/**
*
* @type {Array<string>}
@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto {
* @memberof UpdateAlbumDto
*/
'albumThumbnailAssetId'?: string;
/**
*
* @type {string}
* @memberof UpdateAlbumDto
*/
'description'?: string;
}
/**
*

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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"

View file

@ -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' })

View file

@ -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: [],

View file

@ -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,
});

View file

@ -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[];

View file

@ -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;
}

View file

@ -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);
}

View file

@ -27,6 +27,9 @@ export class AlbumEntity {
@Column({ default: 'Untitled Album' })
albumName!: string;
@Column({ type: 'text', default: '' })
description!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;

View file

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAlbumDescription1691209138541 implements MigrationInterface {
name = 'AddAlbumDescription1691209138541';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" ADD "description" text NOT NULL DEFAULT ''`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "description"`);
}
}

View file

@ -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),
});

View file

@ -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 },
],

View file

@ -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',
});
});
});
});

View file

@ -7,6 +7,7 @@ export const albumStub = {
empty: Object.freeze<AlbumEntity>({
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<AlbumEntity>({
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<AlbumEntity>({
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<AlbumEntity>({
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<AlbumEntity>({
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<AlbumEntity>({
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<AlbumEntity>({
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<AlbumEntity>({
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<AlbumEntity>({
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<AlbumEntity>({
id: 'album-6',
albumName: 'Album with one asset and invalid thumbnail',
description: '',
ownerId: authStub.admin.id,
owner: userStub.admin,
assets: [assetStub.image],

View file

@ -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,

View file

@ -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<string>;
/**
*
* @type {string}
* @memberof CreateAlbumDto
*/
'description'?: string;
/**
*
* @type {Array<string>}
@ -2843,6 +2855,12 @@ export interface UpdateAlbumDto {
* @memberof UpdateAlbumDto
*/
'albumThumbnailAssetId'?: string;
/**
*
* @type {string}
* @memberof UpdateAlbumDto
*/
'description'?: string;
}
/**
*

View file

@ -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;
};
</script>
<section class="bg-immich-bg dark:bg-immich-dark-bg" class:hidden={isShowThumbnailSelection}>
@ -405,6 +428,7 @@
{/if}
<section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40">
<!-- ALBUM TITLE -->
<input
on:keydown={(e) => {
if (e.key == 'Enter') {
@ -421,8 +445,10 @@
bind:value={album.albumName}
disabled={!isOwned}
bind:this={titleInput}
title="Edit Title"
/>
<!-- ALBUM SUMMARY -->
{#if album.assetCount > 0}
<span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<p class="">{getDateRange()}</p>
@ -448,6 +474,17 @@
</div>
{/if}
<!-- ALBUM DESCRIPTION -->
<button
class="mb-12 mt-6 w-full border-b-2 border-transparent pb-2 text-left text-lg font-medium transition-colors hover:border-b-2 dark:text-gray-300"
on:click={() => (isEditingDescription = true)}
class:hover:border-gray-400={isOwned}
disabled={!isOwned}
title="Edit description"
>
{album.description || 'Add description'}
</button>
{#if album.assetCount > 0 && !isShowAssetSelection}
<GalleryViewer assets={album.assets} {sharedLink} bind:selectedAssets={multiSelectAsset} />
{:else}
@ -490,6 +527,7 @@
{#if isShowShareLinkModal}
<CreateSharedLinkModal on:close={() => (isShowShareLinkModal = false)} shareType={SharedLinkType.Album} {album} />
{/if}
{#if isShowShareInfoModal}
<ShareInfoModal on:close={() => (isShowShareInfoModal = false)} {album} on:user-deleted={sharedUserDeletedHandler} />
{/if}
@ -515,3 +553,11 @@
</svelte:fragment>
</ConfirmDialogue>
{/if}
{#if isEditingDescription}
<EditDescriptionModal
{album}
on:close={() => (isEditingDescription = false)}
on:updated={({ detail: description }) => descriptionUpdatedHandler(description)}
/>
{/if}

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { AlbumResponseDto } from '@api';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import Button from '../elements/buttons/button.svelte';
const dispatch = createEventDispatcher<{
close: void;
updated: string;
}>();
export let album: AlbumResponseDto;
let description = album.description;
const handleSave = () => {
dispatch('updated', description);
};
</script>
<FullScreenModal on:clickOutside={() => dispatch('close')}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit description</h1>
</div>
<form on:submit|preventDefault={handleSave} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Description</label>
<!-- svelte-ignore a11y-autofocus -->
<input class="immich-form-input" id="name" name="name" type="text" bind:value={description} autofocus />
</div>
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={() => dispatch('close')}>Cancel</Button>
<Button type="submit" fullwidth>Ok</Button>
</div>
</form>
</div>
</FullScreenModal>

View file

@ -5,6 +5,7 @@ import { userFactory } from './user-factory';
export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
albumName: Sync.each(() => faker.commerce.product()),
description: '',
albumThumbnailAssetId: null,
assetCount: Sync.each((i) => i % 5),
assets: [],