mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat: Synchronize deletions when pulling from source control (#12170)
Co-authored-by: r00gm <raul00gm@gmail.com>
This commit is contained in:
parent
f167578b32
commit
967ee4b89b
|
@ -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' });
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 ||
|
sourceControlledFiles.push({
|
||||||
varMissingInRemote.length > 0 ||
|
id: item.id,
|
||||||
varModifiedInEither.length > 0
|
name: item.key,
|
||||||
) {
|
type: 'variables',
|
||||||
if (options.direction === 'pull' && varRemoteIds.length === 0) {
|
status: options.direction === 'push' ? 'deleted' : 'created',
|
||||||
// if there's nothing to pull, don't show difference as modified
|
location: options.direction === 'push' ? 'local' : 'remote',
|
||||||
} else {
|
conflict: false,
|
||||||
sourceControlledFiles.push({
|
file: getVariablesPath(this.gitFolder),
|
||||||
id: 'variables',
|
updatedAt: new Date().toISOString(),
|
||||||
name: 'variables',
|
});
|
||||||
type: 'variables',
|
});
|
||||||
status: 'modified',
|
|
||||||
location: options.direction === 'push' ? 'local' : 'remote',
|
varMissingInRemote.forEach((item) => {
|
||||||
conflict: false,
|
sourceControlledFiles.push({
|
||||||
file: getVariablesPath(this.gitFolder),
|
id: item.id,
|
||||||
updatedAt: new Date().toISOString(),
|
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 ||
|
sourceControlledFiles.push({
|
||||||
tagsMissingInRemote.length > 0 ||
|
id: item.id,
|
||||||
tagsModifiedInEither.length > 0 ||
|
name: item.name,
|
||||||
mappingsMissingInLocal.length > 0 ||
|
type: 'tags',
|
||||||
mappingsMissingInRemote.length > 0
|
status: options.direction === 'push' ? 'deleted' : 'created',
|
||||||
) {
|
location: options.direction === 'push' ? 'local' : 'remote',
|
||||||
if (
|
conflict: false,
|
||||||
options.direction === 'pull' &&
|
file: getTagsPath(this.gitFolder),
|
||||||
tagMappingsRemote.tags.length === 0 &&
|
updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(),
|
||||||
tagMappingsRemote.mappings.length === 0
|
});
|
||||||
) {
|
});
|
||||||
// if there's nothing to pull, don't show difference as modified
|
tagsMissingInRemote.forEach((item) => {
|
||||||
} else {
|
sourceControlledFiles.push({
|
||||||
sourceControlledFiles.push({
|
id: item.id,
|
||||||
id: 'mappings',
|
name: item.name,
|
||||||
name: 'tags',
|
type: 'tags',
|
||||||
type: 'tags',
|
status: options.direction === 'push' ? 'created' : 'deleted',
|
||||||
status: 'modified',
|
location: options.direction === 'push' ? 'local' : 'remote',
|
||||||
location: options.direction === 'push' ? 'local' : 'remote',
|
conflict: options.direction === 'push' ? false : true,
|
||||||
conflict: false,
|
file: getTagsPath(this.gitFolder),
|
||||||
file: getTagsPath(this.gitFolder),
|
updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(),
|
||||||
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,
|
||||||
|
|
|
@ -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', {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
@ -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' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
|
@ -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'));
|
||||||
|
|
||||||
const modifiedWorkflowFiles = computed(() => {
|
type ItemsList = Array<
|
||||||
return workflowFiles.value.filter((file) => file.status === 'modified');
|
{ 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
acc.push(...groupedFilesByType.value[fileType]);
|
||||||
|
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>
|
</div>
|
||||||
</ul>
|
<DynamicScrollerItem
|
||||||
</div>
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
119
packages/editor-ui/src/utils/sourceControlUtils.test.ts
Normal file
119
packages/editor-ui/src/utils/sourceControlUtils.test.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
142
packages/editor-ui/src/utils/sourceControlUtils.ts
Normal file
142
packages/editor-ui/src/utils/sourceControlUtils.ts
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in a new issue