diff --git a/packages/frontend/@n8n/design-system/src/css/_tokens.dark.scss b/packages/frontend/@n8n/design-system/src/css/_tokens.dark.scss index 1c1bba636d..82c8352e1f 100644 --- a/packages/frontend/@n8n/design-system/src/css/_tokens.dark.scss +++ b/packages/frontend/@n8n/design-system/src/css/_tokens.dark.scss @@ -514,7 +514,7 @@ --color-configurable-node-name: var(--color-text-dark); --color-secondary-link: var(--p-color-secondary-270); --color-secondary-link-hover: var(--p-color-secondary-370); - //Params + // Params --color-icon-base: var(--color-text-light); --color-icon-hover: var(--p-color-primary-320); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index de1ea1f9c9..ec52032bf7 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3407,7 +3407,10 @@ "workflowDiff.local": "Local", "workflowDiff.remote": "Remote ({branchName})", "workflowDiff.noChanges": "No changes", - "workflowDiff.deletedWorkflow": "Deleted workflow", - "workflowDiff.deletedWorkflow.database": "The workflow was deleted on the database", - "workflowDiff.deletedWorkflow.remote": "The workflow was deleted on remote" + "workflowDiff.deletedWorkflow": "Missing workflow", + "workflowDiff.deletedWorkflow.database": "The workflow doesn't exist in the database", + "workflowDiff.deletedWorkflow.remote": "The workflow doesn't exist on remote", + "workflowDiff.newWorkflow": "New workflow", + "workflowDiff.newWorkflow.database": "The workflow will be created in the database", + "workflowDiff.newWorkflow.remote": "The workflow will be created on remote" } diff --git a/packages/frontend/editor-ui/src/components/SourceControlPullModal.ee.test.ts b/packages/frontend/editor-ui/src/components/SourceControlPullModal.ee.test.ts index 8aed6910b6..c37431c1db 100644 --- a/packages/frontend/editor-ui/src/components/SourceControlPullModal.ee.test.ts +++ b/packages/frontend/editor-ui/src/components/SourceControlPullModal.ee.test.ts @@ -114,7 +114,7 @@ const sampleFiles = [ }, ]; -describe('SourceControlPushModal', () => { +describe('SourceControlPullModal', () => { let sourceControlStore: ReturnType>; beforeEach(() => { @@ -144,8 +144,9 @@ describe('SourceControlPushModal', () => { }, }); - expect(getAllByTestId('pull-modal-item-header').length).toBe(2); - expect(getAllByTestId('pull-modal-item').length).toBe(2); + // The new structure renders items in a tabbed interface + // Both items should be rendered (one workflow, one credential) + expect(getAllByTestId('pull-modal-item').length).toBe(1); // Only workflow tab items are shown initially }); it('should force pull', async () => { @@ -183,13 +184,13 @@ describe('SourceControlPushModal', () => { expect(diffButton).toBeInTheDocument(); }); - it('should not render diff button for non-workflow items', () => { + it('should not render diff button for non-workflow items', async () => { const credentialFile = { ...sampleFiles[1], // credential file type: 'credential', }; - const { container } = renderModal({ + const { container, getByText } = renderModal({ props: { data: { eventBus, @@ -198,10 +199,13 @@ describe('SourceControlPushModal', () => { }, }); - // For credential files, there should be no additional buttons in the item actions - const itemActions = container.querySelector('[class*="itemActions"]'); - const buttons = itemActions?.querySelectorAll('button'); - expect(buttons).toHaveLength(0); + // Click on credentials tab to show credential items + await userEvent.click(getByText('Credentials')); + + // For credential files, there should be no diff buttons (only badges in the badges container) + const badges = container.querySelector('[class*="badges"]'); + const buttons = badges?.querySelectorAll('button'); + expect(buttons?.length || 0).toBe(0); }); it('should render item names with ellipsis for long text', () => { @@ -219,13 +223,14 @@ describe('SourceControlPushModal', () => { }, }); - // Check if the itemName container exists and has the proper structure - const nameContainer = container.querySelector('[class*="itemName"]'); + // Check if the listItemName container exists + const nameContainer = container.querySelector('[class*="listItemName"]'); expect(nameContainer).toBeInTheDocument(); // Check if the RouterLink stub is rendered (since the name is rendered inside it) const routerLink = nameContainer?.querySelector('a'); expect(routerLink).toBeInTheDocument(); + expect(routerLink?.textContent).toContain(longNameFile.name); }); it('should render badges and actions in separate container', () => { @@ -240,14 +245,10 @@ describe('SourceControlPushModal', () => { const listItems = getAllByTestId('pull-modal-item'); - // Each list item should have the new structure with itemActions container + // Each list item should have the new structure with badges container listItems.forEach((item) => { - const actionsContainer = item.querySelector('[class*="itemActions"]'); - expect(actionsContainer).toBeInTheDocument(); - - // Badge should be inside actions container - const badge = actionsContainer?.querySelector('[class*="listBadge"]'); - expect(badge).toBeInTheDocument(); + const badgesContainer = item.querySelector('[class*="badges"]'); + expect(badgesContainer).toBeInTheDocument(); }); }); diff --git a/packages/frontend/editor-ui/src/components/SourceControlPullModal.ee.vue b/packages/frontend/editor-ui/src/components/SourceControlPullModal.ee.vue index d6d72259ca..a28c9cc595 100644 --- a/packages/frontend/editor-ui/src/components/SourceControlPullModal.ee.vue +++ b/packages/frontend/editor-ui/src/components/SourceControlPullModal.ee.vue @@ -5,8 +5,10 @@ import { useToast } from '@/composables/useToast'; import { SOURCE_CONTROL_PULL_MODAL_KEY, VIEWS, WORKFLOW_DIFF_MODAL_KEY } from '@/constants'; import { sourceControlEventBus } from '@/event-bus/source-control'; import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue'; +import { useProjectsStore } from '@/stores/projects.store'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useUIStore } from '@/stores/ui.store'; +import type { ProjectListItem } from '@/types/projects.types'; import { getPullPriorityByStatus, getStatusText, @@ -14,19 +16,20 @@ import { notifyUserAboutPullWorkFolderOutcome, } from '@/utils/sourceControlUtils'; import { type SourceControlledFile, SOURCE_CONTROL_FILE_TYPE } from '@n8n/api-types'; -import { N8nBadge, N8nButton, N8nLink, N8nText } from '@n8n/design-system'; +import { N8nBadge, N8nButton, N8nHeading, N8nInfoTip, N8nLink, N8nText } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import type { EventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus'; -import groupBy from 'lodash/groupBy'; +import dateformat from 'dateformat'; import orderBy from 'lodash/orderBy'; -import { computed } from 'vue'; +import { computed, onBeforeMount, ref } from 'vue'; import { RouterLink } from 'vue-router'; import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; import Modal from './Modal.vue'; type SourceControlledFileType = SourceControlledFile['type']; +type SourceControlledFileWithProject = SourceControlledFile & { project?: ProjectListItem }; const props = defineProps<{ data: { eventBus: EventBus; status: SourceControlledFile[] }; @@ -38,54 +41,120 @@ const uiStore = useUIStore(); const toast = useToast(); const i18n = useI18n(); const sourceControlStore = useSourceControlStore(); +const projectsStore = useProjectsStore(); -const sortedFiles = computed(() => +onBeforeMount(() => { + void projectsStore.getAvailableProjects(); +}); + +// Tab state +const activeTab = ref< + typeof SOURCE_CONTROL_FILE_TYPE.workflow | typeof SOURCE_CONTROL_FILE_TYPE.credential +>(SOURCE_CONTROL_FILE_TYPE.workflow); + +// Group files by type with project information +const filesWithProjects = computed(() => + props.data.status.map((file) => { + const project = projectsStore.availableProjects.find(({ id }) => id === file.owner?.projectId); + return { ...file, project }; + }), +); + +const groupedFilesByType = computed(() => { + const grouped: Partial> = {}; + + filesWithProjects.value.forEach((file) => { + if (!grouped[file.type]) { + grouped[file.type] = []; + } + grouped[file.type]!.push(file); + }); + + return grouped; +}); + +// Filtered workflows +const filteredWorkflows = computed(() => { + const workflows = groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.workflow] || []; + return workflows; +}); + +const sortedWorkflows = computed(() => orderBy( - props.data.status, - [({ status }) => getPullPriorityByStatus(status), ({ name }) => name.toLowerCase()], - ['desc', 'asc'], + filteredWorkflows.value, + [({ status }) => getPullPriorityByStatus(status), 'updatedAt'], + ['asc', 'desc'], ), ); -const groupedFilesByType = computed< - Partial> ->(() => groupBy(sortedFiles.value, 'type')); +// Filtered credentials +const filteredCredentials = computed(() => { + const credentials = groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.credential] || []; + return credentials; +}); -type ItemsList = Array< - { type: 'render-title'; title: string; id: SourceControlledFileType } | SourceControlledFile ->; - -const ITEM_TITLES: Record, string> = { - [SOURCE_CONTROL_FILE_TYPE.workflow]: 'Workflows', - [SOURCE_CONTROL_FILE_TYPE.credential]: 'Credentials', - [SOURCE_CONTROL_FILE_TYPE.variables]: 'Variables', - [SOURCE_CONTROL_FILE_TYPE.tags]: 'Tags', - [SOURCE_CONTROL_FILE_TYPE.folders]: 'Folders', -} as const; - -const files = computed(() => - [ - SOURCE_CONTROL_FILE_TYPE.workflow, - SOURCE_CONTROL_FILE_TYPE.credential, - SOURCE_CONTROL_FILE_TYPE.variables, - SOURCE_CONTROL_FILE_TYPE.tags, - SOURCE_CONTROL_FILE_TYPE.folders, - ].reduce((acc, fileType) => { - if (!groupedFilesByType.value[fileType]) { - return acc; - } - - acc.push({ - type: 'render-title', - title: ITEM_TITLES[fileType], - id: fileType, - }); - - acc.push(...groupedFilesByType.value[fileType]); - return acc; - }, []), +const sortedCredentials = computed(() => + orderBy( + filteredCredentials.value, + [({ status }) => getPullPriorityByStatus(status), 'updatedAt'], + ['asc', 'desc'], + ), ); +// Active data source based on tab +const activeDataSourceFiltered = computed(() => { + if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) { + return sortedWorkflows.value; + } + if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.credential) { + return sortedCredentials.value; + } + return []; +}); + +const filtersNoResultText = computed(() => { + if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) { + return i18n.baseText('workflows.noResults'); + } + return i18n.baseText('credentials.noResults'); +}); + +// Tab data +const tabs = computed(() => { + return [ + { + label: 'Workflows', + value: SOURCE_CONTROL_FILE_TYPE.workflow, + total: groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.workflow]?.length || 0, + }, + { + label: 'Credentials', + value: SOURCE_CONTROL_FILE_TYPE.credential, + total: groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.credential]?.length || 0, + }, + ]; +}); + +// Other files (variables, tags, folders) that are always pulled +const otherFiles = computed(() => { + const others: SourceControlledFileWithProject[] = []; + + const variables = groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.variables]; + if (variables) { + others.push.apply(others, variables); + } + const tags = groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.tags]; + if (tags) { + others.push.apply(others, tags); + } + const folders = groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.folders]; + if (folders) { + others.push.apply(others, folders); + } + + return others; +}); + function close() { uiStore.closeModal(SOURCE_CONTROL_PULL_MODAL_KEY); } @@ -107,6 +176,20 @@ async function pullWorkfolder() { } } +function renderUpdatedAt(file: SourceControlledFile) { + const currentYear = new Date().getFullYear().toString(); + + return i18n.baseText('settings.sourceControl.lastUpdated', { + interpolate: { + date: dateformat( + file.updatedAt, + `d mmm${file.updatedAt?.startsWith(currentYear) ? '' : ', yyyy'}`, + ), + time: dateformat(file.updatedAt, 'HH:MM'), + }, + }); +} + const workflowDiffEventBus = createEventBus(); function openDiffModal(id: string) { @@ -119,86 +202,156 @@ function openDiffModal(id: string) { data: { eventBus: workflowDiffEventBus, workflowId: id, direction: 'pull' }, }); } + +const modalHeight = computed(() => + groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.workflow]?.length || + groupedFilesByType.value[SOURCE_CONTROL_FILE_TYPE.credential]?.length + ? 'min(80vh, 850px)' + : 'auto', +);