mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Add Support for Granular Push of Credentials and Workflows (#16439)
This commit is contained in:
committed by
GitHub
parent
006f22f5c2
commit
49b9439ec0
@@ -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[] = [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user