mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	Add web test setup (#597)
* Extract logic from Albums page - move "albums" page logic to `albums-bloc` - add types to AlbumCard custom events * Implement some album-bloc unit-tests - add libraries for testing - add album factory - changes in albums-bloc API * Add rest of albums-bloc test Cleanup and remove console logs * Refactor `isShowContextMenu` writable to derived
This commit is contained in:
		
							
								
								
									
										3
									
								
								web/babel.config.cjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								web/babel.config.cjs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | module.exports = { | ||||||
|  | 	presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'] | ||||||
|  | }; | ||||||
							
								
								
									
										201
									
								
								web/jest.config.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								web/jest.config.mjs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | |||||||
|  | /* | ||||||
|  |  * For a detailed explanation regarding each configuration property, visit: | ||||||
|  |  * https://jestjs.io/docs/configuration | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  | 	// All imported modules in your tests should be mocked automatically | ||||||
|  | 	// automock: false, | ||||||
|  |  | ||||||
|  | 	// Stop running tests after `n` failures | ||||||
|  | 	// bail: 0, | ||||||
|  |  | ||||||
|  | 	// The directory where Jest should store its cached dependency information | ||||||
|  | 	// cacheDirectory: "/private/var/folders/6n/31wm28711gzbt3gzsxhzxx500000gn/T/jest_dx", | ||||||
|  |  | ||||||
|  | 	// Automatically clear mock calls, instances, contexts and results before every test | ||||||
|  | 	clearMocks: true, | ||||||
|  |  | ||||||
|  | 	// Indicates whether the coverage information should be collected while executing the test | ||||||
|  | 	// collectCoverage: false, | ||||||
|  |  | ||||||
|  | 	// An array of glob patterns indicating a set of files for which coverage information should be collected | ||||||
|  | 	// collectCoverageFrom: undefined, | ||||||
|  |  | ||||||
|  | 	// The directory where Jest should output its coverage files | ||||||
|  | 	// coverageDirectory: undefined, | ||||||
|  |  | ||||||
|  | 	// An array of regexp pattern strings used to skip coverage collection | ||||||
|  | 	// coveragePathIgnorePatterns: [ | ||||||
|  | 	//   "/node_modules/" | ||||||
|  | 	// ], | ||||||
|  |  | ||||||
|  | 	// Indicates which provider should be used to instrument code for coverage | ||||||
|  | 	coverageProvider: 'v8', | ||||||
|  |  | ||||||
|  | 	// A list of reporter names that Jest uses when writing coverage reports | ||||||
|  | 	// coverageReporters: [ | ||||||
|  | 	//   "json", | ||||||
|  | 	//   "text", | ||||||
|  | 	//   "lcov", | ||||||
|  | 	//   "clover" | ||||||
|  | 	// ], | ||||||
|  |  | ||||||
|  | 	// An object that configures minimum threshold enforcement for coverage results | ||||||
|  | 	// coverageThreshold: undefined, | ||||||
|  |  | ||||||
|  | 	// A path to a custom dependency extractor | ||||||
|  | 	// dependencyExtractor: undefined, | ||||||
|  |  | ||||||
|  | 	// Make calling deprecated APIs throw helpful error messages | ||||||
|  | 	// errorOnDeprecated: false, | ||||||
|  |  | ||||||
|  | 	// The default configuration for fake timers | ||||||
|  | 	// fakeTimers: { | ||||||
|  | 	//   "enableGlobally": false | ||||||
|  | 	// }, | ||||||
|  |  | ||||||
|  | 	// Force coverage collection from ignored files using an array of glob patterns | ||||||
|  | 	// forceCoverageMatch: [], | ||||||
|  |  | ||||||
|  | 	// A path to a module which exports an async function that is triggered once before all test suites | ||||||
|  | 	// globalSetup: undefined, | ||||||
|  |  | ||||||
|  | 	// A path to a module which exports an async function that is triggered once after all test suites | ||||||
|  | 	// globalTeardown: undefined, | ||||||
|  |  | ||||||
|  | 	// A set of global variables that need to be available in all test environments | ||||||
|  | 	// globals: {}, | ||||||
|  |  | ||||||
|  | 	// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. | ||||||
|  | 	// maxWorkers: "50%", | ||||||
|  |  | ||||||
|  | 	// An array of directory names to be searched recursively up from the requiring module's location | ||||||
|  | 	// moduleDirectories: [ | ||||||
|  | 	//   "node_modules" | ||||||
|  | 	// ], | ||||||
|  |  | ||||||
|  | 	// An array of file extensions your modules use | ||||||
|  | 	// moduleFileExtensions: [ | ||||||
|  | 	//   "js", | ||||||
|  | 	//   "mjs", | ||||||
|  | 	//   "cjs", | ||||||
|  | 	//   "jsx", | ||||||
|  | 	//   "ts", | ||||||
|  | 	//   "tsx", | ||||||
|  | 	//   "json", | ||||||
|  | 	//   "node" | ||||||
|  | 	// ], | ||||||
|  |  | ||||||
|  | 	// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module | ||||||
|  | 	moduleNameMapper: { | ||||||
|  | 		'^\\$lib(.*)$': '<rootDir>/src/lib$1', | ||||||
|  | 		'^\\@api(.*)$': '<rootDir>/src/api$1', | ||||||
|  | 		'^\\@test-data(.*)$': '<rootDir>/src/test-data$1' | ||||||
|  | 	}, | ||||||
|  |  | ||||||
|  | 	// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader | ||||||
|  | 	// modulePathIgnorePatterns: [], | ||||||
|  |  | ||||||
|  | 	// Activates notifications for test results | ||||||
|  | 	// notify: false, | ||||||
|  |  | ||||||
|  | 	// An enum that specifies notification mode. Requires { notify: true } | ||||||
|  | 	// notifyMode: "failure-change", | ||||||
|  |  | ||||||
|  | 	// A preset that is used as a base for Jest's configuration | ||||||
|  | 	// preset: undefined, | ||||||
|  |  | ||||||
|  | 	// Run tests from one or more projects | ||||||
|  | 	// projects: undefined, | ||||||
|  |  | ||||||
|  | 	// Use this configuration option to add custom reporters to Jest | ||||||
|  | 	// reporters: undefined, | ||||||
|  |  | ||||||
|  | 	// Automatically reset mock state before every test | ||||||
|  | 	// resetMocks: false, | ||||||
|  |  | ||||||
|  | 	// Reset the module registry before running each individual test | ||||||
|  | 	// resetModules: false, | ||||||
|  |  | ||||||
|  | 	// A path to a custom resolver | ||||||
|  | 	// resolver: undefined, | ||||||
|  |  | ||||||
|  | 	// Automatically restore mock state and implementation before every test | ||||||
|  | 	// restoreMocks: false, | ||||||
|  |  | ||||||
|  | 	// The root directory that Jest should scan for tests and modules within | ||||||
|  | 	// rootDir: undefined, | ||||||
|  |  | ||||||
|  | 	// A list of paths to directories that Jest should use to search for files in | ||||||
|  | 	// roots: [ | ||||||
|  | 	//   "<rootDir>" | ||||||
|  | 	// ], | ||||||
|  |  | ||||||
|  | 	// Allows you to use a custom runner instead of Jest's default test runner | ||||||
|  | 	// runner: "jest-runner", | ||||||
|  |  | ||||||
|  | 	// The paths to modules that run some code to configure or set up the testing environment before each test | ||||||
|  | 	// setupFiles: [], | ||||||
|  |  | ||||||
|  | 	// A list of paths to modules that run some code to configure or set up the testing framework before each test | ||||||
|  | 	// setupFilesAfterEnv: [], | ||||||
|  |  | ||||||
|  | 	// The number of seconds after which a test is considered as slow and reported as such in the results. | ||||||
|  | 	// slowTestThreshold: 5, | ||||||
|  |  | ||||||
|  | 	// A list of paths to snapshot serializer modules Jest should use for snapshot testing | ||||||
|  | 	// snapshotSerializers: [], | ||||||
|  |  | ||||||
|  | 	// The test environment that will be used for testing | ||||||
|  | 	testEnvironment: 'jsdom', | ||||||
|  |  | ||||||
|  | 	// Options that will be passed to the testEnvironment | ||||||
|  | 	// testEnvironmentOptions: {}, | ||||||
|  |  | ||||||
|  | 	// Adds a location field to test results | ||||||
|  | 	// testLocationInResults: false, | ||||||
|  |  | ||||||
|  | 	// The glob patterns Jest uses to detect test files | ||||||
|  | 	// testMatch: [ | ||||||
|  | 	//   "**/__tests__/**/*.[jt]s?(x)", | ||||||
|  | 	//   "**/?(*.)+(spec|test).[tj]s?(x)" | ||||||
|  | 	// ], | ||||||
|  |  | ||||||
|  | 	// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped | ||||||
|  | 	// testPathIgnorePatterns: [ | ||||||
|  | 	//   "/node_modules/" | ||||||
|  | 	// ], | ||||||
|  |  | ||||||
|  | 	// The regexp pattern or array of patterns that Jest uses to detect test files | ||||||
|  | 	// testRegex: [], | ||||||
|  |  | ||||||
|  | 	// This option allows the use of a custom results processor | ||||||
|  | 	// testResultsProcessor: undefined, | ||||||
|  |  | ||||||
|  | 	// This option allows use of a custom test runner | ||||||
|  | 	// testRunner: "jest-circus/runner", | ||||||
|  |  | ||||||
|  | 	// A map from regular expressions to paths to transformers | ||||||
|  | 	transform: { | ||||||
|  | 		'\\.[jt]sx?$': 'babel-jest' | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation | ||||||
|  | 	// transformIgnorePatterns: [ | ||||||
|  | 	//   "/node_modules/", | ||||||
|  | 	//   "\\.pnp\\.[^\\/]+$" | ||||||
|  | 	// ], | ||||||
|  |  | ||||||
|  | 	// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them | ||||||
|  | 	// unmockedModulePathPatterns: undefined, | ||||||
|  |  | ||||||
|  | 	// Indicates whether each individual test should be reported during the run | ||||||
|  | 	// verbose: undefined, | ||||||
|  |  | ||||||
|  | 	// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode | ||||||
|  | 	// watchPathIgnorePatterns: [], | ||||||
|  |  | ||||||
|  | 	// Whether to use watchman for file crawling | ||||||
|  | 	// watchman: true, | ||||||
|  | }; | ||||||
							
								
								
									
										8577
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8577
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -9,25 +9,17 @@ | |||||||
| 		"check": "svelte-check --tsconfig ./tsconfig.json", | 		"check": "svelte-check --tsconfig ./tsconfig.json", | ||||||
| 		"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", | 		"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", | ||||||
| 		"lint": "prettier --check --plugin-search-dir=. . && eslint .", | 		"lint": "prettier --check --plugin-search-dir=. . && eslint .", | ||||||
| 		"format": "prettier --write --plugin-search-dir=. ." | 		"format": "prettier --write --plugin-search-dir=. .", | ||||||
|  | 		"test": "jest", | ||||||
|  | 		"test:watch": "npm test -- --watch" | ||||||
| 	}, | 	}, | ||||||
| 	"devDependencies": { | 	"devDependencies": { | ||||||
|  | 		"@babel/preset-env": "^7.19.0", | ||||||
|  | 		"@babel/preset-typescript": "^7.18.6", | ||||||
|  | 		"@faker-js/faker": "^7.5.0", | ||||||
| 		"@sveltejs/adapter-auto": "next", | 		"@sveltejs/adapter-auto": "next", | ||||||
| 		"@sveltejs/kit": "next", |  | ||||||
| 		"@typescript-eslint/eslint-plugin": "^5.27.0", |  | ||||||
| 		"@typescript-eslint/parser": "^5.27.0", |  | ||||||
| 		"eslint": "^8.16.0", |  | ||||||
| 		"eslint-config-prettier": "^8.3.0", |  | ||||||
| 		"eslint-plugin-svelte3": "^4.0.0", |  | ||||||
| 		"prettier": "^2.6.2", |  | ||||||
| 		"prettier-plugin-svelte": "^2.7.0", |  | ||||||
| 		"svelte": "^3.44.0", |  | ||||||
| 		"svelte-check": "^2.7.1", |  | ||||||
| 		"svelte-preprocess": "^4.10.6", |  | ||||||
| 		"tslib": "^2.3.1", |  | ||||||
| 		"typescript": "^4.7.4", |  | ||||||
| 		"vite": "^3.0.0", |  | ||||||
| 		"@sveltejs/adapter-node": "next", | 		"@sveltejs/adapter-node": "next", | ||||||
|  | 		"@sveltejs/kit": "next", | ||||||
| 		"@types/bcrypt": "^5.0.0", | 		"@types/bcrypt": "^5.0.0", | ||||||
| 		"@types/cookie": "^0.4.1", | 		"@types/cookie": "^0.4.1", | ||||||
| 		"@types/fluent-ffmpeg": "^2.1.20", | 		"@types/fluent-ffmpeg": "^2.1.20", | ||||||
| @@ -35,9 +27,27 @@ | |||||||
| 		"@types/lodash": "^4.14.182", | 		"@types/lodash": "^4.14.182", | ||||||
| 		"@types/lodash-es": "^4.17.6", | 		"@types/lodash-es": "^4.17.6", | ||||||
| 		"@types/socket.io-client": "^3.0.0", | 		"@types/socket.io-client": "^3.0.0", | ||||||
|  | 		"@typescript-eslint/eslint-plugin": "^5.27.0", | ||||||
|  | 		"@typescript-eslint/parser": "^5.27.0", | ||||||
| 		"autoprefixer": "^10.4.7", | 		"autoprefixer": "^10.4.7", | ||||||
|  | 		"babel-jest": "^29.0.2", | ||||||
|  | 		"eslint": "^8.16.0", | ||||||
|  | 		"eslint-config-prettier": "^8.3.0", | ||||||
|  | 		"eslint-plugin-svelte3": "^4.0.0", | ||||||
|  | 		"factory.ts": "^1.2.0", | ||||||
|  | 		"jest": "^29.0.2", | ||||||
|  | 		"jest-environment-jsdom": "^29.0.2", | ||||||
| 		"postcss": "^8.4.13", | 		"postcss": "^8.4.13", | ||||||
| 		"tailwindcss": "^3.0.24" | 		"prettier": "^2.6.2", | ||||||
|  | 		"prettier-plugin-svelte": "^2.7.0", | ||||||
|  | 		"svelte": "^3.44.0", | ||||||
|  | 		"svelte-check": "^2.7.1", | ||||||
|  | 		"svelte-jester": "^2.3.2", | ||||||
|  | 		"svelte-preprocess": "^4.10.6", | ||||||
|  | 		"tailwindcss": "^3.0.24", | ||||||
|  | 		"tslib": "^2.3.1", | ||||||
|  | 		"typescript": "^4.7.4", | ||||||
|  | 		"vite": "^3.0.0" | ||||||
| 	}, | 	}, | ||||||
| 	"type": "module", | 	"type": "module", | ||||||
| 	"dependencies": { | 	"dependencies": { | ||||||
|   | |||||||
| @@ -1,3 +1,16 @@ | |||||||
|  | <script lang="ts" context="module"> | ||||||
|  | 	type OnShowContextMenu = { | ||||||
|  | 		showalbumcontextmenu: OnShowContextMenuDetail; | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	type OnClick = { | ||||||
|  | 		click: OnClickDetail; | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	export type OnShowContextMenuDetail = { x: number; y: number }; | ||||||
|  | 	export type OnClickDetail = AlbumResponseDto; | ||||||
|  | </script> | ||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| 	import { AlbumResponseDto, api, ThumbnailFormat } from '@api'; | 	import { AlbumResponseDto, api, ThumbnailFormat } from '@api'; | ||||||
| 	import { createEventDispatcher, onMount } from 'svelte'; | 	import { createEventDispatcher, onMount } from 'svelte'; | ||||||
| @@ -8,7 +21,8 @@ | |||||||
| 	export let album: AlbumResponseDto; | 	export let album: AlbumResponseDto; | ||||||
|  |  | ||||||
| 	let imageData: string = `/api/asset/thumbnail/${album.albumThumbnailAssetId}?format=${ThumbnailFormat.Webp}`; | 	let imageData: string = `/api/asset/thumbnail/${album.albumThumbnailAssetId}?format=${ThumbnailFormat.Webp}`; | ||||||
| 	const dispatch = createEventDispatcher(); | 	const dispatchClick = createEventDispatcher<OnClick>(); | ||||||
|  | 	const dispatchShowContextMenu = createEventDispatcher<OnShowContextMenu>(); | ||||||
|  |  | ||||||
| 	const loadHighQualityThumbnail = async (thubmnailId: string | null) => { | 	const loadHighQualityThumbnail = async (thubmnailId: string | null) => { | ||||||
| 		if (thubmnailId == null) { | 		if (thubmnailId == null) { | ||||||
| @@ -25,7 +39,7 @@ | |||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	const showAlbumContextMenu = (e: MouseEvent) => { | 	const showAlbumContextMenu = (e: MouseEvent) => { | ||||||
| 		dispatch('showalbumcontextmenu', { | 		dispatchShowContextMenu('showalbumcontextmenu', { | ||||||
| 			x: e.clientX, | 			x: e.clientX, | ||||||
| 			y: e.clientY | 			y: e.clientY | ||||||
| 		}); | 		}); | ||||||
| @@ -38,7 +52,7 @@ | |||||||
|  |  | ||||||
| <div | <div | ||||||
| 	class="h-[339px] w-[275px] hover:cursor-pointer mt-4 relative" | 	class="h-[339px] w-[275px] hover:cursor-pointer mt-4 relative" | ||||||
| 	on:click={() => dispatch('click', album)} | 	on:click={() => dispatchClick('click', album)} | ||||||
| > | > | ||||||
| 	<div | 	<div | ||||||
| 		id={`icon-${album.id}`} | 		id={`icon-${album.id}`} | ||||||
|   | |||||||
| @@ -6,93 +6,32 @@ | |||||||
| 	import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | 	import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||||
| 	import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte'; | 	import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte'; | ||||||
| 	import type { PageData } from './$types'; | 	import type { PageData } from './$types'; | ||||||
| 	import { AlbumResponseDto, api } from '@api'; |  | ||||||
| 	import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte'; | 	import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte'; | ||||||
| 	import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte'; | 	import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte'; | ||||||
| 	import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte'; | 	import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte'; | ||||||
| 	import { | 	import { useAlbums } from './albums-bloc'; | ||||||
| 		notificationController, |  | ||||||
| 		NotificationType |  | ||||||
| 	} from '$lib/components/shared-components/notification/notification'; |  | ||||||
|  |  | ||||||
| 	export let data: PageData; | 	export let data: PageData; | ||||||
|  |  | ||||||
| 	let isShowContextMenu = false; | 	const { | ||||||
| 	let contextMenuPosition = { x: 0, y: 0 }; | 		albums, | ||||||
| 	let targetAlbum: AlbumResponseDto; | 		isShowContextMenu, | ||||||
|  | 		contextMenuPosition, | ||||||
|  | 		createAlbum, | ||||||
|  | 		deleteSelectedContextAlbum, | ||||||
|  | 		loadAlbums, | ||||||
|  | 		showAlbumContextMenu, | ||||||
|  | 		closeAlbumContextMenu | ||||||
|  | 	} = useAlbums({ albums: data.albums }); | ||||||
|  |  | ||||||
| 	onMount(async () => { | 	onMount(loadAlbums); | ||||||
| 		const getAllAlbumsRes = await api.albumApi.getAllAlbums(); |  | ||||||
| 		data.albums = getAllAlbumsRes.data; |  | ||||||
|  |  | ||||||
| 		// Delete album that has no photos and is named 'Untitled' |  | ||||||
| 		for (const album of data.albums) { |  | ||||||
| 			if (album.albumName === 'Untitled' && album.assetCount === 0) { |  | ||||||
| 				setTimeout(async () => { |  | ||||||
| 					await autoDeleteAlbum(album); |  | ||||||
| 					data.albums = data.albums.filter((a) => a.id !== album.id); |  | ||||||
| 				}, 500); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	}); |  | ||||||
|  |  | ||||||
| 	const createAlbum = async () => { |  | ||||||
| 		try { |  | ||||||
| 			const { data: newAlbum } = await api.albumApi.createAlbum({ |  | ||||||
| 				albumName: 'Untitled' |  | ||||||
| 			}); |  | ||||||
|  |  | ||||||
|  | 	const handleCreateAlbum = async () => { | ||||||
|  | 		const newAlbum = await createAlbum(); | ||||||
|  | 		if (newAlbum) { | ||||||
| 			goto('/albums/' + newAlbum.id); | 			goto('/albums/' + newAlbum.id); | ||||||
| 		} catch (e) { |  | ||||||
| 			console.error('Error [createAlbum] ', e); |  | ||||||
| 			notificationController.show({ |  | ||||||
| 				message: 'Error creating album, check console for more details', |  | ||||||
| 				type: NotificationType.Error |  | ||||||
| 			}); |  | ||||||
| 		} | 		} | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	const autoDeleteAlbum = async (album: AlbumResponseDto) => { |  | ||||||
| 		try { |  | ||||||
| 			await api.albumApi.deleteAlbum(album.id); |  | ||||||
| 			return true; |  | ||||||
| 		} catch (e) { |  | ||||||
| 			console.error('Error [autoDeleteAlbum] ', e); |  | ||||||
| 			return false; |  | ||||||
| 		} |  | ||||||
| 	}; |  | ||||||
|  |  | ||||||
| 	const userDeleteMenu = async () => { |  | ||||||
| 		if ( |  | ||||||
| 			window.confirm( |  | ||||||
| 				`Are you sure you want to delete album ${targetAlbum.albumName}? If the album is shared, other users will not be able to access it.` |  | ||||||
| 			) |  | ||||||
| 		) { |  | ||||||
| 			try { |  | ||||||
| 				await api.albumApi.deleteAlbum(targetAlbum.id); |  | ||||||
| 				data.albums = data.albums.filter((a) => a.id !== targetAlbum.id); |  | ||||||
| 			} catch (e) { |  | ||||||
| 				console.error('Error [userDeleteMenu] ', e); |  | ||||||
| 				notificationController.show({ |  | ||||||
| 					message: 'Error deleting user, check console for more details', |  | ||||||
| 					type: NotificationType.Error |  | ||||||
| 				}); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		isShowContextMenu = false; |  | ||||||
| 	}; |  | ||||||
|  |  | ||||||
| 	const showAlbumContextMenu = (event: CustomEvent, album: AlbumResponseDto) => { |  | ||||||
| 		targetAlbum = album; |  | ||||||
|  |  | ||||||
| 		contextMenuPosition = { |  | ||||||
| 			x: event.detail.x, |  | ||||||
| 			y: event.detail.y |  | ||||||
| 		}; |  | ||||||
|  |  | ||||||
| 		isShowContextMenu = !isShowContextMenu; |  | ||||||
| 	}; |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <svelte:head> | <svelte:head> | ||||||
| @@ -116,7 +55,7 @@ | |||||||
| 				</div> | 				</div> | ||||||
|  |  | ||||||
| 				<div> | 				<div> | ||||||
| 					<button on:click={createAlbum} class="immich-text-button text-sm"> | 					<button on:click={handleCreateAlbum} class="immich-text-button text-sm"> | ||||||
| 						<span> | 						<span> | ||||||
| 							<PlusBoxOutline size="18" /> | 							<PlusBoxOutline size="18" /> | ||||||
| 						</span> | 						</span> | ||||||
| @@ -131,17 +70,20 @@ | |||||||
|  |  | ||||||
| 			<!-- Album Card --> | 			<!-- Album Card --> | ||||||
| 			<div class="flex flex-wrap gap-8"> | 			<div class="flex flex-wrap gap-8"> | ||||||
| 				{#each data.albums as album} | 				{#each $albums as album} | ||||||
| 					{#key album.id} | 					{#key album.id} | ||||||
| 						<a sveltekit:prefetch href={`albums/${album.id}`}> | 						<a sveltekit:prefetch href={`albums/${album.id}`}> | ||||||
| 							<AlbumCard {album} on:showalbumcontextmenu={(e) => showAlbumContextMenu(e, album)} /> | 							<AlbumCard | ||||||
|  | 								{album} | ||||||
|  | 								on:showalbumcontextmenu={(e) => showAlbumContextMenu(e.detail, album)} | ||||||
|  | 							/> | ||||||
| 						</a> | 						</a> | ||||||
| 					{/key} | 					{/key} | ||||||
| 				{/each} | 				{/each} | ||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
| 			<!-- Empty Message --> | 			<!-- Empty Message --> | ||||||
| 			{#if data.albums.length === 0} | 			{#if $albums.length === 0} | ||||||
| 				<div | 				<div | ||||||
| 					class="border p-5 w-[50%] m-auto mt-10 bg-gray-50 rounded-3xl flex flex-col place-content-center place-items-center" | 					class="border p-5 w-[50%] m-auto mt-10 bg-gray-50 rounded-3xl flex flex-col place-content-center place-items-center" | ||||||
| 				> | 				> | ||||||
| @@ -156,9 +98,9 @@ | |||||||
| 	</section> | 	</section> | ||||||
|  |  | ||||||
| 	<!-- Context Menu --> | 	<!-- Context Menu --> | ||||||
| 	{#if isShowContextMenu} | 	{#if $isShowContextMenu} | ||||||
| 		<ContextMenu {...contextMenuPosition} on:clickoutside={() => (isShowContextMenu = false)}> | 		<ContextMenu {...$contextMenuPosition} on:clickoutside={closeAlbumContextMenu}> | ||||||
| 			<MenuOption on:click={userDeleteMenu}> | 			<MenuOption on:click={deleteSelectedContextAlbum}> | ||||||
| 				<span class="flex place-items-center place-content-center gap-2"> | 				<span class="flex place-items-center place-content-center gap-2"> | ||||||
| 					<DeleteOutline size="18" /> | 					<DeleteOutline size="18" /> | ||||||
| 					<p>Delete album</p> | 					<p>Delete album</p> | ||||||
|   | |||||||
							
								
								
									
										185
									
								
								web/src/routes/albums/__tests__/albums-bloc.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								web/src/routes/albums/__tests__/albums-bloc.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,185 @@ | |||||||
|  | import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; | ||||||
|  | import { useAlbums } from '../albums-bloc'; | ||||||
|  | import { api, CreateAlbumDto } from '@api'; | ||||||
|  | import { | ||||||
|  | 	notificationController, | ||||||
|  | 	NotificationType | ||||||
|  | } from '$lib/components/shared-components/notification/notification'; | ||||||
|  | import { albumFactory } from '@test-data'; | ||||||
|  | import { get } from 'svelte/store'; | ||||||
|  |  | ||||||
|  | jest.mock('@api'); | ||||||
|  |  | ||||||
|  | const apiMock: jest.MockedObject<typeof api> = api as jest.MockedObject<typeof api>; | ||||||
|  |  | ||||||
|  | function mockWindowConfirm(result: boolean) { | ||||||
|  | 	jest.spyOn(global, 'confirm').mockReturnValueOnce(result); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | describe('Albums BLoC', () => { | ||||||
|  | 	let sut: ReturnType<typeof useAlbums>; | ||||||
|  | 	const _albums = albumFactory.buildList(5); | ||||||
|  |  | ||||||
|  | 	beforeEach(() => { | ||||||
|  | 		sut = useAlbums({ albums: [..._albums] }); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	afterEach(() => { | ||||||
|  | 		const notifications = get(notificationController.notificationList); | ||||||
|  |  | ||||||
|  | 		notifications.forEach((notification) => | ||||||
|  | 			notificationController.removeNotificationById(notification.id) | ||||||
|  | 		); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('inits with provided albums', () => { | ||||||
|  | 		const albums = get(sut.albums); | ||||||
|  | 		expect(albums.length).toEqual(5); | ||||||
|  | 		expect(albums).toEqual(_albums); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('loads albums from the server', async () => { | ||||||
|  | 		// TODO: this method currently deletes albums with no assets and albumName === 'Untitled' which might not be the best approach | ||||||
|  | 		const loadedAlbums = [..._albums, albumFactory.build({ id: 'new_loaded_uuid' })]; | ||||||
|  |  | ||||||
|  | 		apiMock.albumApi.getAllAlbums.mockResolvedValueOnce({ | ||||||
|  | 			data: loadedAlbums, | ||||||
|  | 			config: {}, | ||||||
|  | 			headers: {}, | ||||||
|  | 			status: 200, | ||||||
|  | 			statusText: '' | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		await sut.loadAlbums(); | ||||||
|  | 		const albums = get(sut.albums); | ||||||
|  |  | ||||||
|  | 		expect(apiMock.albumApi.getAllAlbums).toHaveBeenCalledTimes(1); | ||||||
|  | 		expect(albums).toEqual(loadedAlbums); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('shows error message when it fails loading albums', async () => { | ||||||
|  | 		apiMock.albumApi.getAllAlbums.mockRejectedValueOnce({}); // TODO: implement APIProblem interface in the server | ||||||
|  |  | ||||||
|  | 		expect(get(notificationController.notificationList)).toHaveLength(0); | ||||||
|  | 		await sut.loadAlbums(); | ||||||
|  | 		const albums = get(sut.albums); | ||||||
|  | 		const notifications = get(notificationController.notificationList); | ||||||
|  |  | ||||||
|  | 		expect(apiMock.albumApi.getAllAlbums).toHaveBeenCalledTimes(1); | ||||||
|  | 		expect(albums).toEqual(_albums); | ||||||
|  | 		expect(notifications).toHaveLength(1); | ||||||
|  | 		expect(notifications[0].type).toEqual(NotificationType.Error); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('creates a new album', async () => { | ||||||
|  | 		// TODO: we probably shouldn't hardcode the album name "untitled" here and let the user input the album name before creating it | ||||||
|  | 		const payload: CreateAlbumDto = { | ||||||
|  | 			albumName: 'Untitled' | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		const returnedAlbum = albumFactory.build(); | ||||||
|  |  | ||||||
|  | 		apiMock.albumApi.createAlbum.mockResolvedValueOnce({ | ||||||
|  | 			data: returnedAlbum, | ||||||
|  | 			config: {}, | ||||||
|  | 			headers: {}, | ||||||
|  | 			status: 200, | ||||||
|  | 			statusText: '' | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		const newAlbum = await sut.createAlbum(); | ||||||
|  |  | ||||||
|  | 		expect(apiMock.albumApi.createAlbum).toHaveBeenCalledTimes(1); | ||||||
|  | 		expect(apiMock.albumApi.createAlbum).toHaveBeenCalledWith(payload); | ||||||
|  | 		expect(newAlbum).toEqual(returnedAlbum); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('shows error message when it fails creating an album', async () => { | ||||||
|  | 		apiMock.albumApi.createAlbum.mockRejectedValueOnce({}); | ||||||
|  |  | ||||||
|  | 		const newAlbum = await sut.createAlbum(); | ||||||
|  | 		const notifications = get(notificationController.notificationList); | ||||||
|  |  | ||||||
|  | 		expect(apiMock.albumApi.createAlbum).toHaveBeenCalledTimes(1); | ||||||
|  | 		expect(newAlbum).not.toBeDefined(); | ||||||
|  | 		expect(notifications).toHaveLength(1); | ||||||
|  | 		expect(notifications[0].type).toEqual(NotificationType.Error); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('selects an album and deletes it', async () => { | ||||||
|  | 		apiMock.albumApi.deleteAlbum.mockResolvedValueOnce({ | ||||||
|  | 			data: undefined, | ||||||
|  | 			config: {}, | ||||||
|  | 			headers: {}, | ||||||
|  | 			status: 200, | ||||||
|  | 			statusText: '' | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		mockWindowConfirm(true); | ||||||
|  |  | ||||||
|  | 		const albumToDelete = get(sut.albums)[2]; // delete third album | ||||||
|  | 		const albumToDeleteId = albumToDelete.id; | ||||||
|  | 		const contextMenuCoords = { x: 100, y: 150 }; | ||||||
|  |  | ||||||
|  | 		expect(get(sut.isShowContextMenu)).toBe(false); | ||||||
|  | 		sut.showAlbumContextMenu(contextMenuCoords, albumToDelete); | ||||||
|  | 		expect(get(sut.contextMenuPosition)).toEqual(contextMenuCoords); | ||||||
|  | 		expect(get(sut.isShowContextMenu)).toBe(true); | ||||||
|  |  | ||||||
|  | 		await sut.deleteSelectedContextAlbum(); | ||||||
|  | 		const updatedAlbums = get(sut.albums); | ||||||
|  |  | ||||||
|  | 		expect(apiMock.albumApi.deleteAlbum).toHaveBeenCalledTimes(1); | ||||||
|  | 		expect(apiMock.albumApi.deleteAlbum).toHaveBeenCalledWith(albumToDeleteId); | ||||||
|  | 		expect(updatedAlbums).toHaveLength(4); | ||||||
|  | 		expect(updatedAlbums).not.toContain(albumToDelete); | ||||||
|  | 		expect(get(sut.isShowContextMenu)).toBe(false); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('shows error message when it fails deleting an album', async () => { | ||||||
|  | 		mockWindowConfirm(true); | ||||||
|  |  | ||||||
|  | 		const albumToDelete = get(sut.albums)[2]; // delete third album | ||||||
|  | 		const contextMenuCoords = { x: 100, y: 150 }; | ||||||
|  |  | ||||||
|  | 		apiMock.albumApi.deleteAlbum.mockRejectedValueOnce({}); | ||||||
|  |  | ||||||
|  | 		sut.showAlbumContextMenu(contextMenuCoords, albumToDelete); | ||||||
|  | 		const newAlbum = await sut.deleteSelectedContextAlbum(); | ||||||
|  | 		const notifications = get(notificationController.notificationList); | ||||||
|  |  | ||||||
|  | 		expect(apiMock.albumApi.deleteAlbum).toHaveBeenCalledTimes(1); | ||||||
|  | 		expect(newAlbum).not.toBeDefined(); | ||||||
|  | 		expect(notifications).toHaveLength(1); | ||||||
|  | 		expect(notifications[0].type).toEqual(NotificationType.Error); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('prevents deleting an album when rejecting confirm dialog', async () => { | ||||||
|  | 		const albumToDelete = get(sut.albums)[2]; // delete third album | ||||||
|  |  | ||||||
|  | 		mockWindowConfirm(false); | ||||||
|  |  | ||||||
|  | 		sut.showAlbumContextMenu({ x: 100, y: 150 }, albumToDelete); | ||||||
|  | 		await sut.deleteSelectedContextAlbum(); | ||||||
|  |  | ||||||
|  | 		expect(apiMock.albumApi.deleteAlbum).not.toHaveBeenCalled(); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('prevents deleting an album when not previously selected', async () => { | ||||||
|  | 		mockWindowConfirm(true); | ||||||
|  |  | ||||||
|  | 		await sut.deleteSelectedContextAlbum(); | ||||||
|  |  | ||||||
|  | 		expect(apiMock.albumApi.deleteAlbum).not.toHaveBeenCalled(); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('closes album context menu, deselecting album', () => { | ||||||
|  | 		const albumToDelete = get(sut.albums)[2]; // delete third album | ||||||
|  | 		sut.showAlbumContextMenu({ x: 100, y: 150 }, albumToDelete); | ||||||
|  |  | ||||||
|  | 		expect(get(sut.isShowContextMenu)).toBe(true); | ||||||
|  |  | ||||||
|  | 		sut.closeAlbumContextMenu(); | ||||||
|  | 		expect(get(sut.isShowContextMenu)).toBe(false); | ||||||
|  | 	}); | ||||||
|  | }); | ||||||
							
								
								
									
										114
									
								
								web/src/routes/albums/albums-bloc.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								web/src/routes/albums/albums-bloc.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | |||||||
|  | import { | ||||||
|  | 	notificationController, | ||||||
|  | 	NotificationType | ||||||
|  | } from '$lib/components/shared-components/notification/notification'; | ||||||
|  | import { AlbumResponseDto, api } from '@api'; | ||||||
|  | import { OnShowContextMenuDetail } from '$lib/components/album-page/album-card.svelte'; | ||||||
|  | import { writable, derived, get } from 'svelte/store'; | ||||||
|  |  | ||||||
|  | type AlbumsProps = { albums: AlbumResponseDto[] }; | ||||||
|  |  | ||||||
|  | export const useAlbums = (props: AlbumsProps) => { | ||||||
|  | 	const albums = writable([...props.albums]); | ||||||
|  | 	const contextMenuPosition = writable<OnShowContextMenuDetail>({ x: 0, y: 0 }); | ||||||
|  | 	const contextMenuTargetAlbum = writable<AlbumResponseDto | undefined>(); | ||||||
|  | 	const isShowContextMenu = derived(contextMenuTargetAlbum, ($selectedAlbum) => !!$selectedAlbum); | ||||||
|  |  | ||||||
|  | 	async function loadAlbums(): Promise<void> { | ||||||
|  | 		try { | ||||||
|  | 			const { data } = await api.albumApi.getAllAlbums(); | ||||||
|  | 			albums.set(data); | ||||||
|  |  | ||||||
|  | 			// Delete album that has no photos and is named 'Untitled' | ||||||
|  | 			for (const album of data) { | ||||||
|  | 				if (album.albumName === 'Untitled' && album.assetCount === 0) { | ||||||
|  | 					setTimeout(async () => { | ||||||
|  | 						await deleteAlbum(album); | ||||||
|  | 						const _albums = get(albums); | ||||||
|  | 						albums.set(_albums.filter((a) => a.id !== album.id)); | ||||||
|  | 					}, 500); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} catch { | ||||||
|  | 			notificationController.show({ | ||||||
|  | 				message: 'Error loading albums', | ||||||
|  | 				type: NotificationType.Error | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	async function createAlbum(): Promise<AlbumResponseDto | undefined> { | ||||||
|  | 		try { | ||||||
|  | 			const { data: newAlbum } = await api.albumApi.createAlbum({ | ||||||
|  | 				albumName: 'Untitled' | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			return newAlbum; | ||||||
|  | 		} catch { | ||||||
|  | 			notificationController.show({ | ||||||
|  | 				message: 'Error creating album', | ||||||
|  | 				type: NotificationType.Error | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	async function deleteAlbum(album: AlbumResponseDto): Promise<void> { | ||||||
|  | 		try { | ||||||
|  | 			await api.albumApi.deleteAlbum(album.id); | ||||||
|  | 		} catch { | ||||||
|  | 			// Do nothing? | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	async function showAlbumContextMenu( | ||||||
|  | 		contextMenuDetail: OnShowContextMenuDetail, | ||||||
|  | 		album: AlbumResponseDto | ||||||
|  | 	): Promise<void> { | ||||||
|  | 		contextMenuTargetAlbum.set(album); | ||||||
|  |  | ||||||
|  | 		contextMenuPosition.set({ | ||||||
|  | 			x: contextMenuDetail.x, | ||||||
|  | 			y: contextMenuDetail.y | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	function closeAlbumContextMenu() { | ||||||
|  | 		contextMenuTargetAlbum.set(undefined); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	async function deleteSelectedContextAlbum(): Promise<void> { | ||||||
|  | 		const albumToDelete = get(contextMenuTargetAlbum); | ||||||
|  | 		if (!albumToDelete) { | ||||||
|  | 			return; | ||||||
|  | 		} | ||||||
|  | 		if ( | ||||||
|  | 			window.confirm( | ||||||
|  | 				`Are you sure you want to delete album ${albumToDelete.albumName}? If the album is shared, other users will not be able to access it.` | ||||||
|  | 			) | ||||||
|  | 		) { | ||||||
|  | 			try { | ||||||
|  | 				await api.albumApi.deleteAlbum(albumToDelete.id); | ||||||
|  | 				const _albums = get(albums); | ||||||
|  | 				albums.set(_albums.filter((a) => a.id !== albumToDelete.id)); | ||||||
|  | 			} catch { | ||||||
|  | 				notificationController.show({ | ||||||
|  | 					message: 'Error deleting album', | ||||||
|  | 					type: NotificationType.Error | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		closeAlbumContextMenu(); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return { | ||||||
|  | 		albums, | ||||||
|  | 		isShowContextMenu, | ||||||
|  | 		contextMenuPosition, | ||||||
|  | 		loadAlbums, | ||||||
|  | 		createAlbum, | ||||||
|  | 		showAlbumContextMenu, | ||||||
|  | 		closeAlbumContextMenu, | ||||||
|  | 		deleteSelectedContextAlbum | ||||||
|  | 	}; | ||||||
|  | }; | ||||||
							
								
								
									
										15
									
								
								web/src/test-data/factories/album-factory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								web/src/test-data/factories/album-factory.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | import { AlbumResponseDto } from '@api'; | ||||||
|  | import { Sync } from 'factory.ts'; | ||||||
|  | import { faker } from '@faker-js/faker'; | ||||||
|  |  | ||||||
|  | export const albumFactory = Sync.makeFactory<AlbumResponseDto>({ | ||||||
|  | 	albumName: Sync.each(() => faker.commerce.product()), | ||||||
|  | 	albumThumbnailAssetId: null, | ||||||
|  | 	assetCount: Sync.each((i) => i % 5), | ||||||
|  | 	assets: [], | ||||||
|  | 	createdAt: Sync.each(() => faker.date.past().toISOString()), | ||||||
|  | 	id: Sync.each(() => faker.datatype.uuid()), | ||||||
|  | 	ownerId: Sync.each(() => faker.datatype.uuid()), | ||||||
|  | 	shared: false, | ||||||
|  | 	sharedUsers: [] | ||||||
|  | }); | ||||||
							
								
								
									
										1
									
								
								web/src/test-data/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web/src/test-data/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export * from './factories/album-factory'; | ||||||
| @@ -27,6 +27,9 @@ | |||||||
|       ], |       ], | ||||||
|       "@api": [ |       "@api": [ | ||||||
|         "./src/api" |         "./src/api" | ||||||
|  |       ], | ||||||
|  |       "@test-data": [ | ||||||
|  |         "./src/test-data" | ||||||
|       ] |       ] | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user