feat: Synchronize deletions when pulling from source control (#12170)

Co-authored-by: r00gm <raul00gm@gmail.com>
This commit is contained in:
Danny Martini 2025-01-20 16:53:55 +01:00 committed by GitHub
parent f167578b32
commit 967ee4b89b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 900 additions and 365 deletions

View file

@ -110,7 +110,7 @@ export class Reset extends BaseCommand {
} }
for (const credential of ownedCredentials) { for (const credential of ownedCredentials) {
await Container.get(CredentialsService).delete(credential); await Container.get(CredentialsService).delete(owner, credential.id);
} }
await Container.get(AuthProviderSyncHistoryRepository).delete({ providerType: 'ldap' }); await Container.get(AuthProviderSyncHistoryRepository).delete({ providerType: 'ldap' });

View file

@ -240,7 +240,7 @@ export class UsersController {
} }
for (const credential of ownedCredentials) { for (const credential of ownedCredentials) {
await this.credentialsService.delete(credential); await this.credentialsService.delete(userToDelete, credential.id);
} }
await this.userService.getManager().transaction(async (trx) => { await this.userService.getManager().transaction(async (trx) => {

View file

@ -251,7 +251,7 @@ export class CredentialsController {
); );
} }
await this.credentialsService.delete(credential); await this.credentialsService.delete(req.user, credential.id);
this.eventService.emit('credentials-deleted', { this.eventService.emit('credentials-deleted', {
user: req.user, user: req.user,

View file

@ -406,10 +406,26 @@ export class CredentialsService {
return result; return result;
} }
async delete(credentials: CredentialsEntity) { /**
await this.externalHooks.run('credentials.delete', [credentials.id]); * Deletes a credential.
*
* If the user does not have permission to delete the credential this does
* nothing and returns void.
*/
async delete(user: User, credentialId: string) {
await this.externalHooks.run('credentials.delete', [credentialId]);
await this.credentialsRepository.remove(credentials); const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
user,
['credential:delete'],
);
if (!credential) {
return;
}
await this.credentialsRepository.remove(credential);
} }
async test(user: User, credentials: ICredentialsDecrypted) { async test(user: User, credentials: ICredentialsDecrypted) {

View file

@ -26,6 +26,9 @@ describe('SourceControlImportService', () => {
mock(), mock(),
workflowRepository, workflowRepository,
mock(), mock(),
mock(),
mock(),
mock(),
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }), mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
); );

View file

@ -1,10 +1,19 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings } from 'n8n-core';
import type { TagEntity } from '@/databases/entities/tag-entity';
import type { User } from '@/databases/entities/user';
import type { Variables } from '@/databases/entities/variables';
import type { TagRepository } from '@/databases/repositories/tag.repository';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
import type { SourceControlImportService } from '../source-control-import.service.ee';
import type { ExportableCredential } from '../types/exportable-credential';
import type { SourceControlWorkflowVersionId } from '../types/source-control-workflow-version-id';
describe('SourceControlService', () => { describe('SourceControlService', () => {
const preferencesService = new SourceControlPreferencesService( const preferencesService = new SourceControlPreferencesService(
Container.get(InstanceSettings), Container.get(InstanceSettings),
@ -13,20 +22,25 @@ describe('SourceControlService', () => {
mock(), mock(),
mock(), mock(),
); );
const sourceControlImportService = mock<SourceControlImportService>();
const tagRepository = mock<TagRepository>();
const sourceControlService = new SourceControlService( const sourceControlService = new SourceControlService(
mock(), mock(),
mock(), mock(),
preferencesService, preferencesService,
mock(), mock(),
mock(), sourceControlImportService,
mock(), tagRepository,
mock(), mock(),
); );
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(sourceControlService, 'sanityCheck').mockResolvedValue(undefined);
});
describe('pushWorkfolder', () => { describe('pushWorkfolder', () => {
it('should throw an error if a file is given that is not in the workfolder', async () => { it('should throw an error if a file is given that is not in the workfolder', async () => {
jest.spyOn(sourceControlService, 'sanityCheck').mockResolvedValue(undefined);
await expect( await expect(
sourceControlService.pushWorkfolder({ sourceControlService.pushWorkfolder({
fileNames: [ fileNames: [
@ -46,4 +60,155 @@ describe('SourceControlService', () => {
).rejects.toThrow('File path /etc/passwd is invalid'); ).rejects.toThrow('File path /etc/passwd is invalid');
}); });
}); });
describe('pullWorkfolder', () => {
it('does not filter locally created credentials', async () => {
// ARRANGE
const user = mock<User>();
const statuses = [
mock<SourceControlledFile>({
status: 'created',
location: 'local',
type: 'credential',
}),
mock<SourceControlledFile>({
status: 'created',
location: 'local',
type: 'workflow',
}),
];
jest.spyOn(sourceControlService, 'getStatus').mockResolvedValueOnce(statuses);
// ACT
const result = await sourceControlService.pullWorkfolder(user, {});
// ASSERT
expect(result).toMatchObject({ statusCode: 409, statusResult: statuses });
});
it('does not filter remotely deleted credentials', async () => {
// ARRANGE
const user = mock<User>();
const statuses = [
mock<SourceControlledFile>({
status: 'deleted',
location: 'remote',
type: 'credential',
}),
mock<SourceControlledFile>({
status: 'created',
location: 'local',
type: 'workflow',
}),
];
jest.spyOn(sourceControlService, 'getStatus').mockResolvedValueOnce(statuses);
// ACT
const result = await sourceControlService.pullWorkfolder(user, {});
// ASSERT
expect(result).toMatchObject({ statusCode: 409, statusResult: statuses });
});
it('should throw an error if a file is given that is not in the workfolder', async () => {
await expect(
sourceControlService.pushWorkfolder({
fileNames: [
{
file: '/etc/passwd',
id: 'test',
name: 'secret-file',
type: 'file',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: new Date().toISOString(),
pushed: false,
},
],
}),
).rejects.toThrow('File path /etc/passwd is invalid');
});
});
describe('getStatus', () => {
it('conflict depends on the value of `direction`', async () => {
// ARRANGE
// Define a credential that does only exist locally.
// Pulling this would delete it so it should be marked as a conflict.
// Pushing this is conflict free.
sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([]);
sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([
mock<SourceControlWorkflowVersionId>(),
]);
// Define a credential that does only exist locally.
// Pulling this would delete it so it should be marked as a conflict.
// Pushing this is conflict free.
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([]);
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([
mock<ExportableCredential & { filename: string }>(),
]);
// Define a variable that does only exist locally.
// Pulling this would delete it so it should be marked as a conflict.
// Pushing this is conflict free.
sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]);
sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([mock<Variables>()]);
// Define a tag that does only exist locally.
// Pulling this would delete it so it should be marked as a conflict.
// Pushing this is conflict free.
const tag = mock<TagEntity>({ updatedAt: new Date() });
tagRepository.find.mockResolvedValue([tag]);
sourceControlImportService.getRemoteTagsAndMappingsFromFile.mockResolvedValue({
tags: [],
mappings: [],
});
sourceControlImportService.getLocalTagsAndMappingsFromDb.mockResolvedValue({
tags: [tag],
mappings: [],
});
// ACT
const pullResult = await sourceControlService.getStatus({
direction: 'pull',
verbose: false,
preferLocalVersion: false,
});
const pushResult = await sourceControlService.getStatus({
direction: 'push',
verbose: false,
preferLocalVersion: false,
});
// ASSERT
console.log(pullResult);
console.log(pushResult);
if (!Array.isArray(pullResult)) {
fail('Expected pullResult to be an array.');
}
if (!Array.isArray(pushResult)) {
fail('Expected pushResult to be an array.');
}
expect(pullResult).toHaveLength(4);
expect(pushResult).toHaveLength(4);
expect(pullResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', true);
expect(pushResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', false);
expect(pullResult.find((i) => i.type === 'credential')).toHaveProperty('conflict', true);
expect(pushResult.find((i) => i.type === 'credential')).toHaveProperty('conflict', false);
expect(pullResult.find((i) => i.type === 'variables')).toHaveProperty('conflict', true);
expect(pushResult.find((i) => i.type === 'variables')).toHaveProperty('conflict', false);
expect(pullResult.find((i) => i.type === 'tags')).toHaveProperty('conflict', true);
expect(pushResult.find((i) => i.type === 'tags')).toHaveProperty('conflict', false);
});
});
}); });

View file

@ -9,9 +9,11 @@ import { readFile as fsReadFile } from 'node:fs/promises';
import path from 'path'; import path from 'path';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { CredentialsService } from '@/credentials/credentials.service';
import type { Project } from '@/databases/entities/project'; import type { Project } from '@/databases/entities/project';
import { SharedCredentials } from '@/databases/entities/shared-credentials'; import { SharedCredentials } from '@/databases/entities/shared-credentials';
import type { TagEntity } from '@/databases/entities/tag-entity'; import type { TagEntity } from '@/databases/entities/tag-entity';
import type { User } from '@/databases/entities/user';
import type { Variables } from '@/databases/entities/variables'; import type { Variables } from '@/databases/entities/variables';
import type { WorkflowTagMapping } from '@/databases/entities/workflow-tag-mapping'; import type { WorkflowTagMapping } from '@/databases/entities/workflow-tag-mapping';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
@ -25,7 +27,9 @@ import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import type { IWorkflowToImport } from '@/interfaces'; import type { IWorkflowToImport } from '@/interfaces';
import { isUniqueConstraintError } from '@/response-helper'; import { isUniqueConstraintError } from '@/response-helper';
import { TagService } from '@/services/tag.service';
import { assertNever } from '@/utils'; import { assertNever } from '@/utils';
import { WorkflowService } from '@/workflows/workflow.service';
import { import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
@ -62,6 +66,9 @@ export class SourceControlImportService {
private readonly variablesRepository: VariablesRepository, private readonly variablesRepository: VariablesRepository,
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
private readonly workflowTagMappingRepository: WorkflowTagMappingRepository, private readonly workflowTagMappingRepository: WorkflowTagMappingRepository,
private readonly workflowService: WorkflowService,
private readonly credentialsService: CredentialsService,
private readonly tagService: TagService,
instanceSettings: InstanceSettings, instanceSettings: InstanceSettings,
) { ) {
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
@ -500,6 +507,30 @@ export class SourceControlImportService {
return result; return result;
} }
async deleteWorkflowsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
for (const candidate of candidates) {
await this.workflowService.delete(user, candidate.id);
}
}
async deleteCredentialsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
for (const candidate of candidates) {
await this.credentialsService.delete(user, candidate.id);
}
}
async deleteVariablesNotInWorkfolder(candidates: SourceControlledFile[]) {
for (const candidate of candidates) {
await this.variablesService.delete(candidate.id);
}
}
async deleteTagsNotInWorkfolder(candidates: SourceControlledFile[]) {
for (const candidate of candidates) {
await this.tagService.delete(candidate.id);
}
}
private async findOrCreateOwnerProject(owner: ResourceOwner): Promise<Project | null> { private async findOrCreateOwnerProject(owner: ResourceOwner): Promise<Project | null> {
if (typeof owner === 'string' || owner.type === 'personal') { if (typeof owner === 'string' || owner.type === 'personal') {
const email = typeof owner === 'string' ? owner : owner.personalEmail; const email = typeof owner === 'string' ? owner : owner.personalEmail;

View file

@ -191,7 +191,7 @@ export class SourceControlController {
@Body payload: PullWorkFolderRequestDto, @Body payload: PullWorkFolderRequestDto,
): Promise<SourceControlledFile[] | ImportResult | PullResult | undefined> { ): Promise<SourceControlledFile[] | ImportResult | PullResult | undefined> {
try { try {
const result = await this.sourceControlService.pullWorkfolder(req.user.id, payload); const result = await this.sourceControlService.pullWorkfolder(req.user, payload);
res.statusCode = result.statusCode; res.statusCode = result.statusCode;
return result.statusResult; return result.statusResult;
} catch (error) { } catch (error) {

View file

@ -322,8 +322,44 @@ export class SourceControlService {
}; };
} }
private getConflicts(files: SourceControlledFile[]): SourceControlledFile[] {
return files.filter((file) => file.conflict || file.status === 'modified');
}
private getWorkflowsToImport(files: SourceControlledFile[]): SourceControlledFile[] {
return files.filter((e) => e.type === 'workflow' && e.status !== 'deleted');
}
private getWorkflowsToDelete(files: SourceControlledFile[]): SourceControlledFile[] {
return files.filter((e) => e.type === 'workflow' && e.status === 'deleted');
}
private getCredentialsToImport(files: SourceControlledFile[]): SourceControlledFile[] {
return files.filter((e) => e.type === 'credential' && e.status !== 'deleted');
}
private getCredentialsToDelete(files: SourceControlledFile[]): SourceControlledFile[] {
return files.filter((e) => e.type === 'credential' && e.status === 'deleted');
}
private getTagsToImport(files: SourceControlledFile[]): SourceControlledFile | undefined {
return files.find((e) => e.type === 'tags' && e.status !== 'deleted');
}
private getTagsToDelete(files: SourceControlledFile[]): SourceControlledFile[] {
return files.filter((e) => e.type === 'tags' && e.status === 'deleted');
}
private getVariablesToImport(files: SourceControlledFile[]): SourceControlledFile | undefined {
return files.find((e) => e.type === 'variables' && e.status !== 'deleted');
}
private getVariablesToDelete(files: SourceControlledFile[]): SourceControlledFile[] {
return files.filter((e) => e.type === 'variables' && e.status === 'deleted');
}
async pullWorkfolder( async pullWorkfolder(
userId: User['id'], user: User,
options: PullWorkFolderRequestDto, options: PullWorkFolderRequestDto,
): Promise<{ statusCode: number; statusResult: SourceControlledFile[] }> { ): Promise<{ statusCode: number; statusResult: SourceControlledFile[] }> {
await this.sanityCheck(); await this.sanityCheck();
@ -334,58 +370,51 @@ export class SourceControlService {
preferLocalVersion: false, preferLocalVersion: false,
})) as SourceControlledFile[]; })) as SourceControlledFile[];
// filter out items that will not effect a local change and thus should not if (options.force !== true) {
// trigger a conflict warning in the frontend const possibleConflicts = this.getConflicts(statusResult);
const filteredResult = statusResult.filter((e) => {
// locally created credentials will not create a conflict on pull
if (e.status === 'created' && e.location === 'local') {
return false;
}
// remotely deleted credentials will not delete local credentials
if (e.type === 'credential' && e.status === 'deleted') {
return false;
}
return true;
});
if (!options.force) {
const possibleConflicts = filteredResult?.filter(
(file) => (file.conflict || file.status === 'modified') && file.type === 'workflow',
);
if (possibleConflicts?.length > 0) { if (possibleConflicts?.length > 0) {
await this.gitService.resetBranch(); await this.gitService.resetBranch();
return { return {
statusCode: 409, statusCode: 409,
statusResult: filteredResult, statusResult,
}; };
} }
} }
const workflowsToBeImported = statusResult.filter( const workflowsToBeImported = this.getWorkflowsToImport(statusResult);
(e) => e.type === 'workflow' && e.status !== 'deleted',
);
await this.sourceControlImportService.importWorkflowFromWorkFolder( await this.sourceControlImportService.importWorkflowFromWorkFolder(
workflowsToBeImported, workflowsToBeImported,
userId, user.id,
); );
const workflowsToBeDeleted = this.getWorkflowsToDelete(statusResult);
const credentialsToBeImported = statusResult.filter( await this.sourceControlImportService.deleteWorkflowsNotInWorkfolder(
(e) => e.type === 'credential' && e.status !== 'deleted', user,
workflowsToBeDeleted,
); );
const credentialsToBeImported = this.getCredentialsToImport(statusResult);
await this.sourceControlImportService.importCredentialsFromWorkFolder( await this.sourceControlImportService.importCredentialsFromWorkFolder(
credentialsToBeImported, credentialsToBeImported,
userId, user.id,
);
const credentialsToBeDeleted = this.getCredentialsToDelete(statusResult);
await this.sourceControlImportService.deleteCredentialsNotInWorkfolder(
user,
credentialsToBeDeleted,
); );
const tagsToBeImported = statusResult.find((e) => e.type === 'tags'); const tagsToBeImported = this.getTagsToImport(statusResult);
if (tagsToBeImported) { if (tagsToBeImported) {
await this.sourceControlImportService.importTagsFromWorkFolder(tagsToBeImported); await this.sourceControlImportService.importTagsFromWorkFolder(tagsToBeImported);
} }
const tagsToBeDeleted = this.getTagsToDelete(statusResult);
await this.sourceControlImportService.deleteTagsNotInWorkfolder(tagsToBeDeleted);
const variablesToBeImported = statusResult.find((e) => e.type === 'variables'); const variablesToBeImported = this.getVariablesToImport(statusResult);
if (variablesToBeImported) { if (variablesToBeImported) {
await this.sourceControlImportService.importVariablesFromWorkFolder(variablesToBeImported); await this.sourceControlImportService.importVariablesFromWorkFolder(variablesToBeImported);
} }
const variablesToBeDeleted = this.getVariablesToDelete(statusResult);
await this.sourceControlImportService.deleteVariablesNotInWorkfolder(variablesToBeDeleted);
// #region Tracking Information // #region Tracking Information
this.eventService.emit( this.eventService.emit(
@ -396,7 +425,7 @@ export class SourceControlService {
return { return {
statusCode: 200, statusCode: 200,
statusResult: filteredResult, statusResult,
}; };
} }
@ -536,7 +565,7 @@ export class SourceControlService {
type: 'workflow', type: 'workflow',
status: options.direction === 'push' ? 'created' : 'deleted', status: options.direction === 'push' ? 'created' : 'deleted',
location: options.direction === 'push' ? 'local' : 'remote', location: options.direction === 'push' ? 'local' : 'remote',
conflict: false, conflict: options.direction === 'push' ? false : true,
file: item.filename, file: item.filename,
updatedAt: item.updatedAt ?? new Date().toISOString(), updatedAt: item.updatedAt ?? new Date().toISOString(),
}); });
@ -617,7 +646,7 @@ export class SourceControlService {
type: 'credential', type: 'credential',
status: options.direction === 'push' ? 'created' : 'deleted', status: options.direction === 'push' ? 'created' : 'deleted',
location: options.direction === 'push' ? 'local' : 'remote', location: options.direction === 'push' ? 'local' : 'remote',
conflict: false, conflict: options.direction === 'push' ? false : true,
file: item.filename, file: item.filename,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
@ -669,26 +698,47 @@ export class SourceControlService {
} }
}); });
if ( varMissingInLocal.forEach((item) => {
varMissingInLocal.length > 0 ||
varMissingInRemote.length > 0 ||
varModifiedInEither.length > 0
) {
if (options.direction === 'pull' && varRemoteIds.length === 0) {
// if there's nothing to pull, don't show difference as modified
} else {
sourceControlledFiles.push({ sourceControlledFiles.push({
id: 'variables', id: item.id,
name: 'variables', name: item.key,
type: 'variables', type: 'variables',
status: 'modified', status: options.direction === 'push' ? 'deleted' : 'created',
location: options.direction === 'push' ? 'local' : 'remote', location: options.direction === 'push' ? 'local' : 'remote',
conflict: false, conflict: false,
file: getVariablesPath(this.gitFolder), file: getVariablesPath(this.gitFolder),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
} });
}
varMissingInRemote.forEach((item) => {
sourceControlledFiles.push({
id: item.id,
name: item.key,
type: 'variables',
status: options.direction === 'push' ? 'created' : 'deleted',
location: options.direction === 'push' ? 'local' : 'remote',
// if the we pull and the file is missing in the remote, we will delete
// it locally, which is communicated by marking this as a conflict
conflict: options.direction === 'push' ? false : true,
file: getVariablesPath(this.gitFolder),
updatedAt: new Date().toISOString(),
});
});
varModifiedInEither.forEach((item) => {
sourceControlledFiles.push({
id: item.id,
name: item.key,
type: 'variables',
status: 'modified',
location: options.direction === 'push' ? 'local' : 'remote',
conflict: true,
file: getVariablesPath(this.gitFolder),
updatedAt: new Date().toISOString(),
});
});
return { return {
varMissingInLocal, varMissingInLocal,
varMissingInRemote, varMissingInRemote,
@ -743,32 +793,44 @@ export class SourceControlService {
) === -1, ) === -1,
); );
if ( tagsMissingInLocal.forEach((item) => {
tagsMissingInLocal.length > 0 ||
tagsMissingInRemote.length > 0 ||
tagsModifiedInEither.length > 0 ||
mappingsMissingInLocal.length > 0 ||
mappingsMissingInRemote.length > 0
) {
if (
options.direction === 'pull' &&
tagMappingsRemote.tags.length === 0 &&
tagMappingsRemote.mappings.length === 0
) {
// if there's nothing to pull, don't show difference as modified
} else {
sourceControlledFiles.push({ sourceControlledFiles.push({
id: 'mappings', id: item.id,
name: 'tags', name: item.name,
type: 'tags', type: 'tags',
status: 'modified', status: options.direction === 'push' ? 'deleted' : 'created',
location: options.direction === 'push' ? 'local' : 'remote', location: options.direction === 'push' ? 'local' : 'remote',
conflict: false, conflict: false,
file: getTagsPath(this.gitFolder), file: getTagsPath(this.gitFolder),
updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(),
}); });
} });
} tagsMissingInRemote.forEach((item) => {
sourceControlledFiles.push({
id: item.id,
name: item.name,
type: 'tags',
status: options.direction === 'push' ? 'created' : 'deleted',
location: options.direction === 'push' ? 'local' : 'remote',
conflict: options.direction === 'push' ? false : true,
file: getTagsPath(this.gitFolder),
updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(),
});
});
tagsModifiedInEither.forEach((item) => {
sourceControlledFiles.push({
id: item.id,
name: item.name,
type: 'tags',
status: 'modified',
location: options.direction === 'push' ? 'local' : 'remote',
conflict: true,
file: getTagsPath(this.gitFolder),
updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(),
});
});
return { return {
tagsMissingInLocal, tagsMissingInLocal,
tagsMissingInRemote, tagsMissingInRemote,

View file

@ -36,7 +36,7 @@ export = {
try { try {
const payload = PullWorkFolderRequestDto.parse(req.body); const payload = PullWorkFolderRequestDto.parse(req.body);
const sourceControlService = Container.get(SourceControlService); const sourceControlService = Container.get(SourceControlService);
const result = await sourceControlService.pullWorkfolder(req.user.id, payload); const result = await sourceControlService.pullWorkfolder(req.user, payload);
if (result.statusCode === 200) { if (result.statusCode === 200) {
Container.get(EventService).emit('source-control-user-pulled-api', { Container.get(EventService).emit('source-control-user-pulled-api', {

View file

@ -130,7 +130,7 @@ export class ProjectService {
); );
} else { } else {
for (const sharedCredential of ownedCredentials) { for (const sharedCredential of ownedCredentials) {
await credentialsService.delete(sharedCredential.credentials); await credentialsService.delete(user, sharedCredential.credentials.id);
} }
} }

View file

@ -272,6 +272,13 @@ export class WorkflowService {
return updatedWorkflow; return updatedWorkflow;
} }
/**
* Deletes a workflow and returns it.
*
* If the workflow is active this will deactivate the workflow.
* If the user does not have the permissions to delete the workflow this does
* nothing and returns void.
*/
async delete(user: User, workflowId: string): Promise<WorkflowEntity | undefined> { async delete(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
await this.externalHooks.run('workflow.delete', [workflowId]); await this.externalHooks.run('workflow.delete', [workflowId]);

View file

@ -50,6 +50,9 @@ describe('SourceControlImportService', () => {
mock(), mock(),
mock(), mock(),
mock(), mock(),
mock(),
mock(),
mock(),
mock<InstanceSettings>({ n8nFolder: '/some-path' }), mock<InstanceSettings>({ n8nFolder: '/some-path' }),
); );
}); });

View file

@ -166,95 +166,5 @@ describe('MainSidebarSourceControl', () => {
), ),
); );
}); });
it("should show user's feedback when pulling", async () => {
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([
{
id: '014da93897f146d2b880-baa374b9d02d',
name: 'vuelfow2',
type: 'workflow',
status: 'created',
location: 'remote',
conflict: false,
file: '/014da93897f146d2b880-baa374b9d02d.json',
updatedAt: '2025-01-09T13:12:24.580Z',
},
{
id: 'a102c0b9-28ac-43cb-950e-195723a56d54',
name: 'Gmail account',
type: 'credential',
status: 'created',
location: 'remote',
conflict: false,
file: '/a102c0b9-28ac-43cb-950e-195723a56d54.json',
updatedAt: '2025-01-09T13:12:24.586Z',
},
{
id: 'variables',
name: 'variables',
type: 'variables',
status: 'modified',
location: 'remote',
conflict: false,
file: '/variable_stubs.json',
updatedAt: '2025-01-09T13:12:24.588Z',
},
{
id: 'mappings',
name: 'tags',
type: 'tags',
status: 'modified',
location: 'remote',
conflict: false,
file: '/tags.json',
updatedAt: '2024-12-16T12:53:12.155Z',
},
]);
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
});
await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => {
expect(showToast).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
title: 'Finish setting up your new variables to use in workflows',
}),
);
expect(showToast).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
title: 'Finish setting up your new credentials to use in workflows',
}),
);
expect(showToast).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
message: '1 Workflow, 1 Credential, Variables, and Tags were pulled',
}),
);
});
});
it('should show feedback where there are no change to pull', async () => {
vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([]);
const { getAllByRole } = renderComponent({
pinia,
props: { isCollapsed: false },
});
await userEvent.click(getAllByRole('button')[0]);
await waitFor(() => {
expect(showMessage).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Up to date',
}),
);
});
});
}); });
}); });

