Skip to content

Commit b0fa1e7

Browse files
committed
feat(frontend): allow deletion of BaseImage used in completed campaign
Update the frontend to allow the deletion of a BaseImage even if in use by a Update Campaign, if that campaign is already completed. The user will be informed that the BaseImage is in use before attempting deletion that the operation will fail. Closes #598 Signed-off-by: Damiano Mason <damiano.mason@secomind.com>
1 parent 684b9b0 commit b0fa1e7

3 files changed

Lines changed: 169 additions & 63 deletions

File tree

frontend/src/forms/UpdateCampaignForm.tsx

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@ const UpdateCampaign = ({ campaignRef }: UpdateCampaignProps) => {
156156
}
157157

158158
const { baseImage } = campaignMechanism;
159-
const { baseImageCollection } = baseImage;
160159

161160
return (
162161
<Row>
@@ -181,39 +180,61 @@ const UpdateCampaign = ({ campaignRef }: UpdateCampaignProps) => {
181180
>
182181
<CampaignOutcome campaignRef={campaign} />
183182
</FormRow>
184-
<FormRow
185-
label={
186-
<FormattedMessage
187-
id="forms.UpdateCampaignForm.baseImageCollectionLabel"
188-
defaultMessage="Base Image Collection"
189-
/>
190-
}
191-
>
192-
<Link
193-
route={Route.baseImageCollectionsEdit}
194-
params={{ baseImageCollectionId: baseImageCollection.id }}
195-
>
196-
{baseImageCollection.name}
197-
</Link>
198-
</FormRow>
199-
<FormRow
200-
label={
201-
<FormattedMessage
202-
id="forms.UpdateCampaignForm.baseImageLabel"
203-
defaultMessage="Base Image"
204-
/>
205-
}
206-
>
207-
<Link
208-
route={Route.baseImagesEdit}
209-
params={{
210-
baseImageCollectionId: baseImageCollection.id,
211-
baseImageId: baseImage.id,
212-
}}
183+
{baseImage ? (
184+
<>
185+
<FormRow
186+
label={
187+
<FormattedMessage
188+
id="forms.UpdateCampaignForm.baseImageCollectionLabel"
189+
defaultMessage="Base Image Collection"
190+
/>
191+
}
192+
>
193+
<Link
194+
route={Route.baseImageCollectionsEdit}
195+
params={{
196+
baseImageCollectionId: baseImage.baseImageCollection.id,
197+
}}
198+
>
199+
{baseImage.baseImageCollection.name}
200+
</Link>
201+
</FormRow>
202+
<FormRow
203+
label={
204+
<FormattedMessage
205+
id="forms.UpdateCampaignForm.baseImageLabel"
206+
defaultMessage="Base Image"
207+
/>
208+
}
209+
>
210+
<Link
211+
route={Route.baseImagesEdit}
212+
params={{
213+
baseImageCollectionId: baseImage.baseImageCollection.id,
214+
baseImageId: baseImage.id,
215+
}}
216+
>
217+
{baseImage.name}
218+
</Link>
219+
</FormRow>
220+
</>
221+
) : (
222+
<FormRow
223+
label={
224+
<FormattedMessage
225+
id="forms.UpdateCampaignForm.baseImageLabel"
226+
defaultMessage="Base Image"
227+
/>
228+
}
213229
>
214-
{baseImage.name}
215-
</Link>
216-
</FormRow>
230+
<div className="d-flex align-content-center fst-italic text-muted">
231+
<FormattedMessage
232+
id="forms.UpdateCampaignForm.baseImageDeleted"
233+
defaultMessage="The Base Image has been deleted"
234+
/>
235+
</div>
236+
</FormRow>
237+
)}
217238
<FormRow
218239
label={
219240
<FormattedMessage

frontend/src/i18n/langs/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2125,6 +2125,9 @@
21252125
"forms.UpdateCampaignForm.baseImageCollectionLabel": {
21262126
"defaultMessage": "Base Image Collection"
21272127
},
2128+
"forms.UpdateCampaignForm.baseImageDeleted": {
2129+
"defaultMessage": "The Base Image has been deleted"
2130+
},
21282131
"forms.UpdateCampaignForm.baseImageLabel": {
21292132
"defaultMessage": "Base Image"
21302133
},

frontend/src/pages/BaseImage.tsx

Lines changed: 112 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,22 @@
1-
/*
2-
* This file is part of Edgehog.
3-
*
4-
* Copyright 2023-2025 SECO Mind Srl
5-
*
6-
* Licensed under the Apache License, Version 2.0 (the "License");
7-
* you may not use this file except in compliance with the License.
8-
* You may obtain a copy of the License at
9-
*
10-
* http://www.apache.org/licenses/LICENSE-2.0
11-
*
12-
* Unless required by applicable law or agreed to in writing, software
13-
* distributed under the License is distributed on an "AS IS" BASIS,
14-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15-
* See the License for the specific language governing permissions and
16-
* limitations under the License.
17-
*
18-
* SPDX-License-Identifier: Apache-2.0
19-
*/
20-
21-
import { Suspense, useCallback, useEffect, useState } from "react";
1+
// This file is part of Edgehog.
2+
//
3+
// Copyright 2023 - 2026 SECO Mind Srl
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
//
17+
// SPDX-License-Identifier: Apache-2.0
18+
19+
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
2220
import { useParams } from "react-router-dom";
2321
import { FormattedMessage } from "react-intl";
2422
import { ErrorBoundary } from "react-error-boundary";
@@ -36,6 +34,7 @@ import type {
3634
} from "@/api/__generated__/BaseImage_getBaseImage_Query.graphql";
3735
import type { BaseImage_updateBaseImage_Mutation } from "@/api/__generated__/BaseImage_updateBaseImage_Mutation.graphql";
3836
import type { BaseImage_deleteBaseImage_Mutation } from "@/api/__generated__/BaseImage_deleteBaseImage_Mutation.graphql";
37+
import type { BaseImage_getRelatedUpdateCampaigns_Query } from "@/api/__generated__/BaseImage_getRelatedUpdateCampaigns_Query.graphql";
3938
import Alert from "@/components/Alert";
4039
import Center from "@/components/Center";
4140
import DeleteModal from "@/components/DeleteModal";
@@ -84,16 +83,45 @@ const DELETE_BASE_IMAGE_MUTATION = graphql`
8483
result {
8584
id
8685
}
86+
errors {
87+
message
88+
}
89+
}
90+
}
91+
`;
92+
93+
const RELATED_UPDATE_CAMPAIGNS_QUERY = graphql`
94+
query BaseImage_getRelatedUpdateCampaigns_Query {
95+
updateCampaigns {
96+
edges {
97+
node {
98+
name
99+
status
100+
campaignMechanism {
101+
__typename
102+
... on FirmwareUpgrade {
103+
baseImage {
104+
id
105+
}
106+
}
107+
}
108+
}
109+
}
87110
}
88111
}
89112
`;
90113

91114
type BaseImageContentProps = {
92115
baseImage: NonNullable<BaseImage_getBaseImage_Query$data["baseImage"]>;
93116
queryRef: BaseImage_getBaseImage_Query$data;
117+
getRelatedUpdateCampaignsQuery: PreloadedQuery<BaseImage_getRelatedUpdateCampaigns_Query>;
94118
};
95119

96-
const BaseImageContent = ({ baseImage, queryRef }: BaseImageContentProps) => {
120+
const BaseImageContent = ({
121+
baseImage,
122+
queryRef,
123+
getRelatedUpdateCampaignsQuery,
124+
}: BaseImageContentProps) => {
97125
const baseImageId = baseImage.id;
98126
const baseImageCollectionId = baseImage.baseImageCollection.id;
99127
const navigate = useNavigate();
@@ -108,10 +136,28 @@ const BaseImageContent = ({ baseImage, queryRef }: BaseImageContentProps) => {
108136
const [deleteBaseImage, isDeletingBaseImage] =
109137
useMutation<BaseImage_deleteBaseImage_Mutation>(DELETE_BASE_IMAGE_MUTATION);
110138

139+
// TODO: filter per base image in backend
140+
const relatedUpdateCampaignsData = usePreloadedQuery(
141+
RELATED_UPDATE_CAMPAIGNS_QUERY,
142+
getRelatedUpdateCampaignsQuery,
143+
);
144+
const runningUpdateCampaigns = useMemo(
145+
() =>
146+
relatedUpdateCampaignsData?.updateCampaigns?.edges
147+
?.map(({ node }) => node)
148+
.filter(
149+
(campaign) =>
150+
campaign.status !== "FINISHED" &&
151+
campaign.campaignMechanism.__typename === "FirmwareUpgrade" &&
152+
campaign.campaignMechanism.baseImage?.id === baseImageId,
153+
),
154+
[relatedUpdateCampaignsData, baseImageId],
155+
);
156+
111157
const handleDeleteBaseImage = useCallback(() => {
112158
deleteBaseImage({
113159
variables: { baseImageId },
114-
onCompleted(data, errors) {
160+
onCompleted(_data, errors) {
115161
if (!errors || errors.length === 0 || errors[0].code === "not_found") {
116162
return navigate({
117163
route: Route.baseImageCollectionsEdit,
@@ -121,7 +167,15 @@ const BaseImageContent = ({ baseImage, queryRef }: BaseImageContentProps) => {
121167

122168
const errorFeedback = errors
123169
.map(({ fields, message }) =>
124-
fields.length ? `${fields.join(" ")} ${message}` : message,
170+
message.includes("in use by at least one running campaign") &&
171+
runningUpdateCampaigns
172+
? `${message}:\n` +
173+
runningUpdateCampaigns
174+
.map((campaign) => campaign.name)
175+
.join("\n")
176+
: fields.length
177+
? `${fields.join(" ")} ${message}`
178+
: message,
125179
)
126180
.join(". \n");
127181
setErrorFeedback(errorFeedback);
@@ -149,7 +203,13 @@ const BaseImageContent = ({ baseImage, queryRef }: BaseImageContentProps) => {
149203
?.invalidateRecord();
150204
},
151205
});
152-
}, [deleteBaseImage, baseImageId, baseImageCollectionId, navigate]);
206+
}, [
207+
deleteBaseImage,
208+
baseImageId,
209+
baseImageCollectionId,
210+
navigate,
211+
runningUpdateCampaigns,
212+
]);
153213

154214
const [updateBaseImage, isUpdatingBaseImage] =
155215
useMutation<BaseImage_updateBaseImage_Mutation>(UPDATE_BASE_IMAGE_MUTATION);
@@ -158,7 +218,7 @@ const BaseImageContent = ({ baseImage, queryRef }: BaseImageContentProps) => {
158218
(baseImageChanges: BaseImageChanges) => {
159219
updateBaseImage({
160220
variables: { baseImageId, input: baseImageChanges },
161-
onCompleted(data, errors) {
221+
onCompleted(_data, errors) {
162222
if (errors) {
163223
const errorFeedback = errors
164224
.map(({ fields, message }) =>
@@ -242,9 +302,13 @@ const BaseImageContent = ({ baseImage, queryRef }: BaseImageContentProps) => {
242302

243303
type BaseImageWrapperProps = {
244304
getBaseImageQuery: PreloadedQuery<BaseImage_getBaseImage_Query>;
305+
getRelatedUpdateCampaignsQuery: PreloadedQuery<BaseImage_getRelatedUpdateCampaigns_Query>;
245306
};
246307

247-
const BaseImageWrapper = ({ getBaseImageQuery }: BaseImageWrapperProps) => {
308+
const BaseImageWrapper = ({
309+
getBaseImageQuery,
310+
getRelatedUpdateCampaignsQuery,
311+
}: BaseImageWrapperProps) => {
248312
const { baseImageCollectionId = "" } = useParams();
249313

250314
const queryData = usePreloadedQuery(GET_BASE_IMAGE_QUERY, getBaseImageQuery);
@@ -273,7 +337,11 @@ const BaseImageWrapper = ({ getBaseImageQuery }: BaseImageWrapperProps) => {
273337
}
274338

275339
return (
276-
<BaseImageContent baseImage={queryData.baseImage} queryRef={queryData} />
340+
<BaseImageContent
341+
baseImage={queryData.baseImage}
342+
queryRef={queryData}
343+
getRelatedUpdateCampaignsQuery={getRelatedUpdateCampaignsQuery}
344+
/>
277345
);
278346
};
279347

@@ -289,6 +357,17 @@ const BaseImagePage = () => {
289357

290358
useEffect(fetchBaseImage, [fetchBaseImage]);
291359

360+
const [getRelatedUpdateCampaignsQuery, getRelatedUpdateCampaigns] =
361+
useQueryLoader<BaseImage_getRelatedUpdateCampaigns_Query>(
362+
RELATED_UPDATE_CAMPAIGNS_QUERY,
363+
);
364+
365+
const fetchRelatedUpdateCampaigns = useCallback(() => {
366+
getRelatedUpdateCampaigns({}, { fetchPolicy: "network-only" });
367+
}, [getRelatedUpdateCampaigns]);
368+
369+
useEffect(fetchRelatedUpdateCampaigns, [fetchRelatedUpdateCampaigns]);
370+
292371
return (
293372
<Suspense
294373
fallback={
@@ -305,8 +384,11 @@ const BaseImagePage = () => {
305384
)}
306385
onReset={fetchBaseImage}
307386
>
308-
{getBaseImageQuery && (
309-
<BaseImageWrapper getBaseImageQuery={getBaseImageQuery} />
387+
{getBaseImageQuery && getRelatedUpdateCampaignsQuery && (
388+
<BaseImageWrapper
389+
getBaseImageQuery={getBaseImageQuery}
390+
getRelatedUpdateCampaignsQuery={getRelatedUpdateCampaignsQuery}
391+
/>
310392
)}
311393
</ErrorBoundary>
312394
</Suspense>

0 commit comments

Comments
 (0)