diff --git a/frontend/src/__tests__/cypress/cypress/e2e/pipelines/Pipelines.cy.ts b/frontend/src/__tests__/cypress/cypress/e2e/pipelines/Pipelines.cy.ts index 0d7d7b77a8..1fd6ff6073 100644 --- a/frontend/src/__tests__/cypress/cypress/e2e/pipelines/Pipelines.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/e2e/pipelines/Pipelines.cy.ts @@ -208,6 +208,20 @@ describe('Pipelines', () => { verifyRelativeURL(`/pipelines/${projectName}/pipelineRun/create`); }); + it('navigates to "Schedule run" page from pipeline row', () => { + pipelinesTable.find(); + pipelinesTable + .findRowByName(initialMockPipeline.display_name) + .findByLabelText('Kebab toggle') + .click(); + + pipelinesTable + .findRowByName(initialMockPipeline.display_name) + .findByText('Schedule run') + .click(); + verifyRelativeURL(`/pipelines/${projectName}/pipelineRun/create?runType=scheduled`); + }); + it('navigate to create run page from pipeline version row', () => { // Wait for the pipelines table to load pipelinesTable.find(); @@ -225,7 +239,22 @@ describe('Pipelines', () => { verifyRelativeURL(`/pipelines/${projectName}/pipelineRun/create`); }); - it('navigate to view runs page', () => { + it('navigates to "Schedule run" page from pipeline version row', () => { + pipelinesTable.find(); + pipelinesTable.toggleExpandRowByIndex(0); + pipelinesTable + .findRowByName(initialMockPipelineVersion.display_name) + .findByLabelText('Kebab toggle') + .click(); + + pipelinesTable + .findRowByName(initialMockPipelineVersion.display_name) + .findByText('Schedule run') + .click(); + verifyRelativeURL(`/pipelines/${projectName}/pipelineRun/create?runType=scheduled`); + }); + + it('navigate to view runs page from pipeline version row', () => { // Wait for the pipelines table to load pipelinesTable.find(); pipelinesTable.toggleExpandRowByIndex(0); @@ -239,7 +268,22 @@ describe('Pipelines', () => { .findRowByName(initialMockPipelineVersion.display_name) .findByText('View runs') .click(); - verifyRelativeURL(`/pipelineRuns/${projectName}`); + verifyRelativeURL(`/pipelineRuns/${projectName}?runType=active`); + }); + + it('navigates to "Schedules" page from pipeline version row', () => { + pipelinesTable.find(); + pipelinesTable.toggleExpandRowByIndex(0); + pipelinesTable + .findRowByName(initialMockPipelineVersion.display_name) + .findByLabelText('Kebab toggle') + .click(); + + pipelinesTable + .findRowByName(initialMockPipelineVersion.display_name) + .findByText('View schedules') + .click(); + verifyRelativeURL(`/pipelineRuns/${projectName}?runType=scheduled`); }); }); diff --git a/frontend/src/__tests__/cypress/cypress/e2e/pipelines/PipelinesTopology.cy.ts b/frontend/src/__tests__/cypress/cypress/e2e/pipelines/PipelinesTopology.cy.ts index ed7bde0f15..9f7fd6383e 100644 --- a/frontend/src/__tests__/cypress/cypress/e2e/pipelines/PipelinesTopology.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/e2e/pipelines/PipelinesTopology.cy.ts @@ -204,12 +204,26 @@ describe('Pipeline topology', () => { verifyRelativeURL(`/pipelineRuns/${projectId}/pipelineRun/create`); }); + it('navigates to "Schedule run" page on "Schedule run" click', () => { + pipelineDetails.visit(projectId, mockVersion.pipeline_id, mockVersion.pipeline_version_id); + pipelineDetails.findActionsDropdown().click(); + cy.findByText('Schedule run').click(); + verifyRelativeURL(`/pipelineRuns/${projectId}/pipelineRun/create?runType=scheduled`); + }); + it('Test pipeline details view runs navigation', () => { pipelineDetails.visit(projectId, mockVersion.pipeline_id, mockVersion.pipeline_version_id); pipelineDetails.findActionsDropdown().click(); cy.findByText('View runs').click(); verifyRelativeURL(`/pipelineRuns/${projectId}`); }); + + it('navigates to "Schedules" on "View schedules" click', () => { + pipelineDetails.visit(projectId, mockVersion.pipeline_id, mockVersion.pipeline_version_id); + pipelineDetails.findActionsDropdown().click(); + cy.findByText('View schedules').click(); + verifyRelativeURL(`/pipelineRuns/${projectId}?runType=scheduled`); + }); }); }); diff --git a/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx b/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx index b4615936ff..f0eafa6212 100644 --- a/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx +++ b/frontend/src/concepts/pipelines/content/createRun/RunForm.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Form, FormGroup, FormSection, Text } from '@patternfly/react-core'; +import { Form, FormSection, Text } from '@patternfly/react-core'; import NameDescriptionField from '~/concepts/k8s/NameDescriptionField'; import { RunFormData, @@ -17,6 +17,7 @@ import { PipelineRunType } from '~/pages/pipelines/global/runs'; import ExperimentSection from '~/concepts/pipelines/content/createRun/contentSections/ExperimentSection'; import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; import PipelineSection from './contentSections/PipelineSection'; +import { RunTypeSection } from './contentSections/RunTypeSection'; import { CreateRunPageSections, runPageSectionTitles } from './const'; import { getInputDefinitionParams } from './utils'; @@ -30,7 +31,6 @@ const RunForm: React.FC = ({ data, runType, onValueChange }) => { const [latestVersion] = useLatestPipelineVersion(data.pipeline?.pipeline_id); const selectedVersion = data.version || latestVersion; const paramsRef = React.useRef(data.params); - const isExperimentsAvailable = useIsAreaAvailable(SupportedArea.PIPELINE_EXPERIMENTS).status; const updateInputParams = React.useCallback( @@ -56,17 +56,16 @@ const RunForm: React.FC = ({ data, runType, onValueChange }) => { }, [latestVersion, onValueChange, updateInputParams]); return ( -
{ - e.preventDefault(); - }} - > - - - {getProjectDisplayName(data.project)} - + e.preventDefault()} maxWidth="500px"> + + + + {getProjectDisplayName(data.project)} + = ({ data, runType, onValueChange }) => { setData={(nameDesc) => onValueChange('nameDesc', nameDesc)} /> + {isExperimentsAvailable && ( onValueChange('experiment', experiment)} /> )} + { @@ -91,6 +92,7 @@ const RunForm: React.FC = ({ data, runType, onValueChange }) => { onValueChange('version', undefined); }} /> + = ({ data, runType, onValueChange }) => { updateInputParams(version); }} /> + {runType === PipelineRunType.Scheduled && ( - - onValueChange('runType', { type: RunTypeOption.SCHEDULED, data: scheduleData }) - } - /> + + + onValueChange('runType', { type: RunTypeOption.SCHEDULED, data: scheduleData }) + } + /> + )} + = ({ cloneRun, contextPath, testId }) => { const isExperimentsAvailable = useIsAreaAvailable(SupportedArea.PIPELINE_EXPERIMENTS).status; - const sections = isExperimentsAvailable - ? Object.values(CreateRunPageSections) - : Object.values(CreateRunPageSections).filter( - (section) => section !== CreateRunPageSections.EXPERIMENT, - ); + const jumpToSections = Object.values(CreateRunPageSections).filter( + (section) => + !( + (section === CreateRunPageSections.EXPERIMENT && !isExperimentsAvailable) || + (section === CreateRunPageSections.SCHEDULE_SETTINGS && + runType !== PipelineRunType.Scheduled) + ), + ); const [formData, setFormDataValue] = useRunFormData(cloneRun, { runType: { @@ -85,7 +88,7 @@ const RunPage: React.FC = ({ cloneRun, contextPath, testId }) => { return (
- + = { + [CreateRunPageSections.RUN_TYPE]: 'Run type', + [CreateRunPageSections.PROJECT]: 'Project', [CreateRunPageSections.NAME_DESC]: 'Name and description', [CreateRunPageSections.EXPERIMENT]: 'Experiment', [CreateRunPageSections.PIPELINE]: 'Pipeline', [CreateRunPageSections.PIPELINE_VERSION]: 'Pipeline version', + [CreateRunPageSections.SCHEDULE_SETTINGS]: 'Schedule settings', [CreateRunPageSections.PARAMS]: 'Parameters', }; diff --git a/frontend/src/concepts/pipelines/content/createRun/contentSections/RunTypeSection.tsx b/frontend/src/concepts/pipelines/content/createRun/contentSections/RunTypeSection.tsx new file mode 100644 index 0000000000..a28f1f6513 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/createRun/contentSections/RunTypeSection.tsx @@ -0,0 +1,70 @@ +import React from 'react'; + +import { Alert, AlertActionCloseButton, Button, FormSection } from '@patternfly/react-core'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { PipelineRunTabTitle, PipelineRunType } from '~/pages/pipelines/global/runs'; +import { + CreateRunPageSections, + runPageSectionTitles, +} from '~/concepts/pipelines/content/createRun/const'; +import { PipelineRunSearchParam } from '~/concepts/pipelines/content/types'; +import { routePipelineRunsNamespace } from '~/routes'; + +interface RunTypeSectionProps { + runType: PipelineRunType; +} + +export const RunTypeSection: React.FC = ({ runType }) => { + const navigate = useNavigate(); + const { namespace } = useParams(); + const [isAlertOpen, setIsAlertOpen] = React.useState(true); + + let runTypeValue = 'Run once immediately after creation'; + let alertProps = { + title: 'Go to Schedules to create schedules that execute recurring runs', + label: `Go to ${PipelineRunTabTitle.Schedules}`, + navSearch: `?${PipelineRunSearchParam.RunType}=${PipelineRunType.Scheduled}`, + }; + + if (runType === PipelineRunType.Scheduled) { + runTypeValue = 'Schedule recurring run'; + alertProps = { + title: 'Go to Active runs to create a run that executes once immediately after creation.', + label: `Go to ${PipelineRunTabTitle.Active}`, + navSearch: `?${PipelineRunSearchParam.RunType}=${PipelineRunType.Active}`, + }; + } + + return ( + + {runTypeValue} + + {isAlertOpen && ( + + navigate({ + pathname: routePipelineRunsNamespace(namespace), + search: alertProps.navSearch, + }) + } + > + {alertProps.label} + + } + actionClose={ setIsAlertOpen(false)} />} + /> + )} + + ); +}; diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetailsActions.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetailsActions.tsx index e6fe0f4228..cd4159c6a7 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetailsActions.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetailsActions.tsx @@ -51,7 +51,7 @@ const PipelineDetailsActions: React.FC = ({ setIsVersionImportModalOpen(true)}> Upload new version , - , + , @@ -62,6 +62,23 @@ const PipelineDetailsActions: React.FC = ({ > Create run , + + navigate( + { + pathname: routePipelineRunCreateNamespace(namespace), + search: `?${PipelineRunSearchParam.RunType}=${PipelineRunType.Scheduled}`, + }, + { + state: { lastPipeline: pipeline, lastVersion: pipelineVersion }, + }, + ) + } + > + Schedule run + , + , @@ -78,7 +95,23 @@ const PipelineDetailsActions: React.FC = ({ > View runs , - , + + navigate( + { + pathname: routePipelineRunsNamespace(namespace), + search: `?${PipelineRunSearchParam.RunType}=${PipelineRunType.Scheduled}`, + }, + { + state: { lastVersion: pipelineVersion }, + }, + ) + } + > + View schedules + , + , onDelete()}> Delete pipeline version , diff --git a/frontend/src/concepts/pipelines/content/tables/__tests__/useSetVersionFilter.spec.tsx b/frontend/src/concepts/pipelines/content/tables/__tests__/useSetVersionFilter.spec.tsx new file mode 100644 index 0000000000..08b0a2ad34 --- /dev/null +++ b/frontend/src/concepts/pipelines/content/tables/__tests__/useSetVersionFilter.spec.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; + +import { renderHook } from '~/__tests__/unit/testUtils/hooks'; +import { useSetVersionFilter } from '~/concepts/pipelines/content/tables/useSetVersionFilter'; +import { PipelineVersionKFv2 } from '~/concepts/pipelines/kfTypes'; + +let mockUseLocationState: { lastVersion: Partial } | undefined; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(() => ({ + state: mockUseLocationState, + })), +})); + +describe('useSetVersionFilter', () => { + const onFilterUpdate = jest.fn(); + + it('does not call onFilterUpdate when there is no router state version', async () => { + renderHook(() => useSetVersionFilter(onFilterUpdate), { + wrapper: ({ children }) => {children}, + }); + + expect(onFilterUpdate).not.toHaveBeenCalled(); + }); + + it('calls onFilterUpdate when router state has a version', () => { + mockUseLocationState = { + // eslint-disable-next-line camelcase + lastVersion: { display_name: 'Some version', pipeline_version_id: 'some-id' }, + }; + renderHook(() => useSetVersionFilter(onFilterUpdate), { + wrapper: ({ children }) => {children}, + }); + + expect(onFilterUpdate).toBeCalledTimes(1); + expect(onFilterUpdate).toHaveBeenCalledWith('pipeline_version', { + label: 'Some version', + value: 'some-id', + }); + }); +}); diff --git a/frontend/src/concepts/pipelines/content/tables/pipeline/PipelinesTableRow.tsx b/frontend/src/concepts/pipelines/content/tables/pipeline/PipelinesTableRow.tsx index f3fbbf067a..dfd75ff091 100644 --- a/frontend/src/concepts/pipelines/content/tables/pipeline/PipelinesTableRow.tsx +++ b/frontend/src/concepts/pipelines/content/tables/pipeline/PipelinesTableRow.tsx @@ -11,6 +11,8 @@ import PipelinesTableRowTime from '~/concepts/pipelines/content/tables/Pipelines import usePipelineTableRowData from '~/concepts/pipelines/content/tables/pipeline/usePipelineTableRowData'; import { PipelineAndVersionContext } from '~/concepts/pipelines/content/PipelineAndVersionContext'; import { routePipelineRunCreateNamespacePipelinesPage } from '~/routes'; +import { PipelineRunSearchParam } from '~/concepts/pipelines/content/types'; +import { PipelineRunType } from '~/pages/pipelines/global/runs'; const DISABLE_TOOLTIP = 'All child pipeline versions must be deleted before deleting the parent pipeline'; @@ -112,6 +114,20 @@ const PipelinesTableRow: React.FC = ({ }); }, }, + { + title: 'Schedule run', + onClick: () => { + navigate( + { + pathname: routePipelineRunCreateNamespacePipelinesPage(namespace), + search: `?${PipelineRunSearchParam.RunType}=${PipelineRunType.Scheduled}`, + }, + { + state: { lastPipeline: pipeline }, + }, + ); + }, + }, { isSeparator: true, }, diff --git a/frontend/src/concepts/pipelines/content/tables/pipelineRun/PipelineRunTable.tsx b/frontend/src/concepts/pipelines/content/tables/pipelineRun/PipelineRunTable.tsx index c650a4951a..270ae9d62f 100644 --- a/frontend/src/concepts/pipelines/content/tables/pipelineRun/PipelineRunTable.tsx +++ b/frontend/src/concepts/pipelines/content/tables/pipelineRun/PipelineRunTable.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { Button } from '@patternfly/react-core'; import { TableVariant } from '@patternfly/react-table'; import { TableBase, getTableColumnSort, useCheckboxTable } from '~/components/table'; -import { PipelineRunKFv2, PipelineVersionKFv2 } from '~/concepts/pipelines/kfTypes'; +import { PipelineRunKFv2 } from '~/concepts/pipelines/kfTypes'; import { pipelineRunColumns } from '~/concepts/pipelines/content/tables/columns'; import PipelineRunTableRow from '~/concepts/pipelines/content/tables/pipelineRun/PipelineRunTableRow'; import DashboardEmptyTableView from '~/concepts/dashboard/DashboardEmptyTableView'; @@ -14,15 +14,14 @@ import DeletePipelineRunsModal from '~/concepts/pipelines/content/DeletePipeline import { usePipelinesAPI } from '~/concepts/pipelines/context'; import { PipelineRunType } from '~/pages/pipelines/global/runs/types'; import { PipelinesFilter } from '~/concepts/pipelines/types'; -import usePipelineFilter, { - FilterOptions, -} from '~/concepts/pipelines/content/tables/usePipelineFilter'; +import usePipelineFilter from '~/concepts/pipelines/content/tables/usePipelineFilter'; import SimpleMenuActions from '~/components/SimpleMenuActions'; import { BulkArchiveRunModal } from '~/pages/pipelines/global/runs/BulkArchiveRunModal'; import { BulkRestoreRunModal } from '~/pages/pipelines/global/runs/BulkRestoreRunModal'; import { ArchiveRunModal } from '~/pages/pipelines/global/runs/ArchiveRunModal'; import { RestoreRunModal } from '~/pages/pipelines/global/runs/RestoreRunModal'; import { routePipelineRunCreateNamespace } from '~/routes'; +import { useSetVersionFilter } from '~/concepts/pipelines/content/tables/useSetVersionFilter'; type PipelineRunTableProps = { runs: PipelineRunKFv2[]; @@ -52,11 +51,9 @@ const PipelineRunTable: React.FC = ({ setFilter, ...tableProps }) => { - const { state } = useLocation(); const navigate = useNavigate(); const { namespace, refreshAllAPI } = usePipelinesAPI(); const filterToolbarProps = usePipelineFilter(setFilter); - const lastLocationPipelineVersion: PipelineVersionKFv2 | undefined = state?.lastVersion; const { selections: selectedIds, tableProps: checkboxTableProps, @@ -77,23 +74,6 @@ const PipelineRunTable: React.FC = ({ return acc; }, []); - // Update filter on initial render with the last location-stored pipeline version. - React.useEffect(() => { - if (lastLocationPipelineVersion) { - filterToolbarProps.onFilterUpdate(FilterOptions.PIPELINE_VERSION, { - label: lastLocationPipelineVersion.display_name, - value: lastLocationPipelineVersion.pipeline_version_id, - }); - } - - return () => { - // Reset the location-stored pipeline version to avoid re-creating - // a filter that might otherwise have been removed/changed by the user. - window.history.replaceState({ ...state, lastVersion: undefined }, ''); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const primaryToolbarAction = React.useMemo(() => { if (runType === PipelineRunType.Archived) { return ( @@ -117,6 +97,8 @@ const PipelineRunTable: React.FC = ({ ); }, [runType, selectedIds.length, navigate, namespace]); + useSetVersionFilter(filterToolbarProps.onFilterUpdate); + return ( <> = ({ } = useCheckboxTable(jobs.map(({ recurring_run_id }) => recurring_run_id)); const [deleteResources, setDeleteResources] = React.useState([]); + useSetVersionFilter(filterToolbarProps.onFilterUpdate); + return ( <> = ({ }); }, }, + { + title: 'Schedule run', + onClick: () => { + navigate( + { + pathname: routePipelineRunCreateNamespacePipelinesPage(namespace), + search: `?${PipelineRunSearchParam.RunType}=${PipelineRunType.Scheduled}`, + }, + { + state: { lastPipeline: pipeline, lastVersion: version }, + }, + ); + }, + }, + { + isSeparator: true, + }, { title: 'View runs', onClick: () => { @@ -89,6 +106,20 @@ const PipelineVersionTableRow: React.FC = ({ ); }, }, + { + title: 'View schedules', + onClick: () => { + navigate( + { + pathname: routePipelineRunsNamespace(namespace), + search: `?${PipelineRunSearchParam.RunType}=${PipelineRunType.Scheduled}`, + }, + { + state: { lastVersion: version }, + }, + ); + }, + }, { isSeparator: true, }, diff --git a/frontend/src/concepts/pipelines/content/tables/useSetVersionFilter.ts b/frontend/src/concepts/pipelines/content/tables/useSetVersionFilter.ts new file mode 100644 index 0000000000..c226882bab --- /dev/null +++ b/frontend/src/concepts/pipelines/content/tables/useSetVersionFilter.ts @@ -0,0 +1,32 @@ +import React from 'react'; +import { useLocation } from 'react-router-dom'; + +import { PipelineVersionKFv2 } from '~/concepts/pipelines/kfTypes'; +import { FilterProps } from './PipelineFilterBar'; +import { FilterOptions } from './usePipelineFilter'; + +/** + * Update filter with the last location-stored pipeline version. + * @param onFilterUpdate + */ +export const useSetVersionFilter = (onFilterUpdate: FilterProps['onFilterUpdate']): void => { + const { state } = useLocation(); + const [versionToFilter, setVersionToFilter] = React.useState( + state?.lastVersion, + ); + + React.useEffect(() => { + if (versionToFilter) { + onFilterUpdate(FilterOptions.PIPELINE_VERSION, { + label: versionToFilter.display_name, + value: versionToFilter.pipeline_version_id, + }); + } + + return () => { + // Reset the location-stored pipeline version to avoid re-creating + // a filter that might otherwise have been removed/changed by the user. + setVersionToFilter(undefined); + }; + }, [onFilterUpdate, versionToFilter]); +}; diff --git a/frontend/src/pages/pipelines/global/runs/GlobalPipelineRunsTabs.tsx b/frontend/src/pages/pipelines/global/runs/GlobalPipelineRunsTabs.tsx index 08fefd3488..4d71747c86 100644 --- a/frontend/src/pages/pipelines/global/runs/GlobalPipelineRunsTabs.tsx +++ b/frontend/src/pages/pipelines/global/runs/GlobalPipelineRunsTabs.tsx @@ -21,7 +21,7 @@ const GlobalPipelineRunsTab: React.FC = () => { ); React.useEffect(() => { - if (!runType || !Object.values(PipelineRunType).includes(runType)) { + if (runType && !Object.values(PipelineRunType).includes(runType)) { searchParams.delete(PipelineRunSearchParam.RunType); setSearchParams(searchParams); }