diff --git a/frontend/common/services/useReleasePipelines.ts b/frontend/common/services/useReleasePipelines.ts new file mode 100644 index 000000000000..1321ffb583b6 --- /dev/null +++ b/frontend/common/services/useReleasePipelines.ts @@ -0,0 +1,166 @@ +import { service } from 'common/service' +import { Req } from 'common/types/requests' +import { Res } from 'common/types/responses' +import Utils from 'common/utils/utils' + +export const releasePipelinesService = service + .enhanceEndpoints({ addTagTypes: ['ReleasePipelines'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createPipelineStages: builder.mutation< + Res['pipelineStage'], + Req['createPipelineStage'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'ReleasePipelines' }], + query: (query: Req['createPipelineStage']) => ({ + body: { + actions: query.actions, + environment: query.environment, + name: query.name, + order: query.order, + trigger: query.trigger, + }, + method: 'POST', + url: `projects/${query.project}/release-pipelines/${query.pipeline}/stages/`, + }), + }), + createReleasePipeline: builder.mutation< + Res['releasePipeline'], + Req['createReleasePipeline'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'ReleasePipelines' }], + query: (query: Req['createReleasePipeline']) => ({ + body: { name: query.name }, + method: 'POST', + url: `projects/${query.projectId}/release-pipelines/`, + }), + }), + deleteReleasePipeline: builder.mutation<{}, Req['deleteReleasePipeline']>( + { + invalidatesTags: [{ id: 'LIST', type: 'ReleasePipelines' }], + query: (query: Req['deleteReleasePipeline']) => ({ + method: 'DELETE', + url: `projects/${query.projectId}/release-pipelines/${query.pipelineId}/`, + }), + }, + ), + getPipelineStage: builder.query< + Res['pipelineStage'], + Req['getPipelineStage'] + >({ + query: (query: Req['getPipelineStage']) => ({ + url: `projects/${query.projectId}/release-pipelines/${query.pipelineId}/stages/${query.stageId}/`, + }), + }), + getPipelineStages: builder.query< + Res['pipelineStages'], + Req['getPipelineStages'] + >({ + query: ({ + pipelineId, + projectId, + ...rest + }: Req['getPipelineStages']) => ({ + url: `projects/${projectId}/release-pipelines/${pipelineId}/stages/?${Utils.toParam( + rest, + )}`, + }), + }), + getReleasePipeline: builder.query< + Res['releasePipeline'], + Req['getReleasePipeline'] + >({ + query: (query: Req['getReleasePipeline']) => ({ + url: `projects/${query.projectId}/release-pipelines/${query.pipelineId}/`, + }), + }), + getReleasePipelines: builder.query< + Res['releasePipelines'], + Req['getReleasePipelines'] + >({ + providesTags: [{ id: 'LIST', type: 'ReleasePipelines' }], + query: ({ projectId, ...rest }: Req['getReleasePipelines']) => ({ + url: `projects/${projectId}/release-pipelines/?${Utils.toParam( + rest, + )}`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function getReleasePipelines( + store: any, + data: Req['getReleasePipelines'], + options?: Parameters< + typeof releasePipelinesService.endpoints.getReleasePipelines.initiate + >[1], +) { + return store.dispatch( + releasePipelinesService.endpoints.getReleasePipelines.initiate( + data, + options, + ), + ) +} + +export async function createReleasePipeline( + store: any, + data: Req['createReleasePipeline'], + options?: Parameters< + typeof releasePipelinesService.endpoints.createReleasePipeline.initiate + >[1], +) { + return store.dispatch( + releasePipelinesService.endpoints.createReleasePipeline.initiate( + data, + options, + ), + ) +} + +export async function getPipelineStages( + store: any, + data: Req['getPipelineStages'], + options?: Parameters< + typeof releasePipelinesService.endpoints.getPipelineStages.initiate + >[1], +) { + return store.dispatch( + releasePipelinesService.endpoints.getPipelineStages.initiate(data, options), + ) +} + +export async function createPipelineStages( + store: any, + data: Req['createPipelineStage'], + options?: Parameters< + typeof releasePipelinesService.endpoints.createPipelineStages.initiate + >[1], +) { + return store.dispatch( + releasePipelinesService.endpoints.createPipelineStages.initiate( + data, + options, + ), + ) +} + +// END OF FUNCTION_EXPORTS + +export const { + useCreatePipelineStagesMutation, + useCreateReleasePipelineMutation, + useDeleteReleasePipelineMutation, + useGetPipelineStageQuery, + useGetPipelineStagesQuery, + useGetReleasePipelineQuery, + useGetReleasePipelinesQuery, + // END OF EXPORTS +} = releasePipelinesService + +/* Usage examples: +const { data, isLoading } = useGetReleasePipelinesQuery({ id: 2 }, {}) //get hook +const [createReleasePipelines, { isLoading, data, isSuccess }] = useCreateReleasePipelinesMutation() //create hook +releasePipelinesService.endpoints.getReleasePipelines.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 1177f0b88f57..cd3627f65fd0 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -18,6 +18,9 @@ import { RolePermission, Webhook, IdentityTrait, + StageTrigger, + PipelineStatus, + StageActionType, } from './responses' export type PagedRequest = T & { @@ -68,6 +71,12 @@ export type RegisterRequest = { organisation_name?: string marketing_consent_given?: boolean } + +export interface StageActionRequest { + action_type: StageActionType + action_body: { enabled: boolean; segment_id?: number } +} + export type Req = { getSegments: PagedRequest<{ q?: string @@ -673,5 +682,34 @@ export type Req = { userId: number | undefined level: PermissionLevel } + getReleasePipelines: PagedRequest<{ projectId: number }> + getReleasePipeline: { projectId: number; pipelineId: number } + createReleasePipeline: { + projectId: number + name: string + status: PipelineStatus + } + getPipelineStages: PagedRequest<{ + projectId: number + pipelineId: number + }> + getPipelineStage: { + projectId: number + pipelineId: number + stageId: number + } + createPipelineStage: { + name: string + project: number + environment: number + pipeline: number + order: number + trigger: StageTrigger + actions: StageActionRequest[] + } + deleteReleasePipeline: { + projectId: number + pipelineId: number + } // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index ad18fdeb9c90..c46a8da944bd 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1,5 +1,7 @@ // eslint-disable-next-line @typescript-eslint/no-empty-interface +import { StageActionRequest } from './requests' + export type EdgePagedResponse = PagedResponse & { last_evaluated_key?: string pages?: (string | undefined)[] @@ -798,6 +800,52 @@ export type IdentityTrait = { trait_value: FlagsmithValue } +export enum PipelineStatus { + DRAFT = 'DRAFT', + ACTIVE = 'ACTIVE', +} + +export type ReleasePipeline = { + id: number + status: PipelineStatus + stages_count: number + flags_count: number + name: string + project: number +} + +export enum StageTriggerType { + ON_ENTER = 'ON_ENTER', + WAIT_FOR = 'WAIT_FOR', +} + +export type StageTriggerBody = string | null + +export type StageTrigger = { + trigger_type: StageTriggerType + trigger_body: StageTriggerBody +} + +export enum StageActionType { + TOGGLE_FEATURE = 'TOGGLE_FEATURE', + TOGGLE_FEATURE_FOR_SEGMENT = 'TOGGLE_FEATURE_FOR_SEGMENT', +} +// TODO: Check if this is correct +export interface StageActionResponse + extends Omit { + action_body: string +} + +export type PipelineStage = { + id: number + name: string + pipeline: number + environment: number + order: number + trigger: StageTrigger + actions: StageActionResponse[] +} + export type Res = { segments: PagedResponse segment: Segment @@ -936,5 +984,9 @@ export type Res = { splitTest: PagedResponse onboardingSupportOptIn: { id: string } userPermissions: UserPermissions + releasePipelines: PagedResponse + releasePipeline: ReleasePipeline + pipelineStages: PagedResponse + pipelineStage: PipelineStage // END OF TYPES } diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index b2b9e3ecea59..803f1a8b381a 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -678,6 +678,14 @@ const Utils = Object.assign({}, require('./base/_utils'), { .replace(/[\s_]+/g, '-') .toLowerCase(), + toSelectedValue: ( + value: string, + options: { label: string; value: string }[], + defaultValue?: string, + ) => { + return options?.find((option) => option.value === value) ?? defaultValue + }, + validateMetadataType(type: string, value: any) { switch (type) { case 'int': { diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index 00515875569d..b07aa3a4c736 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -613,6 +613,27 @@ const App = class extends Component { ) } + {Utils.getFlagsmithHasFeature( + 'release_pipelines', + ) && ( + + {({ permission }) => + permission && ( + } + id='release-pipelines-link' + to={`/project/${projectId}/release-pipelines`} + > + Release Pipelines + + ) + } + + )} ) : ( !!AccountStore.getOrganisation() && ( diff --git a/frontend/web/components/base/DropdownMenu.tsx b/frontend/web/components/base/DropdownMenu.tsx new file mode 100644 index 000000000000..bd5e69b743c0 --- /dev/null +++ b/frontend/web/components/base/DropdownMenu.tsx @@ -0,0 +1,110 @@ +import React, { useLayoutEffect, useRef, useState } from 'react' +import Icon, { IconName } from 'components/Icon' +import classNames from 'classnames' +import useOutsideClick from 'common/useOutsideClick' +import { createPortal } from 'react-dom' + +type MenuItem = { + icon?: IconName + label: string + onClick: (e: React.MouseEvent) => void + disabled?: boolean + tooltip?: string + dataTest?: string +} + +type DropdownMenuProps = { + items: MenuItem[] + className?: string + buttonClassName?: string + iconClassName?: string + iconWidth?: number + iconFill?: string +} + +const DropdownMenu: React.FC = ({ + buttonClassName, + className, + iconClassName, + iconFill = '#9DA4AE', + iconWidth = 18, + items, +}) => { + const [isOpen, setIsOpen] = useState(false) + const btnRef = useRef(null) + const dropDownRef = useRef(null) + useOutsideClick(dropDownRef, () => setIsOpen(false)) + + function calculateListPosition( + btnEl: HTMLElement, + listEl: HTMLElement, + ): { top: number; left: number } { + const listPosition = listEl.getBoundingClientRect() + const btnPosition = btnEl.getBoundingClientRect() + const pageTop = window.visualViewport?.pageTop ?? 0 + return { + left: btnPosition.right - listPosition.width, + top: pageTop + btnPosition.bottom, + } + } + + useLayoutEffect(() => { + if (!isOpen || !dropDownRef.current || !btnRef.current) return + const listPosition = calculateListPosition( + btnRef.current, + dropDownRef.current, + ) + dropDownRef.current.style.top = `${listPosition.top}px` + dropDownRef.current.style.left = `${listPosition.left}px` + }, [btnRef, isOpen, dropDownRef]) + + return ( +
+ + + {isOpen && + createPortal( +
+ {items.map((item, index) => ( +
{ + e.stopPropagation() + if (!item.disabled) { + item.onClick(e) + setIsOpen(false) + } + }} + > + {item.icon && ( + + )} + {item.label} +
+ ))} +
, + document.body, + )} +
+ ) +} + +export default DropdownMenu diff --git a/frontend/web/components/pages/CreateReleasePipelinePage.tsx b/frontend/web/components/pages/CreateReleasePipelinePage.tsx new file mode 100644 index 000000000000..ed573ef64ae5 --- /dev/null +++ b/frontend/web/components/pages/CreateReleasePipelinePage.tsx @@ -0,0 +1,16 @@ +import CreateReleasePipeline from 'components/release-pipelines/CreateReleasePipeline' + +type CreateReleasePipelinePageType = { + match: { + params: { + projectId: string + } + } +} + +export default function CreateReleasePipelinePage({ + match, +}: CreateReleasePipelinePageType) { + const { projectId } = match.params + return +} diff --git a/frontend/web/components/pages/ReleasePipelineDetailPage.tsx b/frontend/web/components/pages/ReleasePipelineDetailPage.tsx new file mode 100644 index 000000000000..977a1c3d4462 --- /dev/null +++ b/frontend/web/components/pages/ReleasePipelineDetailPage.tsx @@ -0,0 +1,17 @@ +import ReleasePipelineDetail from 'components/release-pipelines/ReleasePipelineDetail' + +type ReleasePipelineDetailPageType = { + match: { + params: { + projectId: string + id: string + } + } +} + +export default function ReleasePipelineDetailPage({ + match, +}: ReleasePipelineDetailPageType) { + const { projectId } = match.params + return +} diff --git a/frontend/web/components/pages/ReleasePipelinesPage.tsx b/frontend/web/components/pages/ReleasePipelinesPage.tsx new file mode 100644 index 000000000000..a1b857421bb9 --- /dev/null +++ b/frontend/web/components/pages/ReleasePipelinesPage.tsx @@ -0,0 +1,65 @@ +import PageTitle from 'components/PageTitle' +import { useGetReleasePipelinesQuery } from 'common/services/useReleasePipelines' +import { Button } from 'components/base/forms/Button' +import { RouterChildContext } from 'react-router' +import ConfigProvider from 'common/providers/ConfigProvider' +import ReleasePipelinesList from 'components/release-pipelines/ReleasePipelinesList' +import { useState } from 'react' + +type ReleasePipelinesPageType = { + router: RouterChildContext['router'] + match: { + params: { + environmentId: string + projectId: string + } + } +} + +const ReleasePipelinesPage = ({ match, router }: ReleasePipelinesPageType) => { + const [page, setPage] = useState(1) + const pageSize = 10 + const { projectId } = match.params + const { data, isLoading } = useGetReleasePipelinesQuery({ + page, + page_size: pageSize, + projectId: Number(projectId), + }) + + const hasReleasePipelines = !!data?.results?.length + + return ( +
+ + router.history.push( + `/project/${projectId}/release-pipelines/create`, + ) + } + > + Create Release Pipeline + + ) + } + > + {hasReleasePipelines && + 'Define the stages your flags should go from development to launched. Learn more.'} + + +
+ ) +} + +export default ConfigProvider(ReleasePipelinesPage) diff --git a/frontend/web/components/release-pipelines/CreatePipelineStage.tsx b/frontend/web/components/release-pipelines/CreatePipelineStage.tsx new file mode 100644 index 000000000000..02ec32e4c178 --- /dev/null +++ b/frontend/web/components/release-pipelines/CreatePipelineStage.tsx @@ -0,0 +1,237 @@ +import { SyntheticEvent, useEffect, useMemo, useState } from 'react' +import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' +import InputGroup from 'components/base/forms/InputGroup' +import Utils from 'common/utils/utils' +import { useGetSegmentsQuery } from 'common/services/useSegment' +import { + PipelineStage as PipelineStageType, + StageActionType, + StageTrigger, +} from 'common/types/responses' +import Button from 'components/base/forms/Button' +import Icon from 'components/Icon' +import { FLAG_ACTIONS, TRIGGER_OPTIONS } from './constants' +import { StageActionRequest } from 'common/types/requests' + +type DraftStageType = Omit + +const getFlagActions = (trigger: string | undefined) => { + if (trigger === 'ON_ENTER') { + return FLAG_ACTIONS.ON_ENTER + } + return [] +} + +const CreatePipelineStage = ({ + onChange, + onRemove, + projectId, + showRemoveButton, + stageData, +}: { + stageData: DraftStageType + onChange: (stageData: DraftStageType) => void + projectId: string + showRemoveButton?: boolean + onRemove?: () => void +}) => { + const [searchInput, setSearchInput] = useState('') + const [selectedAction, setSelectedAction] = useState<{ + label: string + value: string + }>({ label: 'Select an action', value: '' }) + const [selectedSegment, setSelectedSegment] = useState<{ + label: string + value: number | null + }>() + + const { data: environmentsData, isLoading: isEnvironmentsLoading } = + useGetEnvironmentsQuery( + { + projectId, + }, + { skip: !projectId }, + ) + + const { data: segments, isLoading: isSegmentsLoading } = useGetSegmentsQuery( + { + include_feature_specific: true, + page_size: 1000, + projectId, + }, + { skip: !projectId }, + ) + + const selectedTrigger = useMemo(() => { + return TRIGGER_OPTIONS.find( + (trigger) => trigger.value === stageData.trigger.trigger_type, + ) + }, [stageData.trigger.trigger_type]) + + const segmentOptions = useMemo(() => { + return segments?.results?.map((segment) => ({ + label: segment.name, + value: segment.id, + })) + }, [segments]) + + const environmentOptions = useMemo(() => { + return environmentsData?.results?.map((environment) => ({ + label: environment.name, + value: environment.id, + })) + }, [environmentsData]) + + const handleOnChange = ( + fieldName: keyof DraftStageType, + value: string | number | StageTrigger | StageActionRequest[], + ) => { + onChange({ ...stageData, [fieldName]: value }) + } + + useEffect(() => { + if (environmentOptions?.length && stageData.environment === -1) { + handleOnChange('environment', environmentOptions?.[0]?.value) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [environmentOptions, stageData]) + + useEffect(() => { + if (selectedAction.value === '') { + return handleOnChange('actions', []) + } + + const isSegment = selectedAction.value.includes('FOR_SEGMENT') + const enabled = !selectedAction.value.includes('DISABLE') + + const action_type = isSegment + ? StageActionType.TOGGLE_FEATURE_FOR_SEGMENT + : StageActionType.TOGGLE_FEATURE + + const action_body = { enabled } + + handleOnChange('actions', [{ action_body, action_type }]) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedAction]) + + useEffect(() => { + if (selectedSegment?.value) { + const actions = stageData.actions.map((action) => { + if (action.action_type === StageActionType.TOGGLE_FEATURE_FOR_SEGMENT) { + return { + ...action, + action_body: { enabled: true, segment_id: selectedSegment.value }, + } + } + return action + }) + + handleOnChange('actions', actions) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedSegment]) + + return ( +
+ {showRemoveButton && ( +
+ +
+ )} + + ) => { + handleOnChange('name', e.currentTarget.value) + }} + /> + + + + handleOnChange('environment', option.value) + } + /> + } + /> + + + + handleOnChange('trigger', { + trigger_body: null, + trigger_type: option.value, + } as StageTrigger) + } + /> + } + /> + + +
+
Then
+
+ + + + } + /> + + {selectedAction.value.includes('FOR_SEGMENT') && ( + + + } + /> + + )} +
+ ) +} + +export type { DraftStageType } +export default CreatePipelineStage diff --git a/frontend/web/components/release-pipelines/CreateReleasePipeline.tsx b/frontend/web/components/release-pipelines/CreateReleasePipeline.tsx new file mode 100644 index 000000000000..b1657a567fa9 --- /dev/null +++ b/frontend/web/components/release-pipelines/CreateReleasePipeline.tsx @@ -0,0 +1,219 @@ +import { useEffect, useState } from 'react' +import CreatePipelineStage, { DraftStageType } from './CreatePipelineStage' +import Breadcrumb from 'components/Breadcrumb' +import { Button } from 'components/base/forms/Button' +import PageTitle from 'components/PageTitle' +import InputGroup from 'components/base/forms/InputGroup' +import Utils from 'common/utils/utils' +import { + PipelineStatus, + ReleasePipeline, + StageTriggerType, +} from 'common/types/responses' +import Icon from 'components/Icon' +import { + useCreatePipelineStagesMutation, + useCreateReleasePipelineMutation, +} from 'common/services/useReleasePipelines' +import { withRouter, RouteComponentProps } from 'react-router' +import StageArrow from './StageArrow' + +type CreateReleasePipelineType = { + projectId: string +} & RouteComponentProps + +type DraftPipelineType = Omit< + ReleasePipeline, + 'id' | 'stages_count' | 'flags_count' +> + +const blankStage: DraftStageType = { + actions: [], + environment: -1, + name: '', + order: 0, + trigger: { + trigger_body: null, + trigger_type: StageTriggerType.ON_ENTER, + }, +} + +function CreateReleasePipeline({ + history, + projectId, +}: CreateReleasePipelineType) { + const [ + createReleasePipeline, + { + error: createPipelineError, + isLoading: isCreatingPipeline, + isSuccess: isCreatingPipelineSuccess, + }, + ] = useCreateReleasePipelineMutation() + + const [ + createStages, + { + error: createStageError, + isLoading: isCreatingStage, + isSuccess: isCreatingStageSuccess, + }, + ] = useCreatePipelineStagesMutation() + + const [pipelineData, setPipelineData] = useState({ + name: '', + project: Number(projectId), + status: PipelineStatus.DRAFT, + }) + + const [stages, setStages] = useState([blankStage]) + + const [isEditingName, setIsEditingName] = useState( + !pipelineData?.name?.length, + ) + + const [isStagesCreationSuccess, setStagesCreationSuccess] = + useState(false) + + useEffect(() => { + if (isCreatingPipelineSuccess && isStagesCreationSuccess) { + history.push(`/project/${projectId}/release-pipelines`) + } + }, [isCreatingPipelineSuccess, isStagesCreationSuccess, history, projectId]) + + const handleOnChange = (newStageData: DraftStageType, index: number) => { + const updatedStages = stages.map((stage, i) => + i === index ? newStageData : stage, + ) + setStages(updatedStages) + } + + const validatePipelineData = () => { + return pipelineData?.name !== '' + } + + const handleSave = async () => { + setStagesCreationSuccess(false) + try { + const response = await createReleasePipeline({ + name: pipelineData.name, + projectId: Number(projectId), + status: PipelineStatus.DRAFT, + }) + + if ('data' in response && response.data.id) { + const pipelineId = response.data.id + const stagesCreationPromises = stages.map((stage, index) => { + return createStages({ + ...stage, + order: index, + pipeline: pipelineId, + project: Number(projectId), + }) + }) + + await Promise.all(stagesCreationPromises) + } + setStagesCreationSuccess(true) + } catch (error) { + console.error('erroroooo') + toast('Error creating release pipeline', 'error') + } + } + + const handleRemoveStage = (index: number) => { + const newStages = stages.filter((_, i) => i !== index) + setStages(newStages) + } + + return ( +
+ + + + {!isEditingName && !!pipelineData?.name?.length ? ( +
{pipelineData?.name}
+ ) : ( + { + setPipelineData({ + ...pipelineData, + name: Utils.safeParseEventValue(event), + }) + }} + /> + )} + + + } + cta={ + + } + /> +
+ + {stages?.map((stageData, index) => ( + + + + handleOnChange(stageData, index) + } + projectId={projectId} + showRemoveButton={index > 0} + onRemove={() => handleRemoveStage(index)} + /> +
+ + setStages((prev) => prev.concat(blankStage)) + } + /> + {index === stages.length - 1 && ( +
+ + Launched +
+ )} +
+
+
+ ))} +
+
+
+ ) +} + +export default withRouter(CreateReleasePipeline) diff --git a/frontend/web/components/release-pipelines/ReleasePipelineDetail.tsx b/frontend/web/components/release-pipelines/ReleasePipelineDetail.tsx new file mode 100644 index 000000000000..1e69c6ec3748 --- /dev/null +++ b/frontend/web/components/release-pipelines/ReleasePipelineDetail.tsx @@ -0,0 +1,124 @@ +import Breadcrumb from 'components/Breadcrumb' + +import PageTitle from 'components/PageTitle' + +import { + useGetPipelineStagesQuery, + useGetReleasePipelineQuery, +} from 'common/services/useReleasePipelines' +import { withRouter, RouteComponentProps } from 'react-router' +import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' +import Icon from 'components/Icon' +import StageCard from './StageCard' +import StageInfo from './StageInfo' + +type ReleasePipelineDetailType = { + projectId: string + id: string +} & RouteComponentProps + +const LaunchedCard = () => { + // TODO: Add the logic to get the features that completed this pipeline in the last 30 days + return ( + + + +
Launched
+
+

