From 23f70ff3f772ad09252ada8310a9fb398d3ce64c Mon Sep 17 00:00:00 2001 From: Jacob Ribnik Date: Mon, 15 Jun 2026 14:40:33 -0400 Subject: [PATCH 1/4] feat: add cover image support for work items Let users designate any image attachment on a work item as its cover, shown on kanban cards and the detail/peek views. The cover is set and cleared from the attachment's context menu and persisted via a new cover_image_attachment foreign key on the issue. - API: add Issue.cover_image_attachment FK (migration 0122), expose and validate cover_image_attachment_id in the issue serializer, and include it in the issue list/detail/sub-issue/grouper projections. - Web: resolve the cover URL deterministically from the attachment id, render it on kanban cards and the detail/peek views, and add a set/remove cover action to the attachment menu. - i18n: add the cover toast/menu strings to all locales. Co-Authored-By: Claude Opus 4.7 --- apps/api/plane/app/serializers/issue.py | 20 ++ apps/api/plane/app/views/issue/base.py | 3 + apps/api/plane/app/views/issue/sub_issue.py | 1 + .../0122_issue_cover_image_attachment.py | 19 ++ apps/api/plane/db/models/issue.py | 7 + apps/api/plane/utils/grouper.py | 1 + .../attachment/attachment-item-list.tsx | 42 +++- .../attachment/attachment-list-item.tsx | 28 ++- .../issues/issue-detail/cover-image.tsx | 45 +++++ .../issues/issue-detail/main-content.tsx | 7 + .../issues/issue-layouts/kanban/block.tsx | 46 +++-- .../issue-layouts/kanban/cover-image.tsx | 40 ++++ .../issues/peek-overview/issue-detail.tsx | 180 +++++++++--------- apps/web/core/hooks/use-issue-cover-image.ts | 24 +++ .../store/issue/issue-details/issue.store.ts | 1 + packages/i18n/src/locales/cs/common.json | 14 +- packages/i18n/src/locales/de/common.json | 14 +- packages/i18n/src/locales/en/common.json | 14 +- packages/i18n/src/locales/es/common.json | 14 +- packages/i18n/src/locales/fr/common.json | 14 +- packages/i18n/src/locales/id/common.json | 14 +- packages/i18n/src/locales/it/common.json | 14 +- packages/i18n/src/locales/ja/common.json | 14 +- packages/i18n/src/locales/ko/common.json | 14 +- packages/i18n/src/locales/pl/common.json | 14 +- packages/i18n/src/locales/pt-BR/common.json | 14 +- packages/i18n/src/locales/ro/common.json | 14 +- packages/i18n/src/locales/ru/common.json | 14 +- packages/i18n/src/locales/sk/common.json | 14 +- packages/i18n/src/locales/tr-TR/common.json | 14 +- packages/i18n/src/locales/ua/common.json | 14 +- packages/i18n/src/locales/vi-VN/common.json | 14 +- packages/i18n/src/locales/zh-CN/common.json | 14 +- packages/i18n/src/locales/zh-TW/common.json | 14 +- packages/types/src/issues/issue.ts | 1 + 35 files changed, 600 insertions(+), 131 deletions(-) create mode 100644 apps/api/plane/db/migrations/0122_issue_cover_image_attachment.py create mode 100644 apps/web/core/components/issues/issue-detail/cover-image.tsx create mode 100644 apps/web/core/components/issues/issue-layouts/kanban/cover-image.tsx create mode 100644 apps/web/core/hooks/use-issue-cover-image.ts diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 673a5570616..d033773fe3b 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -97,6 +97,12 @@ class IssueCreateSerializer(BaseSerializer): write_only=True, required=False, ) + cover_image_attachment_id = serializers.PrimaryKeyRelatedField( + source="cover_image_attachment", + queryset=FileAsset.objects.all(), + required=False, + allow_null=True, + ) project_id = serializers.UUIDField(source="project.id", read_only=True) workspace_id = serializers.UUIDField(source="workspace.id", read_only=True) @@ -194,6 +200,19 @@ def validate(self, attrs): ): raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id") + # Cover image must be an image attachment belonging to this issue + if attrs.get("cover_image_attachment"): + cover = attrs["cover_image_attachment"] + if ( + cover.entity_type != FileAsset.EntityTypeContext.ISSUE_ATTACHMENT + or (self.instance is not None and str(cover.issue_id) != str(self.instance.id)) + or str(cover.project_id) != str(self.context.get("project_id")) + or not str(cover.attributes.get("type", "")).startswith("image/") + ): + raise serializers.ValidationError( + "Cover image must be an image attachment belonging to this work item" + ) + return attrs def create(self, validated_data): @@ -800,6 +819,7 @@ class Meta: "link_count", "is_draft", "archived_at", + "cover_image_attachment_id", ] read_only_fields = fields diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index d9e2ea5a5a8..c1a1b781eaf 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -187,6 +187,7 @@ def get(self, request, slug, project_id): "is_draft", "archived_at", "deleted_at", + "cover_image_attachment_id", ) datetime_fields = ["created_at", "updated_at"] issues = user_timezone_converter(issues, datetime_fields, request.user.user_timezone) @@ -452,6 +453,7 @@ def create(self, request, slug, project_id): "is_draft", "archived_at", "deleted_at", + "cover_image_attachment_id", ) .first() ) @@ -883,6 +885,7 @@ def list(self, request, slug, project_id): "link_count", "attachment_count", "sub_issues_count", + "cover_image_attachment_id", ] if str(is_description_required).lower() == "true": diff --git a/apps/api/plane/app/views/issue/sub_issue.py b/apps/api/plane/app/views/issue/sub_issue.py index 5194148dd7b..0abaacad3e2 100644 --- a/apps/api/plane/app/views/issue/sub_issue.py +++ b/apps/api/plane/app/views/issue/sub_issue.py @@ -165,6 +165,7 @@ def get(self, request, slug, project_id, issue_id): "is_draft", "archived_at", "state_group", + "cover_image_attachment_id", ) ) diff --git a/apps/api/plane/db/migrations/0122_issue_cover_image_attachment.py b/apps/api/plane/db/migrations/0122_issue_cover_image_attachment.py new file mode 100644 index 00000000000..6bd17e2affd --- /dev/null +++ b/apps/api/plane/db/migrations/0122_issue_cover_image_attachment.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.29 on 2026-05-28 20:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0121_alter_estimate_type'), + ] + + operations = [ + migrations.AddField( + model_name='issue', + name='cover_image_attachment', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cover_image_issues', to='db.fileasset'), + ), + ] diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index fe23ee681dc..6d26ac394eb 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -168,6 +168,13 @@ class Issue(ChangeTrackerMixin, ProjectBaseModel): null=True, blank=True, ) + cover_image_attachment = models.ForeignKey( + "db.FileAsset", + on_delete=models.SET_NULL, + related_name="cover_image_issues", + null=True, + blank=True, + ) issue_objects = IssueManager() diff --git a/apps/api/plane/utils/grouper.py b/apps/api/plane/utils/grouper.py index ab008796715..d8fc72a25ad 100644 --- a/apps/api/plane/utils/grouper.py +++ b/apps/api/plane/utils/grouper.py @@ -127,6 +127,7 @@ def issue_on_results( "is_draft", "archived_at", "state__group", + "cover_image_attachment_id", ] if group_by in FIELD_MAPPER: diff --git a/apps/web/core/components/issues/attachment/attachment-item-list.tsx b/apps/web/core/components/issues/attachment/attachment-item-list.tsx index 0ac4db84f29..a61efc99cc2 100644 --- a/apps/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/apps/web/core/components/issues/attachment/attachment-item-list.tsx @@ -10,7 +10,7 @@ import type { FileRejection } from "react-dropzone"; import { useDropzone } from "react-dropzone"; import { UploadCloud } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { TOAST_TYPE, setToast, setPromiseToast } from "@plane/propel/toast"; import type { TIssueServiceType } from "@plane/types"; import { EIssueServiceType } from "@plane/types"; // hooks @@ -53,6 +53,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList toggleDeleteAttachmentModal, fetchActivities, } = useIssueDetail(issueServiceType); + const { updateIssue, getIssueById } = useIssueDetail(issueServiceType).issue; const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers; const { create: createAttachment } = attachmentOperations; const { uploadStatus } = attachmentSnapshot; @@ -60,12 +61,43 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList const { maxFileSize } = useFileSize(); // derived values const issueAttachments = getAttachmentsByIssueId(issueId); + const coverImageAttachmentId = getIssueById(issueId)?.cover_image_attachment_id ?? null; // handlers const handleFetchPropertyActivities = useCallback(() => { fetchActivities(workspaceSlug, projectId, issueId); }, [fetchActivities, workspaceSlug, projectId, issueId]); + const handleToggleCoverImage = useCallback( + async (attachmentId: string) => { + const isCurrentCover = getIssueById(issueId)?.cover_image_attachment_id === attachmentId; + const nextCoverId = isCurrentCover ? null : attachmentId; + const coverPromise = updateIssue(workspaceSlug, projectId, issueId, { + cover_image_attachment_id: nextCoverId, + }).then(() => { + handleFetchPropertyActivities(); + return; + }); + + setPromiseToast(coverPromise, { + loading: isCurrentCover ? t("attachment.remove_cover_loading") : t("attachment.set_cover_loading"), + success: { + title: isCurrentCover ? t("attachment.remove_cover_success_title") : t("attachment.set_cover_success_title"), + message: () => + isCurrentCover ? t("attachment.remove_cover_success_message") : t("attachment.set_cover_success_message"), + }, + error: { + title: isCurrentCover ? t("attachment.remove_cover_error_title") : t("attachment.set_cover_error_title"), + message: () => + isCurrentCover ? t("attachment.remove_cover_error_message") : t("attachment.set_cover_error_message"), + }, + }); + + return coverPromise; + }, + [updateIssue, getIssueById, workspaceSlug, projectId, issueId, handleFetchPropertyActivities, t] + ); + const onDrop = useCallback( (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; @@ -100,7 +132,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList }); return; }, - [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities] + [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities, t] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ @@ -112,8 +144,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList return ( <> - {uploadStatus?.map((uploadStatus) => ( - + {uploadStatus?.map((status) => ( + ))} {issueAttachments && ( <> @@ -147,6 +179,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList attachmentId={attachmentId} disabled={disabled} issueServiceType={issueServiceType} + onToggleCoverImage={handleToggleCoverImage} + isCoverImage={coverImageAttachmentId === attachmentId} /> ))} diff --git a/apps/web/core/components/issues/attachment/attachment-list-item.tsx b/apps/web/core/components/issues/attachment/attachment-list-item.tsx index 6c8135d6264..dfd2d1c3a76 100644 --- a/apps/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/apps/web/core/components/issues/attachment/attachment-list-item.tsx @@ -5,6 +5,7 @@ */ import { observer } from "mobx-react"; +import { Image, ImageOff } from "lucide-react"; import { useTranslation } from "@plane/i18n"; import { TrashIcon } from "@plane/propel/icons"; @@ -28,12 +29,20 @@ type TIssueAttachmentsListItem = { attachmentId: string; disabled?: boolean; issueServiceType?: TIssueServiceType; + onToggleCoverImage?: (attachmentId: string) => Promise; + isCoverImage?: boolean; }; export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) { const { t } = useTranslation(); // props - const { attachmentId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props; + const { + attachmentId, + disabled, + issueServiceType = EIssueServiceType.ISSUES, + onToggleCoverImage, + isCoverImage = false, + } = props; // store hooks const { getUserDetails } = useMember(); const { @@ -46,6 +55,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt const fileExtension = getFileExtension(attachment?.attributes.name ?? ""); const fileIcon = getFileIcon(fileExtension, 18); const fileURL = getFileURL(attachment?.asset_url ?? ""); + const isImage = attachment?.attributes.name ? /\.(jpg|jpeg|png|gif|webp)$/i.test(attachment.attributes.name) : false; // hooks const { isMobile } = usePlatformOS(); @@ -87,6 +97,22 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt )} + {isImage && onToggleCoverImage && ( + { + onToggleCoverImage(attachmentId); + }} + > +
+ {isCoverImage ? ( + + ) : ( + + )} + {isCoverImage ? t("attachment.remove_cover_image") : t("attachment.make_cover_image")} +
+
+ )} { toggleDeleteAttachmentModal(attachmentId); diff --git a/apps/web/core/components/issues/issue-detail/cover-image.tsx b/apps/web/core/components/issues/issue-detail/cover-image.tsx new file mode 100644 index 00000000000..efd2f3f8dba --- /dev/null +++ b/apps/web/core/components/issues/issue-detail/cover-image.tsx @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { cn } from "@plane/utils"; +import { useIssueCoverImage } from "@/hooks/use-issue-cover-image"; + +interface IssueDetailCoverImageProps { + issueId: string; + projectId: string | null; + coverImageAttachmentId?: string | null; + // Negative margins must cancel the parent container's horizontal padding so the + // cover bleeds edge-to-edge. Defaults assume a px-8 container (peek overview); + // pass a matching value for other paddings (e.g. px-9 on the browse view). + layoutClassName?: string; +} + +export const IssueDetailCoverImage = observer(function IssueDetailCoverImage(props: IssueDetailCoverImageProps) { + const { issueId, projectId, coverImageAttachmentId, layoutClassName = "-mx-8 w-[calc(100%+4rem)]" } = props; + const { workspaceSlug } = useParams(); + const [imageLoadError, setImageLoadError] = useState(false); + + const coverImageUrl = useIssueCoverImage(workspaceSlug?.toString(), projectId, issueId, coverImageAttachmentId); + + if (!coverImageUrl || imageLoadError) { + return null; + } + + return ( +
+ Cover setImageLoadError(true)} + loading="lazy" + /> +
+ ); +}); diff --git a/apps/web/core/components/issues/issue-detail/main-content.tsx b/apps/web/core/components/issues/issue-detail/main-content.tsx index d14bd35396a..988ac595dca 100644 --- a/apps/web/core/components/issues/issue-detail/main-content.tsx +++ b/apps/web/core/components/issues/issue-detail/main-content.tsx @@ -32,6 +32,7 @@ import { IssueDetailWidgets } from "../issue-detail-widgets"; import { NameDescriptionUpdateStatus } from "../issue-update-status"; import { PeekOverviewProperties } from "../peek-overview/properties"; import { IssueTitleInput } from "../title-input"; +import { IssueDetailCoverImage } from "./cover-image"; import { IssueActivity } from "./issue-activity"; import { IssueParentDetail } from "./parent"; import { IssueReaction } from "./reactions"; @@ -92,6 +93,12 @@ export const IssueMainContent = observer(function IssueMainContent(props: Props) return ( <> +
{issue.parent_id && ( handleIssuePeekOverview(issue)} disabled={!!issue?.tempId} > - - - + +
+ + + +
diff --git a/apps/web/core/components/issues/issue-layouts/kanban/cover-image.tsx b/apps/web/core/components/issues/issue-layouts/kanban/cover-image.tsx new file mode 100644 index 00000000000..d36aaa0a6aa --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/kanban/cover-image.tsx @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { useIssueCoverImage } from "@/hooks/use-issue-cover-image"; + +interface KanbanIssueCoverImageProps { + issueId: string; + projectId: string | null; + coverImageAttachmentId?: string | null; +} + +export const KanbanIssueCoverImage = observer(function KanbanIssueCoverImage(props: KanbanIssueCoverImageProps) { + const { issueId, projectId, coverImageAttachmentId } = props; + const { workspaceSlug } = useParams(); + const [imageLoadError, setImageLoadError] = useState(false); + + const coverImageUrl = useIssueCoverImage(workspaceSlug?.toString(), projectId, issueId, coverImageAttachmentId); + + if (!coverImageUrl || imageLoadError) { + return null; + } + + return ( +
+ Cover setImageLoadError(true)} + loading="lazy" + /> +
+ ); +}); diff --git a/apps/web/core/components/issues/peek-overview/issue-detail.tsx b/apps/web/core/components/issues/peek-overview/issue-detail.tsx index 6bd89124b62..dfb7ac92a83 100644 --- a/apps/web/core/components/issues/peek-overview/issue-detail.tsx +++ b/apps/web/core/components/issues/peek-overview/issue-detail.tsx @@ -30,6 +30,7 @@ import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-dup import { WorkItemVersionService } from "@/services/issue"; // local components import type { TIssueOperations } from "../issue-detail"; +import { IssueDetailCoverImage } from "../issue-detail/cover-image"; import { IssueParentDetail } from "../issue-detail/parent"; import { IssueReaction } from "../issue-detail/reactions"; import { IssueTitleInput } from "../title-input"; @@ -97,101 +98,104 @@ export const PeekOverviewIssueDetails = observer(function PeekOverviewIssueDetai : undefined; return ( -
- {issue.parent_id && ( - - )} -
- - {duplicateIssues?.length > 0 && ( - - )} -
- + setIsSubmitting(value)} - issueOperations={issueOperations} - disabled={disabled || isArchived} - value={issue.name} - containerClassName="-ml-3" + coverImageAttachmentId={issue.cover_image_attachment_id} /> - - { - if (!issue.id || !issue.project_id) return; - await issueOperations.update(workspaceSlug, issue.project_id, issue.id, { - description_html: value.description_html, - ...(isMigrationUpdate ? { skip_activity: "true" } : {}), - }); - }} - setIsSubmitting={(value) => setIsSubmitting(value)} - projectId={issue.project_id} - workspaceSlug={workspaceSlug} - /> - -
- {currentUser && ( - + {issue.parent_id && ( + - )} - {!disabled && ( - - workItemVersionService.listDescriptionVersions( - workspaceSlug, - issue.project_id?.toString() ?? "", - issueId - ), - retrieveDescriptionVersion: (issueId, versionId) => - workItemVersionService.retrieveDescriptionVersion( - workspaceSlug, - issue.project_id?.toString() ?? "", - issueId, - versionId - ), - }} - handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)} - projectId={issue.project_id} - workspaceSlug={workspaceSlug} + issue={issue} + issueOperations={issueOperations} /> )} +
+ + {duplicateIssues?.length > 0 && ( + + )} +
+ setIsSubmitting(value)} + issueOperations={issueOperations} + disabled={disabled || isArchived} + value={issue.name} + containerClassName="-ml-3" + /> + + { + if (!issue.id || !issue.project_id) return; + await issueOperations.update(workspaceSlug, issue.project_id, issue.id, { + description_html: value.description_html, + ...(isMigrationUpdate ? { skip_activity: "true" } : {}), + }); + }} + setIsSubmitting={(value) => setIsSubmitting(value)} + projectId={issue.project_id} + workspaceSlug={workspaceSlug} + /> + +
+ {currentUser && ( + + )} + {!disabled && ( + + workItemVersionService.listDescriptionVersions(workspaceSlug, issue.project_id?.toString() ?? "", id), + retrieveDescriptionVersion: (id, versionId) => + workItemVersionService.retrieveDescriptionVersion( + workspaceSlug, + issue.project_id?.toString() ?? "", + id, + versionId + ), + }} + handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)} + projectId={issue.project_id} + workspaceSlug={workspaceSlug} + /> + )} +
-
+ ); }); diff --git a/apps/web/core/hooks/use-issue-cover-image.ts b/apps/web/core/hooks/use-issue-cover-image.ts new file mode 100644 index 00000000000..8a792ea2d6e --- /dev/null +++ b/apps/web/core/hooks/use-issue-cover-image.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { getFileURL } from "@plane/utils"; + +// Mirrors FileAsset.asset_url for ISSUE_ATTACHMENT on the backend, letting us +// resolve the cover from its attachment id without fetching the attachment list. +const buildAttachmentUrl = (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => + getFileURL( + `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/attachments/${attachmentId}/` + ) ?? null; + +export const useIssueCoverImage = ( + workspaceSlug: string | undefined, + projectId: string | null | undefined, + issueId: string, + coverImageAttachmentId?: string | null +): string | null => { + if (!workspaceSlug || !projectId || !issueId || !coverImageAttachmentId) return null; + return buildAttachmentUrl(workspaceSlug, projectId, issueId, coverImageAttachmentId); +}; diff --git a/apps/web/core/store/issue/issue-details/issue.store.ts b/apps/web/core/store/issue/issue-details/issue.store.ts index 4abf62b4f5c..74ca8878bba 100644 --- a/apps/web/core/store/issue/issue-details/issue.store.ts +++ b/apps/web/core/store/issue/issue-details/issue.store.ts @@ -159,6 +159,7 @@ export class IssueStore implements IIssueStore { cycle_id: issue?.cycle_id, module_ids: issue?.module_ids, type_id: issue?.type_id, + cover_image_attachment_id: issue?.cover_image_attachment_id, created_at: issue?.created_at, updated_at: issue?.updated_at, start_date: issue?.start_date, diff --git a/packages/i18n/src/locales/cs/common.json b/packages/i18n/src/locales/cs/common.json index c1e9372c24e..6c4b5baca21 100644 --- a/packages/i18n/src/locales/cs/common.json +++ b/packages/i18n/src/locales/cs/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Je možné nahrát pouze jeden soubor najednou.", "file_size_limit": "Soubor musí být menší než {size}MB.", "drag_and_drop": "Přetáhněte soubor kamkoli pro nahrání", - "delete": "Smazat přílohu" + "delete": "Smazat přílohu", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Vybrat štítek", diff --git a/packages/i18n/src/locales/de/common.json b/packages/i18n/src/locales/de/common.json index 1e18d4ee26e..4293393f691 100644 --- a/packages/i18n/src/locales/de/common.json +++ b/packages/i18n/src/locales/de/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Es kann jeweils nur eine Datei hochgeladen werden.", "file_size_limit": "Die Datei muss kleiner als {size} MB sein.", "drag_and_drop": "Datei hierher ziehen, um sie hochzuladen", - "delete": "Anhang löschen" + "delete": "Anhang löschen", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Label auswählen", diff --git a/packages/i18n/src/locales/en/common.json b/packages/i18n/src/locales/en/common.json index a138304371e..7036ac154f0 100644 --- a/packages/i18n/src/locales/en/common.json +++ b/packages/i18n/src/locales/en/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Only one file can be uploaded at a time.", "file_size_limit": "File must be of {size}MB or less in size.", "drag_and_drop": "Drag and drop anywhere to upload", - "delete": "Delete attachment" + "delete": "Delete attachment", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Add labels", diff --git a/packages/i18n/src/locales/es/common.json b/packages/i18n/src/locales/es/common.json index 64440c9c653..74061740def 100644 --- a/packages/i18n/src/locales/es/common.json +++ b/packages/i18n/src/locales/es/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Solo se puede subir un archivo a la vez.", "file_size_limit": "El archivo debe tener {size}MB o menos de tamaño.", "drag_and_drop": "Arrastra y suelta en cualquier lugar para subir", - "delete": "Eliminar archivo adjunto" + "delete": "Eliminar archivo adjunto", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Seleccionar etiqueta", diff --git a/packages/i18n/src/locales/fr/common.json b/packages/i18n/src/locales/fr/common.json index a94ab8aba2d..2f8b12740bf 100644 --- a/packages/i18n/src/locales/fr/common.json +++ b/packages/i18n/src/locales/fr/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Un seul fichier peut être téléchargé à la fois.", "file_size_limit": "Le fichier doit faire {size}MB ou moins.", "drag_and_drop": "Glissez-déposez n’importe où pour uploader", - "delete": "Supprimer la pièce jointe" + "delete": "Supprimer la pièce jointe", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Sélectionner une étiquette", diff --git a/packages/i18n/src/locales/id/common.json b/packages/i18n/src/locales/id/common.json index 7d266850dc4..b8086744daa 100644 --- a/packages/i18n/src/locales/id/common.json +++ b/packages/i18n/src/locales/id/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Hanya satu file yang dapat diunggah pada satu waktu.", "file_size_limit": "File harus berukuran {size}MB atau lebih kecil.", "drag_and_drop": "Seret dan jatuhkan di mana saja untuk mengunggah", - "delete": "Hapus lampiran" + "delete": "Hapus lampiran", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Pilih label", diff --git a/packages/i18n/src/locales/it/common.json b/packages/i18n/src/locales/it/common.json index 6fda1b04957..b7dbd47d66a 100644 --- a/packages/i18n/src/locales/it/common.json +++ b/packages/i18n/src/locales/it/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "È possibile caricare un solo file alla volta.", "file_size_limit": "Il file deve essere di {size}MB o meno.", "drag_and_drop": "Trascina e rilascia ovunque per caricare", - "delete": "Elimina allegato" + "delete": "Elimina allegato", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Seleziona etichetta", diff --git a/packages/i18n/src/locales/ja/common.json b/packages/i18n/src/locales/ja/common.json index 4899411b615..219d3a34b3a 100644 --- a/packages/i18n/src/locales/ja/common.json +++ b/packages/i18n/src/locales/ja/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "一度にアップロードできるファイルは1つだけです。", "file_size_limit": "ファイルサイズは{size}MB以下である必要があります。", "drag_and_drop": "どこにでもドラッグ&ドロップでアップロード", - "delete": "添付ファイルを削除" + "delete": "添付ファイルを削除", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "ラベルを選択", diff --git a/packages/i18n/src/locales/ko/common.json b/packages/i18n/src/locales/ko/common.json index 73eea6b1c25..f528e7ffea5 100644 --- a/packages/i18n/src/locales/ko/common.json +++ b/packages/i18n/src/locales/ko/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "한 번에 하나의 파일만 업로드할 수 있습니다.", "file_size_limit": "파일 크기는 {size}MB 이하이어야 합니다.", "drag_and_drop": "업로드하려면 아무 곳에나 드래그 앤 드롭하세요", - "delete": "첨부 파일 삭제" + "delete": "첨부 파일 삭제", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "레이블 선택", diff --git a/packages/i18n/src/locales/pl/common.json b/packages/i18n/src/locales/pl/common.json index 4c4850cbf66..03f7b7c6075 100644 --- a/packages/i18n/src/locales/pl/common.json +++ b/packages/i18n/src/locales/pl/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Możesz przesłać tylko jeden plik naraz.", "file_size_limit": "Plik musi być mniejszy niż {size}MB.", "drag_and_drop": "Przeciągnij plik w dowolne miejsce, aby przesłać", - "delete": "Usuń załącznik" + "delete": "Usuń załącznik", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Wybierz etykietę", diff --git a/packages/i18n/src/locales/pt-BR/common.json b/packages/i18n/src/locales/pt-BR/common.json index 565e85d8739..d3b423bf306 100644 --- a/packages/i18n/src/locales/pt-BR/common.json +++ b/packages/i18n/src/locales/pt-BR/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Apenas um arquivo pode ser enviado por vez.", "file_size_limit": "O arquivo deve ter {size}MB ou menos.", "drag_and_drop": "Arraste e solte em qualquer lugar para enviar", - "delete": "Excluir anexo" + "delete": "Excluir anexo", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Selecionar etiqueta", diff --git a/packages/i18n/src/locales/ro/common.json b/packages/i18n/src/locales/ro/common.json index e2b44ce87ec..e66f8a60536 100644 --- a/packages/i18n/src/locales/ro/common.json +++ b/packages/i18n/src/locales/ro/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Se poate încărca doar un fișier o dată.", "file_size_limit": "Fișierul trebuie să aibă {size}MB sau mai puțin.", "drag_and_drop": "Trage și plasează oriunde pentru a încărca", - "delete": "Șterge atașamentul" + "delete": "Șterge atașamentul", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Selectează eticheta", diff --git a/packages/i18n/src/locales/ru/common.json b/packages/i18n/src/locales/ru/common.json index c2881e55f8a..4971704b314 100644 --- a/packages/i18n/src/locales/ru/common.json +++ b/packages/i18n/src/locales/ru/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Можно загрузить только один файл", "file_size_limit": "Максимальный размер файла - {size} МБ", "drag_and_drop": "Перетащите файл для загрузки", - "delete": "Удалить вложение" + "delete": "Удалить вложение", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Выбрать метку", diff --git a/packages/i18n/src/locales/sk/common.json b/packages/i18n/src/locales/sk/common.json index 4fc339cece6..76546c42930 100644 --- a/packages/i18n/src/locales/sk/common.json +++ b/packages/i18n/src/locales/sk/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Je možné nahrať iba jeden súbor naraz.", "file_size_limit": "Súbor musí byť menší ako {size}MB.", "drag_and_drop": "Pretiahnite súbor kamkoľvek pre nahratie", - "delete": "Zmazať prílohu" + "delete": "Zmazať prílohu", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Vybrať štítok", diff --git a/packages/i18n/src/locales/tr-TR/common.json b/packages/i18n/src/locales/tr-TR/common.json index 74bd5cbf9b3..85e54bd3649 100644 --- a/packages/i18n/src/locales/tr-TR/common.json +++ b/packages/i18n/src/locales/tr-TR/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Aynı anda yalnızca bir dosya yüklenebilir.", "file_size_limit": "Dosya boyutu {size}MB veya daha az olmalıdır.", "drag_and_drop": "Yüklemek için herhangi bir yere sürükleyip bırakın", - "delete": "Eki sil" + "delete": "Eki sil", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Etiket seç", diff --git a/packages/i18n/src/locales/ua/common.json b/packages/i18n/src/locales/ua/common.json index 89bd906d8fa..032963b10f5 100644 --- a/packages/i18n/src/locales/ua/common.json +++ b/packages/i18n/src/locales/ua/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Можна завантажити лише один файл одночасно.", "file_size_limit": "Файл має бути меншим за {size}МБ.", "drag_and_drop": "Перетягніть файл сюди для завантаження", - "delete": "Видалити вкладення" + "delete": "Видалити вкладення", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Вибрати мітку", diff --git a/packages/i18n/src/locales/vi-VN/common.json b/packages/i18n/src/locales/vi-VN/common.json index f9aae43c1b9..8ab6312ba0b 100644 --- a/packages/i18n/src/locales/vi-VN/common.json +++ b/packages/i18n/src/locales/vi-VN/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "Chỉ có thể tải lên một tệp mỗi lần.", "file_size_limit": "Kích thước tệp phải nhỏ hơn hoặc bằng {size}MB.", "drag_and_drop": "Kéo và thả vào bất kỳ đâu để tải lên", - "delete": "Xóa tệp đính kèm" + "delete": "Xóa tệp đính kèm", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "Chọn nhãn", diff --git a/packages/i18n/src/locales/zh-CN/common.json b/packages/i18n/src/locales/zh-CN/common.json index dd67d925a07..91e62688f0e 100644 --- a/packages/i18n/src/locales/zh-CN/common.json +++ b/packages/i18n/src/locales/zh-CN/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "一次只能上传一个文件。", "file_size_limit": "文件大小必须小于或等于 {size}MB。", "drag_and_drop": "拖放到任意位置以上传", - "delete": "删除附件" + "delete": "删除附件", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "选择标签", diff --git a/packages/i18n/src/locales/zh-TW/common.json b/packages/i18n/src/locales/zh-TW/common.json index 834a2d922d8..81d4238deb4 100644 --- a/packages/i18n/src/locales/zh-TW/common.json +++ b/packages/i18n/src/locales/zh-TW/common.json @@ -762,7 +762,19 @@ "only_one_file_allowed": "一次只能上傳一個檔案。", "file_size_limit": "檔案大小必須小於或等於 {size}MB。", "drag_and_drop": "拖曳到任何位置以上傳", - "delete": "刪除附件" + "delete": "刪除附件", + "make_cover_image": "Make cover image", + "remove_cover_image": "Remove cover image", + "set_cover_loading": "Setting cover image...", + "set_cover_success_title": "Cover image set", + "set_cover_success_message": "This image is now the cover image", + "set_cover_error_title": "Couldn't set cover image", + "set_cover_error_message": "Could not set this image as the cover", + "remove_cover_loading": "Removing cover image...", + "remove_cover_success_title": "Cover image removed", + "remove_cover_success_message": "This work item no longer has a cover image", + "remove_cover_error_title": "Couldn't remove cover image", + "remove_cover_error_message": "Could not remove the cover image" }, "label": { "select": "選擇標籤", diff --git a/packages/types/src/issues/issue.ts b/packages/types/src/issues/issue.ts index 8054b4c44c3..398ea5f199b 100644 --- a/packages/types/src/issues/issue.ts +++ b/packages/types/src/issues/issue.ts @@ -63,6 +63,7 @@ export type TBaseIssue = { cycle_id: string | null; module_ids: string[] | null; type_id: string | null; + cover_image_attachment_id: string | null; created_at: string; updated_at: string; From 9750e936372817820901c8dd0215db4b52e805dd Mon Sep 17 00:00:00 2001 From: Jacob Ribnik Date: Mon, 15 Jun 2026 15:27:14 -0400 Subject: [PATCH 2/4] fix: clear work item cover when its attachment is deleted Deleting the attachment that backed a cover image left a dangling cover_image_attachment_id: the v2 attachment endpoint soft-deletes the FileAsset, so the cover FK's on_delete=SET_NULL never fired, and the frontend store never cleared the reference either. - API: clear cover_image_attachment on any issue referencing the asset during the soft delete, so API clients stay consistent too. - Web: when removeAttachment deletes the current cover, also persist cover_image_attachment_id: null on the work item. Co-Authored-By: Claude Opus 4.7 --- apps/api/plane/app/views/issue/attachment.py | 6 +++++- .../core/store/issue/issue-details/attachment.store.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/api/plane/app/views/issue/attachment.py b/apps/api/plane/app/views/issue/attachment.py index 51248b8a428..30f086b0c8a 100644 --- a/apps/api/plane/app/views/issue/attachment.py +++ b/apps/api/plane/app/views/issue/attachment.py @@ -20,7 +20,7 @@ # Module imports from .. import BaseAPIView from plane.app.serializers import IssueAttachmentSerializer -from plane.db.models import FileAsset, Workspace +from plane.db.models import FileAsset, Issue, Workspace from plane.bgtasks.issue_activities_task import issue_activity from plane.app.permissions import allow_permission, ROLE from plane.settings.storage import S3Storage @@ -153,6 +153,10 @@ def delete(self, request, slug, project_id, issue_id, pk): issue_attachment.deleted_at = timezone.now() issue_attachment.save() + # Soft-deleting the asset does not trip the cover FK's on_delete, so any + # work item using it as its cover would keep a dangling reference. Clear it. + Issue.objects.filter(cover_image_attachment_id=pk).update(cover_image_attachment=None) + issue_activity.delay( type="attachment.activity.deleted", requested_data=None, diff --git a/apps/web/core/store/issue/issue-details/attachment.store.ts b/apps/web/core/store/issue/issue-details/attachment.store.ts index c9eeb1158f0..82ac4cc503c 100644 --- a/apps/web/core/store/issue/issue-details/attachment.store.ts +++ b/apps/web/core/store/issue/issue-details/attachment.store.ts @@ -185,6 +185,8 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { }; removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => { + const wasCoverImage = this.rootIssueStore.issues.getIssueById(issueId)?.cover_image_attachment_id === attachmentId; + const response = await this.issueAttachmentService.deleteIssueAttachment( workspaceSlug, projectId, @@ -203,6 +205,14 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { }); }); + // Deleting the attachment that backed the cover would leave a dangling + // cover_image_attachment_id, so clear it on the work item too. + if (wasCoverImage) { + await this.rootIssueDetailStore.issue.updateIssue(workspaceSlug, projectId, issueId, { + cover_image_attachment_id: null, + }); + } + return response; }; } From f3d1027ae0a4157f305b3e7b8e49c5efd19b4e6f Mon Sep 17 00:00:00 2001 From: Jacob Ribnik Date: Mon, 15 Jun 2026 15:40:44 -0400 Subject: [PATCH 3/4] fix: avoid error toast when deleting a cover attachment The previous cover-clear ran an extra updateIssue PATCH after the delete had already succeeded; when that second request rejected, the caller's try/catch surfaced a spurious "Attachment not removed" error even though the delete went through. The backend already nulls the cover FK during the soft delete, so mirror it in the local store instead of issuing a redundant network request. Co-Authored-By: Claude Opus 4.7 --- .../store/issue/issue-details/attachment.store.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/web/core/store/issue/issue-details/attachment.store.ts b/apps/web/core/store/issue/issue-details/attachment.store.ts index 82ac4cc503c..dcecb705151 100644 --- a/apps/web/core/store/issue/issue-details/attachment.store.ts +++ b/apps/web/core/store/issue/issue-details/attachment.store.ts @@ -200,19 +200,14 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { return attachmentIds; }); delete this.attachmentMap[attachmentId]; + // The backend clears cover_image_attachment_id when the asset is deleted, + // so just mirror that locally to avoid leaving a dangling cover reference. this.rootIssueStore.issues.updateIssue(issueId, { attachment_count: this.getAttachmentsCountByIssueId(issueId), + ...(wasCoverImage ? { cover_image_attachment_id: null } : {}), }); }); - // Deleting the attachment that backed the cover would leave a dangling - // cover_image_attachment_id, so clear it on the work item too. - if (wasCoverImage) { - await this.rootIssueDetailStore.issue.updateIssue(workspaceSlug, projectId, issueId, { - cover_image_attachment_id: null, - }); - } - return response; }; } From a6273a7afa19964d1c2ac58ac2d5164da78c2f9c Mon Sep 17 00:00:00 2001 From: Jacob Ribnik Date: Mon, 15 Jun 2026 16:23:50 -0400 Subject: [PATCH 4/4] fix: address cover image review feedback - Reset the image load-error flag when the cover URL changes, so a new valid cover renders after a previous one failed to load (kanban + detail). - Reject a cover_image_attachment on issue create: a new issue has no attachments of its own yet, so the ownership check now requires an existing instance instead of being skipped on create. Co-Authored-By: Claude Opus 4.7 --- apps/api/plane/app/serializers/issue.py | 8 +++++--- .../core/components/issues/issue-detail/cover-image.tsx | 6 +++++- .../issues/issue-layouts/kanban/cover-image.tsx | 6 +++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index d033773fe3b..0d75a1c6ef6 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -200,12 +200,14 @@ def validate(self, attrs): ): raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id") - # Cover image must be an image attachment belonging to this issue + # Cover image must be an image attachment belonging to this issue. A new + # issue has no attachments yet, so a cover can only be set on update. if attrs.get("cover_image_attachment"): cover = attrs["cover_image_attachment"] if ( - cover.entity_type != FileAsset.EntityTypeContext.ISSUE_ATTACHMENT - or (self.instance is not None and str(cover.issue_id) != str(self.instance.id)) + self.instance is None + or cover.entity_type != FileAsset.EntityTypeContext.ISSUE_ATTACHMENT + or str(cover.issue_id) != str(self.instance.id) or str(cover.project_id) != str(self.context.get("project_id")) or not str(cover.attributes.get("type", "")).startswith("image/") ): diff --git a/apps/web/core/components/issues/issue-detail/cover-image.tsx b/apps/web/core/components/issues/issue-detail/cover-image.tsx index efd2f3f8dba..83eb44b26d1 100644 --- a/apps/web/core/components/issues/issue-detail/cover-image.tsx +++ b/apps/web/core/components/issues/issue-detail/cover-image.tsx @@ -4,7 +4,7 @@ * See the LICENSE file for details. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { cn } from "@plane/utils"; @@ -27,6 +27,10 @@ export const IssueDetailCoverImage = observer(function IssueDetailCoverImage(pro const coverImageUrl = useIssueCoverImage(workspaceSlug?.toString(), projectId, issueId, coverImageAttachmentId); + useEffect(() => { + setImageLoadError(false); + }, [coverImageUrl]); + if (!coverImageUrl || imageLoadError) { return null; } diff --git a/apps/web/core/components/issues/issue-layouts/kanban/cover-image.tsx b/apps/web/core/components/issues/issue-layouts/kanban/cover-image.tsx index d36aaa0a6aa..50eb4d779cc 100644 --- a/apps/web/core/components/issues/issue-layouts/kanban/cover-image.tsx +++ b/apps/web/core/components/issues/issue-layouts/kanban/cover-image.tsx @@ -4,7 +4,7 @@ * See the LICENSE file for details. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { useIssueCoverImage } from "@/hooks/use-issue-cover-image"; @@ -22,6 +22,10 @@ export const KanbanIssueCoverImage = observer(function KanbanIssueCoverImage(pro const coverImageUrl = useIssueCoverImage(workspaceSlug?.toString(), projectId, issueId, coverImageAttachmentId); + useEffect(() => { + setImageLoadError(false); + }, [coverImageUrl]); + if (!coverImageUrl || imageLoadError) { return null; }