fix(editor): Improvements to the commit modal (#12031)

This commit is contained in:
Raúl Gómez Morales 2024-12-06 12:53:14 +01:00 committed by GitHub
parent 60b3dccf93
commit 4fe1952e2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 685 additions and 344 deletions

View file

@ -59,7 +59,7 @@ import type {
ROLE, ROLE,
} from '@/constants'; } from '@/constants';
import type { BulkCommand, Undoable } from '@/models/history'; 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'; import type { ProjectSharingData } from '@/types/projects.types';
@ -1361,51 +1361,6 @@ export type SamlPreferencesExtractedData = {
returnUrl: string; 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<SshKeyTypes>;
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 declare namespace Cloud {
export interface PlanData { export interface PlanData {
planId: number; planId: number;

View file

@ -2,7 +2,7 @@ import type { Server, Request } from 'miragejs';
import { Response } from 'miragejs'; import { Response } from 'miragejs';
import { jsonParse } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow';
import type { AppSchema } from '@/__tests__/server/types'; import type { AppSchema } from '@/__tests__/server/types';
import type { SourceControlPreferences } from '@/Interface'; import type { SourceControlPreferences } from '@/types/sourceControl.types';
export function routesForSourceControl(server: Server) { export function routesForSourceControl(server: Server) {
const sourceControlApiRoot = '/rest/source-control'; const sourceControlApiRoot = '/rest/source-control';

View file

@ -1,11 +1,12 @@
import type { IDataObject } from 'n8n-workflow'; import type { IDataObject } from 'n8n-workflow';
import type { IRestApiContext } from '@/Interface';
import type { import type {
IRestApiContext,
SourceControlAggregatedFile, SourceControlAggregatedFile,
SourceControlPreferences, SourceControlPreferences,
SourceControlStatus, SourceControlStatus,
SshKeyTypes, SshKeyTypes,
} from '@/Interface'; } from '@/types/sourceControl.types';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest } from '@/utils/apiUtils';
import type { TupleToUnion } from '@/utils/typeHelpers'; import type { TupleToUnion } from '@/utils/typeHelpers';

View file

@ -8,7 +8,7 @@ import { useLoadingService } from '@/composables/useLoadingService';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants'; 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'; import { sourceControlEventBus } from '@/event-bus/source-control';
defineProps<{ defineProps<{

View file

@ -2,7 +2,7 @@
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
import type { EventBus } from 'n8n-design-system/utils'; 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 { useI18n } from '@/composables/useI18n';
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';

View file

@ -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 userEvent from '@testing-library/user-event';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue'; import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { createEventBus } from 'n8n-design-system'; 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(); const eventBus = createEventBus();
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
useRoute: vi.fn().mockReturnValue({ useRoute: vi.fn().mockReturnValue({
name: vi.fn(),
params: vi.fn(), params: vi.fn(),
fullPath: vi.fn(), fullPath: vi.fn(),
}), }),
@ -20,9 +24,17 @@ vi.mock('vue-router', () => ({
let route: ReturnType<typeof useRoute>; let route: ReturnType<typeof useRoute>;
const RecycleScroller = {
props: {
items: Array,
},
template: '<div><template v-for="item in items"><slot v-bind="{ item }"></slot></template></div>',
};
const renderModal = createComponentRenderer(SourceControlPushModal, { const renderModal = createComponentRenderer(SourceControlPushModal, {
global: { global: {
stubs: { stubs: {
RecycleScroller,
Modal: { Modal: {
template: ` template: `
<div> <div>
@ -40,12 +52,13 @@ const renderModal = createComponentRenderer(SourceControlPushModal, {
describe('SourceControlPushModal', () => { describe('SourceControlPushModal', () => {
beforeEach(() => { beforeEach(() => {
route = useRoute(); route = useRoute();
createTestingPinia();
}); });
it('mounts', () => { it('mounts', () => {
vi.spyOn(route, 'fullPath', 'get').mockReturnValue(''); vi.spyOn(route, 'fullPath', 'get').mockReturnValue('');
const { getByTitle } = renderModal({ const { getByText } = renderModal({
pinia: createTestingPinia(), pinia: createTestingPinia(),
props: { props: {
data: { 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 () => { it('should toggle checkboxes', async () => {
@ -81,10 +94,7 @@ describe('SourceControlPushModal', () => {
}, },
]; ];
vi.spyOn(route, 'fullPath', 'get').mockReturnValue('/home/workflows');
const { getByTestId, getAllByTestId } = renderModal({ const { getByTestId, getAllByTestId } = renderModal({
pinia: createTestingPinia(),
props: { props: {
data: { data: {
eventBus, eventBus,
@ -148,4 +158,222 @@ describe('SourceControlPushModal', () => {
expect(within(files[0]).getByRole('checkbox')).not.toBeChecked(); expect(within(files[0]).getByRole('checkbox')).not.toBeChecked();
expect(within(files[1]).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');
});
});
});
}); });

View file

@ -1,9 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import { CREDENTIAL_EDIT_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants'; import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import type { EventBus } from 'n8n-design-system/utils'; import type { EventBus } from 'n8n-design-system/utils';
import type { SourceControlAggregatedFile } from '@/Interface';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
@ -11,13 +10,38 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import dateformat from 'dateformat'; import dateformat from 'dateformat';
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import type { BaseTextKey } from '@/plugins/i18n';
import { refDebounced } from '@vueuse/core';
import {
N8nHeading,
N8nText,
N8nLink,
N8nCheckbox,
N8nInput,
N8nIcon,
N8nButton,
N8nBadge,
N8nNotice,
N8nPopover,
N8nSelect,
N8nOption,
N8nInputLabel,
} from 'n8n-design-system';
import {
SOURCE_CONTROL_FILE_STATUS,
SOURCE_CONTROL_FILE_TYPE,
SOURCE_CONTROL_FILE_LOCATION,
type SourceControlledFileStatus,
type SourceControlAggregatedFile,
} from '@/types/sourceControl.types';
import { orderBy } from 'lodash-es';
const props = defineProps<{ const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlAggregatedFile[] }; data: { eventBus: EventBus; status: SourceControlAggregatedFile[] };
}>(); }>();
const defaultStagedFileTypes = ['tags', 'variables', 'credential'];
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const uiStore = useUIStore(); const uiStore = useUIStore();
const toast = useToast(); const toast = useToast();
@ -25,165 +49,178 @@ const i18n = useI18n();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const route = useRoute(); const route = useRoute();
const staged = ref<Record<string, boolean>>({}); type Changes = {
const files = ref<SourceControlAggregatedFile[]>( tags: SourceControlAggregatedFile[];
props.data.status.filter((file, index, self) => { variables: SourceControlAggregatedFile[];
// do not show remote workflows that are not yet created locally during push credentials: SourceControlAggregatedFile[];
if (file.location === 'remote' && file.type === 'workflow' && file.status === 'created') { workflows: SourceControlAggregatedFile[];
return false; currentWorkflow?: SourceControlAggregatedFile;
}
return self.findIndex((f) => f.id === file.id) === index;
}) || [],
);
const commitMessage = ref('');
const loading = ref(true);
const context = ref<'workflow' | 'workflows' | 'credentials' | ''>('');
const statusToBadgeThemeMap: Record<string, string> = {
created: 'success',
deleted: 'danger',
modified: 'warning',
renamed: 'warning',
}; };
const isSubmitDisabled = computed(() => { const classifyFilesByType = (
return !commitMessage.value || Object.values(staged.value).every((value) => !value); files: SourceControlAggregatedFile[],
}); currentWorkflowId?: string,
): Changes =>
const workflowId = computed(() => { files.reduce<Changes>(
if (context.value === 'workflow') { (acc, file) => {
return route.params.name as string; // do not show remote workflows that are not yet created locally during push
} if (
file.location === SOURCE_CONTROL_FILE_LOCATION.REMOTE &&
return ''; file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW &&
}); file.status === SOURCE_CONTROL_FILE_STATUS.CREATED
) {
const sortedFiles = computed(() => { return acc;
const statusPriority: Record<string, number> = {
modified: 1,
renamed: 2,
created: 3,
deleted: 4,
};
return [...files.value].sort((a, b) => {
if (context.value === 'workflow') {
if (a.id === workflowId.value) {
return -1;
} else if (b.id === workflowId.value) {
return 1;
} }
if (file.type === SOURCE_CONTROL_FILE_TYPE.VARIABLES) {
acc.variables.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.TAGS) {
acc.tags.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW && currentWorkflowId === file.id) {
acc.currentWorkflow = file;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW) {
acc.workflows.push(file);
return acc;
}
if (file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL) {
acc.credentials.push(file);
return acc;
}
return acc;
},
{ tags: [], variables: [], credentials: [], workflows: [], currentWorkflow: undefined },
);
const workflowId = computed(
() =>
([VIEWS.WORKFLOW].includes(route.name as VIEWS) && route.params.name?.toString()) || undefined,
);
const changes = computed(() => classifyFilesByType(props.data.status, workflowId.value));
const selectedChanges = ref<Set<string>>(new Set());
const toggleSelected = (id: string) => {
if (selectedChanges.value.has(id)) {
selectedChanges.value.delete(id);
} else {
selectedChanges.value.add(id);
}
};
const maybeSelectCurrentWorkflow = (workflow?: SourceControlAggregatedFile) =>
workflow && selectedChanges.value.add(workflow.id);
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
const filters = ref<{ status?: SourceControlledFileStatus }>({
status: undefined,
});
const statusFilterOptions: Array<{ label: string; value: SourceControlledFileStatus }> = [
{
label: 'New',
value: SOURCE_CONTROL_FILE_STATUS.CREATED,
},
{
label: 'Modified',
value: SOURCE_CONTROL_FILE_STATUS.MODIFIED,
},
{
label: 'Deleted',
value: SOURCE_CONTROL_FILE_STATUS.DELETED,
},
] as const;
const search = ref('');
const debouncedSearch = refDebounced(search, 250);
const filterCount = computed(() =>
Object.values(filters.value).reduce((acc, item) => (item ? acc + 1 : acc), 0),
);
const filteredWorkflows = computed(() => {
const searchQuery = debouncedSearch.value.toLocaleLowerCase();
return changes.value.workflows.filter((workflow) => {
if (!workflow.name.toLocaleLowerCase().includes(searchQuery)) {
return false;
} }
if (statusPriority[a.status] < statusPriority[b.status]) { if (filters.value.status && filters.value.status !== workflow.status) {
return -1; return false;
} else if (statusPriority[a.status] > statusPriority[b.status]) {
return 1;
} }
return (a.updatedAt ?? 0) < (b.updatedAt ?? 0) return true;
? 1
: (a.updatedAt ?? 0) > (b.updatedAt ?? 0)
? -1
: 0;
}); });
}); });
const selectAll = computed(() => { const statusPriority: Partial<Record<SourceControlledFileStatus, number>> = {
return files.value.every((file) => staged.value[file.file]); [SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 1,
}); [SOURCE_CONTROL_FILE_STATUS.RENAMED]: 2,
[SOURCE_CONTROL_FILE_STATUS.CREATED]: 3,
[SOURCE_CONTROL_FILE_STATUS.DELETED]: 4,
} as const;
const getPriorityByStatus = (status: SourceControlledFileStatus): number =>
statusPriority[status] ?? 0;
const workflowFiles = computed(() => { const sortedWorkflows = computed(() => {
return files.value.filter((file) => file.type === 'workflow'); const sorted = orderBy(
}); filteredWorkflows.value,
[
const stagedWorkflowFiles = computed(() => { // keep the current workflow at the top of the list
return workflowFiles.value.filter((workflow) => staged.value[workflow.file]); ({ id }) => id === changes.value.currentWorkflow?.id,
}); ({ status }) => getPriorityByStatus(status),
'updatedAt',
const selectAllIndeterminate = computed(() => { ],
return ( ['desc', 'asc', 'desc'],
stagedWorkflowFiles.value.length > 0 &&
stagedWorkflowFiles.value.length < workflowFiles.value.length
); );
return sorted;
}); });
onMounted(async () => { const commitMessage = ref('');
context.value = getContext(); const isSubmitDisabled = computed(() => {
try { if (!commitMessage.value.trim()) {
staged.value = getStagedFilesByContext(files.value); return true;
} catch (error) {
toast.showError(error, i18n.baseText('error'));
} finally {
loading.value = false;
} }
const toBePushed =
changes.value.credentials.length +
changes.value.tags.length +
changes.value.variables.length +
selectedChanges.value.size;
if (toBePushed <= 0) {
return true;
}
return false;
}); });
const selectAll = computed(
() =>
selectedChanges.value.size > 0 && selectedChanges.value.size === sortedWorkflows.value.length,
);
const selectAllIndeterminate = computed(
() => selectedChanges.value.size > 0 && selectedChanges.value.size < sortedWorkflows.value.length,
);
function onToggleSelectAll() { function onToggleSelectAll() {
if (selectAll.value) { if (selectAll.value) {
files.value.forEach((file) => { selectedChanges.value.clear();
if (!defaultStagedFileTypes.includes(file.type)) {
staged.value[file.file] = false;
}
});
} else { } else {
files.value.forEach((file) => { selectedChanges.value = new Set(changes.value.workflows.map((file) => file.id));
if (!defaultStagedFileTypes.includes(file.type)) {
staged.value[file.file] = true;
}
});
} }
} }
function getContext() {
if (route.fullPath.startsWith('/workflows')) {
return 'workflows';
} else if (
route.fullPath.startsWith('/credentials') ||
uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY].open
) {
return 'credentials';
} else if (route.fullPath.startsWith('/workflow/')) {
return 'workflow';
}
return '';
}
function getStagedFilesByContext(
filesByContext: SourceControlAggregatedFile[],
): Record<string, boolean> {
const stagedFiles = filesByContext.reduce(
(acc, file) => {
acc[file.file] = false;
return acc;
},
{} as Record<string, boolean>,
);
filesByContext.forEach((file) => {
if (defaultStagedFileTypes.includes(file.type)) {
stagedFiles[file.file] = true;
}
if (context.value === 'workflow') {
if (file.type === 'workflow' && file.id === workflowId.value) {
stagedFiles[file.file] = true;
}
}
});
return stagedFiles;
}
function setStagedStatus(file: SourceControlAggregatedFile, status: boolean) {
staged.value = {
...staged.value,
[file.file]: status,
};
}
function close() { function close() {
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY); uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
} }
@ -209,7 +246,10 @@ async function onCommitKeyDownEnter() {
} }
async function commitAndPush() { async function commitAndPush() {
const fileNames = files.value.filter((file) => staged.value[file.file]); const files = changes.value.tags
.concat(changes.value.variables)
.concat(changes.value.credentials)
.concat(changes.value.workflows.filter((file) => selectedChanges.value.has(file.id)));
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push')); loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push'));
close(); close();
@ -218,7 +258,7 @@ async function commitAndPush() {
await sourceControlStore.pushWorkfolder({ await sourceControlStore.pushWorkfolder({
force: true, force: true,
commitMessage: commitMessage.value, commitMessage: commitMessage.value,
fileNames, fileNames: files,
}); });
toast.showToast({ toast.showToast({
@ -233,159 +273,203 @@ async function commitAndPush() {
} }
} }
function getStatusText(file: SourceControlAggregatedFile): string { const getStatusText = (status: SourceControlledFileStatus) =>
if (file.status === 'deleted') { i18n.baseText(`settings.sourceControl.status.${status}` as BaseTextKey);
return i18n.baseText('settings.sourceControl.status.deleted'); const getStatusTheme = (status: SourceControlledFileStatus) => {
} const statusToBadgeThemeMap: Partial<
Record<SourceControlledFileStatus, 'success' | 'danger' | 'warning'>
if (file.status === 'created') { > = {
return i18n.baseText('settings.sourceControl.status.created'); [SOURCE_CONTROL_FILE_STATUS.CREATED]: 'success',
} [SOURCE_CONTROL_FILE_STATUS.DELETED]: 'danger',
[SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 'warning',
if (file.status === 'modified') { } as const;
return i18n.baseText('settings.sourceControl.status.modified'); return statusToBadgeThemeMap[status];
} };
return i18n.baseText('settings.sourceControl.status.renamed');
}
</script> </script>
<template> <template>
<Modal <Modal
width="812px" width="812px"
:title="i18n.baseText('settings.sourceControl.modals.push.title')"
:event-bus="data.eventBus" :event-bus="data.eventBus"
:name="SOURCE_CONTROL_PUSH_MODAL_KEY" :name="SOURCE_CONTROL_PUSH_MODAL_KEY"
max-height="80%" max-height="80%"
:custom-class="$style.sourceControlPush"
> >
<template #content> <template #header>
<div :class="$style.container"> <N8nHeading tag="h1" size="xlarge">
<div v-if="files.length > 0"> {{ i18n.baseText('settings.sourceControl.modals.push.title') }}
<div v-if="workflowFiles.length > 0"> </N8nHeading>
<n8n-text tag="div" class="mb-l"> <div class="mb-l mt-l">
{{ i18n.baseText('settings.sourceControl.modals.push.description') }} <N8nText tag="div">
<n8n-link :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')"> {{ i18n.baseText('settings.sourceControl.modals.push.description') }}
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }} <N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
</n8n-link> {{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</n8n-text> </N8nLink>
</N8nText>
<n8n-checkbox <N8nNotice v-if="!changes.workflows.length" class="mt-xs">
:class="$style.selectAll" <i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges">
:indeterminate="selectAllIndeterminate" <template #link>
:model-value="selectAll" <N8nLink size="small" :to="i18n.baseText('settings.sourceControl.docs.using.url')">
data-test-id="source-control-push-modal-toggle-all" {{ i18n.baseText('settings.sourceControl.modals.push.noWorkflowChanges.moreInfo') }}
@update:model-value="onToggleSelectAll" </N8nLink>
> </template>
<n8n-text bold tag="strong"> </i18n-t>
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }} </N8nNotice>
</n8n-text>
<n8n-text v-show="workflowFiles.length > 0" tag="strong">
({{ stagedWorkflowFiles.length }}/{{ workflowFiles.length }})
</n8n-text>
</n8n-checkbox>
<n8n-checkbox
v-for="file in sortedFiles"
:key="file.file"
:class="[
'scopedListItem',
$style.listItem,
{ [$style.hiddenListItem]: defaultStagedFileTypes.includes(file.type) },
]"
data-test-id="source-control-push-modal-file-checkbox"
:model-value="staged[file.file]"
@update:model-value="setStagedStatus(file, !staged[file.file])"
>
<span>
<n8n-text v-if="file.status === 'deleted'" color="text-light">
<span v-if="file.type === 'workflow'"> Deleted Workflow: </span>
<span v-if="file.type === 'credential'"> Deleted Credential: </span>
<strong>{{ file.name || file.id }}</strong>
</n8n-text>
<n8n-text v-else bold> {{ file.name }} </n8n-text>
<n8n-text
v-if="file.updatedAt"
tag="p"
class="mt-0"
color="text-light"
size="small"
>
{{ renderUpdatedAt(file) }}
</n8n-text>
</span>
<span>
<n8n-badge v-if="workflowId === file.id && file.type === 'workflow'" class="mr-2xs">
Current workflow
</n8n-badge>
<n8n-badge :theme="statusToBadgeThemeMap[file.status] || 'default'">
{{ getStatusText(file) }}
</n8n-badge>
</span>
</n8n-checkbox>
</div>
<n8n-notice v-else class="mt-0">
<i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges">
<template #link>
<n8n-link size="small" :to="i18n.baseText('settings.sourceControl.docs.using.url')">
{{
i18n.baseText('settings.sourceControl.modals.push.noWorkflowChanges.moreInfo')
}}
</n8n-link>
</template>
</i18n-t>
</n8n-notice>
<n8n-text bold tag="p" class="mt-l mb-2xs">
{{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }}
</n8n-text>
<n8n-input
v-model="commitMessage"
type="text"
:placeholder="
i18n.baseText('settings.sourceControl.modals.push.commitMessage.placeholder')
"
@keydown.enter="onCommitKeyDownEnter"
/>
</div>
<div v-else-if="!loading">
<n8n-notice class="mt-0 mb-0">
{{ i18n.baseText('settings.sourceControl.modals.push.everythingIsUpToDate') }}
</n8n-notice>
</div>
</div> </div>
<div :class="[$style.filers]">
<N8nCheckbox
:class="$style.selectAll"
:indeterminate="selectAllIndeterminate"
:model-value="selectAll"
data-test-id="source-control-push-modal-toggle-all"
@update:model-value="onToggleSelectAll"
>
<N8nText bold tag="strong">
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
</N8nText>
<N8nText tag="strong">
({{ selectedChanges.size }}/{{ sortedWorkflows.length }})
</N8nText>
</N8nCheckbox>
<N8nPopover trigger="click" width="304" style="align-self: normal">
<template #reference>
<N8nButton
icon="filter"
type="tertiary"
style="height: 100%"
:active="Boolean(filterCount)"
data-test-id="source-control-filter-dropdown"
>
<N8nBadge v-show="filterCount" theme="primary" class="mr-4xs">
{{ filterCount }}
</N8nBadge>
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
</N8nButton>
</template>
<N8nInputLabel
:label="i18n.baseText('workflows.filters.status')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
/>
<N8nSelect v-model="filters.status" data-test-id="source-control-status-filter" clearable>
<N8nOption
v-for="option in statusFilterOptions"
:key="option.label"
data-test-id="source-control-status-filter-option"
v-bind="option"
>
</N8nOption>
</N8nSelect>
</N8nPopover>
<N8nInput
v-model="search"
data-test-id="source-control-push-search"
:placeholder="i18n.baseText('workflows.search.placeholder')"
clearable
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
</div>
</template>
<template #content>
<RecycleScroller
:class="[$style.scroller]"
:items="sortedWorkflows"
:item-size="69"
key-field="id"
>
<template #default="{ item: file }">
<N8nCheckbox
:class="['scopedListItem', $style.listItem]"
data-test-id="source-control-push-modal-file-checkbox"
:model-value="selectedChanges.has(file.id)"
@update:model-value="toggleSelected(file.id)"
>
<span>
<N8nText v-if="file.status === SOURCE_CONTROL_FILE_STATUS.DELETED" color="text-light">
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW">
Deleted Workflow:
</span>
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL">
Deleted Credential:
</span>
<strong>{{ file.name || file.id }}</strong>
</N8nText>
<N8nText v-else bold> {{ file.name }} </N8nText>
<N8nText v-if="file.updatedAt" tag="p" class="mt-0" color="text-light" size="small">
{{ renderUpdatedAt(file) }}
</N8nText>
</span>
<span :class="[$style.badges]">
<N8nBadge
v-if="changes.currentWorkflow && file.id === changes.currentWorkflow.id"
class="mr-2xs"
>
Current workflow
</N8nBadge>
<N8nBadge :theme="getStatusTheme(file.status)">
{{ getStatusText(file.status) }}
</N8nBadge>
</span>
</N8nCheckbox>
</template>
</RecycleScroller>
</template> </template>
<template #footer> <template #footer>
<N8nText bold tag="p" class="mb-2xs">
{{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }}
</N8nText>
<N8nInput
v-model="commitMessage"
data-test-id="source-control-push-modal-commit"
:placeholder="i18n.baseText('settings.sourceControl.modals.push.commitMessage.placeholder')"
@keydown.enter="onCommitKeyDownEnter"
/>
<div :class="$style.footer"> <div :class="$style.footer">
<n8n-button type="tertiary" class="mr-2xs" @click="close"> <N8nButton type="tertiary" class="mr-2xs" @click="close">
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.cancel') }} {{ i18n.baseText('settings.sourceControl.modals.push.buttons.cancel') }}
</n8n-button> </N8nButton>
<n8n-button type="primary" :disabled="isSubmitDisabled" @click="commitAndPush"> <N8nButton
data-test-id="source-control-push-modal-submit"
type="primary"
:disabled="isSubmitDisabled"
@click="commitAndPush"
>
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }} {{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }}
</n8n-button> </N8nButton>
</div> </div>
</template> </template>
</Modal> </Modal>
</template> </template>
<style module lang="scss"> <style module lang="scss">
.container > * { .filers {
overflow-wrap: break-word; display: flex;
align-items: center;
gap: 8px;
} }
.actionButtons { .selectAll {
display: flex; flex-shrink: 0;
justify-content: flex-end; margin-bottom: 0;
align-items: center; }
.scroller {
height: 380px;
max-height: 100%;
} }
.listItem { .listItem {
display: flex;
width: 100%;
align-items: center; align-items: center;
margin: var(--spacing-2xs) 0 var(--spacing-2xs);
padding: var(--spacing-xs); padding: var(--spacing-xs);
cursor: pointer;
transition: border 0.3s ease; transition: border 0.3s ease;
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
border: var(--border-base); border: var(--border-base);
@ -394,37 +478,32 @@ function getStatusText(file: SourceControlAggregatedFile): string {
border-color: var(--color-foreground-dark); border-color: var(--color-foreground-dark);
} }
&:first-child { :global(.el-checkbox__label) {
margin-top: 0; display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
} }
&:last-child { :global(.el-checkbox__inner) {
margin-bottom: 0; transition: none;
}
&.hiddenListItem {
display: none !important;
} }
} }
.selectAll { .badges {
float: left; display: flex;
clear: both;
margin: 0 0 var(--spacing-2xs);
} }
.footer { .footer {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
margin-top: 20px;
} }
</style>
<style scoped lang="scss"> .sourceControlPush {
.scopedListItem :deep(.el-checkbox__label) { :global(.el-dialog__header) {
display: flex; padding-bottom: var(--spacing-xs);
width: 100%; }
justify-content: space-between;
align-items: center;
} }
</style> </style>

View file

@ -4,7 +4,7 @@ import { EnterpriseEditionFeature } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/root.store'; import { useRootStore } from '@/stores/root.store';
import * as vcApi from '@/api/sourceControl'; import * as vcApi from '@/api/sourceControl';
import type { SourceControlPreferences, SshKeyTypes } from '@/Interface'; import type { SourceControlPreferences, SshKeyTypes } from '@/types/sourceControl.types';
import type { TupleToUnion } from '@/utils/typeHelpers'; import type { TupleToUnion } from '@/utils/typeHelpers';
export const useSourceControlStore = defineStore('sourceControl', () => { export const useSourceControlStore = defineStore('sourceControl', () => {

View file

@ -0,0 +1,78 @@
import type { TupleToUnion } from '@/utils/typeHelpers';
export const SOURCE_CONTROL_FILE_STATUS = {
NEW: 'new',
MODIFIED: 'modified',
DELETED: 'deleted',
CREATED: 'created',
RENAMED: 'renamed',
CONFLICTED: 'conflicted',
IGNORED: 'ignored',
STAGED: 'staged',
UNKNOWN: 'unknown',
} as const;
export const SOURCE_CONTROL_FILE_LOCATION = {
LOCAL: 'local',
REMOTE: 'remote',
} as const;
export const SOURCE_CONTROL_FILE_TYPE = {
CREDENTIAL: 'credential',
WORKFLOW: 'workflow',
TAGS: 'tags',
VARIABLES: 'variables',
FILE: 'file',
} as const;
export type SourceControlledFileStatus =
(typeof SOURCE_CONTROL_FILE_STATUS)[keyof typeof SOURCE_CONTROL_FILE_STATUS];
export type SourceControlledFileLocation =
(typeof SOURCE_CONTROL_FILE_LOCATION)[keyof typeof SOURCE_CONTROL_FILE_LOCATION];
export type SourceControlledFileType =
(typeof SOURCE_CONTROL_FILE_TYPE)[keyof typeof SOURCE_CONTROL_FILE_TYPE];
export type SshKeyTypes = ['ed25519', 'rsa'];
export type SourceControlPreferences = {
connected: boolean;
repositoryUrl: string;
branchName: string;
branches: string[];
branchReadOnly: boolean;
branchColor: string;
publicKey?: string;
keyGeneratorType?: TupleToUnion<SshKeyTypes>;
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: SourceControlledFileLocation;
name: string;
status: SourceControlledFileStatus;
type: SourceControlledFileType;
updatedAt?: string;
}

View file

@ -10,7 +10,7 @@ import { useMessage } from '@/composables/useMessage';
import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useDocumentTitle } from '@/composables/useDocumentTitle';
import CopyInput from '@/components/CopyInput.vue'; import CopyInput from '@/components/CopyInput.vue';
import type { TupleToUnion } from '@/utils/typeHelpers'; import type { TupleToUnion } from '@/utils/typeHelpers';
import type { SshKeyTypes } from '@/Interface'; import type { SshKeyTypes } from '@/types/sourceControl.types';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const locale = useI18n(); const locale = useI18n();