+ Features that completed this pipeline in the last 30 days +

+
Features (1)
+

+ Finished 3h ago by John Doe +

+
+ ) +} + +function ReleasePipelineDetail({ id, projectId }: ReleasePipelineDetailType) { + const { data: pipelineData, isLoading: isLoadingPipeline } = + useGetReleasePipelineQuery( + { + pipelineId: Number(id), + projectId: Number(projectId), + }, + { + skip: !id || !projectId, + }, + ) + + const { data: stagesData, isLoading: isLoadingStages } = + useGetPipelineStagesQuery( + { + page: 1, + page_size: 100, + pipelineId: Number(id), + projectId: Number(projectId), + }, + { + skip: !id || !projectId, + }, + ) + + const { data: environmentsData, isLoading: isLoadingEnvironments } = + useGetEnvironmentsQuery( + { + projectId, + }, + { + skip: !projectId, + }, + ) + + const HeaderWrapper = ({ children }: { children: React.ReactNode }) => ( +
+ + {children} +
+ ) + + if (isLoadingPipeline || isLoadingStages || isLoadingEnvironments) { + return ( + +
+ +
+
+ ) + } + + return ( + + + {stagesData?.results?.length === 0 && ( + + This release pipeline has no stages. + + )} +
+ + {stagesData?.results?.map((stageData) => ( + + ))} + {!!stagesData?.results?.length && } + +
+
+ ) +} + +export default withRouter(ReleasePipelineDetail) diff --git a/frontend/web/components/release-pipelines/ReleasePipelinesList.tsx b/frontend/web/components/release-pipelines/ReleasePipelinesList.tsx new file mode 100644 index 000000000000..2a3878a386f2 --- /dev/null +++ b/frontend/web/components/release-pipelines/ReleasePipelinesList.tsx @@ -0,0 +1,138 @@ +import { useDeleteReleasePipelineMutation } from 'common/services/useReleasePipelines' +import { PagedResponse, ReleasePipeline } from 'common/types/responses' +import { RouterChildContext } from 'react-router' +import Button from 'components/base/forms/Button' +import Icon from 'components/Icon' +import DropdownMenu from 'components/base/DropdownMenu' +import PanelSearch from 'components/PanelSearch' + +const NoReleasePipelines = ({ + projectId, + router, +}: { + router: RouterChildContext['router'] + projectId: string +}) => { + return ( +
+
+ +
+

+ Create release pipelines to automate and standardize your release + process throughout your organization. +

+ + + + +
+ ) +} + +type ReleasePipelinesListProps = { + data: PagedResponse | undefined + isLoading: boolean + projectId: string + router: RouterChildContext['router'] + page: number + pageSize: number + onPageChange: (page: number) => void +} + +const ReleasePipelinesList = ({ + data, + isLoading, + onPageChange, + page, + pageSize, + projectId, + router, +}: ReleasePipelinesListProps) => { + const [deleteReleasePipeline] = useDeleteReleasePipelineMutation() + const pipelinesList = data?.results + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!pipelinesList?.length) { + return + } + + return ( + { + return item.name.toLowerCase().includes(search.toLowerCase()) + }} + paging={{ + ...(data || {}), + currentPage: page, + pageSize, + }} + nextPage={() => onPageChange(page + 1)} + prevPage={() => onPageChange(page - 1)} + goToPage={(page: number) => onPageChange(page)} + renderRow={({ flags_count, id, name, stages_count }: ReleasePipeline) => { + return ( + + { + router.history.push( + `/project/${projectId}/release-pipelines/${id}`, + ) + }} + > + {name} + + +
+ {/* TODO: Add stages count */} +
{stages_count ?? 0}
+
Stages
+
+ {/* TODO: Add flags count */} +
+
{flags_count ?? 0}
+
Flags
+
+ { + deleteReleasePipeline({ + pipelineId: id, + projectId: Number(projectId), + }) + }, + }, + ]} + /> +
+
+ ) + }} + /> + ) +} + +export type { ReleasePipelinesListProps } +export default ReleasePipelinesList diff --git a/frontend/web/components/release-pipelines/StageArrow.tsx b/frontend/web/components/release-pipelines/StageArrow.tsx new file mode 100644 index 000000000000..34f96c831aa9 --- /dev/null +++ b/frontend/web/components/release-pipelines/StageArrow.tsx @@ -0,0 +1,34 @@ +import Button from 'components/base/forms/Button' +import Icon from 'components/Icon' + +type StageArrowProps = { + onAddStage?: () => void + showAddStageButton?: boolean +} + +const StageArrow = ({ onAddStage, showAddStageButton }: StageArrowProps) => { + return ( +
+
+
+ {showAddStageButton && ( + + )} +
+
+ +
+
+
+
+ ) +} + +export type { StageArrowProps } +export default StageArrow diff --git a/frontend/web/components/release-pipelines/StageCard.tsx b/frontend/web/components/release-pipelines/StageCard.tsx new file mode 100644 index 000000000000..1c5fd3024d1b --- /dev/null +++ b/frontend/web/components/release-pipelines/StageCard.tsx @@ -0,0 +1,20 @@ +type StageCardProps = { + children: React.ReactNode +} + +const StageCard = ({ children }: StageCardProps) => { + return ( +
+ {children} +
+ ) +} + +export type { StageCardProps } +export default StageCard diff --git a/frontend/web/components/release-pipelines/StageInfo.tsx b/frontend/web/components/release-pipelines/StageInfo.tsx new file mode 100644 index 000000000000..c25a8d2b9cba --- /dev/null +++ b/frontend/web/components/release-pipelines/StageInfo.tsx @@ -0,0 +1,78 @@ +import { + Environment, + StageTriggerType, + StageTriggerBody, + Segment, + StageActionType, +} from 'common/types/responses' + +import { PipelineStage } from 'common/types/responses' +import StageCard from './StageCard' +import StageArrow from './StageArrow' +import { TRIGGER_OPTIONS } from './constants' +import { useGetSegmentQuery } from 'common/services/useSegment' + +type StageInfoProps = { + environmentsData: Environment[] | undefined + stageData: PipelineStage + projectId: number +} + +const getTriggerText = ( + triggerType: StageTriggerType, + segmentData?: Segment, +) => { + if (triggerType === StageTriggerType.ON_ENTER) { + return ( + + When flag is added to this stage, enable flag for{' '} + {segmentData?.name ?? 'everyone'} + + ) + } + + return null +} + +const StageInfo = ({ + environmentsData, + projectId, + stageData, +}: StageInfoProps) => { + const environmentData = environmentsData?.find( + (environment) => environment.id === stageData?.environment, + ) + + // TODO: Fetch segment data based on action body + // const { data: segmentData } = useGetSegmentQuery({ + // id: `${segmentId}`, + // projectId: `${projectId}`, + // }, + // { skip: !segmentId },) + + return ( + + + +
+
{stageData?.name}
+

{environmentData?.name}

+

+ {/* TODO: Add segment data */} + {getTriggerText(stageData?.trigger?.trigger_type, undefined)} +

+ {/* TODO: Add features count */} +
Features (0)
+

No features added to this stage yet.

+
+
+
+ +
+
+
+ ) +} + +export type { StageInfoProps } +export default StageInfo diff --git a/frontend/web/components/release-pipelines/constants.ts b/frontend/web/components/release-pipelines/constants.ts new file mode 100644 index 000000000000..deca36339c02 --- /dev/null +++ b/frontend/web/components/release-pipelines/constants.ts @@ -0,0 +1,19 @@ +import { StageTriggerType } from 'common/types/responses' + +const TRIGGER_OPTIONS = [ + { label: 'When flag is added to this stage', value: 'ON_ENTER' }, +] + +const FLAG_ACTIONS = { + [StageTriggerType.ON_ENTER]: [ + { label: 'Enable flag for everyone', value: 'TOGGLE_FEATURE' }, + { label: 'Disable flag for everyone', value: 'TOGGLE_FEATURE_DISABLE' }, + { label: 'Enable flag for segment', value: 'TOGGLE_FEATURE_FOR_SEGMENT' }, + { + label: 'Disable flag for segment', + value: 'TOGGLE_FEATURE_DISABLE_FOR_SEGMENT', + }, + ], +} + +export { TRIGGER_OPTIONS, FLAG_ACTIONS } diff --git a/frontend/web/project/project-components.js b/frontend/web/project/project-components.js index 64bd77a4395e..fc980afb5751 100644 --- a/frontend/web/project/project-components.js +++ b/frontend/web/project/project-components.js @@ -86,8 +86,15 @@ global.ToggleChip = ToggleChip const Option = (props) => { return ( -
- {props.data.label} +
+
+ {props.data.label} +
{props.data.description}
+
{props.isSelected && ( )} diff --git a/frontend/web/routes.js b/frontend/web/routes.js index 19fd2b10ddb1..7f451bf96ac7 100644 --- a/frontend/web/routes.js +++ b/frontend/web/routes.js @@ -41,7 +41,9 @@ import { ParameterizedRoute } from './components/base/higher-order/Parameterized import FeatureHistoryDetailPage from './components/pages/FeatureHistoryDetailPage' import SplitTestPage from './components/pages/SplitTestPage' import OrganisationIntegrationsPage from './components/pages/OrganisationIntegrationsPage' - +import ReleasePipelinesPage from './components/pages/ReleasePipelinesPage' +import CreateReleasePipelinePage from './components/pages/CreateReleasePipelinePage' +import ReleasePipelineDetailPage from './components/pages/ReleasePipelineDetailPage' export const routes = { 'account': '/account', 'account-settings': '/project/:projectId/environment/:environmentId/account', @@ -55,6 +57,7 @@ export const routes = { 'compare': '/project/:projectId/compare', 'create-environment': '/project/:projectId/environment/create', 'create-organisation': '/create', + 'create-release-pipeline': '/project/:projectId/release-pipelines/create', 'environment-settings': '/project/:projectId/environment/:environmentId/settings', 'feature-history': '/project/:projectId/environment/:environmentId/history', @@ -83,6 +86,8 @@ export const routes = { 'project-settings': '/project/:projectId/settings', 'project-settings-in-environment': '/project/:projectId/environment/:environmentId/project-settings', + 'release-pipelines': '/project/:projectId/release-pipelines', + 'release-pipelines-detail': '/project/:projectId/release-pipelines/:id', 'root': '/', 'saml': '/saml', 'scheduled-change': @@ -238,6 +243,26 @@ export default ( exact component={ProjectRedirectPage} /> + + + +