diff --git a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows.vue b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows.vue index e37e8f1afa50908170b95554977a080c426edaaf..91b9c3817ff9f9ebc026b5b5c6e4d459b369e60e 100644 --- a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows.vue +++ b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows.vue @@ -15,17 +15,23 @@ import aiCatalogFlowsQuery from '../graphql/queries/ai_catalog_flows.query.graph import aiCatalogFlowQuery from '../graphql/queries/ai_catalog_flow.query.graphql'; import deleteAiCatalogFlowMutation from '../graphql/mutations/delete_ai_catalog_flow.mutation.graphql'; import createAiCatalogItemConsumer from '../graphql/mutations/create_ai_catalog_item_consumer.mutation.graphql'; +import setItemToDuplicateMutation from '../graphql/mutations/set_item_to_duplicate.mutation.graphql'; import AiCatalogListHeader from '../components/ai_catalog_list_header.vue'; import AiCatalogList from '../components/ai_catalog_list.vue'; import AiCatalogItemDrawer from '../components/ai_catalog_item_drawer.vue'; import AiCatalogItemConsumerModal from '../components/ai_catalog_item_consumer_modal.vue'; -import { AI_CATALOG_SHOW_QUERY_PARAM, AI_CATALOG_FLOWS_EDIT_ROUTE } from '../router/constants'; +import { + AI_CATALOG_SHOW_QUERY_PARAM, + AI_CATALOG_FLOWS_EDIT_ROUTE, + AI_CATALOG_FLOWS_DUPLICATE_ROUTE, +} from '../router/constants'; import { FLOW_VISIBILITY_LEVEL_DESCRIPTIONS, PAGE_SIZE, TRACK_EVENT_VIEW_AI_CATALOG_ITEM_INDEX, TRACK_EVENT_VIEW_AI_CATALOG_ITEM, TRACK_EVENT_TYPE_FLOW, + AI_CATALOG_TYPE_FLOW, } from '../constants'; export default { @@ -158,6 +164,11 @@ export default { } const adminItems = [ + { + text: s__('AICatalog|Duplicate'), + action: () => this.handleDuplicate(item.id), + icon: 'duplicate', + }, { text: s__('AICatalog|Edit'), to: { @@ -285,6 +296,39 @@ export default { Sentry.captureException(error); } }, + findFlowInList(numberId) { + return this.aiCatalogFlows.find( + (n) => getIdFromGraphQLId(n.id).toString() === String(numberId), + ); + }, + async handleDuplicate(itemId) { + try { + const flow = this.findFlowInList(itemId); + + if (!flow) { + throw new Error(s__('AICatalog|Flow not found.')); + } + + await this.$apollo.mutate({ + mutation: setItemToDuplicateMutation, + variables: { + item: { + id: itemId, + type: AI_CATALOG_TYPE_FLOW, + data: flow, + }, + }, + }); + + this.$router.push({ + name: AI_CATALOG_FLOWS_DUPLICATE_ROUTE, + params: { id: itemId }, + }); + } catch (error) { + this.errors = [error.message]; + Sentry.captureException(error); + } + }, handleNextPage() { this.$apollo.queries.aiCatalogFlows.refetch({ ...this.$apollo.queries.aiCatalogFlows.variables, diff --git a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows_duplicate.vue b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows_duplicate.vue new file mode 100644 index 0000000000000000000000000000000000000000..ff95329270a744515a20b41ad7608b2e81082e8c --- /dev/null +++ b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows_duplicate.vue @@ -0,0 +1,120 @@ + + + + + + + + {{ s__('AICatalog|Create a copy of this flow with all its settings and configuration.') }} + + + + + + + diff --git a/ee/app/assets/javascripts/ai/catalog/router/constants.js b/ee/app/assets/javascripts/ai/catalog/router/constants.js index 57cc425d853eeda86324b2d18dbd6fbc2a11aeb5..75a69e86fce1098fa841a73a7ee27ba285214e5b 100644 --- a/ee/app/assets/javascripts/ai/catalog/router/constants.js +++ b/ee/app/assets/javascripts/ai/catalog/router/constants.js @@ -7,5 +7,6 @@ export const AI_CATALOG_AGENTS_DUPLICATE_ROUTE = '/agents/:id/duplicate'; export const AI_CATALOG_FLOWS_ROUTE = '/flows'; export const AI_CATALOG_FLOWS_EDIT_ROUTE = '/flows/:id/edit'; export const AI_CATALOG_FLOWS_NEW_ROUTE = '/flows/new'; +export const AI_CATALOG_FLOWS_DUPLICATE_ROUTE = '/flows/:id/duplicate'; export const AI_CATALOG_SHOW_QUERY_PARAM = 'show'; diff --git a/ee/app/assets/javascripts/ai/catalog/router/index.js b/ee/app/assets/javascripts/ai/catalog/router/index.js index 391acfbed80c61ec0f5cfcd8492d075a1e738f59..4f737c871726577c919c3db49eb1372dfdc19a7c 100644 --- a/ee/app/assets/javascripts/ai/catalog/router/index.js +++ b/ee/app/assets/javascripts/ai/catalog/router/index.js @@ -13,6 +13,7 @@ import AiCatalogFlow from '../pages/ai_catalog_flow.vue'; import AiCatalogFlows from '../pages/ai_catalog_flows.vue'; import AiCatalogFlowsEdit from '../pages/ai_catalog_flows_edit.vue'; import AiCatalogFlowsNew from '../pages/ai_catalog_flows_new.vue'; +import AiCatalogFlowsDuplicate from '../pages/ai_catalog_flows_duplicate.vue'; import { AI_CATALOG_INDEX_ROUTE, AI_CATALOG_AGENTS_ROUTE, @@ -23,6 +24,7 @@ import { AI_CATALOG_FLOWS_ROUTE, AI_CATALOG_FLOWS_NEW_ROUTE, AI_CATALOG_FLOWS_EDIT_ROUTE, + AI_CATALOG_FLOWS_DUPLICATE_ROUTE, AI_CATALOG_SHOW_QUERY_PARAM, } from './constants'; @@ -156,6 +158,15 @@ export const createRouter = (base) => { text: s__('AICatalog|Edit flow'), }, }, + { + name: AI_CATALOG_FLOWS_DUPLICATE_ROUTE, + path: 'duplicate', + component: AiCatalogFlowsDuplicate, + beforeEnter: requireAuth, + meta: { + text: s__('AICatalog|Duplicate flow'), + }, + }, ], }, ], diff --git a/ee/spec/frontend/ai/catalog/pages/ai_catalog_flows_duplicate_spec.js b/ee/spec/frontend/ai/catalog/pages/ai_catalog_flows_duplicate_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..584a5d356a6d5edc7d767b0b23d09c541626f76b --- /dev/null +++ b/ee/spec/frontend/ai/catalog/pages/ai_catalog_flows_duplicate_spec.js @@ -0,0 +1,243 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import createAiCatalogFlow from 'ee/ai/catalog/graphql/mutations/create_ai_catalog_flow.mutation.graphql'; +import aiCatalogFlowQuery from 'ee/ai/catalog/graphql/queries/ai_catalog_flow.query.graphql'; +import AiCatalogFlowsDuplicate from 'ee/ai/catalog/pages/ai_catalog_flows_duplicate.vue'; +import AiCatalogFlowForm from 'ee/ai/catalog/components/ai_catalog_flow_form.vue'; +import { + AI_CATALOG_FLOWS_ROUTE, + AI_CATALOG_SHOW_QUERY_PARAM, +} from 'ee/ai/catalog/router/constants'; +import { + mockFlow, + mockCreateAiCatalogFlowSuccessMutation, + mockCreateAiCatalogFlowErrorMutation, + mockAiCatalogFlowResponse, + mockAiCatalogFlowNullResponse, +} from '../mock_data'; + +Vue.use(VueApollo); +jest.mock('~/sentry/sentry_browser_wrapper'); + +describe('AiCatalogFlowsDuplicate', () => { + let wrapper; + let createAiCatalogFlowMock; + let aiCatalogFlowQueryMock; + + const mockToast = { + show: jest.fn(), + }; + const mockRouter = { + push: jest.fn(), + }; + const flowId = 1; + const routeParams = { id: flowId }; + + const createComponent = () => { + createAiCatalogFlowMock = jest.fn().mockResolvedValue(mockCreateAiCatalogFlowSuccessMutation); + aiCatalogFlowQueryMock = jest.fn().mockResolvedValue(mockAiCatalogFlowResponse); + + const apolloProvider = createMockApollo([ + [createAiCatalogFlow, createAiCatalogFlowMock], + [aiCatalogFlowQuery, aiCatalogFlowQueryMock], + ]); + + wrapper = shallowMountExtended(AiCatalogFlowsDuplicate, { + apolloProvider, + mocks: { + $route: { + params: routeParams, + }, + $router: mockRouter, + $toast: mockToast, + }, + }); + }; + + const findForm = () => wrapper.findComponent(AiCatalogFlowForm); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Initial Load', () => { + it('fetches the original flow data', () => { + expect(aiCatalogFlowQueryMock).toHaveBeenCalledWith({ + id: mockFlow.id, + }); + }); + + it('renders the form with loading state initially', () => { + expect(findForm().exists()).toBe(true); + expect(findForm().props('isLoading')).toBe(true); + }); + }); + + describe('Form Initial Values', () => { + beforeEach(async () => { + await waitForPromises(); + }); + + it('sets initial values based on the original flow', () => { + const expectedInitialValues = { + projectId: mockFlow.project?.id, + name: `${mockFlow.name} (Copy)`, + description: mockFlow.description, + public: mockFlow.public, + steps: mockFlow.latestVersion?.steps?.nodes || [], + release: true, + }; + + expect(findForm().props('initialValues')).toEqual(expectedInitialValues); + }); + }); + + describe('Form Submit', () => { + const { name, description, project } = mockFlow; + const formValues = { + name: `${name} (Copy)`, + description, + projectId: project.id, + public: false, + steps: [], + release: true, + }; + + const submitForm = () => findForm().vm.$emit('submit', formValues); + + beforeEach(async () => { + await waitForPromises(); + }); + + it('sends a create request', () => { + submitForm(); + + expect(createAiCatalogFlowMock).toHaveBeenCalledTimes(1); + expect(createAiCatalogFlowMock).toHaveBeenCalledWith({ + input: formValues, + }); + }); + + it('sets a loading state on the form while submitting', async () => { + expect(findForm().props('isLoading')).toBe(false); + + await submitForm(); + + expect(findForm().props('isLoading')).toBe(true); + }); + + describe('when request fails', () => { + beforeEach(async () => { + createAiCatalogFlowMock.mockRejectedValue(new Error()); + submitForm(); + await waitForPromises(); + }); + + it('sets error messages and captures exception', () => { + expect(findForm().props('errors')).toEqual(['The flow could not be added. Try again.']); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); + expect(findForm().props('isLoading')).toBe(false); + }); + + it('allows user to dismiss errors', async () => { + await findForm().vm.$emit('dismiss-errors'); + + expect(findForm().props('errors')).toEqual([]); + }); + }); + + describe('when request succeeds but returns error', () => { + beforeEach(async () => { + createAiCatalogFlowMock.mockResolvedValue(mockCreateAiCatalogFlowErrorMutation); + submitForm(); + await waitForPromises(); + }); + + it('shows an alert', () => { + expect(findForm().props('errors')).toEqual([ + mockCreateAiCatalogFlowErrorMutation.data.aiCatalogFlowCreate.errors[0], + ]); + expect(findForm().props('isLoading')).toBe(false); + }); + }); + + describe('when request succeeds', () => { + beforeEach(async () => { + submitForm(); + await waitForPromises(); + }); + + it('shows toast', () => { + expect(mockToast.show).toHaveBeenCalledWith('Flow created successfully.'); + }); + + it('navigates to flows page with show query', async () => { + await waitForPromises(); + expect(mockRouter.push).toHaveBeenCalledWith({ + name: AI_CATALOG_FLOWS_ROUTE, + query: { [AI_CATALOG_SHOW_QUERY_PARAM]: 1 }, + }); + }); + }); + }); + + describe('Error Handling', () => { + describe('when flow query fails', () => { + it('captures the exception', async () => { + const error = new Error('Flow not found.'); + + const apolloProvider = createMockApollo([ + [createAiCatalogFlow, createAiCatalogFlowMock], + [aiCatalogFlowQuery, jest.fn().mockRejectedValue(error)], + ]); + + wrapper = shallowMountExtended(AiCatalogFlowsDuplicate, { + apolloProvider, + mocks: { + $route: { + params: routeParams, + }, + $router: mockRouter, + $toast: mockToast, + }, + }); + + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + }); + + describe('when flow query returns null', () => { + it('handles null response gracefully', async () => { + const apolloProvider = createMockApollo([ + [createAiCatalogFlow, createAiCatalogFlowMock], + [aiCatalogFlowQuery, jest.fn().mockResolvedValue(mockAiCatalogFlowNullResponse)], + ]); + + wrapper = shallowMountExtended(AiCatalogFlowsDuplicate, { + apolloProvider, + mocks: { + $route: { + params: routeParams, + }, + $router: mockRouter, + $toast: mockToast, + }, + }); + + await waitForPromises(); + + expect(findForm().props('initialValues')).toEqual({}); + }); + }); + }); +}); diff --git a/ee/spec/frontend/ai/catalog/pages/ai_catalog_flows_spec.js b/ee/spec/frontend/ai/catalog/pages/ai_catalog_flows_spec.js index eb682776627146c7fd39bdd477b0297525484707..6d9acceadec8fd2dfad3e3b566ad66bf86cdf51d 100644 --- a/ee/spec/frontend/ai/catalog/pages/ai_catalog_flows_spec.js +++ b/ee/spec/frontend/ai/catalog/pages/ai_catalog_flows_spec.js @@ -22,6 +22,7 @@ import { TRACK_EVENT_VIEW_AI_CATALOG_ITEM, TRACK_EVENT_TYPE_FLOW, } from 'ee/ai/catalog/constants'; +import { AI_CATALOG_FLOWS_DUPLICATE_ROUTE } from 'ee/ai/catalog/router/constants'; import { TYPENAME_AI_CATALOG_ITEM } from 'ee/graphql_shared/constants'; import { mockFlow, @@ -57,6 +58,7 @@ describe('AiCatalogFlows', () => { const mockCatalogItemsQueryHandler = jest.fn().mockResolvedValue(mockCatalogFlowsResponse); const deleteCatalogItemMutationHandler = jest.fn(); const createAiCatalogItemConsumerHandler = jest.fn(); + const mockSetItemToDuplicateMutation = jest.fn(); const { bindInternalEventDocument } = useMockInternalEventsTracking(); @@ -67,12 +69,19 @@ describe('AiCatalogFlows', () => { query: $route.query, }); - mockApollo = createMockApollo([ - [aiCatalogFlowQuery, mockFlowQueryHandler], - [aiCatalogFlowsQuery, mockCatalogItemsQueryHandler], - [deleteAiCatalogFlowMutation, deleteCatalogItemMutationHandler], - [createAiCatalogItemConsumer, createAiCatalogItemConsumerHandler], - ]); + mockApollo = createMockApollo( + [ + [aiCatalogFlowQuery, mockFlowQueryHandler], + [aiCatalogFlowsQuery, mockCatalogItemsQueryHandler], + [deleteAiCatalogFlowMutation, deleteCatalogItemMutationHandler], + [createAiCatalogItemConsumer, createAiCatalogItemConsumerHandler], + ], + { + Mutation: { + setItemToDuplicate: mockSetItemToDuplicateMutation, + }, + }, + ); wrapper = shallowMountExtended(AiCatalogFlows, { apolloProvider: mockApollo, @@ -334,6 +343,58 @@ describe('AiCatalogFlows', () => { }); }); + describe('on duplicating a flow', () => { + const duplicateFlow = async (index = 1) => { + await waitForPromises(); + await wrapper.vm.handleDuplicate(mockFlows[index]); + }; + + beforeEach(() => { + createComponent(); + }); + + it('calls setItemToDuplicate mutation with correct variables', async () => { + mockSetItemToDuplicateMutation.mockResolvedValue({ data: {} }); + + await duplicateFlow(); + + expect(mockSetItemToDuplicateMutation).toHaveBeenCalledWith( + {}, + { + item: { + id: getIdFromGraphQLId(mockFlows[1].id), + type: 'FLOW', + data: mockFlows[1], + }, + }, + expect.anything(), + expect.anything(), + ); + }); + + describe('when request succeeds', () => { + it('navigates to duplicate route', async () => { + mockSetItemToDuplicateMutation.mockResolvedValue({ data: {} }); + + await duplicateFlow(); + + expect(mockRouter.push).toHaveBeenCalledWith({ + name: AI_CATALOG_FLOWS_DUPLICATE_ROUTE, + params: { id: getIdFromGraphQLId(mockFlows[1].id) }, + }); + }); + }); + + describe('when flow is not found', () => { + it('shows error message and logs to Sentry', async () => { + await duplicateFlow(3); + + expect(findErrorsAlert().props('errors')).toStrictEqual(['Flow not found.']); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + }); + describe('pagination', () => { beforeEach(async () => { createComponent(); diff --git a/ee/spec/frontend/ai/catalog/router/index_spec.js b/ee/spec/frontend/ai/catalog/router/index_spec.js index a16baca17c1f5c4a00a79aec7a11d3c6eca64be2..fa6336ba9a4166e7f213109d44ab51800aa01e4b 100644 --- a/ee/spec/frontend/ai/catalog/router/index_spec.js +++ b/ee/spec/frontend/ai/catalog/router/index_spec.js @@ -9,6 +9,7 @@ import { AI_CATALOG_FLOWS_ROUTE, AI_CATALOG_FLOWS_EDIT_ROUTE, AI_CATALOG_FLOWS_NEW_ROUTE, + AI_CATALOG_FLOWS_DUPLICATE_ROUTE, AI_CATALOG_SHOW_QUERY_PARAM, } from 'ee/ai/catalog/router/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; @@ -44,6 +45,7 @@ describe('AI Catalog Router', () => { ${'flows index'} | ${'/flows'} | ${AI_CATALOG_FLOWS_ROUTE} ${'flows new'} | ${'/flows/new'} | ${AI_CATALOG_FLOWS_NEW_ROUTE} ${'flows edit'} | ${`/flows/${flowId}/edit`} | ${AI_CATALOG_FLOWS_EDIT_ROUTE} + ${'flows duplicate'} | ${`/flows/${flowId}/duplicate`} | ${AI_CATALOG_FLOWS_DUPLICATE_ROUTE} `('renders $testName child route', async ({ path, expectedRouteName }) => { await router.push(path); @@ -69,6 +71,7 @@ describe('AI Catalog Router', () => { ${'flows index'} | ${'/flows'} | ${AI_CATALOG_INDEX_ROUTE} ${'flows new'} | ${'/flows/new'} | ${AI_CATALOG_INDEX_ROUTE} ${'flows edit'} | ${`/flows/${flowId}/edit`} | ${AI_CATALOG_INDEX_ROUTE} + ${'flows duplicate'} | ${`/flows/${flowId}/duplicate`} | ${AI_CATALOG_INDEX_ROUTE} `('renders $testName child route', async ({ path, expectedRouteName }) => { await router.push(path); @@ -117,6 +120,7 @@ describe('AI Catalog Router', () => { ${'agents duplicate'} | ${`/agents/${agentId}/duplicate`} ${'flows new'} | ${'/flows/new'} ${'flows edit'} | ${`/flows/${flowId}/edit`} + ${'flows duplicate'} | ${`/flows/${flowId}/duplicate`} `('redirects $testName route to index when not logged in', async ({ path }) => { try { await router.push(path); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index fb0cb6974576f50ac53f02409d64a6933e16f84d..39701e9c5d6a8a80babbf2c2113c10453a3e846d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2439,6 +2439,9 @@ msgstr "" msgid "AICatalog| Draft node" msgstr "" +msgid "AICatalog|(Copy)" +msgstr "" + msgid "AICatalog|A public agent can be made private only if it is not used." msgstr "" @@ -2538,6 +2541,9 @@ msgstr "" msgid "AICatalog|Copy user prompt" msgstr "" +msgid "AICatalog|Create a copy of this flow with all its settings and configuration." +msgstr "" + msgid "AICatalog|Create agent" msgstr "" @@ -2568,6 +2574,9 @@ msgstr "" msgid "AICatalog|Duplicate agent" msgstr "" +msgid "AICatalog|Duplicate flow" +msgstr "" + msgid "AICatalog|Duplicate this agent with all its settings and configuration." msgstr ""