From 28b7d37ae7a884c35499735ca10c46d26196aa07 Mon Sep 17 00:00:00 2001 From: Abhi Date: Fri, 8 Mar 2024 20:02:56 +0530 Subject: [PATCH] feat(release): trigger release plan --- .../TriggerRelease/ReleasePlanDropdown.tsx | 34 +++++ .../TriggerRelease/SnapshotDropdown.tsx | 34 +++++ .../TriggerRelease/TriggerReleaseForm.tsx | 102 ++++++++++++++ .../TriggerRelease/TriggerReleaseFormPage.tsx | 133 ++++++++++++++++++ .../__tests__/ReleasePlanDropdown.spec.tsx | 0 .../__tests__/SnapshotDropdown.spec.tsx | 0 .../ReleasePlan/TriggerRelease/form-utils.ts | 132 +++++++++++++++++ .../ReleasePlan/releaseplan-actions.tsx | 7 + src/pages/TriggerReleasePlanPage.tsx | 25 ++++ 9 files changed, 467 insertions(+) create mode 100644 src/components/ReleaseService/ReleasePlan/TriggerRelease/ReleasePlanDropdown.tsx create mode 100644 src/components/ReleaseService/ReleasePlan/TriggerRelease/SnapshotDropdown.tsx create mode 100644 src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseForm.tsx create mode 100644 src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseFormPage.tsx create mode 100644 src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/ReleasePlanDropdown.spec.tsx create mode 100644 src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/SnapshotDropdown.spec.tsx create mode 100644 src/components/ReleaseService/ReleasePlan/TriggerRelease/form-utils.ts create mode 100644 src/pages/TriggerReleasePlanPage.tsx diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/ReleasePlanDropdown.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/ReleasePlanDropdown.tsx new file mode 100644 index 000000000..889be82bb --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/ReleasePlanDropdown.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useField } from 'formik'; +import { useReleasePlans } from '../../../../hooks/useReleasePlans'; +import { DropdownField } from '../../../../shared'; +import { useWorkspaceInfo } from '../../../../utils/workspace-context-utils'; + +type ReleasePlanDropdownProps = Omit< + React.ComponentProps, + 'items' | 'label' | 'placeholder' +>; + +export const ReleasePlanDropdown: React.FC> = ( + props, +) => { + const { namespace } = useWorkspaceInfo(); + const [applications, loaded] = useReleasePlans(namespace); + const [, , { setValue }] = useField(props.name); + + const dropdownItems = React.useMemo( + () => applications.map((a) => ({ key: a.metadata.name, value: a.metadata.name })), + [applications], + ); + + return ( + setValue(app)} + /> + ); +}; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/SnapshotDropdown.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/SnapshotDropdown.tsx new file mode 100644 index 000000000..3e1fd2708 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/SnapshotDropdown.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useField } from 'formik'; +import { useSnapshots } from '../../../../hooks/useSnapshots'; +import { DropdownField } from '../../../../shared'; +import { useWorkspaceInfo } from '../../../../utils/workspace-context-utils'; + +type SnapshotDropdownProps = Omit< + React.ComponentProps, + 'items' | 'label' | 'placeholder' +>; + +export const SnapshotDropdown: React.FC> = ( + props, +) => { + const { namespace } = useWorkspaceInfo(); + const [applications, loaded] = useSnapshots(namespace); + const [, , { setValue }] = useField(props.name); + + const dropdownItems = React.useMemo( + () => applications.map((a) => ({ key: a.metadata.name, value: a.metadata.name })), + [applications], + ); + + return ( + setValue(app)} + /> + ); +}; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseForm.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseForm.tsx new file mode 100644 index 000000000..c2749a360 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseForm.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { Form, PageSection, PageSectionVariants } from '@patternfly/react-core'; +import { FormikProps, useField } from 'formik'; +import isEmpty from 'lodash-es/isEmpty'; +import PageLayout from '../../../../components/PageLayout/PageLayout'; +import { FormFooter, TextAreaField } from '../../../../shared'; +import KeyValueField from '../../../../shared/components/formik-fields/key-value-input-field/KeyValueInputField'; +import { useWorkspaceBreadcrumbs } from '../../../../utils/breadcrumb-utils'; +import { ReleasePlanFormValues } from '../ReleasePlanForm/form-utils'; +import { ReleasePlanDropdown } from './ReleasePlanDropdown'; +import { SnapshotDropdown } from './SnapshotDropdown'; + +type Props = FormikProps & { + edit?: boolean; +}; + +export const TriggerReleaseForm: React.FC = ({ + handleSubmit, + handleReset, + isSubmitting, + dirty, + errors, + status, + edit, +}) => { + const breadcrumbs = useWorkspaceBreadcrumbs(); + const [{ value: labels }] = useField('labels'); + + return ( + + } + > + +
+ + + + + + + + + +
+
+ ); +}; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseFormPage.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseFormPage.tsx new file mode 100644 index 000000000..ec6460505 --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseFormPage.tsx @@ -0,0 +1,133 @@ +import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Formik, FormikHelpers } from 'formik'; +import { ResolverRefParams } from '../../../../components/IntegrationTest/IntegrationTestForm/utils/create-utils'; +import { ReleasePlanKind, ReleasePlanLabel } from '../../../../types/coreBuildService'; +import { useTrackEvent, TrackEvents } from '../../../../utils/analytics'; +import { useWorkspaceInfo } from '../../../../utils/workspace-context-utils'; +import { + ReleasePlanFormValues, + createReleasePlan, + editReleasePlan, + ReleasePipelineLocation, + releasePlanFormParams, + releasePlanFormSchema, +} from '../ReleasePlanForm/form-utils'; +import { TriggerReleaseForm } from './TriggerReleaseForm'; + +type Props = { + releasePlan?: ReleasePlanKind; +}; + +export const TriggerReleaseFormPage: React.FC = ({ releasePlan }) => { + const navigate = useNavigate(); + const track = useTrackEvent(); + const { namespace, workspace } = useWorkspaceInfo(); + const edit = !!releasePlan; + + const handleSubmit = async ( + values: ReleasePlanFormValues, + { setSubmitting, setStatus }: FormikHelpers, + ) => { + if (edit) { + track(TrackEvents.ButtonClicked, { + link_name: 'edit-release-plan-submit', + // eslint-disable-next-line camelcase + release_plan_name: releasePlan.metadata.name, + // eslint-disable-next-line camelcase + target_workspace: releasePlan.spec.target, + app_name: releasePlan.spec.application, + workspace, + }); + } else { + track(TrackEvents.ButtonClicked, { + link_name: 'add-release-plan-submit', + workspace, + }); + } + try { + edit + ? await editReleasePlan(releasePlan, values, workspace, true) + : await createReleasePlan(values, namespace, workspace, true); + const newReleasePlan = edit + ? await editReleasePlan(releasePlan, values, workspace) + : await createReleasePlan(values, namespace, workspace); + track(edit ? 'Release plan edited' : 'Release plan created', { + // eslint-disable-next-line camelcase + release_plan_name: newReleasePlan.metadata.name, + // eslint-disable-next-line camelcase + target_workspace: newReleasePlan.spec.target, + app_name: newReleasePlan.spec.application, + workspace, + }); + navigate('/application-pipeline/release'); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Error while submitting integration test:', e); + setSubmitting(false); + setStatus({ submitError: e.message }); + } + }; + + const handleReset = () => { + if (edit) { + track(TrackEvents.ButtonClicked, { + link_name: 'edit-release-plan-leave', + // eslint-disable-next-line camelcase + release_plan_name: releasePlan.metadata.name, + // eslint-disable-next-line camelcase + target_workspace: releasePlan.spec.target, + app_name: releasePlan.spec.application, + workspace, + }); + } else { + track(TrackEvents.ButtonClicked, { + link_name: 'add-release-plan-leave', + workspace, + }); + } + navigate('/application-pipeline/release'); + }; + + const initialValues: ReleasePlanFormValues = { + name: releasePlan?.metadata?.name ?? '', + application: releasePlan?.spec?.application ?? '', + autoRelease: releasePlan?.metadata?.labels?.[ReleasePlanLabel.AUTO_RELEASE] === 'true' ?? false, + standingAttribution: + releasePlan?.metadata?.labels?.[ReleasePlanLabel.STANDING_ATTRIBUTION] === 'true' ?? false, + releasePipelineLocation: releasePlan?.spec?.target + ? releasePlan.spec.target === workspace + ? ReleasePipelineLocation.current + : ReleasePipelineLocation.target + : undefined, + serviceAccount: releasePlan?.spec?.serviceAccount ?? '', + target: releasePlan?.spec?.target ?? '', + data: releasePlan?.spec?.data ?? '', + params: releasePlanFormParams(releasePlan), + labels: releasePlan?.metadata?.labels + ? Object.entries(releasePlan?.metadata?.labels).map(([key, value]) => ({ key, value })) + : [{ key: '', value: '' }], + git: { + url: + releasePlan?.spec?.pipelineRef?.params?.find((p) => p.name === ResolverRefParams.URL) + ?.value ?? '', + revision: + releasePlan?.spec?.pipelineRef?.params?.find((p) => p.name === ResolverRefParams.REVISION) + ?.value ?? '', + path: + releasePlan?.spec?.pipelineRef?.params?.find((p) => p.name === ResolverRefParams.PATH) + ?.value ?? '', + }, + }; + + return ( + + {(props) => } + + ); +}; diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/ReleasePlanDropdown.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/ReleasePlanDropdown.spec.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/SnapshotDropdown.spec.tsx b/src/components/ReleaseService/ReleasePlan/TriggerRelease/__tests__/SnapshotDropdown.spec.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/ReleaseService/ReleasePlan/TriggerRelease/form-utils.ts b/src/components/ReleaseService/ReleasePlan/TriggerRelease/form-utils.ts new file mode 100644 index 000000000..2c82896bc --- /dev/null +++ b/src/components/ReleaseService/ReleasePlan/TriggerRelease/form-utils.ts @@ -0,0 +1,132 @@ +import { k8sCreateResource } from '@openshift/dynamic-plugin-sdk-utils'; +import * as yup from 'yup'; +import { + gitUrlRegex, + MAX_RESOURCE_NAME_LENGTH, + resourceNameRegex, + RESOURCE_NAME_LENGTH_ERROR_MSG, + RESOURCE_NAME_REGEX_MSG, +} from '../../../../components/ImportForm/utils/validation-utils'; +import { ResolverRefParams } from '../../../../components/IntegrationTest/IntegrationTestForm/utils/create-utils'; +import { ReleasePlanGroupVersionKind, ReleasePlanModel } from '../../../../models'; +import { Param } from '../../../../types'; +import { + ReleasePlanKind, + ReleasePlanLabel, + ResolverType, +} from '../../../../types/coreBuildService'; + +export enum ReleasePipelineLocation { + current, + target, +} + +export type ReleaseFormValues = { + name: string; + application: string; + autoRelease?: boolean; + standingAttribution?: boolean; + releasePipelineLocation: ReleasePipelineLocation; + git: { + url: string; + revision: string; + path: string; + }; + serviceAccount?: string; + target?: string; + labels?: { key: string; value: string }[]; + params?: Param[]; + data?: string; +}; + +export const releaseFormSchema = yup.object({ + name: yup + .string() + .matches(resourceNameRegex, RESOURCE_NAME_REGEX_MSG) + .max(MAX_RESOURCE_NAME_LENGTH, RESOURCE_NAME_LENGTH_ERROR_MSG) + .required('Required'), + application: yup.string().required('Required'), + git: yup.object({ + url: yup.string().matches(gitUrlRegex).required('Required'), + revision: yup.string().required('Required'), + path: yup.string().required('Required'), + }), + serviceAccount: yup.string().when('releasePipelineLocation', { + is: ReleasePipelineLocation.current, + then: yup.string().required('Required'), + }), + target: yup.string().when('releasePipelineLocation', { + is: ReleasePipelineLocation.target, + then: yup.string().required('Required'), + }), +}); + +export const releaseFormParams = (releasePlan: ReleasePlanKind) => + (releasePlan?.spec?.pipelineRef?.params?.filter( + (p) => + p.name !== ResolverRefParams.URL && + p.name !== ResolverRefParams.REVISION && + p.name !== ResolverRefParams.PATH, + ) ?? []) as Param[]; + +export const createRelease = async ( + values: ReleaseFormValues, + namespace: string, + workspace: string, + dryRun?: boolean, +) => { + const { + name, + application, + serviceAccount, + target, + labels: labelPairs, + releasePipelineLocation, + git, + data, + params, + autoRelease, + standingAttribution, + } = values; + const targetWs = releasePipelineLocation === ReleasePipelineLocation.current ? workspace : target; + const labels = labelPairs + .filter((l) => !!l.key) + .reduce((acc, o) => ({ ...acc, [o.key]: o.value }), {} as Record); + const resource: ReleasePlanKind = { + apiVersion: `${ReleasePlanGroupVersionKind.group}/${ReleasePlanGroupVersionKind.version}`, + kind: ReleasePlanGroupVersionKind.kind, + metadata: { + name, + namespace, + labels: { + ...labels, + ...(autoRelease ? { [ReleasePlanLabel.AUTO_RELEASE]: 'true' } : {}), + ...(standingAttribution ? { [ReleasePlanLabel.STANDING_ATTRIBUTION]: 'true' } : {}), + }, + }, + spec: { + application, + ...(data ? { data } : {}), + serviceAccount, + target: targetWs, + pipelineRef: { + resolver: ResolverType.GIT, + params: [ + ...params, + { name: ResolverRefParams.URL, value: git.url }, + { name: ResolverRefParams.REVISION, value: git.revision }, + { name: ResolverRefParams.PATH, value: git.path }, + ], + }, + }, + }; + return k8sCreateResource({ + model: ReleasePlanModel, + queryOptions: { + name, + ns: namespace, + ...(dryRun && { queryParams: { dryRun: 'All' } }), + }, + resource, + }); +}; diff --git a/src/components/ReleaseService/ReleasePlan/releaseplan-actions.tsx b/src/components/ReleaseService/ReleasePlan/releaseplan-actions.tsx index d9e4ee82f..243a23010 100644 --- a/src/components/ReleaseService/ReleasePlan/releaseplan-actions.tsx +++ b/src/components/ReleaseService/ReleasePlan/releaseplan-actions.tsx @@ -11,6 +11,13 @@ export const useReleasePlanActions = (obj: ReleasePlanKind) => { const [canDelete] = useAccessReviewForModel(ReleasePlanModel, 'delete'); const [canUpdate] = useAccessReviewForModel(ReleasePlanModel, 'update'); return [ + { + label: 'Trigger release plan', + id: `trigger-releaseplan-${obj.metadata.name}`, + cta: { + href: `/application-pipeline/release/workspaces/${workspace}/release-plan/trigger/${obj.metadata.name}`, + }, + }, { label: 'Edit release plan', id: `edit-releaseplan-${obj.metadata.name}`, diff --git a/src/pages/TriggerReleasePlanPage.tsx b/src/pages/TriggerReleasePlanPage.tsx new file mode 100644 index 000000000..4a21331d5 --- /dev/null +++ b/src/pages/TriggerReleasePlanPage.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { Helmet } from 'react-helmet'; +import NamespacedPage from '../components/NamespacedPage/NamespacedPage'; +import PageAccessCheck from '../components/PageAccess/PageAccessCheck'; +import { TriggerReleaseFormPage } from '../components/ReleaseService/ReleasePlan/TriggerRelease/TriggerReleaseFormPage'; +import { FULL_APPLICATION_TITLE } from '../consts/labels'; +import { ReleasePlanModel } from '../models'; +import { AccessReviewResources } from '../types'; + +const TriggerReleasePlanPage: React.FC> = () => { + const accessReviewResources: AccessReviewResources = [ + { model: ReleasePlanModel, verb: 'create' }, + ]; + + return ( + + Trigger release plan | ${FULL_APPLICATION_TITLE} + + + + + ); +}; + +export default TriggerReleasePlanPage;