diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json
index 7c3db06d0..1d23b83c7 100644
--- a/libs/i18n/locales/en/translation.json
+++ b/libs/i18n/locales/en/translation.json
@@ -1277,6 +1277,7 @@
"Export images": "Export images",
"Add to catalog": "Add to catalog",
"Delete image build": "Delete image build",
+ "View image promotion": "View image promotion",
"Edit image promotion": "Edit image promotion",
"Delete image promotion": "Delete image promotion",
"Target catalog": "Target catalog",
@@ -1342,17 +1343,24 @@
"Failed to load catalogs": "Failed to load catalogs",
"Failed to load catalog items": "Failed to load catalog items",
"Image Promotion name": "Image Promotion name",
- "Formats": "Formats",
- "Remove {{ exportLabel }}": "Remove {{ exportLabel }}",
- "Add {{ exportLabel }}": "Add {{ exportLabel }}",
- "No additional formats": "No additional formats",
"Add as": "Add as",
"Existing catalog item": "Existing catalog item",
"New catalog item": "New catalog item",
+ "No additional formats": "No additional formats",
+ "No formats selected": "No formats selected",
+ "Formats": "Formats",
+ "Remove {{ exportLabel }}": "Remove {{ exportLabel }}",
+ "Add {{ exportLabel }}": "Add {{ exportLabel }}",
"Add build to catalog": "Add build to catalog",
"Failed to update ImagePromotion": "Failed to update ImagePromotion",
"Failed to create ImagePromotion": "Failed to create ImagePromotion",
"Failed to load Image promotions": "Failed to load Image promotions",
+ "You do not have permissions to update image promotions": "You do not have permissions to update image promotions",
+ "Image promotion cannot be edited while publishing is in progress": "Image promotion cannot be edited while publishing is in progress",
+ "Failed image promotions cannot be edited. Create a new image promotion to retry.": "Failed image promotions cannot be edited. Create a new image promotion to retry.",
+ "Image promotions for failed image builds cannot be edited. Create a new image promotion to retry.": "Image promotions for failed image builds cannot be edited. Create a new image promotion to retry.",
+ "Image promotions for canceled image builds cannot be edited. Create a new image promotion to retry.": "Image promotions for canceled image builds cannot be edited. Create a new image promotion to retry.",
+ "All export formats from this image build are already included in this promotion": "All export formats from this image build are already included in this promotion",
"Catalog item is required": "Catalog item is required",
"Select or create repository": "Select or create repository",
"Add resource sync": "Add resource sync",
diff --git a/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx
index b70a0daed..06589e372 100644
--- a/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx
+++ b/libs/ui-components/src/components/ImageBuilds/ImageBuildDetails/ImageBuildDetailsTab.tsx
@@ -22,8 +22,10 @@ import {
import { TFunction, Trans } from 'react-i18next';
import { InfoCircleIcon } from '@patternfly/react-icons/dist/js/icons/info-circle-icon';
-import { BindingType, ImagePromotion } from '@flightctl/types/imagebuilder';
+import { BindingType, ImageExport, ImagePromotion } from '@flightctl/types/imagebuilder';
import ImagePromotionModal from '../../ImagePromotion/ImagePromotionModal';
+import { getPromotionEditDisabledReason } from '../../ImagePromotion/utils';
+import { getDisabledTooltipProps } from '../../../utils/tooltip';
import ImagePromotionStatus from '../../ImagePromotion/ImagePromotionStatus';
import { getDateDisplay } from '../../../utils/dates';
import { getExportFormatLabel, getImageReference } from '../../../utils/imageBuilds';
@@ -50,40 +52,45 @@ const detailsPermissions = [
{ kind: RESOURCE.IMAGE_PROMOTION, verb: VERB.DELETE },
];
+type PromotionAction = 'view' | 'edit' | 'delete';
+
+type PromotionActionState = {
+ id: string;
+ action: PromotionAction;
+};
+
const ImagePromotionRow = ({
imagePromotion,
- onDeleteClick,
- onEditClick,
- canEditPromotions,
+ editDisabledReason,
+ onActionClick,
canDeletePromotions,
}: {
imagePromotion: ImagePromotion;
- onDeleteClick: VoidFunction;
- onEditClick: VoidFunction;
- canEditPromotions: boolean;
+ editDisabledReason?: string;
+ onActionClick: (action: PromotionAction) => void;
canDeletePromotions: boolean;
}) => {
const { t } = useTranslation();
const actions: IAction[] = [
- ...(canEditPromotions
- ? [
- {
- title: t('Edit image promotion'),
- onClick: onEditClick,
- },
- ]
- : []),
- ...(canDeletePromotions
- ? [
- {
- title: t('Delete image promotion'),
- onClick: onDeleteClick,
- },
- ]
- : []),
+ {
+ title: t('View image promotion'),
+ onClick: () => onActionClick('view'),
+ },
+ {
+ title: t('Edit image promotion'),
+ onClick: () => onActionClick('edit'),
+ ...getDisabledTooltipProps(editDisabledReason),
+ },
];
+ if (canDeletePromotions) {
+ actions.push({
+ title: t('Delete image promotion'),
+ onClick: () => onActionClick('delete'),
+ });
+ }
+
return (
| {imagePromotion.metadata.name || ''} |
@@ -93,11 +100,9 @@ const ImagePromotionRow = ({
|
- {!!actions.length && (
-
-
- |
- )}
+
+
+ |
);
};
@@ -130,8 +135,9 @@ const ImagePromotionsCard = ({
const { error, isLoading, isUpdating, pagination, imagePromotions, refetchPromotions } = useImagePromotionsContext();
- const [promotionToDeleteId, setPromotionToDeleteId] = React.useState();
- const [promotionToEditId, setPromotionToEditId] = React.useState();
+ const [promotionAction, setPromotionAction] = React.useState();
+
+ const availableFormats = imageBuild.imageExports.filter(Boolean).map((e) => (e as ImageExport).spec.format);
let content: React.ReactNode;
if (error) {
@@ -153,9 +159,8 @@ const ImagePromotionsCard = ({
setPromotionToEditId(promotion.metadata.name)}
- onDeleteClick={() => setPromotionToDeleteId(promotion.metadata.name)}
- canEditPromotions={canEditPromotions}
+ editDisabledReason={getPromotionEditDisabledReason(promotion, availableFormats, canEditPromotions, t)}
+ onActionClick={(action) => setPromotionAction({ id: promotion.metadata.name as string, action })}
canDeletePromotions={canDeletePromotions}
/>
))}
@@ -166,30 +171,41 @@ const ImagePromotionsCard = ({
);
}
- const promotionToDelete = imagePromotions.find((p) => p.metadata.name === promotionToDeleteId);
- const promotionToEdit = imagePromotions.find((p) => p.metadata.name === promotionToEditId);
+ const activePromotion = promotionAction
+ ? imagePromotions.find((p) => p.metadata.name === promotionAction.id)
+ : undefined;
+
+ const closePromotionAction = () => setPromotionAction(undefined);
return (
{t('Image promotions')}
{content}
- {promotionToDelete && (
+ {activePromotion && promotionAction?.action === 'delete' && (
{
if (hasDeleted) {
refetchPromotions();
}
- setPromotionToDeleteId(undefined);
+ closePromotionAction();
}}
/>
)}
- {promotionToEdit && (
+ {activePromotion && promotionAction?.action === 'view' && (
+
+ )}
+ {activePromotion && promotionAction?.action === 'edit' && (
{
- setPromotionToEditId(undefined);
+ closePromotionAction();
if (updated) {
refetchPromotions();
}
diff --git a/libs/ui-components/src/components/ImagePromotion/ImagePromotionForm.tsx b/libs/ui-components/src/components/ImagePromotion/ImagePromotionForm.tsx
index 6e9f796fb..b1b4368d6 100644
--- a/libs/ui-components/src/components/ImagePromotion/ImagePromotionForm.tsx
+++ b/libs/ui-components/src/components/ImagePromotion/ImagePromotionForm.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { Alert, Button, FormGroup, FormSection, Spinner, Stack, StackItem } from '@patternfly/react-core';
+import { Alert, FormGroup, FormSection, Spinner } from '@patternfly/react-core';
import FlightCtlForm from '../form/FlightCtlForm';
import { useTranslation } from '../../hooks/useTranslation';
@@ -15,8 +15,8 @@ import NameField from '../form/NameField';
import { getDnsSubdomainValidations } from '../form/validations';
import { useCatalogItems } from '../Catalog/useCatalogs';
import { getErrorMessage } from '../../utils/error';
-import { getExportFormatLabel } from '../../utils/imageBuilds';
import { ImagePromotionFormValues } from './types';
+import ImagePromotionFormatsField from './ImagePromotionFormatsField';
const NewItemForm = ({ isDisabled }: { isDisabled?: boolean }) => {
const { t } = useTranslation();
@@ -110,14 +110,16 @@ const ExistingItemForm = ({ catalogItems, isDisabled }: { catalogItems: CatalogI
const ImagePromotionForm = ({
isEdit,
+ canAmendExportFormats = true,
availableFormats,
}: {
isEdit?: boolean;
+ canAmendExportFormats?: boolean;
availableFormats?: ExportFormatType[];
}) => {
const { t } = useTranslation();
- const { values, setFieldValue } = useFormikContext();
+ const { values } = useFormikContext();
const [catalogList, catalogsLoading, catalogsErr] = useFetchPeriodically({
endpoint: 'catalogs',
@@ -167,10 +169,6 @@ const ImagePromotionForm = ({
);
}
- const hasExports = availableFormats?.length || values.exportFormats.length;
-
- const extraFormats = availableFormats?.filter((f) => !values.exportFormats.includes(f));
-
return (
-
- {hasExports ? (
-
- {values.exportFormats.map((format) => getExportFormatLabel(t, format)).join(', ')}
- {extraFormats?.map((f) => {
- const isIncluded = values.additionalExportFormats?.includes(f);
- const exportLabel = getExportFormatLabel(t, f);
- return (
-
-
-
- );
- })}
-
- ) : (
- t('No additional formats')
- )}
-
+
diff --git a/libs/ui-components/src/components/ImagePromotion/ImagePromotionFormatsField.tsx b/libs/ui-components/src/components/ImagePromotion/ImagePromotionFormatsField.tsx
new file mode 100644
index 000000000..d31b76241
--- /dev/null
+++ b/libs/ui-components/src/components/ImagePromotion/ImagePromotionFormatsField.tsx
@@ -0,0 +1,64 @@
+import * as React from 'react';
+import { Button, FormGroup, Stack, StackItem } from '@patternfly/react-core';
+import { useFormikContext } from 'formik';
+import { ExportFormatType } from '@flightctl/types/imagebuilder';
+
+import { useTranslation } from '../../hooks/useTranslation';
+import { getExportFormatLabel } from '../../utils/imageBuilds';
+import { ImagePromotionFormValues } from './types';
+
+type ImagePromotionFormatsFieldProps = {
+ isEdit?: boolean;
+ canAmendExportFormats: boolean;
+ availableFormats?: ExportFormatType[];
+};
+
+const ImagePromotionFormatsField = ({
+ isEdit,
+ canAmendExportFormats = true,
+ availableFormats,
+}: ImagePromotionFormatsFieldProps) => {
+ const { t } = useTranslation();
+ const { values, setFieldValue } = useFormikContext();
+
+ const hasExports = availableFormats?.length || values.exportFormats.length;
+ const extraFormats = availableFormats?.filter((f) => !values.exportFormats.includes(f)) || [];
+ const emptyFormatsMessage = isEdit ? t('No additional formats') : t('No formats selected');
+
+ return (
+
+ {hasExports ? (
+
+ {values.exportFormats.map((format) => getExportFormatLabel(t, format)).join(', ')}
+ {extraFormats?.map((f) => {
+ const isIncluded = values.additionalExportFormats?.includes(f);
+ const exportLabel = getExportFormatLabel(t, f);
+ return (
+
+
+
+ );
+ })}
+
+ ) : (
+ emptyFormatsMessage
+ )}
+
+ );
+};
+
+export default ImagePromotionFormatsField;
diff --git a/libs/ui-components/src/components/ImagePromotion/ImagePromotionModal.tsx b/libs/ui-components/src/components/ImagePromotion/ImagePromotionModal.tsx
index 1f2b78b1b..74bff8e2b 100644
--- a/libs/ui-components/src/components/ImagePromotion/ImagePromotionModal.tsx
+++ b/libs/ui-components/src/components/ImagePromotion/ImagePromotionModal.tsx
@@ -12,7 +12,7 @@ import { useCatalogItem } from '../Catalog/useCatalogs';
import { ExportFormatType, ImageExport, ImagePromotion, ImagePromotionList } from '@flightctl/types/imagebuilder';
import { PatchRequest } from '@flightctl/types';
import { getErrorMessage } from '../../utils/error';
-import { getImagePromotionValidationSchema } from './utils';
+import { canPromotionBeEdited, getImagePromotionValidationSchema } from './utils';
import { RESOURCE, VERB } from '../../types/rbac';
import { usePermissionsContext } from '../common/PermissionsContext';
import { ImageBuildWithExports } from '../../types/extraTypes';
@@ -46,6 +46,7 @@ type ImagePromotionFormContainerProps = {
imageBuild: ImageBuildWithExports;
parentPromotion: ImagePromotion | undefined;
imagePromotion?: ImagePromotion;
+ canBeEdited: boolean;
};
const ImagePromotionFormContainer = ({
@@ -53,12 +54,14 @@ const ImagePromotionFormContainer = ({
imageBuild,
parentPromotion,
imagePromotion,
+ canBeEdited,
}: ImagePromotionFormContainerProps) => {
const { t } = useTranslation();
const { post, patch } = useFetch();
const [error, setError] = React.useState();
const isEdit = !!imagePromotion;
+ const availableFormats = imageBuild.imageExports.filter(Boolean).map((e) => e?.spec.format as ExportFormatType);
const target = parentPromotion?.spec.target;
const [catalogItem, catalogItemLoading] = useCatalogItem(target?.catalogName, target?.catalogItemName);
@@ -121,11 +124,16 @@ const ImagePromotionFormContainer = ({
>
{({ isValid, isSubmitting, dirty, submitForm }) => (
onClose()} variant="small">
-
+
e?.spec.format as ExportFormatType)}
+ canAmendExportFormats={!isEdit || canBeEdited}
+ availableFormats={availableFormats}
/>
{!!error && (
-
+ {(!isEdit || canBeEdited) && (
+
+ )}
@@ -163,13 +173,17 @@ const ImagePromotionModal = ({
onClose,
imageBuild,
imagePromotion,
+ readOnly,
}: {
onClose: (updated?: boolean) => void;
imageBuild: ImageBuildWithExports;
imagePromotion?: ImagePromotion;
+ readOnly?: boolean;
}) => {
const { t } = useTranslation();
const isEdit = !!imagePromotion;
+ const availableFormats = imageBuild.imageExports.filter(Boolean).map((e) => e?.spec.format as ExportFormatType);
+ const canBeEdited = !readOnly && imagePromotion ? canPromotionBeEdited(imagePromotion, availableFormats) : false;
const parentBuildName = !isEdit ? imageBuild.metadata.annotations?.[NEW_VERSION_FROM_ANNOTATION] : undefined;
const { checkPermissions } = usePermissionsContext();
const [canList] = checkPermissions(promotionPermissions);
@@ -188,7 +202,11 @@ const ImagePromotionModal = ({
if (promotionsError) {
return (
onClose(false)} variant="small">
-
+
{getErrorMessage(promotionsError)}
@@ -206,6 +224,7 @@ const ImagePromotionModal = ({
imageBuild={imageBuild}
parentPromotion={parentPromotion}
imagePromotion={imagePromotion}
+ canBeEdited={canBeEdited}
/>
);
};
diff --git a/libs/ui-components/src/components/ImagePromotion/utils.ts b/libs/ui-components/src/components/ImagePromotion/utils.ts
index 282548b3b..14ec8ccfa 100644
--- a/libs/ui-components/src/components/ImagePromotion/utils.ts
+++ b/libs/ui-components/src/components/ImagePromotion/utils.ts
@@ -1,6 +1,13 @@
import { TFunction } from 'react-i18next';
import * as Yup from 'yup';
-import { ExistingCatalogItemTarget, ImagePromotion, NewCatalogItemTarget } from '@flightctl/types/imagebuilder';
+import {
+ ExistingCatalogItemTarget,
+ ExportFormatType,
+ ImagePromotion,
+ ImagePromotionConditionReason,
+ ImagePromotionConditionType,
+ NewCatalogItemTarget,
+} from '@flightctl/types/imagebuilder';
import { CatalogItem } from '@flightctl/types/alpha';
import semver from 'semver';
@@ -13,6 +20,58 @@ import {
optionalSemverRange,
} from '../Catalog/AddCatalogItemWizard/utils';
+const NON_EDITABLE_PROMOTION_REASONS = new Set([
+ ImagePromotionConditionReason.ImagePromotionConditionReasonFailed,
+ ImagePromotionConditionReason.ImagePromotionConditionReasonBuildFailed,
+ ImagePromotionConditionReason.ImagePromotionConditionReasonBuildCanceled,
+ ImagePromotionConditionReason.ImagePromotionConditionReasonPublishing,
+]);
+
+const getImagePromotionReadyReason = (promotion: ImagePromotion): ImagePromotionConditionReason | undefined => {
+ const readyCondition = promotion.status?.conditions?.find(
+ (c) => c.type === ImagePromotionConditionType.ImagePromotionConditionTypeReady,
+ );
+ return readyCondition?.reason as ImagePromotionConditionReason | undefined;
+};
+
+// Currently only true if additional export formats can be appended to the promotion.
+export const canPromotionBeEdited = (promotion: ImagePromotion, availableFormats: ExportFormatType[]): boolean => {
+ const reason = getImagePromotionReadyReason(promotion);
+ if (reason && NON_EDITABLE_PROMOTION_REASONS.has(reason)) {
+ return false;
+ }
+ const currentFormats = promotion.spec.source.exportFormats || [];
+ return availableFormats.some((format) => !currentFormats.includes(format));
+};
+
+export const getPromotionEditDisabledReason = (
+ promotion: ImagePromotion,
+ availableFormats: ExportFormatType[],
+ canEdit: boolean,
+ t: TFunction,
+): string | undefined => {
+ if (!canEdit) {
+ return t('You do not have permissions to update image promotions');
+ }
+ if (canPromotionBeEdited(promotion, availableFormats)) {
+ return undefined;
+ }
+
+ const reason = getImagePromotionReadyReason(promotion);
+ switch (reason) {
+ case ImagePromotionConditionReason.ImagePromotionConditionReasonPublishing:
+ return t('Image promotion cannot be edited while publishing is in progress');
+ case ImagePromotionConditionReason.ImagePromotionConditionReasonFailed:
+ return t('Failed image promotions cannot be edited. Create a new image promotion to retry.');
+ case ImagePromotionConditionReason.ImagePromotionConditionReasonBuildFailed:
+ return t('Image promotions for failed image builds cannot be edited. Create a new image promotion to retry.');
+ case ImagePromotionConditionReason.ImagePromotionConditionReasonBuildCanceled:
+ return t('Image promotions for canceled image builds cannot be edited. Create a new image promotion to retry.');
+ default:
+ return t('All export formats from this image build are already included in this promotion');
+ }
+};
+
export const getEditInitialValues = (imagePromotion: ImagePromotion): ImagePromotionFormValues => {
const target = imagePromotion.spec.target;
const isExisting = target.type === ExistingCatalogItemTarget.type.EXISTING_CATALOG_ITEM;