View file

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, h, nextTick, ref } from 'vue'; import { computed, ref } from 'vue';
import { createEventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { hasPermission } from '@/utils/rbac/permissions'; import { hasPermission } from '@/utils/rbac/permissions';
@ -9,10 +9,7 @@ import { useUIStore } from '@/stores/ui.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
import { sourceControlEventBus } from '@/event-bus/source-control'; import { sourceControlEventBus } from '@/event-bus/source-control';
import { groupBy } from 'lodash-es'; import { notifyUserAboutPullWorkFolderOutcome } from '@/utils/sourceControlUtils';
import { RouterLink } from 'vue-router';
import { VIEWS } from '@/constants';
import type { SourceControlledFile } from '@n8n/api-types';
defineProps<{ defineProps<{
isCollapsed: boolean; isCollapsed: boolean;
@ -67,66 +64,6 @@ async function pushWorkfolder() {
} }
} }
const variablesToast = {
title: i18n.baseText('settings.sourceControl.pull.upToDate.variables.title'),
message: h(RouterLink, { to: { name: VIEWS.VARIABLES } }, () =>
i18n.baseText('settings.sourceControl.pull.upToDate.variables.description'),
),
type: 'info' as const,
closeOnClick: true,
duration: 0,
};
const credentialsToast = {
title: i18n.baseText('settings.sourceControl.pull.upToDate.credentials.title'),
message: h(RouterLink, { to: { name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } } }, () =>
i18n.baseText('settings.sourceControl.pull.upToDate.credentials.description'),
),
type: 'info' as const,
closeOnClick: true,
duration: 0,
};
const pullMessage = ({
credential,
tags,
variables,
workflow,
}: Partial<Record<SourceControlledFile['type'], SourceControlledFile[]>>) => {
const messages: string[] = [];
if (workflow?.length) {
messages.push(
i18n.baseText('generic.workflow', {
adjustToNumber: workflow.length,
interpolate: { count: workflow.length },
}),
);
}
if (credential?.length) {
messages.push(
i18n.baseText('generic.credential', {
adjustToNumber: credential.length,
interpolate: { count: credential.length },
}),
);
}
if (variables?.length) {
messages.push(i18n.baseText('generic.variable_plural'));
}
if (tags?.length) {
messages.push(i18n.baseText('generic.tag_plural'));
}
return [
new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages),
'were pulled',
].join(' ');
};
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'));
@ -134,38 +71,7 @@ async function pullWorkfolder() {
try { try {
const status = await sourceControlStore.pullWorkfolder(false); const status = await sourceControlStore.pullWorkfolder(false);
if (!status.length) { await notifyUserAboutPullWorkFolderOutcome(status, toast);
toast.showMessage({
title: i18n.baseText('settings.sourceControl.pull.upToDate.title'),
message: i18n.baseText('settings.sourceControl.pull.upToDate.description'),
type: 'success',
});
return;
}
const { credential, tags, variables, workflow } = groupBy(status, 'type');
const toastMessages = [
...(variables?.length ? [variablesToast] : []),
...(credential?.length ? [credentialsToast] : []),
{
title: i18n.baseText('settings.sourceControl.pull.success.title'),
message: pullMessage({ credential, tags, variables, workflow }),
type: 'success' as const,
},
];
for (const message of toastMessages) {
/**
* the toasts stack in a reversed way, resulting in
* Success
* Credentials
* Variables
*/
//
toast.showToast(message);
await nextTick();
}
sourceControlEventBus.emit('pull'); sourceControlEventBus.emit('pull');
} catch (error) { } catch (error) {

View file

@ -0,0 +1,114 @@
import SourceControlPullModalEe from './SourceControlPullModal.ee.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { createEventBus } from 'n8n-design-system';
import userEvent from '@testing-library/user-event';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { mockedStore } from '@/__tests__/utils';
import { waitFor } from '@testing-library/dom';
const eventBus = createEventBus();
const DynamicScrollerStub = {
props: {
items: Array,
},
template: '<div><template v-for="item in items"><slot v-bind="{ item }"></slot></template></div>',
methods: {
scrollToItem: vi.fn(),
},
};
const DynamicScrollerItemStub = {
template: '<slot></slot>',
};
const renderModal = createComponentRenderer(SourceControlPullModalEe, {
global: {
stubs: {
DynamicScroller: DynamicScrollerStub,
DynamicScrollerItem: DynamicScrollerItemStub,
Modal: {
template: `
<div>
<slot name="header" />
<slot name="title" />
<slot name="content" />
<slot name="footer" />
</div>
`,
},
},
},
});
const sampleFiles = [
{
id: '014da93897f146d2b880-baa374b9d02d',
name: 'vuelfow2',
type: 'workflow',
status: 'created',
location: 'remote',
conflict: false,
file: '/014da93897f146d2b880-baa374b9d02d.json',
updatedAt: '2025-01-09T13:12:24.580Z',
},
{
id: 'a102c0b9-28ac-43cb-950e-195723a56d54',
name: 'Gmail account',
type: 'credential',
status: 'created',
location: 'remote',
conflict: false,
file: '/a102c0b9-28ac-43cb-950e-195723a56d54.json',
updatedAt: '2025-01-09T13:12:24.586Z',
},
];
describe('SourceControlPushModal', () => {
beforeEach(() => {
createTestingPinia();
});
it('mounts', () => {
const { getByText } = renderModal({
props: {
data: {
eventBus,
status: [],
},
},
});
expect(getByText('Pull and override')).toBeInTheDocument();
});
it('should renders the changes', () => {
const { getAllByTestId } = renderModal({
props: {
data: {
eventBus,
status: sampleFiles,
},
},
});
expect(getAllByTestId('pull-modal-item-header').length).toBe(2);
expect(getAllByTestId('pull-modal-item').length).toBe(2);
});
it('should force pull', async () => {
const sourceControlStore = mockedStore(useSourceControlStore);
const { getByTestId } = renderModal({
props: {
data: {
eventBus,
status: sampleFiles,
},
},
});
await userEvent.click(getByTestId('force-pull'));
await waitFor(() => expect(sourceControlStore.pullWorkfolder).toHaveBeenCalledWith(true));
});
});

View file

@ -1,37 +1,83 @@
<script lang="ts" setup> <script lang="ts" setup>
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants'; import { SOURCE_CONTROL_PULL_MODAL_KEY, VIEWS } from '@/constants';
import type { EventBus } from 'n8n-design-system/utils'; import type { EventBus } from 'n8n-design-system/utils';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useLoadingService } from '@/composables/useLoadingService'; import { useLoadingService } from '@/composables/useLoadingService';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
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 { computed, nextTick, ref } from 'vue'; import { computed } from 'vue';
import { sourceControlEventBus } from '@/event-bus/source-control'; import { sourceControlEventBus } from '@/event-bus/source-control';
import type { SourceControlledFile } from '@n8n/api-types'; import { orderBy, groupBy } from 'lodash-es';
import { N8nBadge, N8nText, N8nLink, N8nButton } from 'n8n-design-system';
import { RouterLink } from 'vue-router';
import {
getStatusText,
getStatusTheme,
getPullPriorityByStatus,
notifyUserAboutPullWorkFolderOutcome,
} from '@/utils/sourceControlUtils';
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import { type SourceControlledFile, SOURCE_CONTROL_FILE_TYPE } from '@n8n/api-types';
type SourceControlledFileType = SourceControlledFile['type'];
const props = defineProps<{ const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlledFile[] }; data: { eventBus: EventBus; status: SourceControlledFile[] };
}>(); }>();
const incompleteFileTypes = ['variables', 'credential'];
const loadingService = useLoadingService(); const loadingService = useLoadingService();
const uiStore = useUIStore(); const uiStore = useUIStore();
const toast = useToast(); const toast = useToast();
const i18n = useI18n(); const i18n = useI18n();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const files = ref<SourceControlledFile[]>(props.data.status || []); const sortedFiles = computed(() =>
orderBy(
props.data.status,
[({ status }) => getPullPriorityByStatus(status), ({ name }) => name.toLowerCase()],
['desc', 'asc'],
),
);
const workflowFiles = computed(() => { const groupedFilesByType = computed<
return files.value.filter((file) => file.type === 'workflow'); Partial<Record<SourceControlledFileType, SourceControlledFile[]>>
>(() => groupBy(sortedFiles.value, 'type'));
type ItemsList = Array<
{ type: 'render-title'; title: string; id: SourceControlledFileType } | SourceControlledFile
>;
const ITEM_TITLES: Record<Exclude<SourceControlledFileType, 'file'>, string> = {
[SOURCE_CONTROL_FILE_TYPE.workflow]: 'Workflows',
[SOURCE_CONTROL_FILE_TYPE.credential]: 'Credentials',
[SOURCE_CONTROL_FILE_TYPE.variables]: 'Variables',
[SOURCE_CONTROL_FILE_TYPE.tags]: 'Tags',
} as const;
const files = computed<ItemsList>(() =>
[
SOURCE_CONTROL_FILE_TYPE.workflow,
SOURCE_CONTROL_FILE_TYPE.credential,
SOURCE_CONTROL_FILE_TYPE.variables,
SOURCE_CONTROL_FILE_TYPE.tags,
].reduce<ItemsList>((acc, fileType) => {
if (!groupedFilesByType.value[fileType]) {
return acc;
}
acc.push({
type: 'render-title',
title: ITEM_TITLES[fileType],
id: fileType,
}); });
const modifiedWorkflowFiles = computed(() => { acc.push(...groupedFilesByType.value[fileType]);
return workflowFiles.value.filter((file) => file.status === 'modified'); return acc;
}); }, []),
);
function close() { function close() {
uiStore.closeModal(SOURCE_CONTROL_PULL_MODAL_KEY); uiStore.closeModal(SOURCE_CONTROL_PULL_MODAL_KEY);
@ -42,29 +88,10 @@ async function pullWorkfolder() {
close(); close();
try { try {
await sourceControlStore.pullWorkfolder(true); const status = await sourceControlStore.pullWorkfolder(true);
const hasVariablesOrCredentials = files.value.some((file) => { await notifyUserAboutPullWorkFolderOutcome(status, toast);
return incompleteFileTypes.includes(file.type);
});
toast.showMessage({
title: i18n.baseText('settings.sourceControl.pull.success.title'),
type: 'success',
});
if (hasVariablesOrCredentials) {
void nextTick(() => {
toast.showMessage({
message: i18n.baseText('settings.sourceControl.pull.oneLastStep.description'),
title: i18n.baseText('settings.sourceControl.pull.oneLastStep.title'),
type: 'info',
duration: 0,
showClose: true,
offset: 0,
});
});
}
sourceControlEventBus.emit('pull'); sourceControlEventBus.emit('pull');
} catch (error) { } catch (error) {
toast.showError(error, 'Error'); toast.showError(error, 'Error');
@ -82,35 +109,71 @@ async function pullWorkfolder() {
:name="SOURCE_CONTROL_PULL_MODAL_KEY" :name="SOURCE_CONTROL_PULL_MODAL_KEY"
> >
<template #content> <template #content>
<N8nText tag="div" class="mb-xs">
These resources will be updated or deleted, and any local changes to them will be lost. To
keep the local version, push it before pulling.
<br />
<N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</N8nLink>
</N8nText>
<div :class="$style.container"> <div :class="$style.container">
<n8n-text> <DynamicScroller
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }} ref="scroller"
<n8n-link :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')"> :items="files"
{{ i18n.baseText('settings.sourceControl.modals.pull.description.learnMore') }} :min-item-size="47"
</n8n-link> class="full-height scroller"
</n8n-text> style="max-height: 440px"
>
<div v-if="modifiedWorkflowFiles.length > 0" class="mt-l"> <template #default="{ item, index, active }">
<ul :class="$style.filesList"> <div
<li v-for="file in modifiedWorkflowFiles" :key="file.id"> v-if="item.type === 'render-title'"
<n8n-link :class="$style.fileLink" new-window :to="`/workflow/${file.id}`"> :class="$style.listHeader"
{{ file.name }} data-test-id="pull-modal-item-header"
<n8n-icon icon="external-link-alt" /> >
</n8n-link> <N8nText bold>{{ item.title }}</N8nText>
</li>
</ul>
</div> </div>
<DynamicScrollerItem
v-else
:item="item"
:active="active"
:size-dependencies="[item.name]"
:data-index="index"
>
<div :class="$style.listItem" data-test-id="pull-modal-item">
<RouterLink
v-if="item.type === 'credential'"
target="_blank"
:to="{ name: VIEWS.CREDENTIALS, params: { credentialId: item.id } }"
>
<N8nText>{{ item.name }}</N8nText>
</RouterLink>
<RouterLink
v-else-if="item.type === 'workflow'"
target="_blank"
:to="{ name: VIEWS.WORKFLOW, params: { name: item.id } }"
>
<N8nText>{{ item.name }}</N8nText>
</RouterLink>
<N8nText v-else>{{ item.name }}</N8nText>
<N8nBadge :theme="getStatusTheme(item.status)" :class="$style.listBadge">
{{ getStatusText(item.status) }}
</N8nBadge>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<div :class="$style.footer"> <div :class="$style.footer">
<n8n-button type="tertiary" class="mr-2xs" @click="close"> <N8nButton type="tertiary" class="mr-2xs" @click="close">
{{ i18n.baseText('settings.sourceControl.modals.pull.buttons.cancel') }} {{ i18n.baseText('settings.sourceControl.modals.pull.buttons.cancel') }}
</n8n-button> </N8nButton>
<n8n-button type="primary" @click="pullWorkfolder"> <N8nButton type="primary" data-test-id="force-pull" @click="pullWorkfolder">
{{ i18n.baseText('settings.sourceControl.modals.pull.buttons.save') }} {{ i18n.baseText('settings.sourceControl.modals.pull.buttons.save') }}
</n8n-button> </N8nButton>
</div> </div>
</template> </template>
</Modal> </Modal>
@ -131,14 +194,30 @@ async function pullWorkfolder() {
} }
} }
.fileLink { .listHeader {
svg { padding-top: 16px;
display: none; padding-bottom: 12px;
margin-left: var(--spacing-4xs); height: 47px;
} }
&:hover svg { .listBadge {
display: inline-flex; margin-left: auto;
align-self: flex-start;
margin-top: 2px;
}
.listItem {
display: flex;
padding-bottom: 10px;
&::before {
display: block;
content: '';
width: 5px;
height: 5px;
background-color: var(--color-foreground-xdark);
border-radius: 100%;
margin: 7px 8px 6px 2px;
flex-shrink: 0;
} }
} }

View file

@ -12,7 +12,6 @@ import { useRoute } from 'vue-router';
import dateformat from 'dateformat'; import dateformat from 'dateformat';
import { RecycleScroller } from 'vue-virtual-scroller'; import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import type { BaseTextKey } from '@/plugins/i18n';
import { refDebounced } from '@vueuse/core'; import { refDebounced } from '@vueuse/core';
import { import {
N8nHeading, N8nHeading,
@ -37,6 +36,7 @@ import {
SOURCE_CONTROL_FILE_LOCATION, SOURCE_CONTROL_FILE_LOCATION,
} from '@n8n/api-types'; } from '@n8n/api-types';
import { orderBy, groupBy } from 'lodash-es'; import { orderBy, groupBy } from 'lodash-es';
import { getStatusText, getStatusTheme, getPushPriorityByStatus } from '@/utils/sourceControlUtils';
const props = defineProps<{ const props = defineProps<{
data: { eventBus: EventBus; status: SourceControlledFile[] }; data: { eventBus: EventBus; status: SourceControlledFile[] };
@ -185,22 +185,13 @@ const filteredWorkflows = computed(() => {
}); });
}); });
const statusPriority: Partial<Record<SourceControlledFileStatus, number>> = {
[SOURCE_CONTROL_FILE_STATUS.modified]: 1,
[SOURCE_CONTROL_FILE_STATUS.renamed]: 2,
[SOURCE_CONTROL_FILE_STATUS.created]: 3,
[SOURCE_CONTROL_FILE_STATUS.deleted]: 4,
} as const;
const getPriorityByStatus = (status: SourceControlledFileStatus): number =>
statusPriority[status] ?? 0;
const sortedWorkflows = computed(() => { const sortedWorkflows = computed(() => {
const sorted = orderBy( const sorted = orderBy(
filteredWorkflows.value, filteredWorkflows.value,
[ [
// keep the current workflow at the top of the list // keep the current workflow at the top of the list
({ id }) => id === changes.value.currentWorkflow?.id, ({ id }) => id === changes.value.currentWorkflow?.id,
({ status }) => getPriorityByStatus(status), ({ status }) => getPushPriorityByStatus(status),
'updatedAt', 'updatedAt',
], ],
['desc', 'asc', 'desc'], ['desc', 'asc', 'desc'],
@ -329,19 +320,6 @@ async function commitAndPush() {
loadingService.stopLoading(); loadingService.stopLoading();
} }
} }
const getStatusText = (status: SourceControlledFileStatus) =>
i18n.baseText(`settings.sourceControl.status.${status}` as BaseTextKey);
const getStatusTheme = (status: SourceControlledFileStatus) => {
const statusToBadgeThemeMap: Partial<
Record<SourceControlledFileStatus, 'success' | 'danger' | 'warning'>
> = {
[SOURCE_CONTROL_FILE_STATUS.created]: 'success',
[SOURCE_CONTROL_FILE_STATUS.deleted]: 'danger',
[SOURCE_CONTROL_FILE_STATUS.modified]: 'warning',
} as const;
return statusToBadgeThemeMap[status];
};
</script> </script>
<template> <template>

View file

@ -1977,7 +1977,7 @@
"settings.sourceControl.pull.upToDate.credentials.title": "Finish setting up your new credentials to use in workflows", "settings.sourceControl.pull.upToDate.credentials.title": "Finish setting up your new credentials to use in workflows",
"settings.sourceControl.pull.upToDate.credentials.description": "Review Credentials", "settings.sourceControl.pull.upToDate.credentials.description": "Review Credentials",
"settings.sourceControl.modals.pull.title": "Pull changes", "settings.sourceControl.modals.pull.title": "Pull changes",
"settings.sourceControl.modals.pull.description": "These workflows will be updated, and any local changes to them will be overridden. To keep the local version, push it before pulling.", "settings.sourceControl.modals.pull.description": "These resources will be updated or deleted, and any local changes to them will be lost. To keep the local version, push it before pulling.",
"settings.sourceControl.modals.pull.description.learnMore": "More info", "settings.sourceControl.modals.pull.description.learnMore": "More info",
"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",

View file

@ -0,0 +1,119 @@
import {
getStatusText,
getStatusTheme,
getPullPriorityByStatus,
getPushPriorityByStatus,
notifyUserAboutPullWorkFolderOutcome,
} from './sourceControlUtils';
import type { useToast } from '@/composables/useToast';
import { SOURCE_CONTROL_FILE_STATUS } from '@n8n/api-types';
describe('source control utils', () => {
describe('getStatusText()', () => {
it('uses i18n', () => {
expect(getStatusText(SOURCE_CONTROL_FILE_STATUS.new)).toStrictEqual(
expect.stringContaining(SOURCE_CONTROL_FILE_STATUS.new),
);
});
});
describe('getStatusTheme()', () => {
it('only handles known values', () => {
expect(getStatusTheme('unknown')).toBe(undefined);
});
});
describe('getPullPriorityByStatus()', () => {
it('defaults to 0', () => {
expect(getPullPriorityByStatus(SOURCE_CONTROL_FILE_STATUS.new)).toBe(0);
});
});
describe('getPushPriorityByStatus()', () => {
it('defaults to 0', () => {
expect(getPushPriorityByStatus(SOURCE_CONTROL_FILE_STATUS.new)).toBe(0);
});
});
describe('notifyUserAboutPullWorkFolderOutcome()', () => {
it('should show up to date notification when there are no changes', async () => {
const toast = { showMessage: vi.fn() } as unknown as ReturnType<typeof useToast>;
await notifyUserAboutPullWorkFolderOutcome([], toast);
expect(toast.showMessage).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Up to date',
}),
);
});
it('should show granular feedback', async () => {
const toast = { showToast: vi.fn() } as unknown as ReturnType<typeof useToast>;
await notifyUserAboutPullWorkFolderOutcome(
[
{
id: '014da93897f146d2b880-baa374b9d02d',
name: 'vuelfow2',
type: 'workflow',
status: 'created',
location: 'remote',
conflict: false,
file: '/014da93897f146d2b880-baa374b9d02d.json',
updatedAt: '2025-01-09T13:12:24.580Z',
},
{
id: 'a102c0b9-28ac-43cb-950e-195723a56d54',
name: 'Gmail account',
type: 'credential',
status: 'created',
location: 'remote',
conflict: false,
file: '/a102c0b9-28ac-43cb-950e-195723a56d54.json',
updatedAt: '2025-01-09T13:12:24.586Z',
},
{
id: 'variables',
name: 'variables',
type: 'variables',
status: 'modified',
location: 'remote',
conflict: false,
file: '/variable_stubs.json',
updatedAt: '2025-01-09T13:12:24.588Z',
},
{
id: 'mappings',
name: 'tags',
type: 'tags',
status: 'modified',
location: 'remote',
conflict: false,
file: '/tags.json',
updatedAt: '2024-12-16T12:53:12.155Z',
},
],
toast,
);
expect(toast.showToast).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
title: 'Finish setting up your new variables to use in workflows',
}),
);
expect(toast.showToast).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
title: 'Finish setting up your new credentials to use in workflows',
}),
);
expect(toast.showToast).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
message: '1 Workflow, 1 Credential, Variables, and Tags were pulled',
}),
);
});
});
});

