From 9a92750fc5d54fc971b59f0fb2a39fca4b175597 Mon Sep 17 00:00:00 2001 From: Laszlo Moczo Date: Thu, 12 Dec 2024 15:55:52 +0100 Subject: [PATCH] Add Target Based Triggers to Pipelines Tab [BIVS-2906] (#1360) --- .../unified-editor/Triggers/Triggers.tsx | 373 ++++++++++++++++++ .../Triggers/Triggers.types.ts} | 29 ++ .../Triggers/Triggers.utils.ts} | 62 +-- .../components/AddTrigger}/AddTrigger.tsx | 35 +- .../components/AddTrigger}/ConditionCard.tsx | 18 +- .../components}/TriggerConditions.tsx | 4 +- .../WorkflowConfig/WorkflowConfigDrawer.tsx | 6 +- .../WorkflowConfig/WorkflowConfigPanel.tsx | 2 +- .../components/WorkflowConfigHeader.tsx | 13 +- .../WorkflowConfig/tabs/TriggersTab.tsx | 370 +---------------- .../core/models/BitriseYml.schema.ts | 17 + .../core/models/BitriseYmlService.spec.ts | 262 ++++++++++++ .../core/models/BitriseYmlService.ts | 46 ++- .../core/stores/BitriseYmlStore.ts | 16 + .../PipelineConfigDrawer.tsx | 102 +---- .../tabs/PropertiesTab.tsx | 93 +++++ .../PipelineConfigDrawer/tabs/TriggersTab.tsx | 26 ++ .../TriggersPage.stories.tsx | 0 .../TriggersPage => }/TriggersPage.tsx | 4 +- ...PageFunctions.ts => TriggersPage.utils.ts} | 61 ++- .../LegacyTriggers/AddPrTriggerDialog.tsx | 6 +- .../LegacyTriggers/AddPushTriggerDialog.tsx | 6 +- .../LegacyTriggers/AddTagTriggerDialog.tsx | 33 +- .../LegacyTriggers/LegacyTriggers.tsx | 4 +- .../components/LegacyTriggers/TriggerCard.tsx | 4 +- .../TargetBasedTriggers/RegexCheckbox.tsx | 20 - .../TargetBasedTriggers.tsx | 27 +- .../components/Drawers/Drawers.tsx | 2 + source/javascripts/pages/index.ts | 2 +- spec/integration/test_bitrise.yml | 2 +- 30 files changed, 1044 insertions(+), 601 deletions(-) create mode 100644 source/javascripts/components/unified-editor/Triggers/Triggers.tsx rename source/javascripts/{pages/TriggersPage/components/TriggersPage/TriggersPage.types.ts => components/unified-editor/Triggers/Triggers.types.ts} (66%) rename source/javascripts/{pages/TriggersPage/components/TriggersPage/TriggersPage.utils.ts => components/unified-editor/Triggers/Triggers.utils.ts} (61%) rename source/javascripts/{pages/TriggersPage/components/TargetBasedTriggers => components/unified-editor/Triggers/components/AddTrigger}/AddTrigger.tsx (88%) rename source/javascripts/{pages/TriggersPage/components/TargetBasedTriggers => components/unified-editor/Triggers/components/AddTrigger}/ConditionCard.tsx (81%) rename source/javascripts/{pages/TriggersPage/components/TargetBasedTriggers => components/unified-editor/Triggers/components}/TriggerConditions.tsx (96%) create mode 100644 source/javascripts/pages/PipelinesPage/components/PipelineConfigDrawer/tabs/PropertiesTab.tsx create mode 100644 source/javascripts/pages/PipelinesPage/components/PipelineConfigDrawer/tabs/TriggersTab.tsx rename source/javascripts/pages/TriggersPage/{components/TriggersPage => }/TriggersPage.stories.tsx (100%) rename source/javascripts/pages/TriggersPage/{components/TriggersPage => }/TriggersPage.tsx (93%) rename source/javascripts/pages/TriggersPage/{components/TriggersPage/TriggersPageFunctions.ts => TriggersPage.utils.ts} (72%) delete mode 100644 source/javascripts/pages/TriggersPage/components/TargetBasedTriggers/RegexCheckbox.tsx diff --git a/source/javascripts/components/unified-editor/Triggers/Triggers.tsx b/source/javascripts/components/unified-editor/Triggers/Triggers.tsx new file mode 100644 index 000000000..378575360 --- /dev/null +++ b/source/javascripts/components/unified-editor/Triggers/Triggers.tsx @@ -0,0 +1,373 @@ +import { useState } from 'react'; +import { + Box, + Button, + Card, + ExpandableCard, + Link, + Notification, + OverflowMenu, + OverflowMenuItem, + Text, + Toggle, +} from '@bitrise/bitkit'; +import { isEqual } from 'es-toolkit'; +import RuntimeUtils from '@/core/utils/RuntimeUtils'; +import deepCloneSimpleObject from '@/utils/deepCloneSimpleObject'; +import { segmentTrack } from '@/utils/segmentTracking'; +import useUserMetaData from '@/hooks/useUserMetaData'; +import { BitriseYmlStoreState } from '@/core/stores/BitriseYmlStore'; + +import useBitriseYmlStore from '@/hooks/useBitriseYmlStore'; +import { TargetBasedTriggerItem, TargetBasedTriggers, TriggerType } from './Triggers.types'; + +import AddTrigger from './components/AddTrigger/AddTrigger'; +import TriggerConditions from './components/TriggerConditions'; +import { getConditionList, getPipelineableTriggers } from './Triggers.utils'; + +const OPTIONS_MAP: Record> = { + push: { + branch: 'Push branch', + commit_message: 'Commit message', + changed_files: 'File change', + }, + pull_request: { + target_branch: 'Target branch', + source_branch: 'Source branch', + label: 'PR label', + comment: 'PR comment', + commit_message: 'Commit message', + changed_files: 'File change', + }, + tag: { + name: 'Tag', + }, +}; + +const LABELS_MAP: Record> = { + push: { + branch: 'Push branch', + commit_message: 'Enter a commit message', + changed_files: 'Enter a path', + }, + pull_request: { + target_branch: 'Enter a target branch', + source_branch: 'Enter a source branch', + label: 'Enter a label', + comment: 'Enter a comment', + commit_message: 'Enter a commit message', + changed_files: 'Enter a path', + }, + tag: { + tag: 'Enter a tag', + }, +}; + +type TriggerItemProps = { + globalDisabled: boolean; + onTriggerToggle: (triggerDisabled: boolean) => void; + onTriggerEdit: () => void; + onDeleteClick: () => void; + trigger: TargetBasedTriggerItem; + triggerType: TriggerType; +}; + +const TriggerItem = (props: TriggerItemProps) => { + const { globalDisabled, onDeleteClick, onTriggerToggle, onTriggerEdit, trigger, triggerType } = props; + const conditions = getConditionList(trigger); + const triggerDisabled = trigger.enabled === false; + return ( + + + + + Edit trigger + + { + onTriggerToggle(triggerDisabled); + }} + > + {triggerDisabled ? 'Enable trigger' : 'Disable trigger'} + + + Delete trigger + + + + ); +}; + +type TriggersProps = { + additionalTrackingData: Record; + id: string; + triggers: TargetBasedTriggers; + updateTriggers: BitriseYmlStoreState['updateWorkflowTriggers']; + updateTriggersEnabled: BitriseYmlStoreState['updateWorkflowTriggersEnabled']; +}; + +const Triggers = (props: TriggersProps) => { + const { additionalTrackingData, id, triggers: triggersProp, updateTriggers, updateTriggersEnabled } = props; + + const [triggerType, setTriggerType] = useState(undefined); + const [editedItem, setEditedItem] = useState<{ index: number; trigger: TargetBasedTriggerItem } | undefined>( + undefined, + ); + const isWebsiteMode = RuntimeUtils.isWebsiteMode(); + + const { value: metaDataValue, update: updateMetaData } = useUserMetaData( + 'wfe_target_based_triggering_notification_closed', + isWebsiteMode, + ); + + const triggers: TargetBasedTriggers = deepCloneSimpleObject(triggersProp || {}); + + const { triggersInProject, numberOfLegacyTriggers } = useBitriseYmlStore(({ yml }) => ({ + triggersInProject: getPipelineableTriggers(yml), + numberOfLegacyTriggers: yml.trigger_map?.length || 0, + })); + + const trackingData = { + number_of_existing_target_based_triggers_on_target: triggersInProject.filter( + ({ pipelineableId }) => pipelineableId === id, + ).length, + number_of_existing_target_based_triggers_in_project: triggersInProject.length, + number_of_existing_trigger_map_triggers_in_project: numberOfLegacyTriggers, + is_target_based_triggers_enabled_on_target: triggers.enabled !== false, + ...additionalTrackingData, + }; + + const onTriggerDelete = (trigger: TargetBasedTriggerItem, type: TriggerType) => { + triggers[type] = triggers[type]?.filter((t: any) => !isEqual(trigger, t)); + updateTriggers(id, triggers); + }; + + const onTriggerToggle = ( + type: TriggerType, + index: number, + triggerDisabled: boolean, + trigger: TargetBasedTriggerItem, + ) => { + if (!triggerDisabled) { + if (triggers[type]?.[index]) { + (triggers[type][index] as TargetBasedTriggerItem).enabled = false; + } + } else if (triggers[type]?.[index]) { + delete (triggers[type][index] as TargetBasedTriggerItem).enabled; + } + + const triggerConditions: Record = {}; + (Object.keys(trigger) as (keyof typeof trigger)[]).forEach((key) => { + if (key !== 'enabled' && key !== 'draft_enabled') { + if (typeof trigger[key] === 'string') { + triggerConditions[key] = { wildcard: trigger[key] }; + } else { + triggerConditions[key] = trigger[key]; + } + } + }); + + segmentTrack('Workflow Editor Enable Trigger Toggled', { + ...trackingData, + is_selected_trigger_enabled: !triggerDisabled, + trigger_origin: 'workflow_triggers', + trigger_conditions: triggerConditions, + build_trigger_type: type, + }); + updateTriggers(id, triggers); + }; + + const onSubmit = (trigger: TargetBasedTriggerItem) => { + if (triggerType !== undefined) { + if (!Array.isArray(triggers[triggerType])) { + triggers[triggerType] = []; + } + if (editedItem) { + triggers[triggerType][editedItem.index] = trigger; + } else { + triggers[triggerType].push(trigger); + } + + updateTriggers(id, triggers); + } + setTriggerType(undefined); + setEditedItem(undefined); + }; + + const onToggleChange = () => { + segmentTrack('Workflow Editor Enable Target Based Triggers Toggled', { + ...trackingData, + is_target_based_triggers_enabled_on_target: triggers.enabled !== false, + number_of_enabled_existing_target_based_triggers_in_project: triggersInProject.filter( + ({ enabled }) => enabled !== false, + ).length, + }); + updateTriggersEnabled(id, triggers.enabled === false); + }; + + return ( + <> + {triggerType !== undefined && ( + { + setTriggerType(undefined); + setEditedItem(undefined); + }} + optionsMap={OPTIONS_MAP[triggerType]} + labelsMap={LABELS_MAP[triggerType]} + editedItem={editedItem?.trigger} + currentTriggers={(triggers[triggerType] as TargetBasedTriggerItem[]) || []} + trackingData={trackingData} + /> + )} + + {metaDataValue === null && ( + updateMetaData('true')} marginBlockEnd="24"> + Target based triggers + + Set up triggers directly in your Workflows or Pipelines. This way a single Git event can trigger multiple + targets.{' '} + + Learn more + + + + )} + + { + onToggleChange(); + }} + /> + + Push triggers} + > + {(triggers.push as TargetBasedTriggerItem[])?.map((trigger: TargetBasedTriggerItem, index: number) => ( + onTriggerDelete(trigger, 'push')} + trigger={trigger} + triggerType="push" + onTriggerEdit={() => { + setEditedItem({ trigger, index }); + setTriggerType('push'); + }} + onTriggerToggle={(triggerDisabled) => { + onTriggerToggle('push', index, triggerDisabled, trigger); + }} + globalDisabled={triggers.enabled === false} + /> + ))} + + + Pull request triggers} + marginY="12" + > + {(triggers.pull_request as TargetBasedTriggerItem[])?.map( + (trigger: TargetBasedTriggerItem, index: number) => ( + onTriggerDelete(trigger, 'pull_request')} + onTriggerEdit={() => { + setEditedItem({ trigger, index }); + setTriggerType('pull_request'); + }} + trigger={trigger} + onTriggerToggle={(triggerDisabled) => { + onTriggerToggle('pull_request', index, triggerDisabled, trigger); + }} + globalDisabled={triggers.enabled === false} + /> + ), + )} + + + Tag triggers} + > + {(triggers.tag as TargetBasedTriggerItem[])?.map((trigger: TargetBasedTriggerItem, index: number) => ( + onTriggerDelete(trigger, 'tag')} + triggerType="tag" + trigger={trigger} + onTriggerEdit={() => { + setEditedItem({ trigger, index }); + setTriggerType('tag'); + }} + onTriggerToggle={(triggerDisabled) => { + onTriggerToggle('tag', index, triggerDisabled, trigger); + }} + globalDisabled={triggers.enabled === false} + /> + ))} + + + + + ); +}; + +export default Triggers; diff --git a/source/javascripts/pages/TriggersPage/components/TriggersPage/TriggersPage.types.ts b/source/javascripts/components/unified-editor/Triggers/Triggers.types.ts similarity index 66% rename from source/javascripts/pages/TriggersPage/components/TriggersPage/TriggersPage.types.ts rename to source/javascripts/components/unified-editor/Triggers/Triggers.types.ts index 047ecc3a4..f297954fc 100644 --- a/source/javascripts/pages/TriggersPage/components/TriggersPage/TriggersPage.types.ts +++ b/source/javascripts/components/unified-editor/Triggers/Triggers.types.ts @@ -1,3 +1,5 @@ +import { WorkflowYmlObject } from '@/core/models/Workflow'; + export type LegacyTagConditionType = 'tag'; export type TagConditionType = 'name'; @@ -53,3 +55,30 @@ export interface FormItems extends Omit { isDraftPr?: boolean; isActive: boolean; } + +type StringOrRegex = + | string + | { + regex: string; + }; + +export type TargetBasedTriggerItem = { + branch?: StringOrRegex; + changed_files?: StringOrRegex; + commit_message?: StringOrRegex; + comment?: StringOrRegex; + draft_enabled?: boolean; + enabled?: boolean; + label?: StringOrRegex; + source_branch?: StringOrRegex; + target_branch?: StringOrRegex; + tag?: StringOrRegex; +}; + +export type TargetBasedTriggers = WorkflowYmlObject['triggers']; + +export interface DecoratedPipelineableTriggerItem extends TargetBasedTriggerItem { + pipelineableId: string; + pipelineableType: 'pipeline' | 'workflow'; + type: TriggerType; +} diff --git a/source/javascripts/pages/TriggersPage/components/TriggersPage/TriggersPage.utils.ts b/source/javascripts/components/unified-editor/Triggers/Triggers.utils.ts similarity index 61% rename from source/javascripts/pages/TriggersPage/components/TriggersPage/TriggersPage.utils.ts rename to source/javascripts/components/unified-editor/Triggers/Triggers.utils.ts index b5d35bbac..e307fe8a1 100644 --- a/source/javascripts/pages/TriggersPage/components/TriggersPage/TriggersPage.utils.ts +++ b/source/javascripts/components/unified-editor/Triggers/Triggers.utils.ts @@ -1,57 +1,13 @@ -import { isEqual } from 'es-toolkit'; import { isObject } from 'es-toolkit/compat'; import { BitriseYml } from '@/core/models/BitriseYml'; -import { Condition, ConditionType, TriggerItem, TriggerType } from './TriggersPage.types'; - -export const checkIsConditionsUsed = (currentTriggers: TriggerItem[], newTrigger: TriggerItem) => { - let isUsed = false; - currentTriggers.forEach(({ conditions, id }) => { - const newConditions = newTrigger.conditions.map((c) => { - if (c.value === '') { - return { - ...c, - value: '*', - }; - } - return c; - }); - conditions.forEach((c) => { - newConditions.forEach((newC) => { - if (isEqual(c, newC) && id !== newTrigger.id) { - isUsed = true; - } - }); - }); - }); - return isUsed; -}; - -type StringOrRegex = - | string - | { - regex: string; - }; - -export type TargetBasedTriggerItem = { - branch?: StringOrRegex; - changed_files?: StringOrRegex; - commit_message?: StringOrRegex; - comment?: StringOrRegex; - draft_enabled?: boolean; - enabled?: boolean; - label?: StringOrRegex; - source_branch?: StringOrRegex; - target_branch?: StringOrRegex; - tag?: StringOrRegex; -}; - -export type TargetBasedTriggers = Record & { enabled?: boolean }; - -export interface DecoratedPipelineableTriggerItem extends TargetBasedTriggerItem { - pipelineableId: string; - pipelineableType: 'pipeline' | 'workflow'; - type: TriggerType; -} +import WorkflowService from '@/core/models/WorkflowService'; +import { + DecoratedPipelineableTriggerItem, + TriggerType, + TargetBasedTriggerItem, + Condition, + ConditionType, +} from './Triggers.types'; const looper = ( pipelineableId: string, @@ -88,7 +44,7 @@ export const getPipelineableTriggers = (yml: BitriseYml) => { } if (yml.workflows) { Object.entries(yml.workflows).forEach(([id, w]) => { - if (w.triggers) { + if (!WorkflowService.isUtilityWorkflow(id) && w.triggers) { pipelineableTriggers = pipelineableTriggers.concat( looper(id, 'workflow', 'pull_request', w.triggers.pull_request as TargetBasedTriggerItem[]), looper(id, 'workflow', 'push', w.triggers.push as TargetBasedTriggerItem[]), diff --git a/source/javascripts/pages/TriggersPage/components/TargetBasedTriggers/AddTrigger.tsx b/source/javascripts/components/unified-editor/Triggers/components/AddTrigger/AddTrigger.tsx similarity index 88% rename from source/javascripts/pages/TriggersPage/components/TargetBasedTriggers/AddTrigger.tsx rename to source/javascripts/components/unified-editor/Triggers/components/AddTrigger/AddTrigger.tsx index 203760834..e43606af8 100644 --- a/source/javascripts/pages/TriggersPage/components/TargetBasedTriggers/AddTrigger.tsx +++ b/source/javascripts/components/unified-editor/Triggers/components/AddTrigger/AddTrigger.tsx @@ -3,12 +3,12 @@ import { Box, Button, ButtonGroup, Checkbox, Link, Text, Tooltip } from '@bitris import { FormProvider, useFieldArray, useForm } from 'react-hook-form'; import { isEqual } from 'es-toolkit'; import { segmentTrack } from '@/utils/segmentTracking'; -import { Condition, ConditionType, FormItems, TriggerType } from '../TriggersPage/TriggersPage.types'; -import { getConditionList, TargetBasedTriggerItem } from '../TriggersPage/TriggersPage.utils'; +import { TriggerType, TargetBasedTriggerItem, ConditionType, FormItems } from '../../Triggers.types'; +import { getConditionList } from '../../Triggers.utils'; import ConditionCard from './ConditionCard'; type AddTriggerProps = { - workflowId?: string; + id?: string; triggerType: TriggerType; onSubmit: (trigger: TargetBasedTriggerItem) => void; onCancel: () => void; @@ -20,17 +20,8 @@ type AddTriggerProps = { }; const AddTrigger = (props: AddTriggerProps) => { - const { - currentTriggers, - editedItem, - labelsMap, - onCancel, - onSubmit, - optionsMap, - triggerType, - workflowId, - trackingData, - } = props; + const { currentTriggers, editedItem, labelsMap, onCancel, onSubmit, optionsMap, triggerType, id, trackingData } = + props; const defaultConditions = useMemo(() => { if (editedItem) { @@ -69,9 +60,9 @@ const AddTrigger = (props: AddTriggerProps) => { }); }; - const onFormSubmit = (data: any) => { + const onFormSubmit = (data: FormItems) => { const filteredData = data; - filteredData.conditions = data.conditions.map((condition: Condition) => { + filteredData.conditions = data.conditions.map((condition) => { const newCondition = { ...condition }; newCondition.value = newCondition.value.trim(); if (!newCondition.value) { @@ -81,9 +72,11 @@ const AddTrigger = (props: AddTriggerProps) => { }); const newTrigger: any = {}; - filteredData.conditions.forEach((condition: Condition) => { + filteredData.conditions.forEach((condition) => { const value = condition.isRegex ? { regex: condition.value } : condition.value; - newTrigger[condition.type] = value; + if (condition.type) { + newTrigger[condition.type] = value; + } }); if (!data.isDraftPr) { @@ -145,12 +138,12 @@ const AddTrigger = (props: AddTriggerProps) => { return ( - + {title} - Set up the trigger conditions that should all be met to execute the {workflowId} Workflow. + Set up the trigger conditions that should all be met to execute the {id} target. {fields.map((item, index) => { return ( @@ -194,7 +187,7 @@ const AddTrigger = (props: AddTriggerProps) => { )} - + { /> {!!type && ( <> - setValue(`conditions.${conditionNumber}.isRegex`, e.target.checked)} - /> + > + Use regex pattern + + + + ( diff --git a/source/javascripts/pages/TriggersPage/components/TargetBasedTriggers/TriggerConditions.tsx b/source/javascripts/components/unified-editor/Triggers/components/TriggerConditions.tsx similarity index 96% rename from source/javascripts/pages/TriggersPage/components/TargetBasedTriggers/TriggerConditions.tsx rename to source/javascripts/components/unified-editor/Triggers/components/TriggerConditions.tsx index 51af832e4..6119afe9c 100644 --- a/source/javascripts/pages/TriggersPage/components/TargetBasedTriggers/TriggerConditions.tsx +++ b/source/javascripts/components/unified-editor/Triggers/components/TriggerConditions.tsx @@ -1,6 +1,6 @@ +import { Fragment } from 'react'; import { Box, Tag, Text, Tooltip, TypeIconName } from '@bitrise/bitkit'; -import { Fragment } from 'react/jsx-runtime'; -import { Condition, ConditionType, LegacyConditionType, TriggerType } from '../TriggersPage/TriggersPage.types'; +import { Condition, ConditionType, LegacyConditionType, TriggerType } from '../Triggers.types'; type TriggerConditionsProps = { conditions: Condition[]; diff --git a/source/javascripts/components/unified-editor/WorkflowConfig/WorkflowConfigDrawer.tsx b/source/javascripts/components/unified-editor/WorkflowConfig/WorkflowConfigDrawer.tsx index 67fe7d857..bfbdc2e29 100644 --- a/source/javascripts/components/unified-editor/WorkflowConfig/WorkflowConfigDrawer.tsx +++ b/source/javascripts/components/unified-editor/WorkflowConfig/WorkflowConfigDrawer.tsx @@ -10,6 +10,7 @@ import WorkflowConfigProvider from './WorkflowConfig.context'; import ConfigurationTab from './tabs/ConfigurationTab'; import PropertiesTab from './tabs/PropertiesTab'; import WorkflowConfigHeader from './components/WorkflowConfigHeader'; +import TriggersTab from './tabs/TriggersTab'; type Props = Omit & { workflowId: string; @@ -25,7 +26,7 @@ const WorkflowConfigDrawerContent = ({ context, parentWorkflowId, onRename, ...p - + @@ -35,6 +36,9 @@ const WorkflowConfigDrawerContent = ({ context, parentWorkflowId, onRename, ...p + + + diff --git a/source/javascripts/components/unified-editor/WorkflowConfig/WorkflowConfigPanel.tsx b/source/javascripts/components/unified-editor/WorkflowConfig/WorkflowConfigPanel.tsx index 2fbdb5b76..1f7153a9a 100644 --- a/source/javascripts/components/unified-editor/WorkflowConfig/WorkflowConfigPanel.tsx +++ b/source/javascripts/components/unified-editor/WorkflowConfig/WorkflowConfigPanel.tsx @@ -58,7 +58,7 @@ const WorkflowConfigPanelContent = () => { - + diff --git a/source/javascripts/components/unified-editor/WorkflowConfig/components/WorkflowConfigHeader.tsx b/source/javascripts/components/unified-editor/WorkflowConfig/components/WorkflowConfigHeader.tsx index 866c56547..e7dce6c01 100644 --- a/source/javascripts/components/unified-editor/WorkflowConfig/components/WorkflowConfigHeader.tsx +++ b/source/javascripts/components/unified-editor/WorkflowConfig/components/WorkflowConfigHeader.tsx @@ -6,15 +6,16 @@ import { useWorkflowConfigContext } from '../WorkflowConfig.context'; type Props = { variant: 'panel' | 'drawer'; context: 'pipeline' | 'workflow'; + parentWorkflowId?: string; }; -const WorkflowConfigHeader = ({ variant, context }: Props) => { +const WorkflowConfigHeader = ({ variant, context, parentWorkflowId }: Props) => { const { id = '', userValues } = useWorkflowConfigContext() ?? {}; const dependants = useDependantWorkflows({ workflowId: id }); const showSubTitle = context === 'workflow'; - const shouldShowTriggersTab = variant === 'panel' && !WorkflowService.isUtilityWorkflow(id); + const shouldShowTriggersTab = !parentWorkflowId && !WorkflowService.isUtilityWorkflow(id); return ( <> @@ -29,9 +30,11 @@ const WorkflowConfigHeader = ({ variant, context }: Props) => { {userValues?.title || id || 'Workflow'} - - {showSubTitle && WorkflowService.getUsedByText(dependants)} - + {showSubTitle && ( + + {WorkflowService.getUsedByText(dependants)} + + )} diff --git a/source/javascripts/components/unified-editor/WorkflowConfig/tabs/TriggersTab.tsx b/source/javascripts/components/unified-editor/WorkflowConfig/tabs/TriggersTab.tsx index d3379c515..84947c8df 100644 --- a/source/javascripts/components/unified-editor/WorkflowConfig/tabs/TriggersTab.tsx +++ b/source/javascripts/components/unified-editor/WorkflowConfig/tabs/TriggersTab.tsx @@ -1,371 +1,27 @@ -import { useState } from 'react'; -import { - Box, - Button, - Card, - ExpandableCard, - Link, - Notification, - OverflowMenu, - OverflowMenuItem, - Text, - Toggle, -} from '@bitrise/bitkit'; -import { isEqual } from 'es-toolkit'; -import useBitriseYmlStore from '@/hooks/useBitriseYmlStore'; import { useWorkflowConfigContext } from '@/components/unified-editor/WorkflowConfig/WorkflowConfig.context'; -import RuntimeUtils from '@/core/utils/RuntimeUtils'; -import deepCloneSimpleObject from '@/utils/deepCloneSimpleObject'; -import { TriggerType } from '@/pages/TriggersPage/components/TriggersPage/TriggersPage.types'; -import TriggerConditions from '@/pages/TriggersPage/components/TargetBasedTriggers/TriggerConditions'; -import { - getConditionList, - getPipelineableTriggers, - TargetBasedTriggerItem, - TargetBasedTriggers, -} from '@/pages/TriggersPage/components/TriggersPage/TriggersPage.utils'; -import AddTrigger from '@/pages/TriggersPage/components/TargetBasedTriggers/AddTrigger'; -import { segmentTrack } from '@/utils/segmentTracking'; -import useUserMetaData from '../../../../hooks/useUserMetaData'; - -const OPTIONS_MAP: Record> = { - push: { - branch: 'Push branch', - commit_message: 'Commit message', - changed_files: 'File change', - }, - pull_request: { - target_branch: 'Target branch', - source_branch: 'Source branch', - label: 'PR label', - comment: 'PR comment', - commit_message: 'Commit message', - changed_files: 'File change', - }, - tag: { - name: 'Tag', - }, -}; - -const LABELS_MAP: Record> = { - push: { - branch: 'Push branch', - commit_message: 'Enter a commit message', - changed_files: 'Enter a path', - }, - pull_request: { - target_branch: 'Enter a target branch', - source_branch: 'Enter a source branch', - label: 'Enter a label', - comment: 'Enter a comment', - commit_message: 'Enter a commit message', - changed_files: 'Enter a path', - }, - tag: { - tag: 'Enter a tag', - }, -}; - -type TriggerItemProps = { - globalDisabled: boolean; - onTriggerToggle: (triggerDisabled: boolean) => void; - onTriggerEdit: () => void; - onDeleteClick: () => void; - trigger: TargetBasedTriggerItem; - triggerType: TriggerType; -}; - -const TriggerItem = (props: TriggerItemProps) => { - const { globalDisabled, onDeleteClick, onTriggerToggle, onTriggerEdit, trigger, triggerType } = props; - const conditions = getConditionList(trigger); - const triggerDisabled = trigger.enabled === false; - return ( - - - - - Edit trigger - - { - onTriggerToggle(triggerDisabled); - }} - > - {triggerDisabled ? 'Enable trigger' : 'Disable trigger'} - - - Delete trigger - - - - ); -}; +import useBitriseYmlStore from '@/hooks/useBitriseYmlStore'; +import Triggers from '@/components/unified-editor/Triggers/Triggers'; const TriggersTab = () => { - const [triggerType, setTriggerType] = useState(undefined); - const [editedItem, setEditedItem] = useState<{ index: number; trigger: TargetBasedTriggerItem } | undefined>( - undefined, - ); - const isWebsiteMode = RuntimeUtils.isWebsiteMode(); - - const { value: metaDataValue, update: updateMetaData } = useUserMetaData( - 'wfe_target_based_triggering_notification_closed', - isWebsiteMode, - ); - const workflow = useWorkflowConfigContext(); - const { updateWorkflowTriggers, updateWorkflowTriggersEnabled, yml } = useBitriseYmlStore((s) => ({ + const { updateWorkflowTriggers, updateWorkflowTriggersEnabled } = useBitriseYmlStore((s) => ({ updateWorkflowTriggers: s.updateWorkflowTriggers, updateWorkflowTriggersEnabled: s.updateWorkflowTriggersEnabled, - yml: s.yml, })); - const triggers: TargetBasedTriggers = deepCloneSimpleObject( - (workflow?.userValues.triggers as TargetBasedTriggers) || {}, - ); - - const triggersInProject = getPipelineableTriggers(yml); - - const trackingData = { - number_of_existing_target_based_triggers_on_target: triggersInProject.filter( - ({ pipelineableId }) => pipelineableId === workflow?.id, - ).length, - number_of_existing_target_based_triggers_in_project: triggersInProject.length, - number_of_existing_trigger_map_triggers_in_project: yml.trigger_map?.length || 0, - tab_name: 'workflows', - workflow_name: workflow?.id || '', - is_target_based_triggers_enabled_on_target: triggers.enabled !== false, - }; - - const onTriggerDelete = (trigger: TargetBasedTriggerItem, type: TriggerType) => { - triggers[type] = triggers[type]?.filter((t: any) => !isEqual(trigger, t)); - updateWorkflowTriggers(workflow?.id || '', triggers); - }; - - const onTriggerToggle = ( - type: TriggerType, - index: number, - triggerDisabled: boolean, - trigger: TargetBasedTriggerItem, - ) => { - if (!triggerDisabled) { - triggers[type][index].enabled = false; - } else { - delete triggers[type][index].enabled; - } - - const triggerConditions: Record = {}; - (Object.keys(trigger) as (keyof typeof trigger)[]).forEach((key) => { - if (key !== 'enabled' && key !== 'draft_enabled') { - if (typeof trigger[key] === 'string') { - triggerConditions[key] = { wildcard: trigger[key] }; - } else { - triggerConditions[key] = trigger[key]; - } - } - }); - - segmentTrack('Workflow Editor Enable Trigger Toggled', { - ...trackingData, - is_selected_trigger_enabled: !triggerDisabled, - trigger_origin: 'workflow_triggers', - trigger_conditions: triggerConditions, - build_trigger_type: type, - }); - updateWorkflowTriggers(workflow?.id || '', triggers); - }; - - const onSubmit = (trigger: TargetBasedTriggerItem) => { - if (triggerType !== undefined) { - if (!Array.isArray(triggers[triggerType])) { - triggers[triggerType] = []; - } - if (editedItem) { - triggers[triggerType][editedItem.index] = trigger; - } else { - triggers[triggerType].push(trigger); - } - - updateWorkflowTriggers(workflow?.id || '', triggers); - } - setTriggerType(undefined); - setEditedItem(undefined); - }; - - const onToggleChange = () => { - segmentTrack('Workflow Editor Enable Target Based Triggers Toggled', { - ...trackingData, - is_target_based_triggers_enabled_on_target: triggers.enabled !== false, - number_of_enabled_existing_target_based_triggers_in_project: triggersInProject.filter( - ({ enabled }) => enabled !== false, - ).length, - }); - updateWorkflowTriggersEnabled(workflow?.id || '', triggers.enabled === false); - }; + if (!workflow) { + return null; + } return ( - <> - {triggerType !== undefined && ( - { - setTriggerType(undefined); - setEditedItem(undefined); - }} - optionsMap={OPTIONS_MAP[triggerType]} - labelsMap={LABELS_MAP[triggerType]} - editedItem={editedItem?.trigger} - currentTriggers={triggers[triggerType] || []} - trackingData={trackingData} - /> - )} - - {metaDataValue === null && ( - updateMetaData('true')} marginBlockEnd="24"> - Target based triggers - - Set up triggers directly in your Workflows or Pipelines. This way a single Git event can trigger multiple - targets.{' '} - - Learn more - - - - )} - - { - onToggleChange(); - }} - /> - - Push triggers} - > - {(triggers.push as TargetBasedTriggerItem[])?.map((trigger: TargetBasedTriggerItem, index: number) => ( - onTriggerDelete(trigger, 'push')} - trigger={trigger} - triggerType="push" - onTriggerEdit={() => { - setEditedItem({ trigger, index }); - setTriggerType('push'); - }} - onTriggerToggle={(triggerDisabled) => { - onTriggerToggle('push', index, triggerDisabled, trigger); - }} - globalDisabled={triggers.enabled === false} - /> - ))} - - - Pull request triggers} - marginY="12" - > - {(triggers.pull_request as TargetBasedTriggerItem[])?.map( - (trigger: TargetBasedTriggerItem, index: number) => ( - onTriggerDelete(trigger, 'pull_request')} - onTriggerEdit={() => { - setEditedItem({ trigger, index }); - setTriggerType('pull_request'); - }} - trigger={trigger} - onTriggerToggle={(triggerDisabled) => { - onTriggerToggle('pull_request', index, triggerDisabled, trigger); - }} - globalDisabled={triggers.enabled === false} - /> - ), - )} - - - Tag triggers} - > - {(triggers.tag as TargetBasedTriggerItem[])?.map((trigger: TargetBasedTriggerItem, index: number) => ( - onTriggerDelete(trigger, 'tag')} - triggerType="tag" - trigger={trigger} - onTriggerEdit={() => { - setEditedItem({ trigger, index }); - setTriggerType('tag'); - }} - onTriggerToggle={(triggerDisabled) => { - onTriggerToggle('tag', index, triggerDisabled, trigger); - }} - globalDisabled={triggers.enabled === false} - /> - ))} - - - - + ); }; diff --git a/source/javascripts/core/models/BitriseYml.schema.ts b/source/javascripts/core/models/BitriseYml.schema.ts index a6570c13a..e74c344b5 100644 --- a/source/javascripts/core/models/BitriseYml.schema.ts +++ b/source/javascripts/core/models/BitriseYml.schema.ts @@ -272,6 +272,20 @@ const BitriseYmlSchema = { type: 'object', }, triggers: { + properties: { + enabled: { + type: 'boolean', + }, + pull_request: { + type: 'array', + }, + push: { + type: 'array', + }, + tag: { + type: 'array', + }, + }, type: 'object', }, }, @@ -643,6 +657,9 @@ const BitriseYmlSchema = { }, triggers: { properties: { + enabled: { + type: 'boolean', + }, pull_request: { type: 'array', }, diff --git a/source/javascripts/core/models/BitriseYmlService.spec.ts b/source/javascripts/core/models/BitriseYmlService.spec.ts index 6c1e26fa1..6298dbc2d 100644 --- a/source/javascripts/core/models/BitriseYmlService.spec.ts +++ b/source/javascripts/core/models/BitriseYmlService.spec.ts @@ -2689,6 +2689,268 @@ describe('BitriseYmlService', () => { expect(actualYml).toMatchBitriseYml(sourceAndExpectedYml); }); }); + + describe('updateWorkflowTriggers', () => { + it('should add workflow triggers if workflow has no triggers before', () => { + const sourceYml: BitriseYml = { + format_version: '', + workflows: { + wf1: {}, + }, + }; + + const expectedYml: BitriseYml = { + format_version: '', + workflows: { + wf1: { + triggers: { + push: [ + { + branch: 'main', + }, + ], + }, + }, + }, + }; + + const actualYml = BitriseYmlService.updateWorkflowTriggers('wf1', { push: [{ branch: 'main' }] }, sourceYml); + + expect(actualYml).toMatchBitriseYml(expectedYml); + }); + + it('should update workflow triggers', () => { + const sourceYml: BitriseYml = { + format_version: '', + workflows: { + wf1: { + triggers: { + push: [ + { + branch: 'main', + }, + ], + }, + }, + }, + }; + + const expectedYml: BitriseYml = { + format_version: '', + workflows: { + wf1: { + triggers: { + push: [ + { + branch: 'main', + }, + ], + tag: [ + { + name: { + regex: '*', + }, + }, + ], + }, + }, + }, + }; + + const actualYml = BitriseYmlService.updateWorkflowTriggers( + 'wf1', + { push: [{ branch: 'main' }], tag: [{ name: { regex: '*' } }] }, + sourceYml, + ); + + expect(actualYml).toMatchBitriseYml(expectedYml); + }); + }); + + describe('updateWorkflowTriggersEnabled', () => { + it('should disable triggers (set enabled: false)', () => { + const sourceYml: BitriseYml = { + format_version: '', + workflows: { + wf1: { + triggers: {}, + }, + }, + }; + + const expectedYml: BitriseYml = { + format_version: '', + workflows: { + wf1: { + triggers: { + enabled: false, + }, + }, + }, + }; + + const actualYml = BitriseYmlService.updateWorkflowTriggersEnabled('wf1', false, sourceYml); + + expect(actualYml).toMatchBitriseYml(expectedYml); + }); + + it('should enable triggers (remove enabled: false)', () => { + const sourceYml: BitriseYml = { + format_version: '', + workflows: { + wf1: { + triggers: { + enabled: false, + }, + }, + }, + }; + + const expectedYml: BitriseYml = { + format_version: '', + workflows: { + wf1: { + triggers: {}, + }, + }, + }; + + const actualYml = BitriseYmlService.updateWorkflowTriggersEnabled('wf1', true, sourceYml); + + expect(actualYml).toMatchBitriseYml(expectedYml); + }); + }); + + describe('updatePipelineTriggers', () => { + it('should add pipeline triggers if pipeline has no triggers before', () => { + const sourceYml: BitriseYml = { + format_version: '', + pipelines: { + pl1: {}, + }, + }; + + const expectedYml: BitriseYml = { + format_version: '', + pipelines: { + pl1: { + triggers: { + push: [ + { + branch: 'main', + }, + ], + }, + }, + }, + }; + + const actualYml = BitriseYmlService.updatePipelineTriggers('pl1', { push: [{ branch: 'main' }] }, sourceYml); + + expect(actualYml).toMatchBitriseYml(expectedYml); + }); + + it('should update pipeline triggers', () => { + const sourceYml: BitriseYml = { + format_version: '', + pipelines: { + pl1: { + triggers: { + push: [ + { + branch: 'main', + }, + ], + }, + }, + }, + }; + + const expectedYml: BitriseYml = { + format_version: '', + pipelines: { + pl1: { + triggers: { + push: [ + { + branch: 'main', + }, + ], + tag: [ + { + name: { + regex: '*', + }, + }, + ], + }, + }, + }, + }; + + const actualYml = BitriseYmlService.updatePipelineTriggers( + 'pl1', + { push: [{ branch: 'main' }], tag: [{ name: { regex: '*' } }] }, + sourceYml, + ); + + expect(actualYml).toMatchBitriseYml(expectedYml); + }); + }); + + describe('updatePipelineTriggersEnabled', () => { + it('should disable triggers (set enabled: false)', () => { + const sourceYml: BitriseYml = { + format_version: '', + pipelines: { + pl1: { + triggers: {}, + }, + }, + }; + + const expectedYml: BitriseYml = { + format_version: '', + pipelines: { + pl1: { + triggers: { + enabled: false, + }, + }, + }, + }; + + const actualYml = BitriseYmlService.updatePipelineTriggersEnabled('pl1', false, sourceYml); + + expect(actualYml).toMatchBitriseYml(expectedYml); + }); + + it('should enable triggers (remove enabled: false)', () => { + const sourceYml: BitriseYml = { + format_version: '', + pipelines: { + pl1: { + triggers: { + enabled: false, + }, + }, + }, + }; + + const expectedYml: BitriseYml = { + format_version: '', + pipelines: { + pl1: { + triggers: {}, + }, + }, + }; + + const actualYml = BitriseYmlService.updatePipelineTriggersEnabled('pl1', true, sourceYml); + + expect(actualYml).toMatchBitriseYml(expectedYml); + }); + }); }); declare module 'expect' { diff --git a/source/javascripts/core/models/BitriseYmlService.ts b/source/javascripts/core/models/BitriseYmlService.ts index 4fd190cbd..c53a7558c 100644 --- a/source/javascripts/core/models/BitriseYmlService.ts +++ b/source/javascripts/core/models/BitriseYmlService.ts @@ -2,7 +2,6 @@ import { isBoolean, isEqual, isNull, mapKeys, mapValues, omit, omitBy } from 'es import { isEmpty, isNumber } from 'es-toolkit/compat'; import deepCloneSimpleObject from '@/utils/deepCloneSimpleObject'; import StepService from '@/core/models/StepService'; -import { TargetBasedTriggers } from '@/pages/TriggersPage/components/TriggersPage/TriggersPage.utils'; import { EnvVarYml } from './EnvVar'; import { BitriseYml, Meta } from './BitriseYml'; import { StagesYml } from './Stage'; @@ -786,7 +785,9 @@ function updateWorkflowTriggersEnabled(workflowId: string, isEnabled: boolean, y } if (isEnabled === true) { - delete (copy.workflows[workflowId].triggers as TargetBasedTriggers).enabled; + if (copy.workflows[workflowId].triggers) { + delete copy.workflows[workflowId].triggers.enabled; + } } else { copy.workflows[workflowId].triggers = { enabled: false, @@ -797,6 +798,45 @@ function updateWorkflowTriggersEnabled(workflowId: string, isEnabled: boolean, y return copy; } +function updatePipelineTriggers( + pipelineID: string, + triggers: PipelineYmlObject['triggers'], + yml: BitriseYml, +): BitriseYml { + const copy = deepCloneSimpleObject(yml); + + // If the pipeline is missing in the YML just return the YML + if (!copy.pipelines?.[pipelineID]) { + return copy; + } + + copy.pipelines[pipelineID].triggers = triggers; + + return copy; +} + +function updatePipelineTriggersEnabled(pipelineId: string, isEnabled: boolean, yml: BitriseYml): BitriseYml { + const copy = deepCloneSimpleObject(yml); + + // If the pipeline is missing in the YML just return the YML + if (!copy.pipelines?.[pipelineId]) { + return copy; + } + + if (isEnabled === true) { + if (copy.pipelines[pipelineId].triggers) { + delete copy.pipelines[pipelineId].triggers.enabled; + } + } else { + copy.pipelines[pipelineId].triggers = { + enabled: false, + ...(copy.pipelines[pipelineId].triggers || {}), + }; + } + + return copy; +} + // UTILITY FUNCTIONS function isNotEmpty(v: T) { @@ -1059,4 +1099,6 @@ export default { updateWorkflowEnvVars, updateWorkflowTriggers, updateWorkflowTriggersEnabled, + updatePipelineTriggers, + updatePipelineTriggersEnabled, }; diff --git a/source/javascripts/core/stores/BitriseYmlStore.ts b/source/javascripts/core/stores/BitriseYmlStore.ts index 7fb181feb..455640589 100644 --- a/source/javascripts/core/stores/BitriseYmlStore.ts +++ b/source/javascripts/core/stores/BitriseYmlStore.ts @@ -83,6 +83,8 @@ type BitriseYmlStoreState = { updateTriggerMap: (newTriggerMap: TriggerMapYml) => void; updateWorkflowTriggers: (workflowId: string, triggers: WorkflowYmlObject['triggers']) => void; updateWorkflowTriggersEnabled: (workflowId: string, isEnabled: boolean) => void; + updatePipelineTriggers: (pipelineId: string, triggers: PipelineYmlObject['triggers']) => void; + updatePipelineTriggersEnabled: (pipelineId: string, isEnabled: boolean) => void; }; type BitriseYmlStore = StoreApi; @@ -356,6 +358,20 @@ function create(yml: BitriseYml, defaultMeta?: Meta): BitriseYmlStore { }; }); }, + updatePipelineTriggers(pipelineId, triggers) { + return set((state) => { + return { + yml: BitriseYmlService.updatePipelineTriggers(pipelineId, triggers, state.yml), + }; + }); + }, + updatePipelineTriggersEnabled(pipelineId, isEnabled) { + return set((state) => { + return { + yml: BitriseYmlService.updatePipelineTriggersEnabled(pipelineId, isEnabled, state.yml), + }; + }); + }, })); } diff --git a/source/javascripts/pages/PipelinesPage/components/PipelineConfigDrawer/PipelineConfigDrawer.tsx b/source/javascripts/pages/PipelinesPage/components/PipelineConfigDrawer/PipelineConfigDrawer.tsx index 7a4ee9306..5a4e7bf60 100644 --- a/source/javascripts/pages/PipelinesPage/components/PipelineConfigDrawer/PipelineConfigDrawer.tsx +++ b/source/javascripts/pages/PipelinesPage/components/PipelineConfigDrawer/PipelineConfigDrawer.tsx @@ -1,5 +1,4 @@ -import { useCallback } from 'react'; -import { Button, Text, Textarea, useDisclosure } from '@bitrise/bitkit'; +import { Box, Tab, TabList, TabPanel, TabPanels, Tabs, Text } from '@bitrise/bitkit'; import FloatingDrawer, { FloatingDrawerBody, FloatingDrawerCloseButton, @@ -7,64 +6,20 @@ import FloatingDrawer, { FloatingDrawerHeader, FloatingDrawerProps, } from '@/components/unified-editor/FloatingDrawer/FloatingDrawer'; -import useBitriseYmlStore from '@/hooks/useBitriseYmlStore'; -import EditableInput from '@/components/EditableInput/EditableInput'; -import PipelineService from '@/core/models/PipelineService'; -import usePipelineSelector from '../../hooks/usePipelineSelector'; -import useRenamePipeline from '../../hooks/useRenamePipeline'; -import { usePipelinesPageStore } from '../../PipelinesPage.store'; -import DeletePipelineDialog from './components/DeletePipelineDialog/DeletePipelineDialog'; +import PropertiesTab from './tabs/PropertiesTab'; +import TriggersTab from './tabs/TriggersTab'; type Props = Omit & { pipelineId: string; }; const PipelineConfigDrawer = ({ pipelineId, ...props }: Props) => { - const setPipelineId = usePipelinesPageStore((s) => s.setPipelineId); - const { keys, onSelectPipeline } = usePipelineSelector(); - const { isOpen: isDeleteDialogOpen, onOpen: onOpenDeleteDialog, onClose: onCloseDeleteDialog } = useDisclosure(); - - const { summary, description, updatePipeline } = useBitriseYmlStore((s) => ({ - summary: s.yml.pipelines?.[pipelineId]?.summary || '', - description: s.yml.pipelines?.[pipelineId]?.description || '', - updatePipeline: s.updatePipeline, - })); - - const renamePipeline = useRenamePipeline((newPipelineId) => { - onSelectPipeline(newPipelineId); - }); - - const onNameChange = (value: string) => { - setPipelineId(value); - renamePipeline(value); - }; - - const validateName = (value: string) => { - return PipelineService.validateName( - value, - keys.filter((key) => key !== pipelineId), - ); - }; - - const sanitizeName = (value: string) => { - return PipelineService.sanitizeName(value); - }; - - const closeDrawer = props.onClose; - const onDeletePipeline = useCallback( - (deletedId: string) => { - closeDrawer(); - onSelectPipeline(keys.filter((key) => key !== deletedId)[0]); - }, - [keys, closeDrawer, onSelectPipeline], - ); - if (!pipelineId) { return null; } return ( - <> + @@ -72,43 +27,26 @@ const PipelineConfigDrawer = ({ pipelineId, ...props }: Props) => { {pipelineId} + + + Properties + Triggers + + - - -