mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-03 17:07:29 -08:00
fix(editor): Improvements to the commit modal (#12031)
This commit is contained in:
parent
60b3dccf93
commit
4fe1952e2f
|
@ -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;
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -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<{
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
78
packages/editor-ui/src/types/sourceControl.types.ts
Normal file
78
packages/editor-ui/src/types/sourceControl.types.ts
Normal 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;
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
Loading…
Reference in a new issue