View file

@ -0,0 +1,142 @@
import { h, nextTick } from 'vue';
import { RouterLink } from 'vue-router';
import { useI18n } from '@/composables/useI18n';
import { type SourceControlledFile, SOURCE_CONTROL_FILE_STATUS } from '@n8n/api-types';
import type { BaseTextKey } from '@/plugins/i18n';
import { VIEWS } from '@/constants';
import { groupBy } from 'lodash-es';
import type { useToast } from '@/composables/useToast';
type SourceControlledFileStatus = SourceControlledFile['status'];
const i18n = useI18n();
export const getStatusText = (status: SourceControlledFileStatus) =>
i18n.baseText(`settings.sourceControl.status.${status}` as BaseTextKey);
export const getStatusTheme = (status: SourceControlledFileStatus) => {
const statusToBadgeThemeMap: Partial<
Record<SourceControlledFileStatus, 'success' | 'danger' | 'warning'>
> = {
[SOURCE_CONTROL_FILE_STATUS.created]: 'success',
[SOURCE_CONTROL_FILE_STATUS.deleted]: 'danger',
[SOURCE_CONTROL_FILE_STATUS.modified]: 'warning',
} as const;
return statusToBadgeThemeMap[status];
};
type StatusPriority = Partial<Record<SourceControlledFileStatus, number>>;
const pullStatusPriority: StatusPriority = {
[SOURCE_CONTROL_FILE_STATUS.modified]: 2,
[SOURCE_CONTROL_FILE_STATUS.created]: 1,
[SOURCE_CONTROL_FILE_STATUS.deleted]: 3,
} as const;
export const getPullPriorityByStatus = (status: SourceControlledFileStatus) =>
pullStatusPriority[status] ?? 0;
const pushStatusPriority: StatusPriority = {
[SOURCE_CONTROL_FILE_STATUS.modified]: 1,
[SOURCE_CONTROL_FILE_STATUS.renamed]: 2,
[SOURCE_CONTROL_FILE_STATUS.created]: 3,
[SOURCE_CONTROL_FILE_STATUS.deleted]: 4,
} as const;
export const getPushPriorityByStatus = (status: SourceControlledFileStatus) =>
pushStatusPriority[status] ?? 0;
const variablesToast = {
title: i18n.baseText('settings.sourceControl.pull.upToDate.variables.title'),
message: h(RouterLink, { to: { name: VIEWS.VARIABLES }, query: { incomplete: 'true' } }, () =>
i18n.baseText('settings.sourceControl.pull.upToDate.variables.description'),
),
type: 'info' as const,
duration: 0,
};
const credentialsToast = {
title: i18n.baseText('settings.sourceControl.pull.upToDate.credentials.title'),
message: h(RouterLink, { to: { name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } } }, () =>
i18n.baseText('settings.sourceControl.pull.upToDate.credentials.description'),
),
type: 'info' as const,
duration: 0,
};
const pullMessage = ({
credential,
tags,
variables,
workflow,
}: Partial<Record<SourceControlledFile['type'], SourceControlledFile[]>>) => {
const messages: string[] = [];
if (workflow?.length) {
messages.push(
i18n.baseText('generic.workflow', {
adjustToNumber: workflow.length,
interpolate: { count: workflow.length },
}),
);
}
if (credential?.length) {
messages.push(
i18n.baseText('generic.credential', {
adjustToNumber: credential.length,
interpolate: { count: credential.length },
}),
);
}
if (variables?.length) {
messages.push(i18n.baseText('generic.variable_plural'));
}
if (tags?.length) {
messages.push(i18n.baseText('generic.tag_plural'));
}
return [
new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages),
'were pulled',
].join(' ');
};
export const notifyUserAboutPullWorkFolderOutcome = async (
files: SourceControlledFile[],
toast: ReturnType<typeof useToast>,
) => {
if (!files?.length) {
toast.showMessage({
title: i18n.baseText('settings.sourceControl.pull.upToDate.title'),
message: i18n.baseText('settings.sourceControl.pull.upToDate.description'),
type: 'success',
});
return;
}
const { credential, tags, variables, workflow } = groupBy(files, 'type');
const toastMessages = [
...(variables?.length ? [variablesToast] : []),
...(credential?.length ? [credentialsToast] : []),
{
title: i18n.baseText('settings.sourceControl.pull.success.title'),
message: pullMessage({ credential, tags, variables, workflow }),
type: 'success' as const,
},
];
for (const message of toastMessages) {
/**
* the toasts stack in a reversed way, resulting in
* Success
* Credentials
* Variables
*/
//
toast.showToast(message);
await nextTick();
}
};