diff --git a/lib/Controller/ChecklistController.php b/lib/Controller/ChecklistController.php index 318d4ea..5c91bdb 100644 --- a/lib/Controller/ChecklistController.php +++ b/lib/Controller/ChecklistController.php @@ -419,7 +419,7 @@ final class ChecklistController extends OCSController { $original = (string)($data['name'] ?? 'image.jpg'); $fileId = $this->images->uploadForUser($uid, $houseId, $original, $bytes); - $updated = $this->lists->updateItem($itemId, ['imageFileId' => $fileId]); + $updated = $this->lists->updateItem($itemId, ['imageFileId' => $fileId, 'imageUploadedBy' => $uid]); return new DataResponse($updated->jsonSerialize()); }); } @@ -447,7 +447,7 @@ final class ChecklistController extends OCSController { if ($item->getListId() !== $listId) { throw new NotFoundException('Item does not belong to this list'); } - $updated = $this->lists->updateItem($itemId, ['imageFileId' => null]); + $updated = $this->lists->updateItem($itemId, ['imageFileId' => null, 'imageUploadedBy' => null]); return new DataResponse($updated->jsonSerialize()); }); } diff --git a/lib/Controller/ImageController.php b/lib/Controller/ImageController.php new file mode 100644 index 0000000..0ea0d48 --- /dev/null +++ b/lib/Controller/ImageController.php @@ -0,0 +1,133 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Pantry\Controller; + +use OCA\Pantry\Db\PhotoMapper; +use OCA\Pantry\Exception\ForbiddenException; +use OCA\Pantry\Exception\NotFoundException; +use OCA\Pantry\Service\HouseAuthService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\OCSController; +use OCP\Files\File; +use OCP\Files\IRootFolder; +use OCP\IPreview; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Serve images from the owner's storage so any house member can view them, + * regardless of Nextcloud sharing settings. + */ +final class ImageController extends OCSController { + use TranslatesDomainExceptions; + + public function __construct( + string $appName, + IRequest $request, + private HouseAuthService $auth, + private PhotoMapper $photoMapper, + private IRootFolder $rootFolder, + private IPreview $previewManager, + private IUserSession $userSession, + ) { + parent::__construct($appName, $request); + } + + /** + * Serve a photo board image preview + * + * @param int $houseId House id. + * @param int $photoId Photo record id. + * @param int $size Preview size (longest edge). + * + * @return FileDisplayResponse|DataResponse + * + * 200: Preview returned + * 404: Image not found + */ + #[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/photos/{photoId}/preview')] + #[NoAdminRequired] + #[NoCSRFRequired] + public function photoPreview(int $houseId, int $photoId, int $size = 300): FileDisplayResponse|DataResponse { + return $this->runAction(function () use ($houseId, $photoId, $size) { + $this->auth->requireMember($houseId, $this->requireUid()); + + $photo = $this->photoMapper->findById($photoId); + if ($photo->getHouseId() !== $houseId) { + throw new NotFoundException('Photo does not belong to this house'); + } + + return $this->servePreview($photo->getUploadedBy(), $photo->getFileId(), $size); + }); + } + + /** + * Serve a checklist item image preview + * + * @param int $houseId House id. + * @param int $fileId Nextcloud file id. + * @param string $owner File owner uid. + * @param int $size Preview size (longest edge). + * + * @return FileDisplayResponse|DataResponse + * + * 200: Preview returned + * 404: Image not found + */ + #[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/image-preview')] + #[NoAdminRequired] + #[NoCSRFRequired] + public function itemImagePreview(int $houseId, int $fileId, string $owner, int $size = 300): FileDisplayResponse|DataResponse { + return $this->runAction(function () use ($houseId, $fileId, $owner, $size) { + $this->auth->requireMember($houseId, $this->requireUid()); + + return $this->servePreview($owner, $fileId, $size); + }); + } + + private function servePreview(string $ownerUid, int $fileId, int $size): FileDisplayResponse|DataResponse { + $size = max(16, min($size, 2048)); + + $userFolder = $this->rootFolder->getUserFolder($ownerUid); + $nodes = $userFolder->getById($fileId); + if (empty($nodes)) { + return new DataResponse(['error' => 'File not found'], Http::STATUS_NOT_FOUND); + } + + $file = $nodes[0]; + if (!$file instanceof File) { + return new DataResponse(['error' => 'Not a file'], Http::STATUS_NOT_FOUND); + } + + if ($this->previewManager->isAvailable($file)) { + $preview = $this->previewManager->getPreview($file, $size, $size); + $resp = new FileDisplayResponse($preview, Http::STATUS_OK, [ + 'Content-Type' => $preview->getMimeType(), + ]); + } else { + $resp = new FileDisplayResponse($file, Http::STATUS_OK, [ + 'Content-Type' => $file->getMimeType(), + ]); + } + $resp->cacheFor(3600); + return $resp; + } + + private function requireUid(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new ForbiddenException('Not authenticated'); + } + return $user->getUID(); + } +} diff --git a/lib/Db/ChecklistItem.php b/lib/Db/ChecklistItem.php index 65d9e16..fce6cea 100644 --- a/lib/Db/ChecklistItem.php +++ b/lib/Db/ChecklistItem.php @@ -32,6 +32,8 @@ use OCP\AppFramework\Db\Entity; * @method void setNextDueAt(?int $nextDueAt) * @method int|null getImageFileId() * @method void setImageFileId(?int $imageFileId) + * @method string|null getImageUploadedBy() + * @method void setImageUploadedBy(?string $imageUploadedBy) * @method int getSortOrder() * @method void setSortOrder(int $sortOrder) * @method int getCreatedAt() @@ -51,6 +53,7 @@ class ChecklistItem extends Entity implements \JsonSerializable { protected bool $repeatFromCompletion = false; protected ?int $nextDueAt = null; protected ?int $imageFileId = null; + protected ?string $imageUploadedBy = null; protected int $sortOrder = 0; protected int $createdAt = 0; protected int $updatedAt = 0; @@ -88,6 +91,7 @@ class ChecklistItem extends Entity implements \JsonSerializable { 'repeatFromCompletion' => $this->repeatFromCompletion, 'nextDueAt' => $this->nextDueAt, 'imageFileId' => $this->imageFileId, + 'imageUploadedBy' => $this->imageUploadedBy, 'sortOrder' => $this->sortOrder, 'createdAt' => $this->createdAt, 'updatedAt' => $this->updatedAt, diff --git a/lib/Migration/Version1Date20260405000000.php b/lib/Migration/Version1Date20260405000000.php index 98369ce..2ecf0e6 100644 --- a/lib/Migration/Version1Date20260405000000.php +++ b/lib/Migration/Version1Date20260405000000.php @@ -220,6 +220,10 @@ class Version1Date20260405000000 extends SimpleMigrationStep { 'notnull' => false, 'length' => 20, ]); + $table->addColumn('image_uploaded_by', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); $table->addColumn('sort_order', Types::INTEGER, [ 'notnull' => true, 'default' => 0, diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 4dd1962..4dcccc5 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -51,6 +51,7 @@ namespace OCA\Pantry; * repeatFromCompletion: bool, * nextDueAt: int|null, * imageFileId: int|null, + * imageUploadedBy: string|null, * sortOrder: int, * createdAt: int, * updatedAt: int, diff --git a/lib/Service/ChecklistService.php b/lib/Service/ChecklistService.php index 9fda085..8c9d2bb 100644 --- a/lib/Service/ChecklistService.php +++ b/lib/Service/ChecklistService.php @@ -193,6 +193,10 @@ class ChecklistService { if (array_key_exists('imageFileId', $patch)) { $item->setImageFileId($this->intOrNull($patch['imageFileId'])); } + if (array_key_exists('imageUploadedBy', $patch)) { + $v = $patch['imageUploadedBy']; + $item->setImageUploadedBy(is_string($v) && $v !== '' ? $v : null); + } // If already done and rrule or mode changed, recompute next due. if ($item->getDone() && $item->getRrule() !== null && (array_key_exists('rrule', $patch) || array_key_exists('repeatFromCompletion', $patch))) { diff --git a/openapi.json b/openapi.json index e1d4a8d..0e290e0 100644 --- a/openapi.json +++ b/openapi.json @@ -187,6 +187,7 @@ "repeatFromCompletion", "nextDueAt", "imageFileId", + "imageUploadedBy", "sortOrder", "createdAt", "updatedAt" @@ -241,6 +242,10 @@ "format": "int64", "nullable": true }, + "imageUploadedBy": { + "type": "string", + "nullable": true + }, "sortOrder": { "type": "integer", "format": "int64" @@ -3690,6 +3695,291 @@ } } }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/photos/{photoId}/preview": { + "get": { + "operationId": "image-photo-preview", + "summary": "Serve a photo board image preview", + "tags": [ + "image" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "photoId", + "in": "path", + "description": "Photo record id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "size", + "in": "query", + "description": "Preview size (longest edge).", + "schema": { + "type": "integer", + "format": "int64", + "default": 300 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Preview returned", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Image not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/image-preview": { + "get": { + "operationId": "image-item-image-preview", + "summary": "Serve a checklist item image preview", + "tags": [ + "image" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "fileId", + "in": "query", + "description": "Nextcloud file id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "owner", + "in": "query", + "description": "File owner uid.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "size", + "in": "query", + "description": "Preview size (longest edge).", + "schema": { + "type": "integer", + "format": "int64", + "default": 300 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Preview returned", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Image not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/pantry/api/houses/{houseId}/notes": { "get": { "operationId": "note-index-notes", @@ -6228,5 +6518,10 @@ } } }, - "tags": [] + "tags": [ + { + "name": "image", + "description": "Serve images from the owner's storage so any house member can view them, regardless of Nextcloud sharing settings." + } + ] } diff --git a/src/api/images.ts b/src/api/images.ts new file mode 100644 index 0000000..f0dbe6b --- /dev/null +++ b/src/api/images.ts @@ -0,0 +1,29 @@ +import { generateOcsUrl } from '@nextcloud/router' + +/** + * Build a proxied preview URL for a photo board image. + * Served through the Pantry API so any house member can view it. + */ +export function photoPreviewUrl(houseId: number, photoId: number, size = 300): string { + return generateOcsUrl('/apps/pantry/api/houses/{houseId}/photos/{photoId}/preview?size={size}', { + houseId, + photoId, + size, + }) +} + +/** + * Build a proxied preview URL for a checklist item image. + * Served through the Pantry API so any house member can view it. + */ +export function itemImagePreviewUrl( + houseId: number, + fileId: number, + owner: string, + size = 300, +): string { + return generateOcsUrl( + '/apps/pantry/api/houses/{houseId}/image-preview?fileId={fileId}&owner={owner}&size={size}', + { houseId, fileId, owner, size }, + ) +} diff --git a/src/api/types.ts b/src/api/types.ts index 30c752f..e757a74 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -54,6 +54,7 @@ export interface ChecklistItem { repeatFromCompletion: boolean nextDueAt: number | null imageFileId: number | null + imageUploadedBy: string | null sortOrder: number createdAt: number updatedAt: number diff --git a/src/components/Photos/FolderStack.test.ts b/src/components/Photos/FolderStack.test.ts index 314abbf..0c13604 100644 --- a/src/components/Photos/FolderStack.test.ts +++ b/src/components/Photos/FolderStack.test.ts @@ -7,6 +7,13 @@ import type { Photo, PhotoFolder } from '@/api/types' vi.mock('@nextcloud/l10n', () => nextcloudL10nMock) vi.mock('@nextcloud/router', () => ({ generateUrl: (path: string) => path, + generateOcsUrl: (path: string, params: Record) => { + let url = path + for (const [key, value] of Object.entries(params)) { + url = url.replace(`{${key}}`, String(value)) + } + return url + }, })) vi.mock('@icons/Folder.vue', () => createIconMock('FolderIcon')) vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon')) @@ -58,14 +65,14 @@ describe('FolderStack', () => { describe('rendering', () => { it('renders the folder name overlaid at the bottom', () => { const wrapper = mount(FolderStack, { - props: { folder: makeFolder({ name: 'My Folder' }), photos: [] }, + props: { houseId: 1, folder: makeFolder({ name: 'My Folder' }), photos: [] }, }) expect(wrapper.find('.folder-stack__label').text()).toBe('My Folder') }) it('shows empty icon when no photos', () => { const wrapper = mount(FolderStack, { - props: { folder: makeFolder(), photos: [] }, + props: { houseId: 1, folder: makeFolder(), photos: [] }, }) expect(wrapper.find('.folder-stack__empty').exists()).toBe(true) expect(wrapper.findAll('.folder-stack__photo')).toHaveLength(0) @@ -74,7 +81,7 @@ describe('FolderStack', () => { it('shows up to 5 photo thumbnails', () => { const photos = Array.from({ length: 7 }, (_, i) => makePhoto(i + 1, 1)) const wrapper = mount(FolderStack, { - props: { folder: makeFolder(), photos }, + props: { houseId: 1, folder: makeFolder(), photos }, }) expect(wrapper.findAll('.folder-stack__photo')).toHaveLength(5) }) @@ -82,7 +89,7 @@ describe('FolderStack', () => { it('shows all photos when 5 or fewer', () => { const photos = Array.from({ length: 3 }, (_, i) => makePhoto(i + 1, 1)) const wrapper = mount(FolderStack, { - props: { folder: makeFolder(), photos }, + props: { houseId: 1, folder: makeFolder(), photos }, }) expect(wrapper.findAll('.folder-stack__photo')).toHaveLength(3) }) @@ -90,14 +97,14 @@ describe('FolderStack', () => { it('shows photo count badge', () => { const photos = [makePhoto(1, 1), makePhoto(2, 1)] const wrapper = mount(FolderStack, { - props: { folder: makeFolder(), photos }, + props: { houseId: 1, folder: makeFolder(), photos }, }) expect(wrapper.find('.folder-stack__count').text()).toBe('2') }) it('does not show count badge when empty', () => { const wrapper = mount(FolderStack, { - props: { folder: makeFolder(), photos: [] }, + props: { houseId: 1, folder: makeFolder(), photos: [] }, }) expect(wrapper.find('.folder-stack__count').exists()).toBe(false) }) @@ -105,7 +112,7 @@ describe('FolderStack', () => { it('applies unique rotation transforms to stacked photos', () => { const photos = [makePhoto(1, 1), makePhoto(2, 1), makePhoto(3, 1)] const wrapper = mount(FolderStack, { - props: { folder: makeFolder(), photos }, + props: { houseId: 1, folder: makeFolder(), photos }, }) const imgs = wrapper.findAll('.folder-stack__photo') const transforms = imgs.map((img) => img.attributes('style')) @@ -115,7 +122,7 @@ describe('FolderStack', () => { it('is draggable', () => { const wrapper = mount(FolderStack, { - props: { folder: makeFolder(), photos: [] }, + props: { houseId: 1, folder: makeFolder(), photos: [] }, }) expect(wrapper.find('.folder-stack').attributes('draggable')).toBe('true') }) @@ -124,7 +131,7 @@ describe('FolderStack', () => { describe('actions', () => { it('has rename and delete actions', () => { const wrapper = mount(FolderStack, { - props: { folder: makeFolder(), photos: [] }, + props: { houseId: 1, folder: makeFolder(), photos: [] }, }) const texts = wrapper.findAll('.nc-action-button').map((b) => b.text()) expect(texts).toContain('Rename') @@ -151,7 +158,7 @@ describe('FolderStack', () => { it('does not emit open when actions wrapper is clicked', async () => { const wrapper = mount(FolderStack, { - props: { folder: makeFolder(), photos: [] }, + props: { houseId: 1, folder: makeFolder(), photos: [] }, }) await wrapper.find('.folder-stack__actions').trigger('click') expect(wrapper.emitted('open')).toBeFalsy() @@ -169,7 +176,7 @@ describe('FolderStack', () => { it('shows drag-over style on dragover with photo data', async () => { const wrapper = mount(FolderStack, { - props: { folder: makeFolder(), photos: [] }, + props: { houseId: 1, folder: makeFolder(), photos: [] }, }) await wrapper.find('.folder-stack').trigger('dragover', { dataTransfer: { types: ['application/x-pantry-photo'] }, @@ -179,7 +186,7 @@ describe('FolderStack', () => { it('removes drag-over style on dragleave', async () => { const wrapper = mount(FolderStack, { - props: { folder: makeFolder(), photos: [] }, + props: { houseId: 1, folder: makeFolder(), photos: [] }, }) await wrapper.find('.folder-stack').trigger('dragover', { dataTransfer: { types: ['application/x-pantry-photo'] }, diff --git a/src/components/Photos/FolderStack.vue b/src/components/Photos/FolderStack.vue index 458ea54..1256c1c 100644 --- a/src/components/Photos/FolderStack.vue +++ b/src/components/Photos/FolderStack.vue @@ -14,7 +14,7 @@ import { computed, ref } from 'vue' -import { generateUrl } from '@nextcloud/router' import { t } from '@nextcloud/l10n' +import { photoPreviewUrl } from '@/api/images' import NcActions from '@nextcloud/vue/components/NcActions' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import FolderIcon from '@icons/Folder.vue' @@ -54,6 +54,7 @@ import type { Photo, PhotoFolder } from '@/api/types' const props = defineProps<{ folder: PhotoFolder photos: Photo[] + houseId: number }>() const emit = defineEmits<{ @@ -88,9 +89,8 @@ function photoStyle(index: number) { } } -function thumbUrl(fileId: number): string { - const base = generateUrl('/core/preview') - return `${base}?fileId=${fileId}&x=120&y=120&a=1` +function thumbUrl(photoId: number): string { + return photoPreviewUrl(props.houseId, photoId, 120) } function onDragStart(e: DragEvent) { diff --git a/src/components/Photos/PhotoCard.test.ts b/src/components/Photos/PhotoCard.test.ts index 0f0d428..0c2a851 100644 --- a/src/components/Photos/PhotoCard.test.ts +++ b/src/components/Photos/PhotoCard.test.ts @@ -7,6 +7,13 @@ import type { Photo } from '@/api/types' vi.mock('@nextcloud/l10n', () => nextcloudL10nMock) vi.mock('@nextcloud/router', () => ({ generateUrl: (path: string) => path, + generateOcsUrl: (path: string, params: Record) => { + let url = path + for (const [key, value] of Object.entries(params)) { + url = url.replace(`{${key}}`, String(value)) + } + return url + }, })) vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon')) vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon')) @@ -43,64 +50,64 @@ function makePhoto(overrides: Partial = {}): Photo { } } +function mountCard(overrides: Partial = {}) { + return mount(PhotoCard, { props: { photo: makePhoto(overrides), houseId: 1 } }) +} + describe('PhotoCard', () => { describe('rendering', () => { it('renders an image with correct preview URL', () => { - const wrapper = mount(PhotoCard, { props: { photo: makePhoto() } }) + const wrapper = mountCard() const img = wrapper.find('.photo-card__img') expect(img.exists()).toBe(true) - expect(img.attributes('src')).toContain('fileId=42') - expect(img.attributes('src')).toContain('x=300') + expect(img.attributes('src')).toContain('photos/1/preview') + expect(img.attributes('src')).toContain('size=300') }) it('shows caption when provided', () => { - const wrapper = mount(PhotoCard, { - props: { photo: makePhoto({ caption: 'Test caption' }) }, - }) + const wrapper = mountCard({ caption: 'Test caption' }) const caption = wrapper.find('.photo-card__caption') expect(caption.exists()).toBe(true) expect(caption.text()).toBe('Test caption') }) it('does not show caption when null', () => { - const wrapper = mount(PhotoCard, { props: { photo: makePhoto() } }) + const wrapper = mountCard() expect(wrapper.find('.photo-card__caption').exists()).toBe(false) }) it('uses caption as alt text on image', () => { - const wrapper = mount(PhotoCard, { - props: { photo: makePhoto({ caption: 'Alt text' }) }, - }) + const wrapper = mountCard({ caption: 'Alt text' }) expect(wrapper.find('.photo-card__img').attributes('alt')).toBe('Alt text') }) it('is draggable', () => { - const wrapper = mount(PhotoCard, { props: { photo: makePhoto() } }) + const wrapper = mountCard() expect(wrapper.find('.photo-card').attributes('draggable')).toBe('true') }) }) describe('actions', () => { it('always shows Edit action', () => { - const wrapper = mount(PhotoCard, { props: { photo: makePhoto() } }) + const wrapper = mountCard() const texts = wrapper.findAll('.nc-action-button').map((b) => b.text()) expect(texts).toContain('Edit') }) it('always shows Delete action', () => { - const wrapper = mount(PhotoCard, { props: { photo: makePhoto() } }) + const wrapper = mountCard() const texts = wrapper.findAll('.nc-action-button').map((b) => b.text()) expect(texts).toContain('Delete') }) it('shows "Move to wall" action when photo is in a folder', () => { - const wrapper = mount(PhotoCard, { props: { photo: makePhoto({ folderId: 5 }) } }) + const wrapper = mountCard({ folderId: 5 }) const texts = wrapper.findAll('.nc-action-button').map((b) => b.text()) expect(texts).toContain('Move to wall') }) it('hides "Move to wall" action when photo is at root', () => { - const wrapper = mount(PhotoCard, { props: { photo: makePhoto({ folderId: null }) } }) + const wrapper = mountCard({ folderId: null }) const texts = wrapper.findAll('.nc-action-button').map((b) => b.text()) expect(texts).not.toContain('Move to wall') }) @@ -109,7 +116,7 @@ describe('PhotoCard', () => { describe('events', () => { it('emits preview on click', async () => { const photo = makePhoto() - const wrapper = mount(PhotoCard, { props: { photo } }) + const wrapper = mount(PhotoCard, { props: { photo, houseId: 1 } }) await wrapper.find('.photo-card').trigger('click') expect(wrapper.emitted('preview')).toBeTruthy() expect(wrapper.emitted('preview')![0]).toEqual([photo]) @@ -117,7 +124,7 @@ describe('PhotoCard', () => { it('emits edit when Edit action is clicked', async () => { const photo = makePhoto() - const wrapper = mount(PhotoCard, { props: { photo } }) + const wrapper = mount(PhotoCard, { props: { photo, houseId: 1 } }) const editBtn = wrapper.findAll('.nc-action-button').find((b) => b.text() === 'Edit')! await editBtn.trigger('click') expect(wrapper.emitted('edit')).toBeTruthy() @@ -126,7 +133,7 @@ describe('PhotoCard', () => { it('emits delete when Delete action is clicked', async () => { const photo = makePhoto() - const wrapper = mount(PhotoCard, { props: { photo } }) + const wrapper = mount(PhotoCard, { props: { photo, houseId: 1 } }) const delBtn = wrapper.findAll('.nc-action-button').find((b) => b.text() === 'Delete')! await delBtn.trigger('click') expect(wrapper.emitted('delete')).toBeTruthy() @@ -135,7 +142,7 @@ describe('PhotoCard', () => { it('emits move-to-root when "Move to wall" is clicked', async () => { const photo = makePhoto({ folderId: 5 }) - const wrapper = mount(PhotoCard, { props: { photo } }) + const wrapper = mount(PhotoCard, { props: { photo, houseId: 1 } }) const moveBtn = wrapper.findAll('.nc-action-button').find((b) => b.text() === 'Move to wall')! await moveBtn.trigger('click') expect(wrapper.emitted('move-to-root')).toBeTruthy() @@ -143,14 +150,14 @@ describe('PhotoCard', () => { }) it('does not emit preview when actions wrapper is clicked', async () => { - const wrapper = mount(PhotoCard, { props: { photo: makePhoto() } }) + const wrapper = mountCard() await wrapper.find('.photo-card__actions').trigger('click') expect(wrapper.emitted('preview')).toBeFalsy() }) it('emits drag-start on dragstart', async () => { const photo = makePhoto({ id: 7 }) - const wrapper = mount(PhotoCard, { props: { photo } }) + const wrapper = mount(PhotoCard, { props: { photo, houseId: 1 } }) await wrapper.find('.photo-card').trigger('dragstart', { dataTransfer: { effectAllowed: '', setData: vi.fn() }, }) @@ -159,7 +166,7 @@ describe('PhotoCard', () => { }) it('applies dragging class on dragstart and removes on dragend', async () => { - const wrapper = mount(PhotoCard, { props: { photo: makePhoto() } }) + const wrapper = mountCard() const card = wrapper.find('.photo-card') await card.trigger('dragstart', { diff --git a/src/components/Photos/PhotoCard.vue b/src/components/Photos/PhotoCard.vue index 16d42ce..716b2de 100644 --- a/src/components/Photos/PhotoCard.vue +++ b/src/components/Photos/PhotoCard.vue @@ -8,7 +8,7 @@ @dragover.prevent="onDragOver" @click="$emit('preview', photo)" > - +
@@ -31,8 +31,8 @@