feat: Add various source control improvements (#6533)

* feat: update source control notice wording

* feat: update source control paywall state

* fix: remove source control git repository ssh input hint

* feat: hide tags, variables, and credentials from push modal

* feat: add status colors and current workflow marking and sorting

* feat: add select all workflows to push modal

* fix: push everything besides current workflow with push workflow action

* feat: add source control pull modal

* feat: add updatedAt integration

* fix: add time to last updated

* fix: fix sorting, taking deleted into account

* fix: update 409 pull workflow test

* fix: add status priority sorting

* fix: fix linting issue
This commit is contained in:
Alex Grozav 2023-06-28 14:59:07 +03:00 committed by GitHub
parent 42721dba80
commit 68fdc20789
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 385 additions and 70 deletions

View file

@ -16,7 +16,10 @@ export default defineComponent({
theme: { theme: {
type: String, type: String,
default: 'default', default: 'default',
validator: (value: string) => ['default', 'primary', 'secondary', 'tertiary'].includes(value), validator: (value: string) =>
['default', 'success', 'warning', 'danger', 'primary', 'secondary', 'tertiary'].includes(
value,
),
}, },
size: { size: {
type: String, type: String,
@ -49,6 +52,27 @@ export default defineComponent({
border-color: var(--color-text-light); border-color: var(--color-text-light);
} }
.success {
composes: badge;
border-radius: var(--border-radius-base);
color: var(--color-success);
border-color: var(--color-success);
}
.warning {
composes: badge;
border-radius: var(--border-radius-base);
color: var(--color-warning);
border-color: var(--color-warning);
}
.danger {
composes: badge;
border-radius: var(--border-radius-base);
color: var(--color-danger);
border-color: var(--color-danger);
}
.primary { .primary {
composes: badge; composes: badge;
padding: var(--spacing-5xs) var(--spacing-3xs); padding: var(--spacing-5xs) var(--spacing-3xs);

View file

@ -1480,6 +1480,7 @@ export interface SourceControlAggregatedFile {
name: string; name: string;
status: string; status: string;
type: string; type: string;
updatedAt?: string;
} }
export declare namespace Cloud { export declare namespace Cloud {

View file

@ -133,6 +133,7 @@ import {
MAX_WORKFLOW_NAME_LENGTH, MAX_WORKFLOW_NAME_LENGTH,
MODAL_CONFIRM, MODAL_CONFIRM,
PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID,
SOURCE_CONTROL_PUSH_MODAL_KEY,
VIEWS, VIEWS,
WORKFLOW_MENU_ACTIONS, WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
@ -151,7 +152,7 @@ import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '@/Interface'; import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '@/Interface';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { useTitleChange, useToast, useMessage } from '@/composables'; import { useTitleChange, useToast, useMessage, useLoadingService } from '@/composables';
import type { MessageBoxInputData } from 'element-ui/types/message-box'; import type { MessageBoxInputData } from 'element-ui/types/message-box';
import { import {
useUIStore, useUIStore,
@ -161,6 +162,7 @@ import {
useTagsStore, useTagsStore,
useUsersStore, useUsersStore,
useUsageStore, useUsageStore,
useSourceControlStore,
} from '@/stores'; } from '@/stores';
import type { IPermissions } from '@/permissions'; import type { IPermissions } from '@/permissions';
import { getWorkflowPermissions } from '@/permissions'; import { getWorkflowPermissions } from '@/permissions';
@ -197,7 +199,10 @@ export default defineComponent({
}, },
}, },
setup() { setup() {
const loadingService = useLoadingService();
return { return {
loadingService,
...useTitleChange(), ...useTitleChange(),
...useToast(), ...useToast(),
...useMessage(), ...useMessage(),
@ -211,6 +216,7 @@ export default defineComponent({
tagsEditBus: createEventBus(), tagsEditBus: createEventBus(),
MAX_WORKFLOW_NAME_LENGTH, MAX_WORKFLOW_NAME_LENGTH,
tagsSaving: false, tagsSaving: false,
eventBus: createEventBus(),
EnterpriseEditionFeature, EnterpriseEditionFeature,
}; };
}, },
@ -224,6 +230,7 @@ export default defineComponent({
useWorkflowsStore, useWorkflowsStore,
useUsersStore, useUsersStore,
useCloudPlanStore, useCloudPlanStore,
useSourceControlStore,
), ),
currentUser(): IUser | null { currentUser(): IUser | null {
return this.usersStore.currentUser; return this.usersStore.currentUser;
@ -305,6 +312,15 @@ export default defineComponent({
); );
} }
actions.push({
id: WORKFLOW_MENU_ACTIONS.PUSH,
label: this.$locale.baseText('menuActions.push'),
disabled:
!this.sourceControlStore.isEnterpriseSourceControlEnabled ||
!this.onWorkflowPage ||
this.onExecutionsTab,
});
actions.push({ actions.push({
id: WORKFLOW_MENU_ACTIONS.SETTINGS, id: WORKFLOW_MENU_ACTIONS.SETTINGS,
label: this.$locale.baseText('generic.settings'), label: this.$locale.baseText('generic.settings'),
@ -514,6 +530,30 @@ export default defineComponent({
(this.$refs.importFile as HTMLInputElement).click(); (this.$refs.importFile as HTMLInputElement).click();
break; break;
} }
case WORKFLOW_MENU_ACTIONS.PUSH: {
this.loadingService.startLoading();
try {
await this.onSaveButtonClick();
const status = await this.sourceControlStore.getAggregatedStatus();
const workflowStatus = status.filter(
(s) =>
(s.id === this.currentWorkflowId && s.type === 'workflow') || s.type !== 'workflow',
);
this.uiStore.openModalWithData({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: { eventBus: this.eventBus, status: workflowStatus },
});
} catch (error) {
this.showError(error, this.$locale.baseText('error'));
} finally {
this.loadingService.stopLoading();
this.loadingService.setLoadingText(this.$locale.baseText('genericHelpers.loading'));
}
break;
}
case WORKFLOW_MENU_ACTIONS.SETTINGS: { case WORKFLOW_MENU_ACTIONS.SETTINGS: {
this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY); this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
break; break;

View file

@ -4,12 +4,16 @@ import { useRouter } from 'vue-router/composables';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import { useI18n, useLoadingService, useMessage, useToast } from '@/composables'; import { useI18n, useLoadingService, useMessage, useToast } from '@/composables';
import { useUIStore, useSourceControlStore } from '@/stores'; import { useUIStore, useSourceControlStore } from '@/stores';
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
const props = defineProps<{ const props = defineProps<{
isCollapsed: boolean; isCollapsed: boolean;
}>(); }>();
const responseStatuses = {
CONFLICT: 409,
};
const router = useRouter(); const router = useRouter();
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const uiStore = useUIStore(); const uiStore = useUIStore();
@ -47,28 +51,17 @@ async function pushWorkfolder() {
async function pullWorkfolder() { async function pullWorkfolder() {
loadingService.startLoading(); loadingService.startLoading();
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull')); loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
try { try {
await sourceControlStore.pullWorkfolder(false); await sourceControlStore.pullWorkfolder(false);
} catch (error) { } catch (error) {
const errorResponse = error.response; const errorResponse = error.response;
if (errorResponse?.status === 409) { if (errorResponse?.status === responseStatuses.CONFLICT) {
const confirm = await message.confirm( uiStore.openModalWithData({
i18n.baseText('settings.sourceControl.modals.pull.description'), name: SOURCE_CONTROL_PULL_MODAL_KEY,
i18n.baseText('settings.sourceControl.modals.pull.title'), data: { eventBus, status: errorResponse.data.data },
{ });
confirmButtonText: i18n.baseText('settings.sourceControl.modals.pull.buttons.save'),
cancelButtonText: i18n.baseText('settings.sourceControl.modals.pull.buttons.cancel'),
},
);
try {
if (confirm === 'confirm') {
await sourceControlStore.pullWorkfolder(true);
}
} catch (error) {
toast.showError(error, 'Error');
}
} else { } else {
toast.showError(error, 'Error'); toast.showError(error, 'Error');
} }

View file

@ -117,6 +117,12 @@
<SourceControlPushModal :modalName="modalName" :data="data" /> <SourceControlPushModal :modalName="modalName" :data="data" />
</template> </template>
</ModalRoot> </ModalRoot>
<ModalRoot :name="SOURCE_CONTROL_PULL_MODAL_KEY">
<template #default="{ modalName, data }">
<SourceControlPullModal :modalName="modalName" :data="data" />
</template>
</ModalRoot>
</div> </div>
</template> </template>
@ -146,6 +152,7 @@ import {
LOG_STREAM_MODAL_KEY, LOG_STREAM_MODAL_KEY,
ASK_AI_MODAL_KEY, ASK_AI_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
} from '@/constants'; } from '@/constants';
import AboutModal from './AboutModal.vue'; import AboutModal from './AboutModal.vue';
@ -172,6 +179,7 @@ import ImportCurlModal from './ImportCurlModal.vue';
import WorkflowShareModal from './WorkflowShareModal.ee.vue'; import WorkflowShareModal from './WorkflowShareModal.ee.vue';
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue'; import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue'; import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue';
export default defineComponent({ export default defineComponent({
name: 'Modals', name: 'Modals',
@ -200,6 +208,7 @@ export default defineComponent({
ImportCurlModal, ImportCurlModal,
EventDestinationSettingsModal, EventDestinationSettingsModal,
SourceControlPushModal, SourceControlPushModal,
SourceControlPullModal,
}, },
data: () => ({ data: () => ({
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
@ -225,6 +234,7 @@ export default defineComponent({
IMPORT_CURL_MODAL_KEY, IMPORT_CURL_MODAL_KEY,
LOG_STREAM_MODAL_KEY, LOG_STREAM_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
}), }),
}); });
</script> </script>

View file

@ -0,0 +1,89 @@
<script lang="ts" setup>
import Modal from './Modal.vue';
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
import type { PropType } from 'vue';
import type { EventBus } from 'n8n-design-system/utils';
import type { SourceControlStatus } from '@/Interface';
import { useI18n, useLoadingService, useToast } from '@/composables';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores';
import { useRoute } from 'vue-router/composables';
const props = defineProps({
data: {
type: Object as PropType<{ eventBus: EventBus; status: SourceControlStatus }>,
default: () => ({}),
},
});
const defaultStagedFileTypes = ['tags', 'variables', 'credential'];
const loadingService = useLoadingService();
const uiStore = useUIStore();
const toast = useToast();
const { i18n } = useI18n();
const sourceControlStore = useSourceControlStore();
const route = useRoute();
function close() {
uiStore.closeModal(SOURCE_CONTROL_PULL_MODAL_KEY);
}
async function pullWorkfolder() {
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.pull'));
close();
try {
await sourceControlStore.pullWorkfolder(true);
toast.showMessage({
message: i18n.baseText('settings.sourceControl.pull.success.description'),
title: i18n.baseText('settings.sourceControl.pull.success.title'),
type: 'success',
});
} catch (error) {
toast.showError(error, 'Error');
} finally {
loadingService.stopLoading();
}
}
</script>
<template>
<Modal
width="500px"
:title="i18n.baseText('settings.sourceControl.modals.pull.title')"
:eventBus="data.eventBus"
:name="SOURCE_CONTROL_PULL_MODAL_KEY"
>
<template #content>
<div :class="$style.container">
<n8n-text>
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }}
</n8n-text>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<n8n-button type="tertiary" class="mr-2xs" @click="close">
{{ i18n.baseText('settings.sourceControl.modals.pull.buttons.cancel') }}
</n8n-button>
<n8n-button type="primary" @click="pullWorkfolder">
{{ i18n.baseText('settings.sourceControl.modals.pull.buttons.save') }}
</n8n-button>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.container > * {
overflow-wrap: break-word;
}
.footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
</style>

View file

@ -9,6 +9,7 @@ import { useI18n, useLoadingService, useToast } from '@/composables';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores'; import { useUIStore } from '@/stores';
import { useRoute } from 'vue-router/composables'; import { useRoute } from 'vue-router/composables';
import dateformat from 'dateformat';
const props = defineProps({ const props = defineProps({
data: { data: {
@ -17,6 +18,8 @@ const props = defineProps({
}, },
}); });
const defaultStagedFileTypes = ['tags', 'variables', 'credential'];
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const uiStore = useUIStore(); const uiStore = useUIStore();
const toast = useToast(); const toast = useToast();
@ -31,10 +34,71 @@ const commitMessage = ref('');
const loading = ref(true); const loading = ref(true);
const context = ref<'workflow' | 'workflows' | 'credentials' | string>(''); const context = ref<'workflow' | 'workflows' | 'credentials' | string>('');
const statusToBadgeThemeMap = {
created: 'success',
deleted: 'danger',
modified: 'warning',
renamed: 'warning',
};
const isSubmitDisabled = computed(() => { const isSubmitDisabled = computed(() => {
return !commitMessage.value || Object.values(staged.value).every((value) => !value); return !commitMessage.value || Object.values(staged.value).every((value) => !value);
}); });
const workflowId = computed(() => {
if (context.value === 'workflow') {
return route.params.name as string;
}
return '';
});
const sortedFiles = computed(() => {
const statusPriority = {
deleted: 1,
modified: 2,
renamed: 3,
created: 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 (statusPriority[a.status] < statusPriority[b.status]) {
return -1;
} else if (statusPriority[a.status] > statusPriority[b.status]) {
return 1;
}
return a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0;
});
});
const selectAll = computed(() => {
return files.value.every((file) => staged.value[file.file]);
});
const workflowFiles = computed(() => {
return files.value.filter((file) => file.type === 'workflow');
});
const stagedWorkflowFiles = computed(() => {
return workflowFiles.value.filter((workflow) => staged.value[workflow.file]);
});
const selectAllIndeterminate = computed(() => {
return (
stagedWorkflowFiles.value.length > 0 &&
stagedWorkflowFiles.value.length < workflowFiles.value.length
);
});
onMounted(async () => { onMounted(async () => {
context.value = getContext(); context.value = getContext();
try { try {
@ -46,6 +110,22 @@ onMounted(async () => {
} }
}); });
function onToggleSelectAll() {
if (selectAll.value) {
files.value.forEach((file) => {
if (!defaultStagedFileTypes.includes(file.type)) {
staged.value[file.file] = false;
}
});
} else {
files.value.forEach((file) => {
if (!defaultStagedFileTypes.includes(file.type)) {
staged.value[file.file] = true;
}
});
}
}
function getContext() { function getContext() {
if (route.fullPath.startsWith('/workflows')) { if (route.fullPath.startsWith('/workflows')) {
return 'workflows'; return 'workflows';
@ -62,20 +142,24 @@ function getContext() {
} }
function getStagedFilesByContext(files: SourceControlAggregatedFile[]): Record<string, boolean> { function getStagedFilesByContext(files: SourceControlAggregatedFile[]): Record<string, boolean> {
const stagedFiles: SourceControlAggregatedFile[] = []; const stagedFiles = files.reduce((acc, file) => {
if (context.value === 'workflows') { acc[file.file] = false;
stagedFiles.push(...files.filter((file) => file.file.startsWith('workflows')));
} else if (context.value === 'credentials') {
stagedFiles.push(...files.filter((file) => file.file.startsWith('credentials')));
} else if (context.value === 'workflow') {
const workflowId = route.params.name as string;
stagedFiles.push(...files.filter((file) => file.type === 'workflow' && file.id === workflowId));
}
return stagedFiles.reduce<Record<string, boolean>>((acc, file) => {
acc[file.file] = true;
return acc; return acc;
}, {}); }, {});
files.forEach((file) => {
if (defaultStagedFileTypes.includes(file.type)) {
stagedFiles[file.file] = true;
}
if (context.value === 'workflow' && file.type === 'workflow' && file.id === workflowId.value) {
stagedFiles[file.file] = true;
} else if (context.value === 'workflows' && file.type === 'workflow') {
stagedFiles[file.file] = true;
}
});
return stagedFiles;
} }
function setStagedStatus(file: SourceControlAggregatedFile, status: boolean) { function setStagedStatus(file: SourceControlAggregatedFile, status: boolean) {
@ -89,6 +173,20 @@ function close() {
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY); uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
} }
function renderUpdatedAt(file: SourceControlAggregatedFile) {
const currentYear = new Date().getFullYear();
return i18n.baseText('settings.sourceControl.lastUpdated', {
interpolate: {
date: dateformat(
file.updatedAt,
`d mmm${file.updatedAt.startsWith(currentYear) ? '' : ', yyyy'}`,
),
time: dateformat(file.updatedAt, 'HH:MM'),
},
});
}
async function commitAndPush() { async function commitAndPush() {
const fileNames = files.value.filter((file) => staged.value[file.file]).map((file) => file.file); const fileNames = files.value.filter((file) => staged.value[file.file]).map((file) => file.file);
@ -135,12 +233,24 @@ async function commitAndPush() {
</n8n-link> </n8n-link>
</n8n-text> </n8n-text>
<div v-if="files.length > 0"> <div v-if="workflowFiles.length > 0">
<n8n-text bold tag="p" class="mt-l mb-2xs"> <div class="mt-l mb-2xs">
{{ i18n.baseText('settings.sourceControl.modals.push.filesToCommit') }} <n8n-checkbox
</n8n-text> :indeterminate="selectAllIndeterminate"
:value="selectAll"
@input="onToggleSelectAll"
>
<n8n-text bold tag="strong">
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
</n8n-text>
<n8n-text tag="strong" v-show="workflowFiles.length > 0">
({{ stagedWorkflowFiles.length }}/{{ workflowFiles.length }})
</n8n-text>
</n8n-checkbox>
</div>
<n8n-card <n8n-card
v-for="file in files" v-for="file in sortedFiles"
v-show="!defaultStagedFileTypes.includes(file.type)"
:key="file.file" :key="file.file"
:class="$style.listItem" :class="$style.listItem"
@click="setStagedStatus(file, !staged[file.file])" @click="setStagedStatus(file, !staged[file.file])"
@ -151,19 +261,34 @@ async function commitAndPush() {
:class="$style.listItemCheckbox" :class="$style.listItemCheckbox"
@input="setStagedStatus(file, !staged[file.file])" @input="setStagedStatus(file, !staged[file.file])"
/> />
<n8n-text bold> <div>
<span v-if="file.status === 'deleted'"> <n8n-text v-if="file.status === 'deleted'" color="text-light">
<span v-if="file.type === 'workflow'"> Workflow </span> <span v-if="file.type === 'workflow'"> Deleted Workflow: </span>
<span v-if="file.type === 'credential'"> Credential </span> <span v-if="file.type === 'credential'"> Deleted Credential: </span>
Id: {{ file.id }} <strong>{{ file.id }}</strong>
</span> </n8n-text>
<span v-else> <n8n-text bold v-else>
{{ file.name }} {{ file.name }}
</span> </n8n-text>
</n8n-text> <div v-if="file.updatedAt">
<n8n-badge :class="$style.listItemStatus"> <n8n-text color="text-light" size="small">
{{ file.status }} {{ renderUpdatedAt(file) }}
</n8n-badge> </n8n-text>
</div>
<div v-if="file.conflict">
<n8n-text color="danger" size="small">
{{ i18n.baseText('settings.sourceControl.modals.push.overrideVersionInGit') }}
</n8n-text>
</div>
</div>
<div :class="$style.listItemStatus">
<n8n-badge class="mr-2xs" v-if="workflowId === file.id && file.type === 'workflow'">
Current workflow
</n8n-badge>
<n8n-badge :theme="statusToBadgeThemeMap[file.status] || 'default'">
{{ file.status }}
</n8n-badge>
</div>
</div> </div>
</n8n-card> </n8n-card>
@ -228,22 +353,22 @@ async function commitAndPush() {
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
}
.listItemBody { .listItemBody {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
}
.listItemCheckbox { .listItemCheckbox {
display: inline-flex !important; display: inline-flex !important;
margin-bottom: 0 !important; margin-bottom: 0 !important;
margin-right: var(--spacing-2xs); margin-right: var(--spacing-2xs) !important;
} }
.listItemStatus { .listItemStatus {
margin-left: var(--spacing-2xs); margin-left: auto;
}
}
} }
.footer { .footer {

View file

@ -4,15 +4,16 @@ import userEvent from '@testing-library/user-event';
import { PiniaVuePlugin } from 'pinia'; import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { merge } from 'lodash-es'; import { merge } from 'lodash-es';
import { STORES } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY, STORES } from '@/constants';
import { i18nInstance } from '@/plugins/i18n'; import { i18nInstance } from '@/plugins/i18n';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue'; import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { useUsersStore, useSourceControlStore } from '@/stores'; import { useUsersStore, useSourceControlStore, useUIStore } from '@/stores';
let pinia: ReturnType<typeof createTestingPinia>; let pinia: ReturnType<typeof createTestingPinia>;
let sourceControlStore: ReturnType<typeof useSourceControlStore>; let sourceControlStore: ReturnType<typeof useSourceControlStore>;
let usersStore: ReturnType<typeof useUsersStore>; let usersStore: ReturnType<typeof useUsersStore>;
let uiStore: ReturnType<typeof useUIStore>;
const renderComponent = (renderOptions: Parameters<typeof render>[1] = {}) => { const renderComponent = (renderOptions: Parameters<typeof render>[1] = {}) => {
return render( return render(
@ -42,6 +43,7 @@ describe('MainSidebarSourceControl', () => {
}); });
sourceControlStore = useSourceControlStore(); sourceControlStore = useSourceControlStore();
uiStore = useUIStore();
usersStore = useUsersStore(); usersStore = useUsersStore();
}); });
@ -89,13 +91,25 @@ describe('MainSidebarSourceControl', () => {
}); });
it('should show confirm if pull response http status code is 409', async () => { it('should show confirm if pull response http status code is 409', async () => {
const status = {};
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockRejectedValueOnce({ vi.spyOn(sourceControlStore, 'pullWorkfolder').mockRejectedValueOnce({
response: { status: 409 }, response: { status: 409, data: { data: status } },
}); });
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
const { getAllByRole, getByRole } = renderComponent({ props: { isCollapsed: false } }); const { getAllByRole, getByRole } = renderComponent({ props: { isCollapsed: false } });
await userEvent.click(getAllByRole('button')[0]); await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => expect(getByRole('dialog')).toBeInTheDocument()); await waitFor(() =>
expect(openModalSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: SOURCE_CONTROL_PULL_MODAL_KEY,
data: expect.objectContaining({
status,
}),
}),
),
);
}); });
}); });
}); });

View file

@ -49,6 +49,7 @@ export const IMPORT_CURL_MODAL_KEY = 'importCurl';
export const LOG_STREAM_MODAL_KEY = 'settingsLogStream'; export const LOG_STREAM_MODAL_KEY = 'settingsLogStream';
export const SOURCE_CONTROL_PUSH_MODAL_KEY = 'sourceControlPush'; export const SOURCE_CONTROL_PUSH_MODAL_KEY = 'sourceControlPush';
export const SOURCE_CONTROL_PULL_MODAL_KEY = 'sourceControlPull';
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = { export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
UNINSTALL: 'uninstall', UNINSTALL: 'uninstall',
@ -429,6 +430,7 @@ export const enum WORKFLOW_MENU_ACTIONS {
DOWNLOAD = 'download', DOWNLOAD = 'download',
IMPORT_FROM_URL = 'import-from-url', IMPORT_FROM_URL = 'import-from-url',
IMPORT_FROM_FILE = 'import-from-file', IMPORT_FROM_FILE = 'import-from-file',
PUSH = 'push',
SETTINGS = 'settings', SETTINGS = 'settings',
DELETE = 'delete', DELETE = 'delete',
} }

View file

@ -630,6 +630,7 @@
"mainSidebar.executions": "All executions", "mainSidebar.executions": "All executions",
"menuActions.duplicate": "Duplicate", "menuActions.duplicate": "Duplicate",
"menuActions.download": "Download", "menuActions.download": "Download",
"menuActions.push": "Push to Git",
"menuActions.importFromUrl": "Import from URL...", "menuActions.importFromUrl": "Import from URL...",
"menuActions.importFromFile": "Import from File...", "menuActions.importFromFile": "Import from File...",
"menuActions.delete": "Delete", "menuActions.delete": "Delete",
@ -1328,10 +1329,11 @@
"settings.usageAndPlan.desktop.description": "Cloud plans allow you to collaborate with teammates. Plus you dont need to leave this app open all the time for your workflows to run.", "settings.usageAndPlan.desktop.description": "Cloud plans allow you to collaborate with teammates. Plus you dont need to leave this app open all the time for your workflows to run.",
"settings.sourceControl.title": "Source Control", "settings.sourceControl.title": "Source Control",
"settings.sourceControl.actionBox.title": "Available on Enterprise plan", "settings.sourceControl.actionBox.title": "Available on Enterprise plan",
"settings.sourceControl.actionBox.description": "Use Source Control to connect your instance to an external Git repository to backup and track changes made to your workflows, variables, and credentials. With Source Control you can also sync instances across multiple environments (development, production...).", "settings.sourceControl.actionBox.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository.",
"settings.sourceControl.actionBox.description.link": "More info",
"settings.sourceControl.actionBox.buttonText": "See plans", "settings.sourceControl.actionBox.buttonText": "See plans",
"settings.sourceControl.description": "Source Control allows you to connect your n8n instance to a Git branch of a repository. You can connect your branches to multiples n8n instances to create a multi environments setup. {link}", "settings.sourceControl.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository. {link}",
"settings.sourceControl.description.link": "Learn how to set up Source Control and Environments in n8n.", "settings.sourceControl.description.link": "More info",
"settings.sourceControl.gitConfig": "Git configuration", "settings.sourceControl.gitConfig": "Git configuration",
"settings.sourceControl.repoUrl": "Git repository URL (SSH)", "settings.sourceControl.repoUrl": "Git repository URL (SSH)",
"settings.sourceControl.repoUrlPlaceholder": "e.g. git@github.com:my-team/my-repository", "settings.sourceControl.repoUrlPlaceholder": "e.g. git@github.com:my-team/my-repository",
@ -1371,14 +1373,18 @@
"settings.sourceControl.modals.push.description.learnMore": "Learn more", "settings.sourceControl.modals.push.description.learnMore": "Learn more",
"settings.sourceControl.modals.push.description.learnMore.url": "https://docs.n8n.io/source-control/using/", "settings.sourceControl.modals.push.description.learnMore.url": "https://docs.n8n.io/source-control/using/",
"settings.sourceControl.modals.push.filesToCommit": "Files to commit", "settings.sourceControl.modals.push.filesToCommit": "Files to commit",
"settings.sourceControl.modals.push.workflowsToCommit": "Workflows to commit",
"settings.sourceControl.modals.push.everythingIsUpToDate": "Everything is up to date", "settings.sourceControl.modals.push.everythingIsUpToDate": "Everything is up to date",
"settings.sourceControl.modals.push.overrideVersionInGit": "This will override the version in Git",
"settings.sourceControl.modals.push.commitMessage": "Commit message", "settings.sourceControl.modals.push.commitMessage": "Commit message",
"settings.sourceControl.modals.push.commitMessage.placeholder": "e.g. My commit", "settings.sourceControl.modals.push.commitMessage.placeholder": "e.g. My commit",
"settings.sourceControl.modals.push.buttons.cancel": "Cancel", "settings.sourceControl.modals.push.buttons.cancel": "Cancel",
"settings.sourceControl.modals.push.buttons.save": "Commit and Push", "settings.sourceControl.modals.push.buttons.save": "Commit and Push",
"settings.sourceControl.modals.push.success.title": "Pushed successfully", "settings.sourceControl.modals.push.success.title": "Pushed successfully",
"settings.sourceControl.modals.push.success.description": "The files you selected were committed and pushed to the remote repository", "settings.sourceControl.modals.push.success.description": "The files you selected were committed and pushed to the remote repository",
"settings.sourceControl.modals.pull.title": "Override local changes", "settings.sourceControl.pull.success.title": "Pulled successfully",
"settings.sourceControl.pull.success.description": "Make sure you fill out the details of any new credentials or variables",
"settings.sourceControl.modals.pull.title": "Override local changes?",
"settings.sourceControl.modals.pull.description": "Some remote changes are going to override some of your local changes. Are you sure you want to continue?", "settings.sourceControl.modals.pull.description": "Some remote changes are going to override some of your local changes. Are you sure you want to continue?",
"settings.sourceControl.modals.pull.buttons.cancel": "@:_reusableBaseText.cancel", "settings.sourceControl.modals.pull.buttons.cancel": "@:_reusableBaseText.cancel",
"settings.sourceControl.modals.pull.buttons.save": "Pull and override", "settings.sourceControl.modals.pull.buttons.save": "Pull and override",
@ -1398,6 +1404,7 @@
"settings.sourceControl.toast.disconnected.error": "Error disconnecting from Git", "settings.sourceControl.toast.disconnected.error": "Error disconnecting from Git",
"settings.sourceControl.loading.pull": "Pulling from remote", "settings.sourceControl.loading.pull": "Pulling from remote",
"settings.sourceControl.loading.push": "Pushing to remote", "settings.sourceControl.loading.push": "Pushing to remote",
"settings.sourceControl.lastUpdated": "Last updated {date} at {time}",
"settings.sourceControl.saved.title": "Settings successfully saved", "settings.sourceControl.saved.title": "Settings successfully saved",
"settings.sourceControl.refreshBranches.tooltip": "Reload branches list", "settings.sourceControl.refreshBranches.tooltip": "Reload branches list",
"settings.sourceControl.refreshBranches.success": "Branches successfully refreshed", "settings.sourceControl.refreshBranches.success": "Branches successfully refreshed",

View file

@ -31,6 +31,7 @@ import {
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
} from '@/constants'; } from '@/constants';
import type { import type {
CurlToJSONResponse, CurlToJSONResponse,
@ -136,6 +137,9 @@ export const useUIStore = defineStore(STORES.UI, {
[SOURCE_CONTROL_PUSH_MODAL_KEY]: { [SOURCE_CONTROL_PUSH_MODAL_KEY]: {
open: false, open: false,
}, },
[SOURCE_CONTROL_PULL_MODAL_KEY]: {
open: false,
},
}, },
modalStack: [], modalStack: [],
sidebarMenuCollapsed: true, sidebarMenuCollapsed: true,

View file

@ -379,6 +379,12 @@ const refreshBranches = async () => {
<template #heading> <template #heading>
<span>{{ locale.baseText('settings.sourceControl.actionBox.title') }}</span> <span>{{ locale.baseText('settings.sourceControl.actionBox.title') }}</span>
</template> </template>
<template #description>
{{ locale.baseText('settings.sourceControl.actionBox.description') }}
<a :href="locale.baseText('settings.sourceControl.docs.url')" target="_blank">
{{ locale.baseText('settings.sourceControl.actionBox.description.link') }}
</a>
</template>
</n8n-action-box> </n8n-action-box>
</div> </div>
</template> </template>