diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index ea4e0122e6..744a30593d 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -59,7 +59,7 @@ import type { ROLE, } from '@/constants'; import type { BulkCommand, Undoable } from '@/models/history'; -import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers'; +import type { PartialBy } from '@/utils/typeHelpers'; import type { ProjectSharingData } from '@/types/projects.types'; @@ -1361,51 +1361,6 @@ export type SamlPreferencesExtractedData = { returnUrl: string; }; -export type SshKeyTypes = ['ed25519', 'rsa']; - -export type SourceControlPreferences = { - connected: boolean; - repositoryUrl: string; - branchName: string; - branches: string[]; - branchReadOnly: boolean; - branchColor: string; - publicKey?: string; - keyGeneratorType?: TupleToUnion; - currentBranch?: string; -}; - -export interface SourceControlStatus { - ahead: number; - behind: number; - conflicted: string[]; - created: string[]; - current: string; - deleted: string[]; - detached: boolean; - files: Array<{ - path: string; - index: string; - working_dir: string; - }>; - modified: string[]; - not_added: string[]; - renamed: string[]; - staged: string[]; - tracking: null; -} - -export interface SourceControlAggregatedFile { - conflict: boolean; - file: string; - id: string; - location: string; - name: string; - status: string; - type: string; - updatedAt?: string; -} - export declare namespace Cloud { export interface PlanData { planId: number; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts b/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts index 46f86fa693..653fbc3b51 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/sourceControl.ts @@ -2,7 +2,7 @@ import type { Server, Request } from 'miragejs'; import { Response } from 'miragejs'; import { jsonParse } from 'n8n-workflow'; import type { AppSchema } from '@/__tests__/server/types'; -import type { SourceControlPreferences } from '@/Interface'; +import type { SourceControlPreferences } from '@/types/sourceControl.types'; export function routesForSourceControl(server: Server) { const sourceControlApiRoot = '/rest/source-control'; diff --git a/packages/editor-ui/src/api/sourceControl.ts b/packages/editor-ui/src/api/sourceControl.ts index 615c7d6660..c8e4eebcbc 100644 --- a/packages/editor-ui/src/api/sourceControl.ts +++ b/packages/editor-ui/src/api/sourceControl.ts @@ -1,11 +1,12 @@ import type { IDataObject } from 'n8n-workflow'; +import type { IRestApiContext } from '@/Interface'; import type { - IRestApiContext, SourceControlAggregatedFile, SourceControlPreferences, SourceControlStatus, SshKeyTypes, -} from '@/Interface'; +} from '@/types/sourceControl.types'; + import { makeRestApiRequest } from '@/utils/apiUtils'; import type { TupleToUnion } from '@/utils/typeHelpers'; diff --git a/packages/editor-ui/src/components/MainSidebarSourceControl.vue b/packages/editor-ui/src/components/MainSidebarSourceControl.vue index 1b792b4cd5..d00f5364fa 100644 --- a/packages/editor-ui/src/components/MainSidebarSourceControl.vue +++ b/packages/editor-ui/src/components/MainSidebarSourceControl.vue @@ -8,7 +8,7 @@ import { useLoadingService } from '@/composables/useLoadingService'; import { useUIStore } from '@/stores/ui.store'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants'; -import type { SourceControlAggregatedFile } from '../Interface'; +import type { SourceControlAggregatedFile } from '@/types/sourceControl.types'; import { sourceControlEventBus } from '@/event-bus/source-control'; defineProps<{ diff --git a/packages/editor-ui/src/components/SourceControlPullModal.ee.vue b/packages/editor-ui/src/components/SourceControlPullModal.ee.vue index 624b2dd61f..ac3bce1922 100644 --- a/packages/editor-ui/src/components/SourceControlPullModal.ee.vue +++ b/packages/editor-ui/src/components/SourceControlPullModal.ee.vue @@ -2,7 +2,7 @@ import Modal from './Modal.vue'; import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants'; import type { EventBus } from 'n8n-design-system/utils'; -import type { SourceControlAggregatedFile } from '@/Interface'; +import type { SourceControlAggregatedFile } from '@/types/sourceControl.types'; import { useI18n } from '@/composables/useI18n'; import { useLoadingService } from '@/composables/useLoadingService'; import { useToast } from '@/composables/useToast'; diff --git a/packages/editor-ui/src/components/SourceControlPushModal.ee.test.ts b/packages/editor-ui/src/components/SourceControlPushModal.ee.test.ts index c2aecd6999..e4f9da72a6 100644 --- a/packages/editor-ui/src/components/SourceControlPushModal.ee.test.ts +++ b/packages/editor-ui/src/components/SourceControlPushModal.ee.test.ts @@ -1,16 +1,20 @@ -import { within } from '@testing-library/dom'; +import { within, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import { useRoute } from 'vue-router'; import { createComponentRenderer } from '@/__tests__/render'; import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue'; import { createTestingPinia } from '@pinia/testing'; import { createEventBus } from 'n8n-design-system'; -import type { SourceControlAggregatedFile } from '@/Interface'; +import type { SourceControlAggregatedFile } from '@/types/sourceControl.types'; +import { useSourceControlStore } from '@/stores/sourceControl.store'; +import { mockedStore } from '@/__tests__/utils'; +import { VIEWS } from '@/constants'; const eventBus = createEventBus(); vi.mock('vue-router', () => ({ useRoute: vi.fn().mockReturnValue({ + name: vi.fn(), params: vi.fn(), fullPath: vi.fn(), }), @@ -20,9 +24,17 @@ vi.mock('vue-router', () => ({ let route: ReturnType; +const RecycleScroller = { + props: { + items: Array, + }, + template: '
', +}; + const renderModal = createComponentRenderer(SourceControlPushModal, { global: { stubs: { + RecycleScroller, Modal: { template: `
@@ -40,12 +52,13 @@ const renderModal = createComponentRenderer(SourceControlPushModal, { describe('SourceControlPushModal', () => { beforeEach(() => { route = useRoute(); + createTestingPinia(); }); it('mounts', () => { vi.spyOn(route, 'fullPath', 'get').mockReturnValue(''); - const { getByTitle } = renderModal({ + const { getByText } = renderModal({ pinia: createTestingPinia(), props: { data: { @@ -54,7 +67,7 @@ describe('SourceControlPushModal', () => { }, }, }); - expect(getByTitle('Commit and push changes')).toBeInTheDocument(); + expect(getByText('Commit and push changes')).toBeInTheDocument(); }); it('should toggle checkboxes', async () => { @@ -81,10 +94,7 @@ describe('SourceControlPushModal', () => { }, ]; - vi.spyOn(route, 'fullPath', 'get').mockReturnValue('/home/workflows'); - const { getByTestId, getAllByTestId } = renderModal({ - pinia: createTestingPinia(), props: { data: { eventBus, @@ -148,4 +158,222 @@ describe('SourceControlPushModal', () => { expect(within(files[0]).getByRole('checkbox')).not.toBeChecked(); expect(within(files[1]).getByRole('checkbox')).not.toBeChecked(); }); + + it('should push non workflow entities', async () => { + const status: SourceControlAggregatedFile[] = [ + { + id: 'gTbbBkkYTnNyX1jD', + name: 'credential', + type: 'credential', + status: 'created', + location: 'local', + conflict: false, + file: '', + updatedAt: '2024-09-20T10:31:40.000Z', + }, + { + id: 'JIGKevgZagmJAnM6', + name: 'variables', + type: 'variables', + status: 'created', + location: 'local', + conflict: false, + file: '', + updatedAt: '2024-09-20T14:42:51.968Z', + }, + { + id: 'mappings', + name: 'tags', + type: 'tags', + status: 'modified', + location: 'local', + conflict: false, + file: '/Users/raul/.n8n/git/tags.json', + updatedAt: '2024-12-04T11:29:22.095Z', + }, + ]; + + const sourceControlStore = mockedStore(useSourceControlStore); + + const { getByTestId, getByText } = renderModal({ + props: { + data: { + eventBus, + status, + }, + }, + }); + + const submitButton = getByTestId('source-control-push-modal-submit'); + const commitMessage = 'commit message'; + expect(submitButton).toBeDisabled(); + expect( + getByText( + 'No workflow changes to push. Only modified credentials, variables, and tags will be pushed.', + ), + ).toBeInTheDocument(); + + await userEvent.type(getByTestId('source-control-push-modal-commit'), commitMessage); + + expect(submitButton).not.toBeDisabled(); + await userEvent.click(submitButton); + + expect(sourceControlStore.pushWorkfolder).toHaveBeenCalledWith( + expect.objectContaining({ + commitMessage, + fileNames: expect.arrayContaining(status), + force: true, + }), + ); + }); + + it('should auto select currentWorkflow', async () => { + const status: SourceControlAggregatedFile[] = [ + { + id: 'gTbbBkkYTnNyX1jD', + name: 'My workflow 1', + type: 'workflow', + status: 'created', + location: 'local', + conflict: false, + file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json', + updatedAt: '2024-09-20T10:31:40.000Z', + }, + { + id: 'JIGKevgZagmJAnM6', + name: 'My workflow 2', + type: 'workflow', + status: 'created', + location: 'local', + conflict: false, + file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json', + updatedAt: '2024-09-20T14:42:51.968Z', + }, + ]; + + vi.spyOn(route, 'name', 'get').mockReturnValue(VIEWS.WORKFLOW); + vi.spyOn(route, 'params', 'get').mockReturnValue({ name: 'gTbbBkkYTnNyX1jD' }); + + const { getByTestId, getAllByTestId } = renderModal({ + props: { + data: { + eventBus, + status, + }, + }, + }); + + const submitButton = getByTestId('source-control-push-modal-submit'); + expect(submitButton).toBeDisabled(); + + const files = getAllByTestId('source-control-push-modal-file-checkbox'); + expect(files).toHaveLength(2); + + await waitFor(() => expect(within(files[0]).getByRole('checkbox')).toBeChecked()); + expect(within(files[1]).getByRole('checkbox')).not.toBeChecked(); + + await userEvent.type(getByTestId('source-control-push-modal-commit'), 'message'); + expect(submitButton).not.toBeDisabled(); + }); + + describe('filters', () => { + it('should filter by name', async () => { + const status: SourceControlAggregatedFile[] = [ + { + id: 'gTbbBkkYTnNyX1jD', + name: 'My workflow 1', + type: 'workflow', + status: 'created', + location: 'local', + conflict: false, + file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json', + updatedAt: '2024-09-20T10:31:40.000Z', + }, + { + id: 'JIGKevgZagmJAnM6', + name: 'My workflow 2', + type: 'workflow', + status: 'created', + location: 'local', + conflict: false, + file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json', + updatedAt: '2024-09-20T14:42:51.968Z', + }, + ]; + + const { getByTestId, getAllByTestId } = renderModal({ + props: { + data: { + eventBus, + status, + }, + }, + }); + + expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2); + + await userEvent.type(getByTestId('source-control-push-search'), '1'); + await waitFor(() => + expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1), + ); + }); + + it('should filter by status', async () => { + const status: SourceControlAggregatedFile[] = [ + { + id: 'gTbbBkkYTnNyX1jD', + name: 'Created Workflow', + type: 'workflow', + status: 'created', + location: 'local', + conflict: false, + file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json', + updatedAt: '2024-09-20T10:31:40.000Z', + }, + { + id: 'JIGKevgZagmJAnM6', + name: 'Modified workflow', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: false, + file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json', + updatedAt: '2024-09-20T14:42:51.968Z', + }, + ]; + + const { getByTestId, getAllByTestId } = renderModal({ + props: { + data: { + eventBus, + status, + }, + }, + }); + + expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2); + + await userEvent.click(getByTestId('source-control-filter-dropdown')); + + expect(getByTestId('source-control-status-filter')).toBeVisible(); + + await userEvent.click( + within(getByTestId('source-control-status-filter')).getByRole('combobox'), + ); + + await waitFor(() => + expect(getAllByTestId('source-control-status-filter-option')[0]).toBeVisible(), + ); + + const menu = getAllByTestId('source-control-status-filter-option')[0] + .parentElement as HTMLElement; + + await userEvent.click(within(menu).getByText('New')); + await waitFor(() => { + const items = getAllByTestId('source-control-push-modal-file-checkbox'); + expect(items).toHaveLength(1); + expect(items[0]).toHaveTextContent('Created Workflow'); + }); + }); + }); }); diff --git a/packages/editor-ui/src/components/SourceControlPushModal.ee.vue b/packages/editor-ui/src/components/SourceControlPushModal.ee.vue index 0a0d99389a..cf7f0b9d2b 100644 --- a/packages/editor-ui/src/components/SourceControlPushModal.ee.vue +++ b/packages/editor-ui/src/components/SourceControlPushModal.ee.vue @@ -1,9 +1,8 @@