feat(editor): Add Support for Granular Push of Credentials and Workflows (#16439)

This commit is contained in:
Raúl Gómez Morales
2025-06-18 09:42:52 +02:00
committed by GitHub
parent 006f22f5c2
commit 49b9439ec0
2 changed files with 385 additions and 182 deletions

View File

@@ -182,7 +182,7 @@ describe('SourceControlPushModal', () => {
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked(); expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
}); });
it('should push non workflow entities', async () => { it('should push all entities besides workflows and credentials', async () => {
const status: SourceControlledFile[] = [ const status: SourceControlledFile[] = [
{ {
id: 'gTbbBkkYTnNyX1jD', id: 'gTbbBkkYTnNyX1jD',
@@ -240,7 +240,6 @@ describe('SourceControlPushModal', () => {
const submitButton = getByTestId('source-control-push-modal-submit'); const submitButton = getByTestId('source-control-push-modal-submit');
const commitMessage = 'commit message'; const commitMessage = 'commit message';
expect(submitButton).toBeDisabled(); expect(submitButton).toBeDisabled();
expect(getByRole('alert').textContent).toContain('Credentials: 1 added.');
expect(getByRole('alert').textContent).toContain('Variables: at least one new or modified.'); expect(getByRole('alert').textContent).toContain('Variables: at least one new or modified.');
expect(getByRole('alert').textContent).toContain('Tags: at least one new or modified.'); expect(getByRole('alert').textContent).toContain('Tags: at least one new or modified.');
expect(getByRole('alert').textContent).toContain('Folders: at least one new or modified.'); expect(getByRole('alert').textContent).toContain('Folders: at least one new or modified.');
@@ -253,7 +252,7 @@ describe('SourceControlPushModal', () => {
expect(sourceControlStore.pushWorkfolder).toHaveBeenCalledWith( expect(sourceControlStore.pushWorkfolder).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
commitMessage, commitMessage,
fileNames: expect.arrayContaining(status), fileNames: expect.arrayContaining(status.filter((file) => file.type !== 'credential')),
force: true, force: true,
}), }),
); );
@@ -308,6 +307,64 @@ describe('SourceControlPushModal', () => {
expect(submitButton).not.toBeDisabled(); expect(submitButton).not.toBeDisabled();
}); });
it('should show credentials in a different tab', async () => {
// source-control-push-modal-tab
const status: SourceControlledFile[] = [
{
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',
},
{
id: 'JIGKevgZagmJAnM6',
name: 'My credential',
type: 'credential',
status: 'created',
location: 'local',
conflict: false,
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
updatedAt: '2024-09-20T14:42:51.968Z',
},
];
const { getAllByTestId } = renderModal({
props: {
data: {
eventBus,
status,
},
},
});
const workflows = getAllByTestId('source-control-push-modal-file-checkbox');
expect(workflows).toHaveLength(2);
const tab = getAllByTestId('source-control-push-modal-tab').filter(({ textContent }) =>
textContent?.includes('Credentials'),
);
await userEvent.click(tab[0]);
const credentials = getAllByTestId('source-control-push-modal-file-checkbox');
expect(credentials).toHaveLength(1);
expect(within(credentials[0]).getByText('My credential')).toBeInTheDocument();
});
describe('filters', () => { describe('filters', () => {
it('should filter by name', async () => { it('should filter by name', async () => {
const status: SourceControlledFile[] = [ const status: SourceControlledFile[] = [

View File

@@ -1,44 +1,40 @@
<script lang="ts" setup> <script lang="ts" setup>
import Modal from './Modal.vue';
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import { computed, onMounted, ref, toRaw, watch } from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@n8n/i18n';
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useRoute } from 'vue-router'; import { getPushPriorityByStatus, getStatusText, getStatusTheme } from '@/utils/sourceControlUtils';
import dateformat from 'dateformat';
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import { refDebounced } from '@vueuse/core';
import {
N8nHeading,
N8nText,
N8nLink,
N8nCheckbox,
N8nInput,
N8nIcon,
N8nButton,
N8nBadge,
N8nNotice,
N8nPopover,
N8nSelect,
N8nOption,
N8nInputLabel,
N8nInfoTip,
} from '@n8n/design-system';
import { import {
type SourceControlledFile, type SourceControlledFile,
SOURCE_CONTROL_FILE_LOCATION,
SOURCE_CONTROL_FILE_STATUS, SOURCE_CONTROL_FILE_STATUS,
SOURCE_CONTROL_FILE_TYPE, SOURCE_CONTROL_FILE_TYPE,
SOURCE_CONTROL_FILE_LOCATION,
} from '@n8n/api-types'; } from '@n8n/api-types';
import groupBy from 'lodash/groupBy'; import {
N8nBadge,
N8nButton,
N8nHeading,
N8nIcon,
N8nInput,
N8nInputLabel,
N8nNotice,
N8nOption,
N8nPopover,
N8nSelect,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus';
import { refDebounced } from '@vueuse/core';
import dateformat from 'dateformat';
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
import { getStatusText, getStatusTheme, getPushPriorityByStatus } from '@/utils/sourceControlUtils'; import { computed, onMounted, reactive, ref, toRaw, watch } from 'vue';
import { useTelemetry } from '@/composables/useTelemetry'; import { useRoute } from 'vue-router';
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import Modal from './Modal.vue';
const props = defineProps<{ const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlledFile[] }; data: { eventBus: EventBus; status: SourceControlledFile[] };
@@ -60,8 +56,8 @@ type SourceControlledFileStatus = SourceControlledFile['status'];
type Changes = { type Changes = {
tags: SourceControlledFile[]; tags: SourceControlledFile[];
variables: SourceControlledFile[]; variables: SourceControlledFile[];
credentials: SourceControlledFile[]; credential: SourceControlledFile[];
workflows: SourceControlledFile[]; workflow: SourceControlledFile[];
currentWorkflow?: SourceControlledFile; currentWorkflow?: SourceControlledFile;
folders: SourceControlledFile[]; folders: SourceControlledFile[];
}; };
@@ -98,12 +94,12 @@ const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?:
} }
if (file.type === SOURCE_CONTROL_FILE_TYPE.workflow) { if (file.type === SOURCE_CONTROL_FILE_TYPE.workflow) {
acc.workflows.push(file); acc.workflow.push(file);
return acc; return acc;
} }
if (file.type === SOURCE_CONTROL_FILE_TYPE.credential) { if (file.type === SOURCE_CONTROL_FILE_TYPE.credential) {
acc.credentials.push(file); acc.credential.push(file);
return acc; return acc;
} }
@@ -112,8 +108,8 @@ const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?:
{ {
tags: [], tags: [],
variables: [], variables: [],
credentials: [], credential: [],
workflows: [], workflow: [],
folders: [], folders: [],
currentWorkflow: undefined, currentWorkflow: undefined,
}, },
@@ -122,19 +118,6 @@ const classifyFilesByType = (files: SourceControlledFile[], currentWorkflowId?:
const userNotices = computed(() => { const userNotices = computed(() => {
const messages: Array<{ title: string; content: string }> = []; const messages: Array<{ title: string; content: string }> = [];
if (changes.value.credentials.length) {
const { created, deleted, modified } = groupBy(changes.value.credentials, 'status');
messages.push({
title: 'Credentials',
content: concatenateWithAnd([
...(created?.length ? [`${created.length} added`] : []),
...(deleted?.length ? [`${deleted.length} deleted`] : []),
...(modified?.length ? [`${modified.length} changed`] : []),
]),
});
}
if (changes.value.variables.length) { if (changes.value.variables.length) {
messages.push({ messages.push({
title: 'Variables', title: 'Variables',
@@ -165,23 +148,20 @@ const workflowId = computed(
const changes = computed(() => classifyFilesByType(props.data.status, workflowId.value)); const changes = computed(() => classifyFilesByType(props.data.status, workflowId.value));
const selectedChanges = ref<Set<string>>(new Set()); const selectedWorkflows = reactive<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?: SourceControlledFile) => const maybeSelectCurrentWorkflow = (workflow?: SourceControlledFile) =>
workflow && selectedChanges.value.add(workflow.id); workflow && selectedWorkflows.add(workflow.id);
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow)); onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
const filters = ref<{ status?: SourceControlledFileStatus }>({}); const filters = ref<{ status?: SourceControlledFileStatus }>({});
const filtersApplied = computed(() => Boolean(Object.keys(filters.value).length)); const filtersApplied = computed(
() => Boolean(search.value) || Boolean(Object.keys(filters.value).length),
);
const resetFilters = () => { const resetFilters = () => {
filters.value = {}; filters.value = {};
search.value = '';
}; };
const statusFilterOptions: Array<{ label: string; value: SourceControlledFileStatus }> = [ const statusFilterOptions: Array<{ label: string; value: SourceControlledFileStatus }> = [
@@ -209,7 +189,7 @@ const filterCount = computed(() =>
const filteredWorkflows = computed(() => { const filteredWorkflows = computed(() => {
const searchQuery = debouncedSearch.value.toLocaleLowerCase(); const searchQuery = debouncedSearch.value.toLocaleLowerCase();
return changes.value.workflows.filter((workflow) => { return changes.value.workflow.filter((workflow) => {
if (!workflow.name.toLocaleLowerCase().includes(searchQuery)) { if (!workflow.name.toLocaleLowerCase().includes(searchQuery)) {
return false; return false;
} }
@@ -231,6 +211,28 @@ const sortedWorkflows = computed(() =>
), ),
); );
const selectedCredentials = reactive<Set<string>>(new Set());
const filteredCredentials = computed(() => {
const searchQuery = debouncedSearch.value.toLocaleLowerCase();
return changes.value.credential.filter((credential) => {
if (!credential.name.toLocaleLowerCase().includes(searchQuery)) {
return false;
}
return !(filters.value.status && filters.value.status !== credential.status);
});
});
const sortedCredentials = computed(() =>
orderBy(
filteredCredentials.value,
[({ status }) => getPushPriorityByStatus(status), 'updatedAt'],
['asc', 'desc'],
),
);
const commitMessage = ref(''); const commitMessage = ref('');
const isSubmitDisabled = computed(() => { const isSubmitDisabled = computed(() => {
if (!commitMessage.value.trim()) { if (!commitMessage.value.trim()) {
@@ -238,47 +240,43 @@ const isSubmitDisabled = computed(() => {
} }
const toBePushed = const toBePushed =
changes.value.credentials.length + selectedCredentials.size +
changes.value.tags.length + changes.value.tags.length +
changes.value.variables.length + changes.value.variables.length +
changes.value.folders.length + changes.value.folders.length +
selectedChanges.value.size; selectedWorkflows.size;
return toBePushed <= 0; return toBePushed <= 0;
}); });
const sortedWorkflowsSet = computed(() => new Set(sortedWorkflows.value.map(({ id }) => id)));
const selectAll = computed(() => {
if (!selectedChanges.value.size) {
return false;
}
const notSelectedVisibleItems = toRaw(sortedWorkflowsSet.value).difference(selectedChanges.value);
return !Boolean(notSelectedVisibleItems.size);
});
const selectAllIndeterminate = computed(() => { const selectAllIndeterminate = computed(() => {
if (!selectedChanges.value.size) { if (!activeSelection.value.size) {
return false; return false;
} }
const selectedVisibleItems = toRaw(selectedChanges.value).intersection(sortedWorkflowsSet.value); const selectedVisibleItems = toRaw(activeSelection.value).intersection(
new Set(activeDataSourceFiltered.value.map(({ id }) => id)),
);
if (selectedVisibleItems.size === 0) { if (selectedVisibleItems.size === 0) {
return false; return false;
} }
return !selectAll.value; return !allVisibleItemsSelected.value;
}); });
const selectedCount = computed(() => selectedWorkflows.size + selectedCredentials.size);
function onToggleSelectAll() { function onToggleSelectAll() {
const selected = toRaw(selectedChanges.value); if (allVisibleItemsSelected.value) {
if (selectAll.value) { const diff = toRaw(activeSelection.value).difference(
selectedChanges.value = selected.difference(sortedWorkflowsSet.value); new Set(activeDataSourceFiltered.value.map(({ id }) => id)),
);
activeSelection.value.clear();
diff.forEach((id) => activeSelection.value.add(id));
} else { } else {
selectedChanges.value = selected.union(sortedWorkflowsSet.value); activeDataSourceFiltered.value.forEach((file) => activeSelection.value.add(file.id));
} }
} }
@@ -309,20 +307,20 @@ async function onCommitKeyDownEnter() {
const successNotificationMessage = () => { const successNotificationMessage = () => {
const messages: string[] = []; const messages: string[] = [];
if (selectedChanges.value.size) { if (selectedWorkflows.size) {
messages.push( messages.push(
i18n.baseText('generic.workflow', { i18n.baseText('generic.workflow', {
adjustToNumber: selectedChanges.value.size, adjustToNumber: selectedWorkflows.size,
interpolate: { count: selectedChanges.value.size }, interpolate: { count: selectedWorkflows.size },
}), }),
); );
} }
if (changes.value.credentials.length) { if (selectedCredentials.size) {
messages.push( messages.push(
i18n.baseText('generic.credential', { i18n.baseText('generic.credential', {
adjustToNumber: changes.value.credentials.length, adjustToNumber: selectedCredentials.size,
interpolate: { count: changes.value.credentials.length }, interpolate: { count: selectedCredentials.size },
}), }),
); );
} }
@@ -348,9 +346,9 @@ const successNotificationMessage = () => {
async function commitAndPush() { async function commitAndPush() {
const files = changes.value.tags const files = changes.value.tags
.concat(changes.value.variables) .concat(changes.value.variables)
.concat(changes.value.credentials) .concat(changes.value.credential.filter((file) => selectedCredentials.has(file.id)))
.concat(changes.value.folders) .concat(changes.value.folders)
.concat(changes.value.workflows.filter((file) => selectedChanges.value.has(file.id))); .concat(changes.value.workflow.filter((file) => selectedWorkflows.has(file.id)));
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push')); loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push'));
close(); close();
@@ -373,7 +371,7 @@ async function commitAndPush() {
} }
} }
const modalHeight = computed(() => (changes.value.workflows.length ? 'min(80vh, 850px)' : 'auto')); const modalHeight = computed(() => (changes.value.workflow.length ? 'min(80vh, 850px)' : 'auto'));
watch( watch(
() => filters.value.status, () => filters.value.status,
@@ -384,6 +382,94 @@ watch(
watch(refDebounced(search, 500), (term) => { watch(refDebounced(search, 500), (term) => {
telemetry.track('User searched workflows in commit modal', { search: term }); telemetry.track('User searched workflows in commit modal', { search: term });
}); });
const activeTab = ref<
typeof SOURCE_CONTROL_FILE_TYPE.workflow | typeof SOURCE_CONTROL_FILE_TYPE.credential
>(SOURCE_CONTROL_FILE_TYPE.workflow);
const allVisibleItemsSelected = computed(() => {
if (!activeSelection.value.size) {
return false;
}
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
const workflowsSet = new Set(sortedWorkflows.value.map(({ id }) => id));
const notSelectedVisibleItems = workflowsSet.difference(toRaw(activeSelection.value));
return !Boolean(notSelectedVisibleItems.size);
}
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.credential) {
const credentialsSet = new Set(sortedCredentials.value.map(({ id }) => id));
const notSelectedVisibleItems = credentialsSet.difference(toRaw(activeSelection.value));
return !Boolean(notSelectedVisibleItems.size);
}
return false;
});
function toggleSelected(id: string) {
if (activeSelection.value.has(id)) {
activeSelection.value.delete(id);
} else {
activeSelection.value.add(id);
}
}
const activeDataSource = computed(() => {
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
return changes.value.workflow;
}
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.credential) {
return changes.value.credential;
}
return [];
});
const activeDataSourceFiltered = computed(() => {
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
return sortedWorkflows.value;
}
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.credential) {
return sortedCredentials.value;
}
return [];
});
const activeSelection = computed(() => {
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
return selectedWorkflows;
}
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.credential) {
return selectedCredentials;
}
return new Set<string>();
});
const tabs = computed(() => {
return [
{
label: 'Workflows',
value: SOURCE_CONTROL_FILE_TYPE.workflow,
selected: selectedWorkflows.size,
total: changes.value.workflow.length,
},
{
label: 'Credentials',
value: SOURCE_CONTROL_FILE_TYPE.credential,
selected: selectedCredentials.size,
total: changes.value.credential.length,
},
];
});
const filtersActiveText = computed(() => {
if (activeTab.value === SOURCE_CONTROL_FILE_TYPE.workflow) {
return i18n.baseText('workflows.filters.active');
}
return i18n.baseText('credentials.filters.active');
});
</script> </script>
<template> <template>
@@ -399,7 +485,7 @@ watch(refDebounced(search, 500), (term) => {
{{ i18n.baseText('settings.sourceControl.modals.push.title') }} {{ i18n.baseText('settings.sourceControl.modals.push.title') }}
</N8nHeading> </N8nHeading>
<div v-if="changes.workflows.length" :class="[$style.filtersRow]" class="mt-l"> <div v-if="changes.workflow.length" :class="[$style.filtersRow]" class="mt-l">
<div :class="[$style.filters]"> <div :class="[$style.filters]">
<N8nInput <N8nInput
v-model="search" v-model="search"
@@ -448,106 +534,132 @@ watch(refDebounced(search, 500), (term) => {
</N8nSelect> </N8nSelect>
</N8nPopover> </N8nPopover>
</div> </div>
<div>
<N8nText bold color="text-base" size="small">
{{ selectedChanges.size }} of {{ changes.workflows.length }}
</N8nText>
<N8nText color="text-base" size="small"> workflows selected</N8nText>
</div>
</div> </div>
</template> </template>
<template #content> <template #content>
<div v-if="changes.workflows.length" :class="[$style.table]"> <div style="display: flex; height: 100%">
<div :class="[$style.tableHeader]"> <div :class="$style.tabs">
<N8nCheckbox <template v-for="tab in tabs" :key="tab.value">
:class="$style.selectAll" <button
:indeterminate="selectAllIndeterminate" type="button"
:model-value="selectAll" :class="[$style.tab, { [$style.tabActive]: activeTab === tab.value }]"
data-test-id="source-control-push-modal-toggle-all" data-test-id="source-control-push-modal-tab"
@update:model-value="onToggleSelectAll" @click="activeTab = tab.value"
> >
<N8nText> Title </N8nText> <div>{{ tab.label }}</div>
</N8nCheckbox> <N8nText tag="div" color="text-light">
</div> {{ tab.selected }} / {{ tab.total }} selected</N8nText
<div style="flex: 1; overflow: hidden">
<N8nInfoTip v-if="filtersApplied && !sortedWorkflows.length" :bold="false">
{{ i18n.baseText('workflows.filters.active') }}
<N8nLink size="small" data-test-id="source-control-filters-reset" @click="resetFilters">
{{ i18n.baseText('workflows.filters.active.reset') }}
</N8nLink>
</N8nInfoTip>
<DynamicScroller
v-if="sortedWorkflows.length"
:class="[$style.scroller]"
:items="sortedWorkflows"
:min-item-size="58"
item-class="scrollerItem"
>
<template #default="{ item: file, active, index }">
<DynamicScrollerItem
:item="file"
:active="active"
:size-dependencies="[file.name, file.id]"
:data-index="index"
> >
<N8nCheckbox </button>
:class="[$style.listItem]" </template>
data-test-id="source-control-push-modal-file-checkbox" </div>
:model-value="selectedChanges.has(file.id)" <div style="flex: 1">
@update:model-value="toggleSelected(file.id)" <div :class="[$style.table]">
<div :class="[$style.tableHeader]">
<N8nCheckbox
:class="$style.selectAll"
:indeterminate="selectAllIndeterminate"
:model-value="allVisibleItemsSelected"
data-test-id="source-control-push-modal-toggle-all"
@update:model-value="onToggleSelectAll"
>
<N8nText> Title </N8nText>
</N8nCheckbox>
</div>
<div style="flex: 1; overflow: hidden">
<N8nInfoTip
v-if="filtersApplied && activeDataSource.length && !activeDataSourceFiltered.length"
class="p-xs"
:bold="false"
>
{{ filtersActiveText }}
<N8nLink
size="small"
data-test-id="source-control-filters-reset"
@click="resetFilters"
> >
<span> {{ i18n.baseText('workflows.filters.active.reset') }}
<N8nText </N8nLink>
v-if="file.status === SOURCE_CONTROL_FILE_STATUS.deleted" </N8nInfoTip>
color="text-light" <DynamicScroller
v-if="activeDataSourceFiltered.length"
:class="[$style.scroller]"
:items="activeDataSourceFiltered"
:min-item-size="57"
item-class="scrollerItem"
>
<template #default="{ item: file, active, index }">
<DynamicScrollerItem
:item="file"
:active="active"
:size-dependencies="[file.name, file.id]"
:data-index="index"
>
<N8nCheckbox
:class="[$style.listItem]"
data-test-id="source-control-push-modal-file-checkbox"
:model-value="activeSelection.has(file.id)"
@update:model-value="toggleSelected(file.id)"
> >
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"> <span>
Deleted Workflow: <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>
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.folders">
Deleted Folders:
</span>
<strong>{{ file.name || file.id }}</strong>
</N8nText>
<N8nText
v-else
tag="div"
bold
color="text-dark"
:class="[$style.listItemName]"
>
{{ file.name }}
</N8nText>
<N8nText
v-if="file.updatedAt"
tag="p"
class="mt-0"
color="text-light"
size="small"
>
{{ renderUpdatedAt(file) }}
</N8nText>
</span> </span>
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.credential"> <span :class="[$style.badges]">
Deleted Credential: <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> </span>
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.folders"> </N8nCheckbox>
Deleted Folders: </DynamicScrollerItem>
</span> </template>
<strong>{{ file.name || file.id }}</strong> </DynamicScroller>
</N8nText> </div>
<N8nText v-else tag="div" bold color="text-dark" :class="[$style.listItemName]"> </div>
{{ 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>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</div> </div>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<N8nNotice v-if="userNotices.length" :compact="false" class="mt-0"> <N8nNotice v-if="userNotices.length" :compact="false" class="mt-0">
<N8nText bold size="medium">Changes to credentials, variables, tags and folders </N8nText> <N8nText bold size="medium">Changes to variables, tags and folders </N8nText>
<br /> <br />
<template v-for="{ title, content } in userNotices" :key="title"> <template v-for="{ title, content } in userNotices" :key="title">
<N8nText bold size="small">{{ title }}</N8nText> <N8nText bold size="small">{{ title }}</N8nText>
@@ -577,7 +689,7 @@ watch(refDebounced(search, 500), (term) => {
@click="commitAndPush" @click="commitAndPush"
> >
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }} {{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }}
{{ selectedChanges.size ? `(${selectedChanges.size})` : undefined }} {{ selectedCount ? `(${selectedCount})` : undefined }}
</N8nButton> </N8nButton>
</div> </div>
</template> </template>
@@ -673,11 +785,45 @@ watch(refDebounced(search, 500), (term) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: var(--border-base); border: var(--border-base);
border-radius: 8px; border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
} }
.tableHeader { .tableHeader {
border-bottom: var(--border-base); border-bottom: var(--border-base);
padding: 10px 16px; padding: 10px 16px;
} }
.tabs {
display: flex;
flex-direction: column;
gap: 4px;
width: 165px;
padding: var(--spacing-2xs);
border: var(--border-base);
border-right: 0;
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.tab {
color: var(--color-text-base);
background-color: transparent;
border: 1px solid transparent;
padding: var(--spacing-2xs);
cursor: pointer;
border-radius: 4px;
text-align: left;
display: flex;
flex-direction: column;
gap: 2px;
&:hover {
border-color: var(--color-background-base);
}
}
.tabActive {
background-color: var(--color-background-base);
color: var(--color-text-dark);
}
</style> </style>