mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 12:44:07 -08:00
fix(editor): Connect up new project viewer role to the FE (#9913)
This commit is contained in:
parent
56c4692c94
commit
117e2d968f
|
@ -1,4 +1,5 @@
|
||||||
import { CredentialsModal, WorkflowPage } from '../pages';
|
import { CredentialsModal, WorkflowPage } from '../pages';
|
||||||
|
import { getVisibleSelect } from '../utils';
|
||||||
|
|
||||||
const workflowPage = new WorkflowPage();
|
const workflowPage = new WorkflowPage();
|
||||||
const credentialsModal = new CredentialsModal();
|
const credentialsModal = new CredentialsModal();
|
||||||
|
@ -11,18 +12,25 @@ export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
|
||||||
export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]');
|
export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]');
|
||||||
export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
|
export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
|
||||||
export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]');
|
export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]');
|
||||||
export const getProjectSettingsNameInput = () => cy.getByTestId('project-settings-name-input');
|
export const getProjectSettingsNameInput = () =>
|
||||||
|
cy.getByTestId('project-settings-name-input').find('input');
|
||||||
export const getProjectSettingsSaveButton = () => cy.getByTestId('project-settings-save-button');
|
export const getProjectSettingsSaveButton = () => cy.getByTestId('project-settings-save-button');
|
||||||
export const getProjectSettingsCancelButton = () =>
|
export const getProjectSettingsCancelButton = () =>
|
||||||
cy.getByTestId('project-settings-cancel-button');
|
cy.getByTestId('project-settings-cancel-button');
|
||||||
export const getProjectSettingsDeleteButton = () =>
|
export const getProjectSettingsDeleteButton = () =>
|
||||||
cy.getByTestId('project-settings-delete-button');
|
cy.getByTestId('project-settings-delete-button');
|
||||||
export const getProjectMembersSelect = () => cy.getByTestId('project-members-select');
|
export const getProjectMembersSelect = () => cy.getByTestId('project-members-select');
|
||||||
export const addProjectMember = (email: string) => {
|
export const addProjectMember = (email: string, role?: string) => {
|
||||||
getProjectMembersSelect().click();
|
getProjectMembersSelect().click();
|
||||||
getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click();
|
getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click();
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
cy.getByTestId(`user-list-item-${email}`)
|
||||||
|
.find('[data-test-id="projects-settings-user-role-select"]')
|
||||||
|
.click();
|
||||||
|
getVisibleSelect().find('li').contains(role).click();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
export const getProjectNameInput = () => cy.get('#projectName').find('input');
|
|
||||||
export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal');
|
export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal');
|
||||||
export const getResourceMoveConfirmModal = () =>
|
export const getResourceMoveConfirmModal = () =>
|
||||||
cy.getByTestId('project-move-resource-confirm-modal');
|
cy.getByTestId('project-move-resource-confirm-modal');
|
||||||
|
@ -31,12 +39,7 @@ export const getProjectMoveSelect = () => cy.getByTestId('project-move-resource-
|
||||||
export function createProject(name: string) {
|
export function createProject(name: string) {
|
||||||
getAddProjectButton().click();
|
getAddProjectButton().click();
|
||||||
|
|
||||||
getProjectNameInput()
|
getProjectSettingsNameInput().should('be.visible').clear().type(name);
|
||||||
.should('be.visible')
|
|
||||||
.should('be.focused')
|
|
||||||
.should('have.value', 'My project')
|
|
||||||
.clear()
|
|
||||||
.type(name);
|
|
||||||
getProjectSettingsSaveButton().click();
|
getProjectSettingsSaveButton().click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -145,7 +145,16 @@ describe('Canvas Actions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete connections by pressing the delete button', () => {
|
it('should delete node by pressing keyboard backspace', () => {
|
||||||
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
|
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||||
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
|
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click();
|
||||||
|
cy.get('body').type('{backspace}');
|
||||||
|
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete connections by clicking on the delete button', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
|
|
|
@ -264,6 +264,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
|
||||||
describe('Credential Usage in Cross Shared Workflows', () => {
|
describe('Credential Usage in Cross Shared Workflows', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.resetDatabase();
|
cy.resetDatabase();
|
||||||
|
cy.enableFeature('sharing');
|
||||||
cy.enableFeature('advancedPermissions');
|
cy.enableFeature('advancedPermissions');
|
||||||
cy.enableFeature('projectRole:admin');
|
cy.enableFeature('projectRole:admin');
|
||||||
cy.enableFeature('projectRole:editor');
|
cy.enableFeature('projectRole:editor');
|
||||||
|
@ -274,11 +275,6 @@ describe('Credential Usage in Cross Shared Workflows', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should only show credentials from the same team project', () => {
|
it('should only show credentials from the same team project', () => {
|
||||||
cy.enableFeature('advancedPermissions');
|
|
||||||
cy.enableFeature('projectRole:admin');
|
|
||||||
cy.enableFeature('projectRole:editor');
|
|
||||||
cy.changeQuota('maxTeamProjects', -1);
|
|
||||||
|
|
||||||
// Create a notion credential in the home project
|
// Create a notion credential in the home project
|
||||||
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||||
credentialsModal.actions.createNewCredential('Notion API');
|
credentialsModal.actions.createNewCredential('Notion API');
|
||||||
|
@ -305,10 +301,36 @@ describe('Credential Usage in Cross Shared Workflows', () => {
|
||||||
getVisibleSelect().find('li').should('have.length', 2);
|
getVisibleSelect().find('li').should('have.length', 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should only show credentials in their personal project for members', () => {
|
||||||
|
// Create a notion credential as the owner
|
||||||
|
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||||
|
credentialsModal.actions.createNewCredential('Notion API');
|
||||||
|
|
||||||
|
// Create another notion credential as the owner, but share it with member
|
||||||
|
// 0
|
||||||
|
credentialsPage.getters.createCredentialButton().click();
|
||||||
|
credentialsModal.actions.createNewCredential('Notion API', false);
|
||||||
|
credentialsModal.actions.changeTab('Sharing');
|
||||||
|
credentialsModal.actions.addUser(INSTANCE_MEMBERS[0].email);
|
||||||
|
credentialsModal.actions.saveSharing();
|
||||||
|
|
||||||
|
// As the member, create a new notion credential and a workflow
|
||||||
|
cy.signinAsMember();
|
||||||
|
cy.visit(credentialsPage.url);
|
||||||
|
credentialsPage.getters.createCredentialButton().click();
|
||||||
|
credentialsModal.actions.createNewCredential('Notion API');
|
||||||
|
cy.visit(workflowsPage.url);
|
||||||
|
workflowsPage.actions.createWorkflowFromCard();
|
||||||
|
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
|
||||||
|
|
||||||
|
// Only the own credential the shared one (+ the 'Create new' option)
|
||||||
|
// should be in the dropdown
|
||||||
|
workflowPage.getters.nodeCredentialsSelect().click();
|
||||||
|
getVisibleSelect().find('li').should('have.length', 3);
|
||||||
|
});
|
||||||
|
|
||||||
it('should only show credentials in their personal project for members if the workflow was shared with them', () => {
|
it('should only show credentials in their personal project for members if the workflow was shared with them', () => {
|
||||||
const workflowName = 'Test workflow';
|
const workflowName = 'Test workflow';
|
||||||
cy.enableFeature('sharing');
|
|
||||||
cy.reload();
|
|
||||||
|
|
||||||
// Create a notion credential as the owner and a workflow that is shared
|
// Create a notion credential as the owner and a workflow that is shared
|
||||||
// with member 0
|
// with member 0
|
||||||
|
@ -339,7 +361,6 @@ describe('Credential Usage in Cross Shared Workflows', () => {
|
||||||
|
|
||||||
it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => {
|
it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => {
|
||||||
const workflowName = 'Test workflow';
|
const workflowName = 'Test workflow';
|
||||||
cy.enableFeature('sharing');
|
|
||||||
|
|
||||||
// As member 1, create a new notion credential. This should not show up.
|
// As member 1, create a new notion credential. This should not show up.
|
||||||
cy.signinAsMember(1);
|
cy.signinAsMember(1);
|
||||||
|
@ -384,8 +405,6 @@ describe('Credential Usage in Cross Shared Workflows', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show all personal credentials if the global owner owns the workflow', () => {
|
it('should show all personal credentials if the global owner owns the workflow', () => {
|
||||||
cy.enableFeature('sharing');
|
|
||||||
|
|
||||||
// As member 0, create a new notion credential.
|
// As member 0, create a new notion credential.
|
||||||
cy.signinAsMember();
|
cy.signinAsMember();
|
||||||
cy.visit(credentialsPage.url);
|
cy.visit(credentialsPage.url);
|
||||||
|
|
|
@ -1,9 +1,4 @@
|
||||||
import {
|
import { INSTANCE_MEMBERS, MANUAL_TRIGGER_NODE_NAME, NOTION_NODE_NAME } from '../constants';
|
||||||
INSTANCE_MEMBERS,
|
|
||||||
INSTANCE_OWNER,
|
|
||||||
MANUAL_TRIGGER_NODE_NAME,
|
|
||||||
NOTION_NODE_NAME,
|
|
||||||
} from '../constants';
|
|
||||||
import {
|
import {
|
||||||
WorkflowsPage,
|
WorkflowsPage,
|
||||||
WorkflowPage,
|
WorkflowPage,
|
||||||
|
@ -11,9 +6,10 @@ import {
|
||||||
CredentialsPage,
|
CredentialsPage,
|
||||||
WorkflowExecutionsTab,
|
WorkflowExecutionsTab,
|
||||||
NDV,
|
NDV,
|
||||||
|
MainSidebar,
|
||||||
} from '../pages';
|
} from '../pages';
|
||||||
import * as projects from '../composables/projects';
|
import * as projects from '../composables/projects';
|
||||||
import { getVisibleSelect } from '../utils';
|
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
|
||||||
|
|
||||||
const workflowsPage = new WorkflowsPage();
|
const workflowsPage = new WorkflowsPage();
|
||||||
const workflowPage = new WorkflowPage();
|
const workflowPage = new WorkflowPage();
|
||||||
|
@ -21,6 +17,7 @@ const credentialsPage = new CredentialsPage();
|
||||||
const credentialsModal = new CredentialsModal();
|
const credentialsModal = new CredentialsModal();
|
||||||
const executionsTab = new WorkflowExecutionsTab();
|
const executionsTab = new WorkflowExecutionsTab();
|
||||||
const ndv = new NDV();
|
const ndv = new NDV();
|
||||||
|
const mainSidebar = new MainSidebar();
|
||||||
|
|
||||||
describe('Projects', { disableAutoLogin: true }, () => {
|
describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
before(() => {
|
before(() => {
|
||||||
|
@ -241,6 +238,26 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
projects.getMenuItems().should('not.exist');
|
projects.getMenuItems().should('not.exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not show viewer role if not licensed', () => {
|
||||||
|
cy.signinAsOwner();
|
||||||
|
cy.visit(workflowsPage.url);
|
||||||
|
|
||||||
|
projects.getMenuItems().first().click();
|
||||||
|
projects.getProjectTabSettings().click();
|
||||||
|
|
||||||
|
cy.get(
|
||||||
|
`[data-test-id="user-list-item-${INSTANCE_MEMBERS[0].email}"] [data-test-id="projects-settings-user-role-select"]`,
|
||||||
|
).click();
|
||||||
|
|
||||||
|
cy.get('.el-select-dropdown__item.is-disabled')
|
||||||
|
.should('contain.text', 'Viewer')
|
||||||
|
.get('span:contains("Upgrade")')
|
||||||
|
.filter(':visible')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
getVisibleModalOverlay().should('contain.text', 'Upgrade to unlock additional roles');
|
||||||
|
});
|
||||||
|
|
||||||
describe('when starting from scratch', () => {
|
describe('when starting from scratch', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.resetDatabase();
|
cy.resetDatabase();
|
||||||
|
@ -257,7 +274,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
|
|
||||||
// Create a project and add a credential to it
|
// Create a project and add a credential to it
|
||||||
cy.intercept('POST', '/rest/projects').as('projectCreate');
|
cy.intercept('POST', '/rest/projects').as('projectCreate');
|
||||||
projects.getAddProjectButton().should('contain', 'Add project').should('be.visible').click();
|
projects.getAddProjectButton().click();
|
||||||
cy.wait('@projectCreate');
|
cy.wait('@projectCreate');
|
||||||
projects.getMenuItems().should('have.length', 1);
|
projects.getMenuItems().should('have.length', 1);
|
||||||
projects.getMenuItems().first().click();
|
projects.getMenuItems().first().click();
|
||||||
|
@ -418,7 +435,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should move resources between projects', () => {
|
it('should move resources between projects', () => {
|
||||||
cy.signin(INSTANCE_OWNER);
|
cy.signinAsOwner();
|
||||||
cy.visit(workflowsPage.url);
|
cy.visit(workflowsPage.url);
|
||||||
|
|
||||||
// Create a workflow and a credential in the Home project
|
// Create a workflow and a credential in the Home project
|
||||||
|
@ -563,5 +580,80 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
projects.getProjectTabCredentials().click();
|
projects.getProjectTabCredentials().click();
|
||||||
credentialsPage.getters.credentialCards().should('have.length', 2);
|
credentialsPage.getters.credentialCards().should('have.length', 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle viewer role', () => {
|
||||||
|
cy.enableFeature('projectRole:viewer');
|
||||||
|
cy.signinAsOwner();
|
||||||
|
cy.visit(workflowsPage.url);
|
||||||
|
|
||||||
|
projects.createProject('Development');
|
||||||
|
projects.addProjectMember(INSTANCE_MEMBERS[0].email, 'Viewer');
|
||||||
|
projects.getProjectSettingsSaveButton().click();
|
||||||
|
|
||||||
|
projects.getProjectTabWorkflows().click();
|
||||||
|
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||||
|
projects.createWorkflow('Test_workflow_4_executions_view.json', 'WF with random error');
|
||||||
|
executionsTab.actions.createManualExecutions(2);
|
||||||
|
executionsTab.actions.toggleNodeEnabled('Error');
|
||||||
|
executionsTab.actions.createManualExecutions(2);
|
||||||
|
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
|
||||||
|
|
||||||
|
projects.getMenuItems().first().click();
|
||||||
|
projects.getProjectTabCredentials().click();
|
||||||
|
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||||
|
projects.createCredential('Notion API');
|
||||||
|
|
||||||
|
mainSidebar.actions.openUserMenu();
|
||||||
|
cy.getByTestId('user-menu-item-logout').click();
|
||||||
|
|
||||||
|
cy.get('input[name="email"]').type(INSTANCE_MEMBERS[0].email);
|
||||||
|
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
|
||||||
|
cy.getByTestId('form-submit-button').click();
|
||||||
|
|
||||||
|
mainSidebar.getters.executions().click();
|
||||||
|
cy.getByTestId('global-execution-list-item').first().find('td:last button').click();
|
||||||
|
getVisibleDropdown()
|
||||||
|
.find('li')
|
||||||
|
.filter(':contains("Retry")')
|
||||||
|
.should('have.class', 'is-disabled');
|
||||||
|
getVisibleDropdown()
|
||||||
|
.find('li')
|
||||||
|
.filter(':contains("Delete")')
|
||||||
|
.should('have.class', 'is-disabled');
|
||||||
|
|
||||||
|
projects.getMenuItems().first().click();
|
||||||
|
cy.getByTestId('workflow-card-name').should('be.visible').first().click();
|
||||||
|
workflowPage.getters.nodeViewRoot().should('be.visible');
|
||||||
|
workflowPage.getters.executeWorkflowButton().should('not.exist');
|
||||||
|
workflowPage.getters.nodeCreatorPlusButton().should('not.exist');
|
||||||
|
workflowPage.getters.canvasNodes().should('have.length', 3).last().click();
|
||||||
|
cy.get('body').type('{backspace}');
|
||||||
|
workflowPage.getters.canvasNodes().should('have.length', 3).last().rightclick();
|
||||||
|
getVisibleDropdown()
|
||||||
|
.find('li')
|
||||||
|
.should('be.visible')
|
||||||
|
.filter(
|
||||||
|
':contains("Open"), :contains("Copy"), :contains("Select all"), :contains("Clear selection")',
|
||||||
|
)
|
||||||
|
.should('not.have.class', 'is-disabled');
|
||||||
|
cy.get('body').type('{esc}');
|
||||||
|
|
||||||
|
executionsTab.actions.switchToExecutionsTab();
|
||||||
|
cy.getByTestId('retry-execution-button')
|
||||||
|
.should('be.visible')
|
||||||
|
.find('.is-disabled')
|
||||||
|
.should('exist');
|
||||||
|
cy.get('button:contains("Debug")').should('be.disabled');
|
||||||
|
cy.get('button[title="Retry execution"]').should('be.disabled');
|
||||||
|
cy.get('button[title="Delete this execution"]').should('be.disabled');
|
||||||
|
|
||||||
|
projects.getMenuItems().first().click();
|
||||||
|
projects.getProjectTabCredentials().click();
|
||||||
|
credentialsPage.getters.credentialCards().filter(':contains("Notion")').click();
|
||||||
|
cy.getByTestId('node-credentials-config-container')
|
||||||
|
.should('be.visible')
|
||||||
|
.find('input')
|
||||||
|
.should('not.have.length');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
23
packages/@n8n/permissions/src/constants.ts
Normal file
23
packages/@n8n/permissions/src/constants.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const;
|
||||||
|
export const RESOURCES = {
|
||||||
|
auditLogs: ['manage'] as const,
|
||||||
|
banner: ['dismiss'] as const,
|
||||||
|
communityPackage: ['install', 'uninstall', 'update', 'list', 'manage'] as const,
|
||||||
|
credential: ['share', 'move', ...DEFAULT_OPERATIONS] as const,
|
||||||
|
externalSecretsProvider: ['sync', ...DEFAULT_OPERATIONS] as const,
|
||||||
|
externalSecret: ['list', 'use'] as const,
|
||||||
|
eventBusDestination: ['test', ...DEFAULT_OPERATIONS] as const,
|
||||||
|
ldap: ['sync', 'manage'] as const,
|
||||||
|
license: ['manage'] as const,
|
||||||
|
logStreaming: ['manage'] as const,
|
||||||
|
orchestration: ['read', 'list'] as const,
|
||||||
|
project: [...DEFAULT_OPERATIONS] as const,
|
||||||
|
saml: ['manage'] as const,
|
||||||
|
securityAudit: ['generate'] as const,
|
||||||
|
sourceControl: ['pull', 'push', 'manage'] as const,
|
||||||
|
tag: [...DEFAULT_OPERATIONS] as const,
|
||||||
|
user: ['resetPassword', 'changeRole', ...DEFAULT_OPERATIONS] as const,
|
||||||
|
variable: [...DEFAULT_OPERATIONS] as const,
|
||||||
|
workersView: ['manage'] as const,
|
||||||
|
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
|
||||||
|
} as const;
|
|
@ -1,3 +1,4 @@
|
||||||
export type * from './types';
|
export type * from './types';
|
||||||
|
export * from './constants';
|
||||||
export * from './hasScope';
|
export * from './hasScope';
|
||||||
export * from './combineScopes';
|
export * from './combineScopes';
|
||||||
|
|
|
@ -1,25 +1,7 @@
|
||||||
export type DefaultOperations = 'create' | 'read' | 'update' | 'delete' | 'list';
|
import type { DEFAULT_OPERATIONS, RESOURCES } from './constants';
|
||||||
export type Resource =
|
|
||||||
| 'auditLogs'
|
export type DefaultOperations = (typeof DEFAULT_OPERATIONS)[number];
|
||||||
| 'banner'
|
export type Resource = keyof typeof RESOURCES;
|
||||||
| 'communityPackage'
|
|
||||||
| 'credential'
|
|
||||||
| 'externalSecretsProvider'
|
|
||||||
| 'externalSecret'
|
|
||||||
| 'eventBusDestination'
|
|
||||||
| 'ldap'
|
|
||||||
| 'license'
|
|
||||||
| 'logStreaming'
|
|
||||||
| 'orchestration'
|
|
||||||
| 'project'
|
|
||||||
| 'saml'
|
|
||||||
| 'securityAudit'
|
|
||||||
| 'sourceControl'
|
|
||||||
| 'tag'
|
|
||||||
| 'user'
|
|
||||||
| 'variable'
|
|
||||||
| 'workersView'
|
|
||||||
| 'workflow';
|
|
||||||
|
|
||||||
export type ResourceScope<
|
export type ResourceScope<
|
||||||
R extends Resource,
|
R extends Resource,
|
||||||
|
|
|
@ -15,13 +15,19 @@
|
||||||
</slot>
|
</slot>
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
|
<N8nTooltip :disabled="!buttonDisabled">
|
||||||
|
<template #content>
|
||||||
|
<slot name="disabledButtonTooltip"></slot>
|
||||||
|
</template>
|
||||||
<N8nButton
|
<N8nButton
|
||||||
v-if="buttonText"
|
v-if="buttonText"
|
||||||
:label="buttonText"
|
:label="buttonText"
|
||||||
:type="buttonType"
|
:type="buttonType"
|
||||||
|
:disabled="buttonDisabled"
|
||||||
size="large"
|
size="large"
|
||||||
@click="$emit('click:button', $event)"
|
@click="$emit('click:button', $event)"
|
||||||
/>
|
/>
|
||||||
|
</N8nTooltip>
|
||||||
<N8nCallout
|
<N8nCallout
|
||||||
v-if="calloutText"
|
v-if="calloutText"
|
||||||
:theme="calloutTheme"
|
:theme="calloutTheme"
|
||||||
|
@ -41,12 +47,14 @@ import N8nHeading from '../N8nHeading';
|
||||||
import N8nText from '../N8nText';
|
import N8nText from '../N8nText';
|
||||||
import N8nCallout, { type CalloutTheme } from '../N8nCallout';
|
import N8nCallout, { type CalloutTheme } from '../N8nCallout';
|
||||||
import type { ButtonType } from 'n8n-design-system/types/button';
|
import type { ButtonType } from 'n8n-design-system/types/button';
|
||||||
|
import N8nTooltip from 'n8n-design-system/components/N8nTooltip/Tooltip.vue';
|
||||||
|
|
||||||
interface ActionBoxProps {
|
interface ActionBoxProps {
|
||||||
emoji: string;
|
emoji: string;
|
||||||
heading: string;
|
heading: string;
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
buttonType: ButtonType;
|
buttonType: ButtonType;
|
||||||
|
buttonDisabled?: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
calloutText?: string;
|
calloutText?: string;
|
||||||
calloutTheme?: CalloutTheme;
|
calloutTheme?: CalloutTheme;
|
||||||
|
|
|
@ -9,7 +9,9 @@ exports[`N8NActionBox > should render correctly 1`] = `
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<n8n-text-stub color="text-base" bold="false" size="medium" compact="false" tag="span"></n8n-text-stub>
|
<n8n-text-stub color="text-base" bold="false" size="medium" compact="false" tag="span"></n8n-text-stub>
|
||||||
</div>
|
</div>
|
||||||
<n8n-button-stub block="false" element="button" label="Do something" square="false" active="false" disabled="false" loading="false" outline="false" size="large" text="false" type="primary"></n8n-button-stub>
|
<n8n-button-stub block="false" element="button" label="Do something" square="false" active="false" disabled="false" loading="false" outline="false" size="large" text="false" type="primary" class="el-tooltip__trigger"></n8n-button-stub>
|
||||||
|
<!--teleport start-->
|
||||||
|
<!--teleport end-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
</div>"
|
</div>"
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
:trigger="trigger"
|
:trigger="trigger"
|
||||||
:popper-class="popperClass"
|
:popper-class="popperClass"
|
||||||
:teleported="teleported"
|
:teleported="teleported"
|
||||||
|
:disabled="disabled"
|
||||||
@command="onSelect"
|
@command="onSelect"
|
||||||
@visible-change="onVisibleChange"
|
@visible-change="onVisibleChange"
|
||||||
>
|
>
|
||||||
|
@ -76,6 +77,7 @@ interface ActionDropdownProps {
|
||||||
trigger?: (typeof TRIGGER)[number];
|
trigger?: (typeof TRIGGER)[number];
|
||||||
hideArrow?: boolean;
|
hideArrow?: boolean;
|
||||||
teleported?: boolean;
|
teleported?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<ActionDropdownProps>(), {
|
const props = withDefaults(defineProps<ActionDropdownProps>(), {
|
||||||
|
@ -86,6 +88,7 @@ const props = withDefaults(defineProps<ActionDropdownProps>(), {
|
||||||
trigger: 'click',
|
trigger: 'click',
|
||||||
hideArrow: false,
|
hideArrow: false,
|
||||||
teleported: true,
|
teleported: true,
|
||||||
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
|
|
@ -19,6 +19,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include mixins.when(disabled) {
|
||||||
|
.el-tooltip__trigger {
|
||||||
|
opacity: 0.25;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
& .el-dropdown__caret-button {
|
& .el-dropdown__caret-button {
|
||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
|
|
|
@ -5,7 +5,7 @@ import type { ICredentialsResponse } from '@/Interface';
|
||||||
import { MODAL_CONFIRM, PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
|
import { MODAL_CONFIRM, PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import CredentialIcon from '@/components/CredentialIcon.vue';
|
import CredentialIcon from '@/components/CredentialIcon.vue';
|
||||||
import { getCredentialPermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import TimeAgo from '@/components/TimeAgo.vue';
|
import TimeAgo from '@/components/TimeAgo.vue';
|
||||||
|
@ -48,7 +48,7 @@ const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase());
|
const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase());
|
||||||
const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type));
|
const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type));
|
||||||
const credentialPermissions = computed(() => getCredentialPermissions(props.data));
|
const credentialPermissions = computed(() => getResourcePermissions(props.data.scopes).credential);
|
||||||
const actions = computed(() => {
|
const actions = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -172,14 +172,13 @@ import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { BUILTIN_CREDENTIALS_DOCS_URL, DOCS_DOMAIN, EnterpriseEditionFeature } from '@/constants';
|
import { BUILTIN_CREDENTIALS_DOCS_URL, DOCS_DOMAIN, EnterpriseEditionFeature } from '@/constants';
|
||||||
import type { PermissionsMap } from '@/permissions';
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
import { addCredentialTranslation } from '@/plugins/i18n';
|
import { addCredentialTranslation } from '@/plugins/i18n';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import type { CredentialScope } from '@n8n/permissions';
|
|
||||||
import Banner from '../Banner.vue';
|
import Banner from '../Banner.vue';
|
||||||
import CopyInput from '../CopyInput.vue';
|
import CopyInput from '../CopyInput.vue';
|
||||||
import CredentialInputs from './CredentialInputs.vue';
|
import CredentialInputs from './CredentialInputs.vue';
|
||||||
|
@ -194,7 +193,7 @@ type Props = {
|
||||||
credentialProperties: INodeProperties[];
|
credentialProperties: INodeProperties[];
|
||||||
credentialData: ICredentialDataDecryptedObject;
|
credentialData: ICredentialDataDecryptedObject;
|
||||||
credentialId?: string;
|
credentialId?: string;
|
||||||
credentialPermissions?: PermissionsMap<CredentialScope>;
|
credentialPermissions: PermissionsRecord['credential'];
|
||||||
parentTypes?: string[];
|
parentTypes?: string[];
|
||||||
showValidationWarning?: boolean;
|
showValidationWarning?: boolean;
|
||||||
authError?: string;
|
authError?: string;
|
||||||
|
@ -212,7 +211,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
credentialId: '',
|
credentialId: '',
|
||||||
authError: '',
|
authError: '',
|
||||||
showValidationWarning: false,
|
showValidationWarning: false,
|
||||||
credentialPermissions: () => ({}) as PermissionsMap<CredentialScope>,
|
credentialPermissions: () => ({}) as PermissionsRecord['credential'],
|
||||||
});
|
});
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
update: [value: IUpdateInformation];
|
update: [value: IUpdateInformation];
|
||||||
|
|
|
@ -145,8 +145,7 @@ import { useMessage } from '@/composables/useMessage';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { CREDENTIAL_EDIT_MODAL_KEY, EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
|
import { CREDENTIAL_EDIT_MODAL_KEY, EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
|
||||||
import type { PermissionsMap } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { getCredentialPermissions } from '@/permissions';
|
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
@ -169,7 +168,6 @@ import {
|
||||||
updateNodeAuthType,
|
updateNodeAuthType,
|
||||||
} from '@/utils/nodeTypesUtils';
|
} from '@/utils/nodeTypesUtils';
|
||||||
import { isCredentialModalState, isValidCredentialResponse } from '@/utils/typeGuards';
|
import { isCredentialModalState, isValidCredentialResponse } from '@/utils/typeGuards';
|
||||||
import type { CredentialScope } from '@n8n/permissions';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
modalName: string;
|
modalName: string;
|
||||||
|
@ -395,14 +393,11 @@ const requiredPropertiesFilled = computed(() => {
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const credentialPermissions = computed<PermissionsMap<CredentialScope>>(() => {
|
const credentialPermissions = computed(() => {
|
||||||
if (loading.value) {
|
return getResourcePermissions(
|
||||||
return {} as PermissionsMap<CredentialScope>;
|
((credentialId.value ? currentCredential.value : credentialData.value) as ICredentialsResponse)
|
||||||
}
|
?.scopes,
|
||||||
|
).credential;
|
||||||
return getCredentialPermissions(
|
|
||||||
(credentialId.value ? currentCredential.value : credentialData.value) as ICredentialsResponse,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const sidebarItems = computed(() => {
|
const sidebarItems = computed(() => {
|
||||||
|
|
|
@ -68,8 +68,7 @@ import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
|
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||||
import type { PermissionsMap } from '@/permissions';
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
import type { CredentialScope } from '@n8n/permissions';
|
|
||||||
import type { EventBus } from 'n8n-design-system/utils';
|
import type { EventBus } from 'n8n-design-system/utils';
|
||||||
import { useRolesStore } from '@/stores/roles.store';
|
import { useRolesStore } from '@/stores/roles.store';
|
||||||
import type { RoleMap } from '@/types/roles.types';
|
import type { RoleMap } from '@/types/roles.types';
|
||||||
|
@ -94,7 +93,7 @@ export default defineComponent({
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
credentialPermissions: {
|
credentialPermissions: {
|
||||||
type: Object as PropType<PermissionsMap<CredentialScope>>,
|
type: Object as PropType<PermissionsRecord['credential']>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
modalBus: {
|
modalBus: {
|
||||||
|
|
|
@ -13,9 +13,6 @@ import {
|
||||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||||
WORKFLOW_SHARE_MODAL_KEY,
|
WORKFLOW_SHARE_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type { PermissionsMap } from '@/permissions';
|
|
||||||
import type { WorkflowScope } from '@n8n/permissions';
|
|
||||||
|
|
||||||
import ShortenName from '@/components/ShortenName.vue';
|
import ShortenName from '@/components/ShortenName.vue';
|
||||||
import TagsContainer from '@/components/TagsContainer.vue';
|
import TagsContainer from '@/components/TagsContainer.vue';
|
||||||
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
|
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
|
||||||
|
@ -38,8 +35,7 @@ import { saveAs } from 'file-saver';
|
||||||
import { useTitleChange } from '@/composables/useTitleChange';
|
import { useTitleChange } from '@/composables/useTitleChange';
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { getWorkflowPermissions } from '@/permissions';
|
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
import { nodeViewEventBus } from '@/event-bus';
|
import { nodeViewEventBus } from '@/event-bus';
|
||||||
import { hasPermission } from '@/utils/rbac/permissions';
|
import { hasPermission } from '@/utils/rbac/permissions';
|
||||||
|
@ -55,7 +51,7 @@ import type {
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import type { BaseTextKey } from '../../plugins/i18n';
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
|
||||||
|
@ -140,9 +136,9 @@ const onExecutionsTab = computed(() => {
|
||||||
].includes((route.name as string) || '');
|
].includes((route.name as string) || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
const workflowPermissions = computed<PermissionsMap<WorkflowScope>>(() => {
|
const workflowPermissions = computed(
|
||||||
return getWorkflowPermissions(workflowsStore.getWorkflowById(props.workflow.id));
|
() => getResourcePermissions(workflowsStore.getWorkflowById(props.workflow.id)?.scopes).workflow,
|
||||||
});
|
);
|
||||||
|
|
||||||
const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
|
const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
|
||||||
const actions: ActionDropdownItem[] = [
|
const actions: ActionDropdownItem[] = [
|
||||||
|
@ -153,7 +149,7 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!props.readOnly) {
|
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
|
||||||
actions.unshift({
|
actions.unshift({
|
||||||
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
|
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
|
||||||
label: locale.baseText('menuActions.duplicate'),
|
label: locale.baseText('menuActions.duplicate'),
|
||||||
|
@ -631,7 +627,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
||||||
:preview-value="shortenedName"
|
:preview-value="shortenedName"
|
||||||
:is-edit-enabled="isNameEditEnabled"
|
:is-edit-enabled="isNameEditEnabled"
|
||||||
:max-length="MAX_WORKFLOW_NAME_LENGTH"
|
:max-length="MAX_WORKFLOW_NAME_LENGTH"
|
||||||
:disabled="readOnly"
|
:disabled="readOnly || (!isNewWorkflow && !workflowPermissions.update)"
|
||||||
placeholder="Enter workflow name"
|
placeholder="Enter workflow name"
|
||||||
class="name"
|
class="name"
|
||||||
@toggle="onNameToggle"
|
@toggle="onNameToggle"
|
||||||
|
@ -644,7 +640,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
||||||
|
|
||||||
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
|
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
|
||||||
<TagsDropdown
|
<TagsDropdown
|
||||||
v-if="isTagsEditEnabled && !readOnly"
|
v-if="isTagsEditEnabled && !readOnly && (isNewWorkflow || workflowPermissions.update)"
|
||||||
ref="dropdown"
|
ref="dropdown"
|
||||||
v-model="appliedTagIds"
|
v-model="appliedTagIds"
|
||||||
:event-bus="tagsEventBus"
|
:event-bus="tagsEventBus"
|
||||||
|
@ -654,7 +650,13 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
||||||
@blur="onTagsBlur"
|
@blur="onTagsBlur"
|
||||||
@esc="onTagsEditEsc"
|
@esc="onTagsEditEsc"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="(workflow.tags ?? []).length === 0 && !readOnly">
|
<div
|
||||||
|
v-else-if="
|
||||||
|
(workflow.tags ?? []).length === 0 &&
|
||||||
|
!readOnly &&
|
||||||
|
(isNewWorkflow || workflowPermissions.update)
|
||||||
|
"
|
||||||
|
>
|
||||||
<span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable">
|
<span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable">
|
||||||
+ {{ $locale.baseText('workflowDetails.addTag') }}
|
+ {{ $locale.baseText('workflowDetails.addTag') }}
|
||||||
</span>
|
</span>
|
||||||
|
@ -673,7 +675,11 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
||||||
|
|
||||||
<PushConnectionTracker class="actions">
|
<PushConnectionTracker class="actions">
|
||||||
<span :class="`activator ${$style.group}`">
|
<span :class="`activator ${$style.group}`">
|
||||||
<WorkflowActivator :workflow-active="workflow.active" :workflow-id="workflow.id" />
|
<WorkflowActivator
|
||||||
|
:workflow-active="workflow.active"
|
||||||
|
:workflow-id="workflow.id"
|
||||||
|
:workflow-permissions="workflowPermissions"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
|
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
|
||||||
<div :class="$style.group">
|
<div :class="$style.group">
|
||||||
|
@ -717,9 +723,11 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
||||||
<SaveButton
|
<SaveButton
|
||||||
type="primary"
|
type="primary"
|
||||||
:saved="!uiStore.stateIsDirty && !isNewWorkflow"
|
:saved="!uiStore.stateIsDirty && !isNewWorkflow"
|
||||||
:disabled="isWorkflowSaving || readOnly"
|
:disabled="
|
||||||
|
isWorkflowSaving || readOnly || (!isNewWorkflow && !workflowPermissions.update)
|
||||||
|
"
|
||||||
:is-saving="isWorkflowSaving"
|
:is-saving="isWorkflowSaving"
|
||||||
with-shortcut
|
:with-shortcut="!readOnly && workflowPermissions.update"
|
||||||
:shortcut-tooltip="$locale.baseText('saveWorkflowButton.hint')"
|
:shortcut-tooltip="$locale.baseText('saveWorkflowButton.hint')"
|
||||||
data-test-id="workflow-save-button"
|
data-test-id="workflow-save-button"
|
||||||
@click="onSaveButtonClick"
|
@click="onSaveButtonClick"
|
||||||
|
|
|
@ -56,12 +56,7 @@
|
||||||
<div :class="$style.userArea">
|
<div :class="$style.userArea">
|
||||||
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
||||||
<!-- This dropdown is only enabled when sidebar is collapsed -->
|
<!-- This dropdown is only enabled when sidebar is collapsed -->
|
||||||
<el-dropdown
|
<el-dropdown placement="right-end" trigger="click" @command="onUserActionToggle">
|
||||||
:disabled="!isCollapsed"
|
|
||||||
placement="right-end"
|
|
||||||
trigger="click"
|
|
||||||
@command="onUserActionToggle"
|
|
||||||
>
|
|
||||||
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
|
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
|
||||||
<n8n-avatar
|
<n8n-avatar
|
||||||
:first-name="usersStore.currentUser?.firstName"
|
:first-name="usersStore.currentUser?.firstName"
|
||||||
|
@ -69,7 +64,7 @@
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template #dropdown>
|
<template v-if="isCollapsed" #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item command="settings">
|
<el-dropdown-item command="settings">
|
||||||
{{ $locale.baseText('settings') }}
|
{{ $locale.baseText('settings') }}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { useRoute } from 'vue-router';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import { getProjectPermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
@ -13,6 +13,9 @@ const route = useRoute();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
const selectedTab = ref<RouteRecordName | null | undefined>('');
|
const selectedTab = ref<RouteRecordName | null | undefined>('');
|
||||||
|
const projectPermissions = computed(
|
||||||
|
() => getResourcePermissions(projectsStore.currentProject?.scopes).project,
|
||||||
|
);
|
||||||
const options = computed(() => {
|
const options = computed(() => {
|
||||||
const projectId = route?.params?.projectId;
|
const projectId = route?.params?.projectId;
|
||||||
const to = projectId
|
const to = projectId
|
||||||
|
@ -47,7 +50,7 @@ const options = computed(() => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (projectId && getProjectPermissions(projectsStore.currentProject).update) {
|
if (projectId && projectPermissions.value.update) {
|
||||||
tabs.push({
|
tabs.push({
|
||||||
label: locale.baseText('projects.settings'),
|
label: locale.baseText('projects.settings'),
|
||||||
value: VIEWS.PROJECT_SETTINGS,
|
value: VIEWS.PROJECT_SETTINGS,
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { useClipboard } from '@/composables/useClipboard';
|
||||||
import { EnterpriseEditionFeature } from '@/constants';
|
import { EnterpriseEditionFeature } from '@/constants';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { getVariablesPermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import type { IResource } from './layouts/ResourcesListLayout.vue';
|
import type { IResource } from './layouts/ResourcesListLayout.vue';
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
@ -34,7 +34,9 @@ const props = withDefaults(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const permissions = computed(() => getVariablesPermissions(usersStore.currentUser));
|
const permissions = computed(
|
||||||
|
() => getResourcePermissions(usersStore.currentUser?.globalScopes).variable,
|
||||||
|
);
|
||||||
const modelValue = ref<IResource>({ ...props.data });
|
const modelValue = ref<IResource>({ ...props.data });
|
||||||
|
|
||||||
const formValidationStatus = ref<Record<string, boolean>>({
|
const formValidationStatus = ref<Record<string, boolean>>({
|
||||||
|
|
|
@ -5,8 +5,14 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { getActivatableTriggerNodes } from '@/utils/nodeTypesUtils';
|
import { getActivatableTriggerNodes } from '@/utils/nodeTypesUtils';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
|
import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
|
||||||
|
|
||||||
const props = defineProps<{ workflowActive: boolean; workflowId: string }>();
|
const props = defineProps<{
|
||||||
|
workflowActive: boolean;
|
||||||
|
workflowId: string;
|
||||||
|
workflowPermissions: PermissionsRecord['workflow'];
|
||||||
|
}>();
|
||||||
const { showMessage } = useToast();
|
const { showMessage } = useToast();
|
||||||
const workflowActivate = useWorkflowActivate();
|
const workflowActivate = useWorkflowActivate();
|
||||||
|
|
||||||
|
@ -35,9 +41,15 @@ const containsTrigger = computed((): boolean => {
|
||||||
return foundTriggers.length > 0;
|
return foundTriggers.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isNewWorkflow = computed(
|
||||||
|
() =>
|
||||||
|
!props.workflowId ||
|
||||||
|
props.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID ||
|
||||||
|
props.workflowId === 'new',
|
||||||
|
);
|
||||||
|
|
||||||
const disabled = computed((): boolean => {
|
const disabled = computed((): boolean => {
|
||||||
const isNewWorkflow = !props.workflowId;
|
if (isNewWorkflow.value || isCurrentWorkflow.value) {
|
||||||
if (isNewWorkflow || isCurrentWorkflow.value) {
|
|
||||||
return !props.workflowActive && !containsTrigger.value;
|
return !props.workflowActive && !containsTrigger.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,7 +120,11 @@ async function displayActivationError() {
|
||||||
? i18n.baseText('workflowActivator.deactivateWorkflow')
|
? i18n.baseText('workflowActivator.deactivateWorkflow')
|
||||||
: i18n.baseText('workflowActivator.activateWorkflow')
|
: i18n.baseText('workflowActivator.activateWorkflow')
|
||||||
"
|
"
|
||||||
:disabled="disabled || workflowActivate.updatingWorkflowActivation.value"
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
workflowActivate.updatingWorkflowActivation.value ||
|
||||||
|
(!isNewWorkflow && !workflowPermissions.update)
|
||||||
|
"
|
||||||
:active-color="getActiveColor"
|
:active-color="getActiveColor"
|
||||||
inactive-color="#8899AA"
|
inactive-color="#8899AA"
|
||||||
data-test-id="workflow-activate-switch"
|
data-test-id="workflow-activate-switch"
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { getWorkflowPermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import dateformat from 'dateformat';
|
import dateformat from 'dateformat';
|
||||||
import WorkflowActivator from '@/components/WorkflowActivator.vue';
|
import WorkflowActivator from '@/components/WorkflowActivator.vue';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
@ -75,7 +75,7 @@ const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
|
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
|
||||||
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
||||||
const workflowPermissions = computed(() => getWorkflowPermissions(props.data));
|
const workflowPermissions = computed(() => getResourcePermissions(props.data.scopes).workflow);
|
||||||
const actions = computed(() => {
|
const actions = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
|
@ -88,7 +88,7 @@ const actions = computed(() => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!props.readOnly) {
|
if (workflowPermissions.value.create && !props.readOnly) {
|
||||||
items.push({
|
items.push({
|
||||||
label: locale.baseText('workflows.item.duplicate'),
|
label: locale.baseText('workflows.item.duplicate'),
|
||||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
|
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
|
||||||
|
@ -274,6 +274,7 @@ function moveResource() {
|
||||||
class="mr-s"
|
class="mr-s"
|
||||||
:workflow-active="data.active"
|
:workflow-active="data.active"
|
||||||
:workflow-id="data.id"
|
:workflow-id="data.id"
|
||||||
|
:workflow-permissions="workflowPermissions"
|
||||||
data-test-id="workflow-card-activator"
|
data-test-id="workflow-card-activator"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,8 @@ import { EnterpriseEditionFeature, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constan
|
||||||
|
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
import type { IWorkflowDb } from '@/Interface';
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
|
import * as permissions from '@/permissions';
|
||||||
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
|
|
||||||
let pinia: ReturnType<typeof createPinia>;
|
let pinia: ReturnType<typeof createPinia>;
|
||||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
|
@ -52,6 +54,11 @@ describe('WorkflowSettingsVue', () => {
|
||||||
updatedAt: 1,
|
updatedAt: 1,
|
||||||
versionId: '123',
|
versionId: '123',
|
||||||
} as IWorkflowDb);
|
} as IWorkflowDb);
|
||||||
|
vi.spyOn(permissions, 'getResourcePermissions').mockReturnValue({
|
||||||
|
workflow: {
|
||||||
|
update: true,
|
||||||
|
},
|
||||||
|
} as PermissionsRecord);
|
||||||
|
|
||||||
uiStore.modalsById[WORKFLOW_SETTINGS_MODAL_KEY] = {
|
uiStore.modalsById[WORKFLOW_SETTINGS_MODAL_KEY] = {
|
||||||
open: true,
|
open: true,
|
|
@ -23,7 +23,7 @@
|
||||||
placeholder="Select Execution Order"
|
placeholder="Select Execution Order"
|
||||||
size="medium"
|
size="medium"
|
||||||
filterable
|
filterable
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:limit-popper-width="true"
|
:limit-popper-width="true"
|
||||||
data-test-id="workflow-settings-execution-order"
|
data-test-id="workflow-settings-execution-order"
|
||||||
>
|
>
|
||||||
|
@ -53,7 +53,7 @@
|
||||||
v-model="workflowSettings.errorWorkflow"
|
v-model="workflowSettings.errorWorkflow"
|
||||||
placeholder="Select Workflow"
|
placeholder="Select Workflow"
|
||||||
filterable
|
filterable
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:limit-popper-width="true"
|
:limit-popper-width="true"
|
||||||
data-test-id="workflow-settings-error-workflow"
|
data-test-id="workflow-settings-error-workflow"
|
||||||
>
|
>
|
||||||
|
@ -82,7 +82,7 @@
|
||||||
<el-col :span="14" class="ignore-key-press">
|
<el-col :span="14" class="ignore-key-press">
|
||||||
<n8n-select
|
<n8n-select
|
||||||
v-model="workflowSettings.callerPolicy"
|
v-model="workflowSettings.callerPolicy"
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||||
filterable
|
filterable
|
||||||
:limit-popper-width="true"
|
:limit-popper-width="true"
|
||||||
|
@ -110,7 +110,7 @@
|
||||||
<el-col :span="14">
|
<el-col :span="14">
|
||||||
<n8n-input
|
<n8n-input
|
||||||
v-model="workflowSettings.callerIds"
|
v-model="workflowSettings.callerIds"
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:placeholder="$locale.baseText('workflowSettings.callerIds.placeholder')"
|
:placeholder="$locale.baseText('workflowSettings.callerIds.placeholder')"
|
||||||
type="text"
|
type="text"
|
||||||
data-test-id="workflow-caller-policy-workflow-ids"
|
data-test-id="workflow-caller-policy-workflow-ids"
|
||||||
|
@ -134,7 +134,7 @@
|
||||||
v-model="workflowSettings.timezone"
|
v-model="workflowSettings.timezone"
|
||||||
placeholder="Select Timezone"
|
placeholder="Select Timezone"
|
||||||
filterable
|
filterable
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:limit-popper-width="true"
|
:limit-popper-width="true"
|
||||||
data-test-id="workflow-settings-timezone"
|
data-test-id="workflow-settings-timezone"
|
||||||
>
|
>
|
||||||
|
@ -163,7 +163,7 @@
|
||||||
v-model="workflowSettings.saveDataErrorExecution"
|
v-model="workflowSettings.saveDataErrorExecution"
|
||||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||||
filterable
|
filterable
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:limit-popper-width="true"
|
:limit-popper-width="true"
|
||||||
data-test-id="workflow-settings-save-failed-executions"
|
data-test-id="workflow-settings-save-failed-executions"
|
||||||
>
|
>
|
||||||
|
@ -192,7 +192,7 @@
|
||||||
v-model="workflowSettings.saveDataSuccessExecution"
|
v-model="workflowSettings.saveDataSuccessExecution"
|
||||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||||
filterable
|
filterable
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:limit-popper-width="true"
|
:limit-popper-width="true"
|
||||||
data-test-id="workflow-settings-save-success-executions"
|
data-test-id="workflow-settings-save-success-executions"
|
||||||
>
|
>
|
||||||
|
@ -221,7 +221,7 @@
|
||||||
v-model="workflowSettings.saveManualExecutions"
|
v-model="workflowSettings.saveManualExecutions"
|
||||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||||
filterable
|
filterable
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:limit-popper-width="true"
|
:limit-popper-width="true"
|
||||||
data-test-id="workflow-settings-save-manual-executions"
|
data-test-id="workflow-settings-save-manual-executions"
|
||||||
>
|
>
|
||||||
|
@ -250,7 +250,7 @@
|
||||||
v-model="workflowSettings.saveExecutionProgress"
|
v-model="workflowSettings.saveExecutionProgress"
|
||||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||||
filterable
|
filterable
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:limit-popper-width="true"
|
:limit-popper-width="true"
|
||||||
data-test-id="workflow-settings-save-execution-progress"
|
data-test-id="workflow-settings-save-execution-progress"
|
||||||
>
|
>
|
||||||
|
@ -278,7 +278,7 @@
|
||||||
<div>
|
<div>
|
||||||
<el-switch
|
<el-switch
|
||||||
ref="inputField"
|
ref="inputField"
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:model-value="(workflowSettings.executionTimeout ?? -1) > -1"
|
:model-value="(workflowSettings.executionTimeout ?? -1) > -1"
|
||||||
active-color="#13ce66"
|
active-color="#13ce66"
|
||||||
data-test-id="workflow-settings-timeout-workflow"
|
data-test-id="workflow-settings-timeout-workflow"
|
||||||
|
@ -303,7 +303,7 @@
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="4">
|
<el-col :span="4">
|
||||||
<n8n-input
|
<n8n-input
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:model-value="timeoutHMS.hours"
|
:model-value="timeoutHMS.hours"
|
||||||
:min="0"
|
:min="0"
|
||||||
@update:model-value="(value: string) => setTimeout('hours', value)"
|
@update:model-value="(value: string) => setTimeout('hours', value)"
|
||||||
|
@ -313,7 +313,7 @@
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="4" class="timeout-input">
|
<el-col :span="4" class="timeout-input">
|
||||||
<n8n-input
|
<n8n-input
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:model-value="timeoutHMS.minutes"
|
:model-value="timeoutHMS.minutes"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="60"
|
:max="60"
|
||||||
|
@ -324,7 +324,7 @@
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="4" class="timeout-input">
|
<el-col :span="4" class="timeout-input">
|
||||||
<n8n-input
|
<n8n-input
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:model-value="timeoutHMS.seconds"
|
:model-value="timeoutHMS.seconds"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="60"
|
:max="60"
|
||||||
|
@ -340,7 +340,7 @@
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="action-buttons" data-test-id="workflow-settings-save-button">
|
<div class="action-buttons" data-test-id="workflow-settings-save-button">
|
||||||
<n8n-button
|
<n8n-button
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||||
:label="$locale.baseText('workflowSettings.save')"
|
:label="$locale.baseText('workflowSettings.save')"
|
||||||
size="large"
|
size="large"
|
||||||
float="right"
|
float="right"
|
||||||
|
@ -379,12 +379,10 @@ import { useRootStore } from '@/stores/root.store';
|
||||||
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
|
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
import type { PermissionsMap } from '@/permissions';
|
|
||||||
import type { WorkflowScope } from '@n8n/permissions';
|
|
||||||
import { getWorkflowPermissions } from '@/permissions';
|
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'WorkflowSettings',
|
name: 'WorkflowSettings',
|
||||||
|
@ -489,8 +487,8 @@ export default defineComponent({
|
||||||
|
|
||||||
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflowId}`, fallback);
|
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflowId}`, fallback);
|
||||||
},
|
},
|
||||||
workflowPermissions(): PermissionsMap<WorkflowScope> {
|
workflowPermissions() {
|
||||||
return getWorkflowPermissions(this.workflow);
|
return getResourcePermissions(this.workflow?.scopes).workflow;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
@ -515,17 +513,17 @@ export default defineComponent({
|
||||||
this.defaultValues.workflowCallerPolicy = this.settingsStore.workflowCallerPolicyDefaultOption;
|
this.defaultValues.workflowCallerPolicy = this.settingsStore.workflowCallerPolicyDefaultOption;
|
||||||
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
const promises = [];
|
|
||||||
promises.push(this.loadWorkflows());
|
|
||||||
promises.push(this.loadSaveDataErrorExecutionOptions());
|
|
||||||
promises.push(this.loadSaveDataSuccessExecutionOptions());
|
|
||||||
promises.push(this.loadSaveExecutionProgressOptions());
|
|
||||||
promises.push(this.loadSaveManualOptions());
|
|
||||||
promises.push(this.loadTimezones());
|
|
||||||
promises.push(this.loadWorkflowCallerPolicyOptions());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(promises);
|
await Promise.all([
|
||||||
|
this.loadWorkflows(),
|
||||||
|
this.loadSaveDataErrorExecutionOptions(),
|
||||||
|
this.loadSaveDataSuccessExecutionOptions(),
|
||||||
|
this.loadSaveExecutionProgressOptions(),
|
||||||
|
this.loadSaveManualOptions(),
|
||||||
|
this.loadTimezones(),
|
||||||
|
this.loadWorkflowCallerPolicyOptions(),
|
||||||
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showError(
|
this.showError(
|
||||||
error,
|
error,
|
||||||
|
|
|
@ -130,9 +130,7 @@ import {
|
||||||
WORKFLOW_SHARE_MODAL_KEY,
|
WORKFLOW_SHARE_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type { IUser, IWorkflowDb } from '@/Interface';
|
import type { IUser, IWorkflowDb } from '@/Interface';
|
||||||
import type { PermissionsMap } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import type { WorkflowScope } from '@n8n/permissions';
|
|
||||||
import { getWorkflowPermissions } from '@/permissions';
|
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { nodeViewEventBus } from '@/event-bus';
|
import { nodeViewEventBus } from '@/event-bus';
|
||||||
|
@ -224,8 +222,8 @@ export default defineComponent({
|
||||||
currentUser(): IUser | null {
|
currentUser(): IUser | null {
|
||||||
return this.usersStore.currentUser;
|
return this.usersStore.currentUser;
|
||||||
},
|
},
|
||||||
workflowPermissions(): PermissionsMap<WorkflowScope> {
|
workflowPermissions() {
|
||||||
return getWorkflowPermissions(this.workflow);
|
return getResourcePermissions(this.workflow?.scopes).workflow;
|
||||||
},
|
},
|
||||||
workflowOwnerName(): string {
|
workflowOwnerName(): string {
|
||||||
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflow.id}`);
|
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflow.id}`);
|
||||||
|
|
|
@ -100,6 +100,9 @@ describe('GlobalExecutionsList', () => {
|
||||||
const { queryAllByTestId, queryByTestId, getByTestId } = renderComponent({
|
const { queryAllByTestId, queryByTestId, getByTestId } = renderComponent({
|
||||||
props: {
|
props: {
|
||||||
executions: [],
|
executions: [],
|
||||||
|
filters: {},
|
||||||
|
total: 0,
|
||||||
|
estimated: false,
|
||||||
},
|
},
|
||||||
pinia,
|
pinia,
|
||||||
});
|
});
|
||||||
|
@ -121,6 +124,8 @@ describe('GlobalExecutionsList', () => {
|
||||||
executions: executionsData[0].results,
|
executions: executionsData[0].results,
|
||||||
total: executionsData[0].count,
|
total: executionsData[0].count,
|
||||||
filteredExecutions: executionsData[0].results,
|
filteredExecutions: executionsData[0].results,
|
||||||
|
filters: {},
|
||||||
|
estimated: false,
|
||||||
},
|
},
|
||||||
pinia,
|
pinia,
|
||||||
});
|
});
|
||||||
|
@ -185,6 +190,8 @@ describe('GlobalExecutionsList', () => {
|
||||||
executions: executionsData[0].results,
|
executions: executionsData[0].results,
|
||||||
total: executionsData[0].count,
|
total: executionsData[0].count,
|
||||||
filteredExecutions: executionsData[0].results,
|
filteredExecutions: executionsData[0].results,
|
||||||
|
filters: {},
|
||||||
|
estimated: false,
|
||||||
},
|
},
|
||||||
pinia,
|
pinia,
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,6 +11,8 @@ import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
||||||
import type { ExecutionSummary } from 'n8n-workflow';
|
import type { ExecutionSummary } from 'n8n-workflow';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useExecutionsStore } from '@/stores/executions.store';
|
import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -162,6 +164,12 @@ function getExecutionWorkflowName(execution: ExecutionSummary): string {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExecutionWorkflowPermissions(
|
||||||
|
execution: ExecutionSummary,
|
||||||
|
): PermissionsRecord['workflow'] {
|
||||||
|
return getResourcePermissions(execution.scopes).workflow;
|
||||||
|
}
|
||||||
|
|
||||||
function getWorkflowName(workflowId: string): string | undefined {
|
function getWorkflowName(workflowId: string): string | undefined {
|
||||||
return workflows.value.find((data: IWorkflowDb) => data.id === workflowId)?.name;
|
return workflows.value.find((data: IWorkflowDb) => data.id === workflowId)?.name;
|
||||||
}
|
}
|
||||||
|
@ -344,7 +352,9 @@ async function onAutoRefreshToggle(value: boolean) {
|
||||||
:key="execution.id"
|
:key="execution.id"
|
||||||
:execution="execution"
|
:execution="execution"
|
||||||
:workflow-name="getExecutionWorkflowName(execution)"
|
:workflow-name="getExecutionWorkflowName(execution)"
|
||||||
|
:workflow-permissions="getExecutionWorkflowPermissions(execution)"
|
||||||
:selected="selectedItems[execution.id] || allExistingSelected"
|
:selected="selectedItems[execution.id] || allExistingSelected"
|
||||||
|
data-test-id="global-execution-list-item"
|
||||||
@stop="stopExecution"
|
@stop="stopExecution"
|
||||||
@delete="deleteExecution"
|
@delete="deleteExecution"
|
||||||
@select="toggleSelectExecution"
|
@select="toggleSelectExecution"
|
||||||
|
|
|
@ -55,6 +55,9 @@ describe('GlobalExecutionsListItem', () => {
|
||||||
retrySuccessfulId: undefined,
|
retrySuccessfulId: undefined,
|
||||||
waitTill: false,
|
waitTill: false,
|
||||||
},
|
},
|
||||||
|
workflowPermissions: {
|
||||||
|
execute: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -73,6 +76,9 @@ describe('GlobalExecutionsListItem', () => {
|
||||||
id: 123,
|
id: 123,
|
||||||
stoppedAt: undefined,
|
stoppedAt: undefined,
|
||||||
},
|
},
|
||||||
|
workflowPermissions: {
|
||||||
|
update: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -84,7 +90,10 @@ describe('GlobalExecutionsListItem', () => {
|
||||||
global.window.open = vi.fn();
|
global.window.open = vi.fn();
|
||||||
|
|
||||||
const { getByText } = renderComponent({
|
const { getByText } = renderComponent({
|
||||||
props: { execution: { status: 'success', id: 123, workflowName: 'TestWorkflow' } },
|
props: {
|
||||||
|
execution: { status: 'success', id: 123, workflowName: 'TestWorkflow' },
|
||||||
|
workflowPermissions: {},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent.click(getByText('TestWorkflow'));
|
await fireEvent.click(getByText('TestWorkflow'));
|
||||||
|
@ -94,7 +103,10 @@ describe('GlobalExecutionsListItem', () => {
|
||||||
it('should show formatted start date', () => {
|
it('should show formatted start date', () => {
|
||||||
const testDate = '2022-01-01T12:00:00Z';
|
const testDate = '2022-01-01T12:00:00Z';
|
||||||
const { getByText } = renderComponent({
|
const { getByText } = renderComponent({
|
||||||
props: { execution: { status: 'success', id: 123, startedAt: testDate } },
|
props: {
|
||||||
|
execution: { status: 'success', id: 123, startedAt: testDate },
|
||||||
|
workflowPermissions: {},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||||
import { i18n as locale } from '@/plugins/i18n';
|
import { i18n as locale } from '@/plugins/i18n';
|
||||||
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
||||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||||
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
|
|
||||||
type Command = 'retrySaved' | 'retryOriginal' | 'delete';
|
type Command = 'retrySaved' | 'retryOriginal' | 'delete';
|
||||||
|
|
||||||
|
@ -24,9 +25,11 @@ const props = withDefaults(
|
||||||
execution: ExecutionSummary;
|
execution: ExecutionSummary;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
workflowName?: string;
|
workflowName?: string;
|
||||||
|
workflowPermissions: PermissionsRecord['workflow'];
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
selected: false,
|
selected: false,
|
||||||
|
workflowName: '',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -266,6 +269,7 @@ async function handleActionItemClick(commandData: Command) {
|
||||||
data-test-id="execution-retry-saved-dropdown-item"
|
data-test-id="execution-retry-saved-dropdown-item"
|
||||||
:class="$style.retryAction"
|
:class="$style.retryAction"
|
||||||
command="retrySaved"
|
command="retrySaved"
|
||||||
|
:disabled="!workflowPermissions.execute"
|
||||||
>
|
>
|
||||||
{{ i18n.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
|
{{ i18n.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
|
||||||
</ElDropdownItem>
|
</ElDropdownItem>
|
||||||
|
@ -274,6 +278,7 @@ async function handleActionItemClick(commandData: Command) {
|
||||||
data-test-id="execution-retry-original-dropdown-item"
|
data-test-id="execution-retry-original-dropdown-item"
|
||||||
:class="$style.retryAction"
|
:class="$style.retryAction"
|
||||||
command="retryOriginal"
|
command="retryOriginal"
|
||||||
|
:disabled="!workflowPermissions.execute"
|
||||||
>
|
>
|
||||||
{{ i18n.baseText('executionsList.retryWithOriginalWorkflow') }}
|
{{ i18n.baseText('executionsList.retryWithOriginalWorkflow') }}
|
||||||
</ElDropdownItem>
|
</ElDropdownItem>
|
||||||
|
@ -281,6 +286,7 @@ async function handleActionItemClick(commandData: Command) {
|
||||||
data-test-id="execution-delete-dropdown-item"
|
data-test-id="execution-delete-dropdown-item"
|
||||||
:class="$style.deleteAction"
|
:class="$style.deleteAction"
|
||||||
command="delete"
|
command="delete"
|
||||||
|
:disabled="!workflowPermissions.update"
|
||||||
>
|
>
|
||||||
{{ i18n.baseText('generic.delete') }}
|
{{ i18n.baseText('generic.delete') }}
|
||||||
</ElDropdownItem>
|
</ElDropdownItem>
|
||||||
|
|
|
@ -2,6 +2,13 @@ import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
|
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => ({
|
||||||
|
params: {},
|
||||||
|
}),
|
||||||
|
RouterLink: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
|
const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
|
@ -25,53 +32,102 @@ describe('WorkflowExecutionsCard', () => {
|
||||||
test.each([
|
test.each([
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
execution: {
|
||||||
id: '1',
|
id: '1',
|
||||||
mode: 'manual',
|
mode: 'manual',
|
||||||
status: 'success',
|
status: 'success',
|
||||||
retryOf: null,
|
retryOf: null,
|
||||||
retrySuccessId: null,
|
retrySuccessId: null,
|
||||||
},
|
},
|
||||||
|
workflowPermissions: {
|
||||||
|
execute: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
false,
|
false,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
execution: {
|
||||||
id: '2',
|
id: '2',
|
||||||
mode: 'manual',
|
mode: 'manual',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
retryOf: null,
|
retryOf: null,
|
||||||
retrySuccessId: null,
|
retrySuccessId: null,
|
||||||
},
|
},
|
||||||
true,
|
workflowPermissions: {
|
||||||
],
|
execute: true,
|
||||||
[
|
},
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
mode: 'manual',
|
|
||||||
status: 'error',
|
|
||||||
retryOf: '2',
|
|
||||||
retrySuccessId: null,
|
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: '4',
|
execution: {
|
||||||
|
id: '3',
|
||||||
mode: 'manual',
|
mode: 'manual',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
retryOf: null,
|
retryOf: null,
|
||||||
retrySuccessId: '3',
|
retrySuccessId: '3',
|
||||||
},
|
},
|
||||||
|
workflowPermissions: {
|
||||||
|
execute: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
false,
|
false,
|
||||||
],
|
],
|
||||||
])('with execution %j retry button visibility is %s', (execution, shouldRenderRetryBtn) => {
|
[
|
||||||
const { queryByTestId } = renderComponent({
|
{
|
||||||
props: {
|
execution: {
|
||||||
execution,
|
id: '4',
|
||||||
|
mode: 'manual',
|
||||||
|
status: 'success',
|
||||||
|
retryOf: '4',
|
||||||
|
retrySuccessId: null,
|
||||||
},
|
},
|
||||||
|
workflowPermissions: {
|
||||||
|
execute: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
execution: {
|
||||||
|
id: '2',
|
||||||
|
mode: 'manual',
|
||||||
|
status: 'error',
|
||||||
|
retryOf: null,
|
||||||
|
retrySuccessId: null,
|
||||||
|
},
|
||||||
|
workflowPermissions: {},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'with execution %j retry button visibility is %s and if visible is disabled %s',
|
||||||
|
(props, shouldRenderRetryBtn, disabled) => {
|
||||||
|
const { queryByTestId } = renderComponent({
|
||||||
|
props,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(!!queryByTestId('retry-execution-button') && shouldRenderRetryBtn).toBe(
|
const retryButton = queryByTestId('retry-execution-button');
|
||||||
shouldRenderRetryBtn,
|
|
||||||
|
if (shouldRenderRetryBtn) {
|
||||||
|
expect(retryButton).toBeVisible();
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
expect(retryButton?.querySelector('.is-disabled')).toBeVisible();
|
||||||
|
} else {
|
||||||
|
expect(retryButton?.querySelector('.is-disabled')).toBe(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expect(retryButton).toBe(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
|
@ -1,3 +1,59 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
||||||
|
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||||
|
import type { ExecutionSummary } from 'n8n-workflow';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
execution: ExecutionSummary;
|
||||||
|
highlight?: boolean;
|
||||||
|
showGap?: boolean;
|
||||||
|
workflowPermissions: PermissionsRecord['workflow'];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
retryExecution: [{ execution: ExecutionSummary; command: string }];
|
||||||
|
mounted: [string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const locale = useI18n();
|
||||||
|
|
||||||
|
const executionHelpers = useExecutionHelpers();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
|
const currentWorkflow = computed(() => (route.params.name as string) || workflowsStore.workflowId);
|
||||||
|
const retryExecutionActions = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'current-workflow',
|
||||||
|
label: locale.baseText('executionsList.retryWithCurrentlySavedWorkflow'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'original-workflow',
|
||||||
|
label: locale.baseText('executionsList.retryWithOriginalWorkflow'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const executionUIDetails = computed<IExecutionUIData>(() =>
|
||||||
|
executionHelpers.getUIDetails(props.execution),
|
||||||
|
);
|
||||||
|
const isActive = computed(() => props.execution.id === route.params.executionId);
|
||||||
|
const isRetriable = computed(() => executionHelpers.isExecutionRetriable(props.execution));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
emit('mounted', props.execution.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onRetryMenuItemSelect(action: string): void {
|
||||||
|
emit('retryExecution', { execution: props.execution, command: action });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
|
@ -12,153 +68,74 @@
|
||||||
<router-link
|
<router-link
|
||||||
:class="$style.executionLink"
|
:class="$style.executionLink"
|
||||||
:to="{
|
:to="{
|
||||||
name: executionPreviewViewName,
|
name: VIEWS.EXECUTION_PREVIEW,
|
||||||
params: { name: currentWorkflow, executionId: execution.id },
|
params: { name: currentWorkflow, executionId: execution.id },
|
||||||
}"
|
}"
|
||||||
:data-test-execution-status="executionUIDetails.name"
|
:data-test-execution-status="executionUIDetails.name"
|
||||||
>
|
>
|
||||||
<div :class="$style.description">
|
<div :class="$style.description">
|
||||||
<n8n-text color="text-dark" :bold="true" size="medium" data-test-id="execution-time">
|
<N8nText color="text-dark" :bold="true" size="medium" data-test-id="execution-time">
|
||||||
{{ executionUIDetails.startTime }}
|
{{ executionUIDetails.startTime }}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
<div :class="$style.executionStatus">
|
<div :class="$style.executionStatus">
|
||||||
<n8n-spinner
|
<N8nSpinner
|
||||||
v-if="executionUIDetails.name === 'running'"
|
v-if="executionUIDetails.name === 'running'"
|
||||||
size="small"
|
size="small"
|
||||||
:class="[$style.spinner, 'mr-4xs']"
|
:class="[$style.spinner, 'mr-4xs']"
|
||||||
/>
|
/>
|
||||||
<n8n-text :class="$style.statusLabel" size="small">{{
|
<N8nText :class="$style.statusLabel" size="small">{{ executionUIDetails.label }}</N8nText>
|
||||||
executionUIDetails.label
|
|
||||||
}}</n8n-text>
|
|
||||||
{{ ' ' }}
|
{{ ' ' }}
|
||||||
<n8n-text
|
<N8nText
|
||||||
v-if="executionUIDetails.name === 'running'"
|
v-if="executionUIDetails.name === 'running'"
|
||||||
:color="isActive ? 'text-dark' : 'text-base'"
|
:color="isActive ? 'text-dark' : 'text-base'"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{{ $locale.baseText('executionDetails.runningTimeRunning') }}
|
{{ locale.baseText('executionDetails.runningTimeRunning') }}
|
||||||
<ExecutionsTime :start-time="execution.startedAt" />
|
<ExecutionsTime :start-time="execution.startedAt" />
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
<n8n-text
|
<N8nText
|
||||||
v-else-if="executionUIDetails.runningTime !== ''"
|
v-else-if="executionUIDetails.runningTime !== ''"
|
||||||
:color="isActive ? 'text-dark' : 'text-base'"
|
:color="isActive ? 'text-dark' : 'text-base'"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
$locale.baseText('executionDetails.runningTimeFinished', {
|
locale.baseText('executionDetails.runningTimeFinished', {
|
||||||
interpolate: { time: executionUIDetails?.runningTime },
|
interpolate: { time: executionUIDetails?.runningTime },
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="execution.mode === 'retry'">
|
<div v-if="execution.mode === 'retry'">
|
||||||
<n8n-text :color="isActive ? 'text-dark' : 'text-base'" size="small">
|
<N8nText :color="isActive ? 'text-dark' : 'text-base'" size="small">
|
||||||
{{ $locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
|
{{ locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.icons">
|
<div :class="$style.icons">
|
||||||
<n8n-action-dropdown
|
<N8nActionDropdown
|
||||||
v-if="isRetriable"
|
v-if="isRetriable"
|
||||||
:class="[$style.icon, $style.retry]"
|
:class="[$style.icon, $style.retry]"
|
||||||
:items="retryExecutionActions"
|
:items="retryExecutionActions"
|
||||||
|
:disabled="!workflowPermissions.execute"
|
||||||
activator-icon="redo"
|
activator-icon="redo"
|
||||||
data-test-id="retry-execution-button"
|
data-test-id="retry-execution-button"
|
||||||
@select="onRetryMenuItemSelect"
|
@select="onRetryMenuItemSelect"
|
||||||
/>
|
/>
|
||||||
<n8n-tooltip v-if="execution.mode === 'manual'" placement="top">
|
<N8nTooltip v-if="execution.mode === 'manual'" placement="top">
|
||||||
<template #content>
|
<template #content>
|
||||||
<span>{{ $locale.baseText('executionsList.test') }}</span>
|
<span>{{ locale.baseText('executionsList.test') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<font-awesome-icon
|
<FontAwesomeIcon
|
||||||
v-if="execution.mode === 'manual'"
|
v-if="execution.mode === 'manual'"
|
||||||
:class="[$style.icon, $style.manual]"
|
:class="[$style.icon, $style.manual]"
|
||||||
icon="flask"
|
icon="flask"
|
||||||
/>
|
/>
|
||||||
</n8n-tooltip>
|
</N8nTooltip>
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
|
||||||
import { VIEWS } from '@/constants';
|
|
||||||
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
|
||||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
|
||||||
import type { ExecutionSummary } from 'n8n-workflow';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'WorkflowExecutionsCard',
|
|
||||||
components: {
|
|
||||||
ExecutionsTime,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
execution: {
|
|
||||||
type: Object as () => ExecutionSummary,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
highlight: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
showGap: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ['retryExecution', 'mounted'],
|
|
||||||
setup() {
|
|
||||||
const executionHelpers = useExecutionHelpers();
|
|
||||||
|
|
||||||
return {
|
|
||||||
executionHelpers,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useWorkflowsStore),
|
|
||||||
currentWorkflow(): string {
|
|
||||||
return (this.$route.params.name as string) || this.workflowsStore.workflowId;
|
|
||||||
},
|
|
||||||
retryExecutionActions(): object[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'current-workflow',
|
|
||||||
label: this.$locale.baseText('executionsList.retryWithCurrentlySavedWorkflow'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'original-workflow',
|
|
||||||
label: this.$locale.baseText('executionsList.retryWithOriginalWorkflow'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
executionUIDetails(): IExecutionUIData {
|
|
||||||
return this.executionHelpers.getUIDetails(this.execution);
|
|
||||||
},
|
|
||||||
isActive(): boolean {
|
|
||||||
return this.execution.id === this.$route.params.executionId;
|
|
||||||
},
|
|
||||||
isRetriable(): boolean {
|
|
||||||
return this.executionHelpers.isExecutionRetriable(this.execution);
|
|
||||||
},
|
|
||||||
executionPreviewViewName() {
|
|
||||||
return VIEWS.EXECUTION_PREVIEW;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.$emit('mounted', this.execution.id);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onRetryMenuItemSelect(action: string): void {
|
|
||||||
this.$emit('retryExecution', { execution: this.execution, command: action });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
@import '@/styles/variables';
|
@import '@/styles/variables';
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,194 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
|
||||||
|
import type { IWorkflowSettings } from 'n8n-workflow';
|
||||||
|
import { deepCopy } from 'n8n-workflow';
|
||||||
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
|
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
|
interface IWorkflowSaveSettings {
|
||||||
|
saveFailedExecutions: boolean;
|
||||||
|
saveSuccessfulExecutions: boolean;
|
||||||
|
saveTestExecutions: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
initiallyExpanded: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
initiallyExpanded: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const workflowHelpers = useWorkflowHelpers({ router });
|
||||||
|
const locale = useI18n();
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const npsSurveyStore = useNpsSurveyStore();
|
||||||
|
|
||||||
|
const defaultValues = ref({
|
||||||
|
saveFailedExecutions: 'all',
|
||||||
|
saveSuccessfulExecutions: 'all',
|
||||||
|
saveManualExecutions: false,
|
||||||
|
});
|
||||||
|
const workflowSaveSettings = ref({
|
||||||
|
saveFailedExecutions: false,
|
||||||
|
saveSuccessfulExecutions: false,
|
||||||
|
saveTestExecutions: false,
|
||||||
|
} as IWorkflowSaveSettings);
|
||||||
|
|
||||||
|
const accordionItems = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'productionExecutions',
|
||||||
|
label: locale.baseText('executionsLandingPage.emptyState.accordion.productionExecutions'),
|
||||||
|
icon: productionExecutionsIcon.value.icon,
|
||||||
|
iconColor: productionExecutionsIcon.value.color,
|
||||||
|
tooltip:
|
||||||
|
productionExecutionsStatus.value === 'unknown'
|
||||||
|
? locale.baseText(
|
||||||
|
'executionsLandingPage.emptyState.accordion.productionExecutionsWarningTooltip',
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'manualExecutions',
|
||||||
|
label: locale.baseText('executionsLandingPage.emptyState.accordion.testExecutions'),
|
||||||
|
icon: workflowSaveSettings.value.saveTestExecutions ? 'check' : 'times',
|
||||||
|
iconColor: workflowSaveSettings.value.saveTestExecutions ? 'success' : 'danger',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const shouldExpandAccordion = computed(() => {
|
||||||
|
if (!props.initiallyExpanded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
!workflowSaveSettings.value.saveFailedExecutions ||
|
||||||
|
!workflowSaveSettings.value.saveSuccessfulExecutions ||
|
||||||
|
!workflowSaveSettings.value.saveTestExecutions
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const productionExecutionsIcon = computed(() => {
|
||||||
|
if (productionExecutionsStatus.value === 'saving') {
|
||||||
|
return { icon: 'check', color: 'success' };
|
||||||
|
} else if (productionExecutionsStatus.value === 'not-saving') {
|
||||||
|
return { icon: 'times', color: 'danger' };
|
||||||
|
}
|
||||||
|
return { icon: 'exclamation-triangle', color: 'warning' };
|
||||||
|
});
|
||||||
|
const productionExecutionsStatus = computed(() => {
|
||||||
|
if (
|
||||||
|
workflowSaveSettings.value.saveSuccessfulExecutions ===
|
||||||
|
workflowSaveSettings.value.saveFailedExecutions
|
||||||
|
) {
|
||||||
|
if (workflowSaveSettings.value.saveSuccessfulExecutions) {
|
||||||
|
return 'saving';
|
||||||
|
}
|
||||||
|
return 'not-saving';
|
||||||
|
} else {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const workflowSettings = computed(() => deepCopy(workflowsStore.workflowSettings));
|
||||||
|
const accordionIcon = computed(() => {
|
||||||
|
if (
|
||||||
|
!workflowSaveSettings.value.saveTestExecutions ||
|
||||||
|
productionExecutionsStatus.value !== 'saving'
|
||||||
|
) {
|
||||||
|
return { icon: 'exclamation-triangle', color: 'warning' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const currentWorkflowId = computed(() => workflowsStore.workflowId);
|
||||||
|
const isNewWorkflow = computed(() => {
|
||||||
|
return (
|
||||||
|
!currentWorkflowId.value ||
|
||||||
|
currentWorkflowId.value === PLACEHOLDER_EMPTY_WORKFLOW_ID ||
|
||||||
|
currentWorkflowId.value === 'new'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const workflowName = computed(() => workflowsStore.workflowName);
|
||||||
|
const currentWorkflowTagIds = computed(() => workflowsStore.workflowTags);
|
||||||
|
|
||||||
|
watch(workflowSettings, (newSettings: IWorkflowSettings) => {
|
||||||
|
updateSettings(newSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
defaultValues.value.saveFailedExecutions = settingsStore.saveDataErrorExecution;
|
||||||
|
defaultValues.value.saveSuccessfulExecutions = settingsStore.saveDataSuccessExecution;
|
||||||
|
defaultValues.value.saveManualExecutions = settingsStore.saveManualExecutions;
|
||||||
|
updateSettings(workflowSettings.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateSettings(wfSettings: IWorkflowSettings): void {
|
||||||
|
workflowSaveSettings.value.saveFailedExecutions =
|
||||||
|
wfSettings.saveDataErrorExecution === undefined
|
||||||
|
? defaultValues.value.saveFailedExecutions === 'all'
|
||||||
|
: wfSettings.saveDataErrorExecution === 'all';
|
||||||
|
workflowSaveSettings.value.saveSuccessfulExecutions =
|
||||||
|
wfSettings.saveDataSuccessExecution === undefined
|
||||||
|
? defaultValues.value.saveSuccessfulExecutions === 'all'
|
||||||
|
: wfSettings.saveDataSuccessExecution === 'all';
|
||||||
|
workflowSaveSettings.value.saveTestExecutions =
|
||||||
|
wfSettings.saveManualExecutions === undefined
|
||||||
|
? defaultValues.value.saveManualExecutions
|
||||||
|
: (wfSettings.saveManualExecutions as boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAccordionClick(event: MouseEvent): void {
|
||||||
|
if (event.target instanceof HTMLAnchorElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onItemTooltipClick(item: string, event: MouseEvent): void {
|
||||||
|
if (item === 'productionExecutions' && event.target instanceof HTMLAnchorElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWorkflowSettings(): void {
|
||||||
|
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveWorkflowClick(): Promise<void> {
|
||||||
|
let currentId: string | undefined = undefined;
|
||||||
|
if (currentWorkflowId.value !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
||||||
|
currentId = currentWorkflowId.value;
|
||||||
|
} else if (
|
||||||
|
router.currentRoute.value.params.name &&
|
||||||
|
router.currentRoute.value.params.name !== 'new'
|
||||||
|
) {
|
||||||
|
const routeName = router.currentRoute.value.params.name;
|
||||||
|
currentId = Array.isArray(routeName) ? routeName[0] : routeName;
|
||||||
|
}
|
||||||
|
if (!currentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = await workflowHelpers.saveCurrentWorkflow({
|
||||||
|
id: currentId,
|
||||||
|
name: workflowName.value,
|
||||||
|
tags: currentWorkflowTagIds.value,
|
||||||
|
});
|
||||||
|
if (saved) {
|
||||||
|
await npsSurveyStore.fetchPromptsData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n8n-info-accordion
|
<N8nInfoAccordion
|
||||||
:class="[$style.accordion, 'mt-2xl']"
|
:class="[$style.accordion, 'mt-2xl']"
|
||||||
:title="$locale.baseText('executionsLandingPage.emptyState.accordion.title')"
|
:title="$locale.baseText('executionsLandingPage.emptyState.accordion.title')"
|
||||||
:items="accordionItems"
|
:items="accordionItems"
|
||||||
|
@ -11,232 +200,30 @@
|
||||||
<template #customContent>
|
<template #customContent>
|
||||||
<footer class="mt-2xs">
|
<footer class="mt-2xs">
|
||||||
{{ $locale.baseText('executionsLandingPage.emptyState.accordion.footer') }}
|
{{ $locale.baseText('executionsLandingPage.emptyState.accordion.footer') }}
|
||||||
<n8n-tooltip :disabled="!isNewWorkflow">
|
<N8nTooltip :disabled="!isNewWorkflow">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div>
|
<div>
|
||||||
<n8n-link @click.prevent="onSaveWorkflowClick">{{
|
<N8nLink @click.prevent="onSaveWorkflowClick">{{
|
||||||
$locale.baseText('executionsLandingPage.emptyState.accordion.footer.tooltipLink')
|
$locale.baseText('executionsLandingPage.emptyState.accordion.footer.tooltipLink')
|
||||||
}}</n8n-link>
|
}}</N8nLink>
|
||||||
{{
|
{{
|
||||||
$locale.baseText('executionsLandingPage.emptyState.accordion.footer.tooltipText')
|
$locale.baseText('executionsLandingPage.emptyState.accordion.footer.tooltipText')
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<n8n-link
|
<N8nLink
|
||||||
:class="{ [$style.disabled]: isNewWorkflow }"
|
:class="{ [$style.disabled]: isNewWorkflow }"
|
||||||
size="small"
|
size="small"
|
||||||
@click.prevent="openWorkflowSettings"
|
@click.prevent="openWorkflowSettings"
|
||||||
>
|
>
|
||||||
{{ $locale.baseText('executionsLandingPage.emptyState.accordion.footer.settingsLink') }}
|
{{ $locale.baseText('executionsLandingPage.emptyState.accordion.footer.settingsLink') }}
|
||||||
</n8n-link>
|
</N8nLink>
|
||||||
</n8n-tooltip>
|
</N8nTooltip>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
</n8n-info-accordion>
|
</N8nInfoAccordion>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useRootStore } from '@/stores/root.store';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
|
|
||||||
import type { IWorkflowSettings } from 'n8n-workflow';
|
|
||||||
import { deepCopy } from 'n8n-workflow';
|
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
|
||||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
|
||||||
|
|
||||||
interface IWorkflowSaveSettings {
|
|
||||||
saveFailedExecutions: boolean;
|
|
||||||
saveSuccessfulExecutions: boolean;
|
|
||||||
saveTestExecutions: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'WorkflowExecutionsInfoAccordion',
|
|
||||||
props: {
|
|
||||||
initiallyExpanded: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const router = useRouter();
|
|
||||||
const workflowHelpers = useWorkflowHelpers({ router });
|
|
||||||
|
|
||||||
return {
|
|
||||||
workflowHelpers,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
defaultValues: {
|
|
||||||
saveFailedExecutions: 'all',
|
|
||||||
saveSuccessfulExecutions: 'all',
|
|
||||||
saveManualExecutions: false,
|
|
||||||
},
|
|
||||||
workflowSaveSettings: {
|
|
||||||
saveFailedExecutions: false,
|
|
||||||
saveSuccessfulExecutions: false,
|
|
||||||
saveTestExecutions: false,
|
|
||||||
} as IWorkflowSaveSettings,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useRootStore, useSettingsStore, useUIStore, useWorkflowsStore, useNpsSurveyStore),
|
|
||||||
accordionItems(): object[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'productionExecutions',
|
|
||||||
label: this.$locale.baseText(
|
|
||||||
'executionsLandingPage.emptyState.accordion.productionExecutions',
|
|
||||||
),
|
|
||||||
icon: this.productionExecutionsIcon.icon,
|
|
||||||
iconColor: this.productionExecutionsIcon.color,
|
|
||||||
tooltip:
|
|
||||||
this.productionExecutionsStatus === 'unknown'
|
|
||||||
? this.$locale.baseText(
|
|
||||||
'executionsLandingPage.emptyState.accordion.productionExecutionsWarningTooltip',
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'manualExecutions',
|
|
||||||
label: this.$locale.baseText('executionsLandingPage.emptyState.accordion.testExecutions'),
|
|
||||||
icon: this.workflowSaveSettings.saveTestExecutions ? 'check' : 'times',
|
|
||||||
iconColor: this.workflowSaveSettings.saveTestExecutions ? 'success' : 'danger',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
shouldExpandAccordion(): boolean {
|
|
||||||
if (!this.initiallyExpanded) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
!this.workflowSaveSettings.saveFailedExecutions ||
|
|
||||||
!this.workflowSaveSettings.saveSuccessfulExecutions ||
|
|
||||||
!this.workflowSaveSettings.saveTestExecutions
|
|
||||||
);
|
|
||||||
},
|
|
||||||
productionExecutionsIcon(): { icon: string; color: string } {
|
|
||||||
if (this.productionExecutionsStatus === 'saving') {
|
|
||||||
return { icon: 'check', color: 'success' };
|
|
||||||
} else if (this.productionExecutionsStatus === 'not-saving') {
|
|
||||||
return { icon: 'times', color: 'danger' };
|
|
||||||
}
|
|
||||||
return { icon: 'exclamation-triangle', color: 'warning' };
|
|
||||||
},
|
|
||||||
productionExecutionsStatus(): string {
|
|
||||||
if (
|
|
||||||
this.workflowSaveSettings.saveSuccessfulExecutions ===
|
|
||||||
this.workflowSaveSettings.saveFailedExecutions
|
|
||||||
) {
|
|
||||||
if (this.workflowSaveSettings.saveSuccessfulExecutions) {
|
|
||||||
return 'saving';
|
|
||||||
}
|
|
||||||
return 'not-saving';
|
|
||||||
} else {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
workflowSettings(): IWorkflowSettings {
|
|
||||||
const workflowSettings = deepCopy(this.workflowsStore.workflowSettings);
|
|
||||||
return workflowSettings;
|
|
||||||
},
|
|
||||||
accordionIcon(): { icon: string; color: string } | null {
|
|
||||||
if (
|
|
||||||
!this.workflowSaveSettings.saveTestExecutions ||
|
|
||||||
this.productionExecutionsStatus !== 'saving'
|
|
||||||
) {
|
|
||||||
return { icon: 'exclamation-triangle', color: 'warning' };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
currentWorkflowId(): string {
|
|
||||||
return this.workflowsStore.workflowId;
|
|
||||||
},
|
|
||||||
isNewWorkflow(): boolean {
|
|
||||||
return (
|
|
||||||
!this.currentWorkflowId ||
|
|
||||||
this.currentWorkflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID ||
|
|
||||||
this.currentWorkflowId === 'new'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
workflowName(): string {
|
|
||||||
return this.workflowsStore.workflowName;
|
|
||||||
},
|
|
||||||
currentWorkflowTagIds(): string[] {
|
|
||||||
return this.workflowsStore.workflowTags;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
workflowSettings(newSettings: IWorkflowSettings) {
|
|
||||||
this.updateSettings(newSettings);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.defaultValues.saveFailedExecutions = this.settingsStore.saveDataErrorExecution;
|
|
||||||
this.defaultValues.saveSuccessfulExecutions = this.settingsStore.saveDataSuccessExecution;
|
|
||||||
this.defaultValues.saveManualExecutions = this.settingsStore.saveManualExecutions;
|
|
||||||
this.updateSettings(this.workflowSettings);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
updateSettings(workflowSettings: IWorkflowSettings): void {
|
|
||||||
this.workflowSaveSettings.saveFailedExecutions =
|
|
||||||
workflowSettings.saveDataErrorExecution === undefined
|
|
||||||
? this.defaultValues.saveFailedExecutions === 'all'
|
|
||||||
: workflowSettings.saveDataErrorExecution === 'all';
|
|
||||||
this.workflowSaveSettings.saveSuccessfulExecutions =
|
|
||||||
workflowSettings.saveDataSuccessExecution === undefined
|
|
||||||
? this.defaultValues.saveSuccessfulExecutions === 'all'
|
|
||||||
: workflowSettings.saveDataSuccessExecution === 'all';
|
|
||||||
this.workflowSaveSettings.saveTestExecutions =
|
|
||||||
workflowSettings.saveManualExecutions === undefined
|
|
||||||
? this.defaultValues.saveManualExecutions
|
|
||||||
: (workflowSettings.saveManualExecutions as boolean);
|
|
||||||
},
|
|
||||||
onAccordionClick(event: MouseEvent): void {
|
|
||||||
if (event.target instanceof HTMLAnchorElement) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onItemTooltipClick(item: string, event: MouseEvent): void {
|
|
||||||
if (item === 'productionExecutions' && event.target instanceof HTMLAnchorElement) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
openWorkflowSettings(): void {
|
|
||||||
this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
|
|
||||||
},
|
|
||||||
async onSaveWorkflowClick(): Promise<void> {
|
|
||||||
let currentId: string | undefined = undefined;
|
|
||||||
if (this.currentWorkflowId !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
|
||||||
currentId = this.currentWorkflowId;
|
|
||||||
} else if (this.$route.params.name && this.$route.params.name !== 'new') {
|
|
||||||
const routeName = this.$route.params.name;
|
|
||||||
currentId = Array.isArray(routeName) ? routeName[0] : routeName;
|
|
||||||
}
|
|
||||||
if (!currentId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const saved = await this.workflowHelpers.saveCurrentWorkflow({
|
|
||||||
id: currentId,
|
|
||||||
name: this.workflowName,
|
|
||||||
tags: this.currentWorkflowTagIds,
|
|
||||||
});
|
|
||||||
if (saved) {
|
|
||||||
await this.npsSurveyStore.fetchPromptsData();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.accordion {
|
.accordion {
|
||||||
background: none;
|
background: none;
|
||||||
|
|
|
@ -1,67 +1,62 @@
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<div :class="['workflow-executions-container', $style.container]">
|
import { computed } from 'vue';
|
||||||
<div v-if="executionCount === 0" :class="[$style.messageContainer, $style.noExecutionsMessage]">
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
<div v-if="!containsTrigger">
|
|
||||||
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
|
|
||||||
{{ $locale.baseText('executionsLandingPage.emptyState.noTrigger.heading') }}
|
|
||||||
</n8n-heading>
|
|
||||||
<n8n-text size="medium">
|
|
||||||
{{ $locale.baseText('executionsLandingPage.emptyState.message') }}
|
|
||||||
</n8n-text>
|
|
||||||
<n8n-button class="mt-l" type="tertiary" size="large" @click="onSetupFirstStep">
|
|
||||||
{{ $locale.baseText('executionsLandingPage.emptyState.noTrigger.buttonText') }}
|
|
||||||
</n8n-button>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
|
|
||||||
{{ $locale.baseText('executionsLandingPage.emptyState.heading') }}
|
|
||||||
</n8n-heading>
|
|
||||||
<WorkflowExecutionsInfoAccordion />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
|
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import WorkflowExecutionsInfoAccordion from './WorkflowExecutionsInfoAccordion.vue';
|
import WorkflowExecutionsInfoAccordion from './WorkflowExecutionsInfoAccordion.vue';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
export default defineComponent({
|
const router = useRouter();
|
||||||
name: 'ExecutionsLandingPage',
|
const route = useRoute();
|
||||||
components: {
|
const locale = useI18n();
|
||||||
WorkflowExecutionsInfoAccordion,
|
|
||||||
},
|
const uiStore = useUIStore();
|
||||||
computed: {
|
const workflowsStore = useWorkflowsStore();
|
||||||
...mapStores(useUIStore, useWorkflowsStore),
|
|
||||||
executionCount(): number {
|
const executionCount = computed(() => workflowsStore.currentWorkflowExecutions.length);
|
||||||
return this.workflowsStore.currentWorkflowExecutions.length;
|
const containsTrigger = computed(() => workflowsStore.workflowTriggerNodes.length > 0);
|
||||||
},
|
|
||||||
containsTrigger(): boolean {
|
function onSetupFirstStep(): void {
|
||||||
return this.workflowsStore.workflowTriggerNodes.length > 0;
|
uiStore.addFirstStepOnLoad = true;
|
||||||
},
|
const workflowRoute = getWorkflowRoute();
|
||||||
},
|
void router.push(workflowRoute);
|
||||||
methods: {
|
}
|
||||||
onSetupFirstStep(): void {
|
|
||||||
this.uiStore.addFirstStepOnLoad = true;
|
function getWorkflowRoute(): { name: string; params: {} } {
|
||||||
const workflowRoute = this.getWorkflowRoute();
|
const workflowId = workflowsStore.workflowId || route.params.name;
|
||||||
void this.$router.push(workflowRoute);
|
|
||||||
},
|
|
||||||
getWorkflowRoute(): { name: string; params: {} } {
|
|
||||||
const workflowId = this.workflowsStore.workflowId || this.$route.params.name;
|
|
||||||
if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
||||||
return { name: VIEWS.NEW_WORKFLOW, params: {} };
|
return { name: VIEWS.NEW_WORKFLOW, params: {} };
|
||||||
} else {
|
} else {
|
||||||
return { name: VIEWS.WORKFLOW, params: { name: workflowId } };
|
return { name: VIEWS.WORKFLOW, params: { name: workflowId } };
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['workflow-executions-container', $style.container]">
|
||||||
|
<div v-if="executionCount === 0" :class="[$style.messageContainer, $style.noExecutionsMessage]">
|
||||||
|
<div v-if="!containsTrigger">
|
||||||
|
<N8nHeading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
|
||||||
|
{{ locale.baseText('executionsLandingPage.emptyState.noTrigger.heading') }}
|
||||||
|
</N8nHeading>
|
||||||
|
<N8nText size="medium">
|
||||||
|
{{ locale.baseText('executionsLandingPage.emptyState.message') }}
|
||||||
|
</N8nText>
|
||||||
|
<N8nButton class="mt-l" type="tertiary" size="large" @click="onSetupFirstStep">
|
||||||
|
{{ locale.baseText('executionsLandingPage.emptyState.noTrigger.buttonText') }}
|
||||||
|
</N8nButton>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<N8nHeading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
|
||||||
|
{{ locale.baseText('executionsLandingPage.emptyState.heading') }}
|
||||||
|
</N8nHeading>
|
||||||
|
<WorkflowExecutionsInfoAccordion />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -1,30 +1,5 @@
|
||||||
<template>
|
|
||||||
<div :class="$style.container">
|
|
||||||
<WorkflowExecutionsSidebar
|
|
||||||
:executions="executions"
|
|
||||||
:loading="loading && !executions.length"
|
|
||||||
:loading-more="loadingMore"
|
|
||||||
:temporary-execution="temporaryExecution"
|
|
||||||
@update:auto-refresh="emit('update:auto-refresh', $event)"
|
|
||||||
@reload-executions="emit('reload')"
|
|
||||||
@filter-updated="emit('update:filters', $event)"
|
|
||||||
@load-more="emit('load-more')"
|
|
||||||
@retry-execution="onRetryExecution"
|
|
||||||
/>
|
|
||||||
<div v-if="!hidePreview" :class="$style.content">
|
|
||||||
<router-view
|
|
||||||
name="executionPreview"
|
|
||||||
:execution="execution"
|
|
||||||
@delete-current-execution="onDeleteCurrentExecution"
|
|
||||||
@retry-execution="onRetryExecution"
|
|
||||||
@stop-execution="onStopExecution"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import { onBeforeRouteLeave, useRouter } from 'vue-router';
|
import { onBeforeRouteLeave, useRouter } from 'vue-router';
|
||||||
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
|
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
|
||||||
import { MAIN_HEADER_TABS, VIEWS } from '@/constants';
|
import { MAIN_HEADER_TABS, VIEWS } from '@/constants';
|
||||||
|
@ -32,7 +7,6 @@ import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
||||||
import type { ExecutionSummary } from 'n8n-workflow';
|
import type { ExecutionSummary } from 'n8n-workflow';
|
||||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
import { getNodeViewTab } from '@/utils/canvasUtils';
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
import { watch } from 'vue';
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -62,10 +36,11 @@ const emit = defineEmits<{
|
||||||
const workflowHelpers = useWorkflowHelpers({ router: useRouter() });
|
const workflowHelpers = useWorkflowHelpers({ router: useRouter() });
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const temporaryExecution = computed(() => {
|
const temporaryExecution = computed<ExecutionSummary | undefined>(() =>
|
||||||
const isTemporary = !props.executions.find((execution) => execution.id === props.execution?.id);
|
props.executions.find((execution) => execution.id === props.execution?.id)
|
||||||
return isTemporary ? props.execution : undefined;
|
? undefined
|
||||||
});
|
: props.execution ?? undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const hidePreview = computed(() => {
|
const hidePreview = computed(() => {
|
||||||
return props.loading || (!props.execution && props.executions.length);
|
return props.loading || (!props.execution && props.executions.length);
|
||||||
|
@ -118,6 +93,32 @@ onBeforeRouteLeave(async (to, _, next) => {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<WorkflowExecutionsSidebar
|
||||||
|
:executions="executions"
|
||||||
|
:loading="loading && !executions.length"
|
||||||
|
:loading-more="loadingMore"
|
||||||
|
:temporary-execution="temporaryExecution"
|
||||||
|
:workflow="workflow"
|
||||||
|
@update:auto-refresh="emit('update:auto-refresh', $event)"
|
||||||
|
@reload-executions="emit('reload')"
|
||||||
|
@filter-updated="emit('update:filters', $event)"
|
||||||
|
@load-more="emit('load-more')"
|
||||||
|
@retry-execution="onRetryExecution"
|
||||||
|
/>
|
||||||
|
<div v-if="!hidePreview" :class="$style.content">
|
||||||
|
<router-view
|
||||||
|
name="executionPreview"
|
||||||
|
:execution="execution"
|
||||||
|
@delete-current-execution="onDeleteCurrentExecution"
|
||||||
|
@retry-execution="onRetryExecution"
|
||||||
|
@stop-execution="onStopExecution"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -11,6 +11,8 @@ import { EnterpriseEditionFeature, VIEWS } from '@/constants';
|
||||||
import { i18nInstance, I18nPlugin } from '@/plugins/i18n';
|
import { i18nInstance, I18nPlugin } from '@/plugins/i18n';
|
||||||
import { FontAwesomePlugin } from '@/plugins/icons';
|
import { FontAwesomePlugin } from '@/plugins/icons';
|
||||||
import { GlobalComponentsPlugin } from '@/plugins/components';
|
import { GlobalComponentsPlugin } from '@/plugins/components';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
|
|
||||||
let pinia: ReturnType<typeof createPinia>;
|
let pinia: ReturnType<typeof createPinia>;
|
||||||
|
|
||||||
|
@ -59,10 +61,12 @@ const executionDataFactory = (): ExecutionSummary => ({
|
||||||
nodeExecutionStatus: {},
|
nodeExecutionStatus: {},
|
||||||
retryOf: generateUndefinedNullOrString(),
|
retryOf: generateUndefinedNullOrString(),
|
||||||
retrySuccessId: generateUndefinedNullOrString(),
|
retrySuccessId: generateUndefinedNullOrString(),
|
||||||
|
scopes: ['workflow:update'],
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('WorkflowExecutionsPreview.vue', () => {
|
describe('WorkflowExecutionsPreview.vue', () => {
|
||||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||||
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
const executionData: ExecutionSummary = executionDataFactory();
|
const executionData: ExecutionSummary = executionDataFactory();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -70,19 +74,25 @@ describe('WorkflowExecutionsPreview.vue', () => {
|
||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
|
|
||||||
settingsStore = useSettingsStore();
|
settingsStore = useSettingsStore();
|
||||||
|
workflowsStore = useWorkflowsStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
[false, '/'],
|
[false, [], '/'],
|
||||||
[true, `/workflow/${executionData.workflowId}/debug/${executionData.id}`],
|
[false, ['workflow:update'], '/'],
|
||||||
|
[true, [], '/'],
|
||||||
|
[true, ['workflow:read'], '/'],
|
||||||
|
[true, ['workflow:update'], `/workflow/${executionData.workflowId}/debug/${executionData.id}`],
|
||||||
])(
|
])(
|
||||||
'when debug enterprise feature is %s it should handle debug link click accordingly',
|
'when debug enterprise feature is %s with workflow scopes %s it should handle debug link click accordingly',
|
||||||
async (availability, path) => {
|
async (availability, scopes, path) => {
|
||||||
settingsStore.settings.enterprise = {
|
settingsStore.settings.enterprise = {
|
||||||
...(settingsStore.settings.enterprise ?? {}),
|
...(settingsStore.settings.enterprise ?? {}),
|
||||||
[EnterpriseEditionFeature.DebugInEditor]: availability,
|
[EnterpriseEditionFeature.DebugInEditor]: availability,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({ scopes } as IWorkflowDb);
|
||||||
|
|
||||||
// Not using createComponentRenderer helper here because this component should not stub `router-link`
|
// Not using createComponentRenderer helper here because this component should not stub `router-link`
|
||||||
const { getByTestId } = render(WorkflowExecutionsPreview, {
|
const { getByTestId } = render(WorkflowExecutionsPreview, {
|
||||||
props: {
|
props: {
|
||||||
|
|
|
@ -1,22 +1,113 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { ElDropdown } from 'element-plus';
|
||||||
|
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
|
||||||
|
import { useMessage } from '@/composables/useMessage';
|
||||||
|
import WorkflowPreview from '@/components/WorkflowPreview.vue';
|
||||||
|
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||||
|
import type { ExecutionSummary } from 'n8n-workflow';
|
||||||
|
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
||||||
|
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
|
type RetryDropdownRef = InstanceType<typeof ElDropdown>;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
execution: ExecutionSummary;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
deleteCurrentExecution: [];
|
||||||
|
retryExecution: Array<{ execution: ExecutionSummary; command: string }>;
|
||||||
|
stopExecution: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const locale = useI18n();
|
||||||
|
|
||||||
|
const executionHelpers = useExecutionHelpers();
|
||||||
|
const message = useMessage();
|
||||||
|
const executionDebugging = useExecutionDebugging();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
|
const retryDropdownRef = ref<RetryDropdownRef | null>(null);
|
||||||
|
const workflowId = computed(() => route.params.name as string);
|
||||||
|
const workflowPermissions = computed(
|
||||||
|
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
|
||||||
|
);
|
||||||
|
const executionId = computed(() => route.params.executionId as string);
|
||||||
|
const executionUIDetails = computed<IExecutionUIData | null>(() =>
|
||||||
|
props.execution ? executionHelpers.getUIDetails(props.execution) : null,
|
||||||
|
);
|
||||||
|
const debugButtonData = computed(() =>
|
||||||
|
props.execution?.status === 'success'
|
||||||
|
? {
|
||||||
|
text: locale.baseText('executionsList.debug.button.copyToEditor'),
|
||||||
|
type: 'secondary',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
text: locale.baseText('executionsList.debug.button.debugInEditor'),
|
||||||
|
type: 'primary',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const isRetriable = computed(
|
||||||
|
() => !!props.execution && executionHelpers.isExecutionRetriable(props.execution),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onDeleteExecution(): Promise<void> {
|
||||||
|
const deleteConfirmed = await message.confirm(
|
||||||
|
locale.baseText('executionDetails.confirmMessage.message'),
|
||||||
|
locale.baseText('executionDetails.confirmMessage.headline'),
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: locale.baseText('executionDetails.confirmMessage.confirmButtonText'),
|
||||||
|
cancelButtonText: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (deleteConfirmed !== MODAL_CONFIRM) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('deleteCurrentExecution');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRetryClick(command: string) {
|
||||||
|
emit('retryExecution', { execution: props.execution, command });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStopClick() {
|
||||||
|
emit('stopExecution');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRetryButtonBlur(event: FocusEvent) {
|
||||||
|
// Hide dropdown when clicking outside of current document
|
||||||
|
if (retryDropdownRef.value && event.relatedTarget === null) {
|
||||||
|
retryDropdownRef.value.handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="executionUIDetails?.name === 'new'" :class="$style.newInfo">
|
<div v-if="executionUIDetails?.name === 'new'" :class="$style.newInfo">
|
||||||
<n8n-text :class="$style.newMessage" color="text-light">
|
<N8nText :class="$style.newMessage" color="text-light">
|
||||||
{{ $locale.baseText('executionDetails.newMessage') }}
|
{{ locale.baseText('executionDetails.newMessage') }}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
<n8n-button class="mt-l" type="tertiary" @click="handleStopClick">
|
<N8nButton class="mt-l" type="tertiary" @click="handleStopClick">
|
||||||
{{ $locale.baseText('executionsList.stopExecution') }}
|
{{ locale.baseText('executionsList.stopExecution') }}
|
||||||
</n8n-button>
|
</N8nButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="executionUIDetails?.name === 'running'" :class="$style.runningInfo">
|
<div v-else-if="executionUIDetails?.name === 'running'" :class="$style.runningInfo">
|
||||||
<div :class="$style.spinner">
|
<div :class="$style.spinner">
|
||||||
<n8n-spinner type="ring" />
|
<N8nSpinner type="ring" />
|
||||||
</div>
|
</div>
|
||||||
<n8n-text :class="$style.runningMessage" color="text-light">
|
<N8nText :class="$style.runningMessage" color="text-light">
|
||||||
{{ $locale.baseText('executionDetails.runningMessage') }}
|
{{ locale.baseText('executionDetails.runningMessage') }}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
<n8n-button class="mt-l" type="tertiary" @click="handleStopClick">
|
<N8nButton class="mt-l" type="tertiary" @click="handleStopClick">
|
||||||
{{ $locale.baseText('executionsList.stopExecution') }}
|
{{ locale.baseText('executionsList.stopExecution') }}
|
||||||
</n8n-button>
|
</N8nButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="executionUIDetails" :class="$style.previewContainer">
|
<div v-else-if="executionUIDetails" :class="$style.previewContainer">
|
||||||
<div
|
<div
|
||||||
|
@ -25,57 +116,53 @@
|
||||||
:data-test-id="`execution-preview-details-${executionId}`"
|
:data-test-id="`execution-preview-details-${executionId}`"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<n8n-text size="large" color="text-base" :bold="true" data-test-id="execution-time">{{
|
<N8nText size="large" color="text-base" :bold="true" data-test-id="execution-time">{{
|
||||||
executionUIDetails?.startTime
|
executionUIDetails?.startTime
|
||||||
}}</n8n-text
|
}}</N8nText
|
||||||
><br />
|
><br />
|
||||||
<n8n-spinner
|
<N8nSpinner
|
||||||
v-if="executionUIDetails?.name === 'running'"
|
v-if="executionUIDetails?.name === 'running'"
|
||||||
size="small"
|
size="small"
|
||||||
:class="[$style.spinner, 'mr-4xs']"
|
:class="[$style.spinner, 'mr-4xs']"
|
||||||
/>
|
/>
|
||||||
<n8n-text
|
<N8nText
|
||||||
size="medium"
|
size="medium"
|
||||||
:class="[$style.status, $style[executionUIDetails.name]]"
|
:class="[$style.status, $style[executionUIDetails.name]]"
|
||||||
data-test-id="execution-preview-label"
|
data-test-id="execution-preview-label"
|
||||||
>
|
>
|
||||||
{{ executionUIDetails.label }}
|
{{ executionUIDetails.label }}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
{{ ' ' }}
|
{{ ' ' }}
|
||||||
<n8n-text
|
<N8nText v-if="executionUIDetails?.showTimestamp === false" color="text-base" size="medium">
|
||||||
v-if="executionUIDetails?.showTimestamp === false"
|
|
||||||
color="text-base"
|
|
||||||
size="medium"
|
|
||||||
>
|
|
||||||
| ID#{{ execution.id }}
|
| ID#{{ execution.id }}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
<n8n-text v-else-if="executionUIDetails.name === 'running'" color="text-base" size="medium">
|
<N8nText v-else-if="executionUIDetails.name === 'running'" color="text-base" size="medium">
|
||||||
{{
|
{{
|
||||||
$locale.baseText('executionDetails.runningTimeRunning', {
|
locale.baseText('executionDetails.runningTimeRunning', {
|
||||||
interpolate: { time: executionUIDetails?.runningTime },
|
interpolate: { time: executionUIDetails?.runningTime },
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
| ID#{{ execution.id }}
|
| ID#{{ execution.id }}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
<n8n-text
|
<N8nText
|
||||||
v-else-if="executionUIDetails.name !== 'waiting'"
|
v-else-if="executionUIDetails.name !== 'waiting'"
|
||||||
color="text-base"
|
color="text-base"
|
||||||
size="medium"
|
size="medium"
|
||||||
data-test-id="execution-preview-id"
|
data-test-id="execution-preview-id"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
$locale.baseText('executionDetails.runningTimeFinished', {
|
locale.baseText('executionDetails.runningTimeFinished', {
|
||||||
interpolate: { time: executionUIDetails?.runningTime ?? 'unknown' },
|
interpolate: { time: executionUIDetails?.runningTime ?? 'unknown' },
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
| ID#{{ execution.id }}
|
| ID#{{ execution.id }}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
<br /><n8n-text v-if="execution.mode === 'retry'" color="text-base" size="medium">
|
<br /><N8nText v-if="execution.mode === 'retry'" color="text-base" size="medium">
|
||||||
{{ $locale.baseText('executionDetails.retry') }}
|
{{ locale.baseText('executionDetails.retry') }}
|
||||||
<router-link
|
<router-link
|
||||||
:class="$style.executionLink"
|
:class="$style.executionLink"
|
||||||
:to="{
|
:to="{
|
||||||
name: executionPreviewViewName,
|
name: VIEWS.EXECUTION_PREVIEW,
|
||||||
params: {
|
params: {
|
||||||
workflowId: execution.workflowId,
|
workflowId: execution.workflowId,
|
||||||
executionId: execution.retryOf,
|
executionId: execution.retryOf,
|
||||||
|
@ -84,24 +171,31 @@
|
||||||
>
|
>
|
||||||
#{{ execution.retryOf }}
|
#{{ execution.retryOf }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<n8n-button size="medium" :type="debugButtonData.type" :class="$style.debugLink">
|
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: executionDebugViewName,
|
name: VIEWS.EXECUTION_DEBUG,
|
||||||
params: {
|
params: {
|
||||||
name: execution.workflowId,
|
name: execution.workflowId,
|
||||||
executionId: execution.id,
|
executionId: execution.id,
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span data-test-id="execution-debug-button" @click="handleDebugLinkClick">{{
|
<N8nButton
|
||||||
debugButtonData.text
|
size="medium"
|
||||||
}}</span>
|
:type="debugButtonData.type"
|
||||||
|
:class="$style.debugLink"
|
||||||
|
:disabled="!workflowPermissions.update"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-test-id="execution-debug-button"
|
||||||
|
@click="executionDebugging.handleDebugLinkClick"
|
||||||
|
>{{ debugButtonData.text }}</span
|
||||||
|
>
|
||||||
|
</N8nButton>
|
||||||
</router-link>
|
</router-link>
|
||||||
</n8n-button>
|
|
||||||
|
|
||||||
<ElDropdown
|
<ElDropdown
|
||||||
v-if="isRetriable"
|
v-if="isRetriable"
|
||||||
|
@ -111,28 +205,30 @@
|
||||||
@command="handleRetryClick"
|
@command="handleRetryClick"
|
||||||
>
|
>
|
||||||
<span class="retry-button">
|
<span class="retry-button">
|
||||||
<n8n-icon-button
|
<N8nIconButton
|
||||||
size="medium"
|
size="medium"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
:title="$locale.baseText('executionsList.retryExecution')"
|
:title="locale.baseText('executionsList.retryExecution')"
|
||||||
|
:disabled="!workflowPermissions.update"
|
||||||
icon="redo"
|
icon="redo"
|
||||||
data-test-id="execution-preview-retry-button"
|
data-test-id="execution-preview-retry-button"
|
||||||
@blur="onRetryButtonBlur"
|
@blur="onRetryButtonBlur"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<ElDropdownMenu>
|
||||||
<el-dropdown-item command="current-workflow">
|
<ElDropdownItem command="current-workflow">
|
||||||
{{ $locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
|
{{ locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
|
||||||
</el-dropdown-item>
|
</ElDropdownItem>
|
||||||
<el-dropdown-item command="original-workflow">
|
<ElDropdownItem command="original-workflow">
|
||||||
{{ $locale.baseText('executionsList.retryWithOriginalWorkflow') }}
|
{{ locale.baseText('executionsList.retryWithOriginalWorkflow') }}
|
||||||
</el-dropdown-item>
|
</ElDropdownItem>
|
||||||
</el-dropdown-menu>
|
</ElDropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
</ElDropdown>
|
</ElDropdown>
|
||||||
<n8n-icon-button
|
<N8nIconButton
|
||||||
:title="$locale.baseText('executionDetails.deleteExecution')"
|
:title="locale.baseText('executionDetails.deleteExecution')"
|
||||||
|
:disabled="!workflowPermissions.update"
|
||||||
icon="trash"
|
icon="trash"
|
||||||
size="medium"
|
size="medium"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
|
@ -145,115 +241,11 @@
|
||||||
mode="execution"
|
mode="execution"
|
||||||
loader-type="spinner"
|
loader-type="spinner"
|
||||||
:execution-id="executionId"
|
:execution-id="executionId"
|
||||||
:execution-mode="executionMode"
|
:execution-mode="execution?.mode || ''"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { ElDropdown } from 'element-plus';
|
|
||||||
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
|
|
||||||
import { useMessage } from '@/composables/useMessage';
|
|
||||||
import WorkflowPreview from '@/components/WorkflowPreview.vue';
|
|
||||||
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
|
||||||
import type { ExecutionSummary } from 'n8n-workflow';
|
|
||||||
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
|
||||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
|
|
||||||
type RetryDropdownRef = InstanceType<typeof ElDropdown> & { hide: () => void };
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'WorkflowExecutionsPreview',
|
|
||||||
components: {
|
|
||||||
ElDropdown,
|
|
||||||
WorkflowPreview,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
execution: {
|
|
||||||
type: Object as PropType<ExecutionSummary>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const executionHelpers = useExecutionHelpers();
|
|
||||||
|
|
||||||
return {
|
|
||||||
executionHelpers,
|
|
||||||
...useMessage(),
|
|
||||||
...useExecutionDebugging(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useWorkflowsStore),
|
|
||||||
executionId(): string {
|
|
||||||
return this.$route.params.executionId as string;
|
|
||||||
},
|
|
||||||
executionUIDetails(): IExecutionUIData | null {
|
|
||||||
return this.execution ? this.executionHelpers.getUIDetails(this.execution) : null;
|
|
||||||
},
|
|
||||||
executionMode(): string {
|
|
||||||
return this.execution?.mode || '';
|
|
||||||
},
|
|
||||||
debugButtonData(): Record<string, string> {
|
|
||||||
return this.execution?.status === 'success'
|
|
||||||
? {
|
|
||||||
text: this.$locale.baseText('executionsList.debug.button.copyToEditor'),
|
|
||||||
type: 'secondary',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
text: this.$locale.baseText('executionsList.debug.button.debugInEditor'),
|
|
||||||
type: 'primary',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
isRetriable(): boolean {
|
|
||||||
return !!this.execution && this.executionHelpers.isExecutionRetriable(this.execution);
|
|
||||||
},
|
|
||||||
executionDebugViewName() {
|
|
||||||
return VIEWS.EXECUTION_DEBUG;
|
|
||||||
},
|
|
||||||
executionPreviewViewName() {
|
|
||||||
return VIEWS.EXECUTION_PREVIEW;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async onDeleteExecution(): Promise<void> {
|
|
||||||
const deleteConfirmed = await this.confirm(
|
|
||||||
this.$locale.baseText('executionDetails.confirmMessage.message'),
|
|
||||||
this.$locale.baseText('executionDetails.confirmMessage.headline'),
|
|
||||||
{
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: this.$locale.baseText(
|
|
||||||
'executionDetails.confirmMessage.confirmButtonText',
|
|
||||||
),
|
|
||||||
cancelButtonText: '',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (deleteConfirmed !== MODAL_CONFIRM) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.$emit('deleteCurrentExecution');
|
|
||||||
},
|
|
||||||
handleRetryClick(command: string): void {
|
|
||||||
this.$emit('retryExecution', { execution: this.execution, command });
|
|
||||||
},
|
|
||||||
handleStopClick(): void {
|
|
||||||
this.$emit('stopExecution');
|
|
||||||
},
|
|
||||||
onRetryButtonBlur(event: FocusEvent): void {
|
|
||||||
// Hide dropdown when clicking outside of current document
|
|
||||||
const retryDropdownRef = this.$refs.retryDropdown as RetryDropdownRef | undefined;
|
|
||||||
if (retryDropdownRef && event.relatedTarget === null) {
|
|
||||||
retryDropdownRef.handleClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.previewContainer {
|
.previewContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -321,7 +313,6 @@ export default defineComponent({
|
||||||
|
|
||||||
.debugLink {
|
.debugLink {
|
||||||
margin-right: var(--spacing-xs);
|
margin-right: var(--spacing-xs);
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
a > span {
|
a > span {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
@ -1,6 +1,170 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import type { ComponentPublicInstance } from 'vue';
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
|
||||||
|
import WorkflowExecutionsInfoAccordion from '@/components/executions/workflow/WorkflowExecutionsInfoAccordion.vue';
|
||||||
|
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import type { ExecutionSummary } from 'n8n-workflow';
|
||||||
|
import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
|
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
||||||
|
import { isComponentPublicInstance } from '@/utils/typeGuards';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
|
type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean };
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
workflow?: IWorkflowDb;
|
||||||
|
executions: ExecutionSummary[];
|
||||||
|
loading: boolean;
|
||||||
|
loadingMore: boolean;
|
||||||
|
temporaryExecution?: ExecutionSummary;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
retryExecution: [payload: { execution: ExecutionSummary; command: string }];
|
||||||
|
loadMore: [amount: number];
|
||||||
|
filterUpdated: [filter: ExecutionFilterType];
|
||||||
|
'update:autoRefresh': [boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const executionsStore = useExecutionsStore();
|
||||||
|
|
||||||
|
const mountedItems = ref<string[]>([]);
|
||||||
|
const autoScrollDeps = ref<AutoScrollDeps>({
|
||||||
|
activeExecutionSet: false,
|
||||||
|
cardsMounted: false,
|
||||||
|
scroll: true,
|
||||||
|
});
|
||||||
|
const currentWorkflowExecutionsCardRefs = ref<Record<string, ComponentPublicInstance>>({});
|
||||||
|
const sidebarContainerRef = ref<HTMLElement | null>(null);
|
||||||
|
const executionListRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const workflowPermissions = computed(() => getResourcePermissions(props.workflow?.scopes).workflow);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route,
|
||||||
|
(to: RouteLocationNormalizedLoaded, from: RouteLocationNormalizedLoaded) => {
|
||||||
|
if (from.name === VIEWS.EXECUTION_PREVIEW && to.name === VIEWS.EXECUTION_HOME) {
|
||||||
|
// Skip parent route when navigating through executions with back button
|
||||||
|
router.go(-1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => executionsStore.activeExecution,
|
||||||
|
(newValue: ExecutionSummary | null, oldValue: ExecutionSummary | null) => {
|
||||||
|
if (newValue && newValue.id !== oldValue?.id) {
|
||||||
|
autoScrollDeps.value.activeExecutionSet = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
autoScrollDeps,
|
||||||
|
(updatedDeps: AutoScrollDeps) => {
|
||||||
|
if (Object.values(updatedDeps).every(Boolean)) {
|
||||||
|
scrollToActiveCard();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function addCurrentWorkflowExecutionsCardRef(
|
||||||
|
comp: Element | ComponentPublicInstance | null,
|
||||||
|
id?: string,
|
||||||
|
) {
|
||||||
|
if (comp && isComponentPublicInstance(comp) && id) {
|
||||||
|
currentWorkflowExecutionsCardRefs.value[id] = comp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onItemMounted(id: string): void {
|
||||||
|
mountedItems.value.push(id);
|
||||||
|
if (mountedItems.value.length === props.executions.length) {
|
||||||
|
autoScrollDeps.value.cardsMounted = true;
|
||||||
|
checkListSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (executionsStore.activeExecution?.id === id) {
|
||||||
|
autoScrollDeps.value.activeExecutionSet = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore(limit = 20): void {
|
||||||
|
if (!props.loading) {
|
||||||
|
if (executionListRef.value) {
|
||||||
|
const diff =
|
||||||
|
executionListRef.value.offsetHeight -
|
||||||
|
(executionListRef.value.scrollHeight - executionListRef.value.scrollTop);
|
||||||
|
if (diff > -10 && diff < 10) {
|
||||||
|
emit('loadMore', limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRetryExecution(payload: { execution: ExecutionSummary; command: string }) {
|
||||||
|
emit('retryExecution', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterChanged(filter: ExecutionFilterType) {
|
||||||
|
autoScrollDeps.value.activeExecutionSet = false;
|
||||||
|
autoScrollDeps.value.scroll = true;
|
||||||
|
mountedItems.value = [];
|
||||||
|
emit('filterUpdated', filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAutoRefreshChange(enabled: boolean) {
|
||||||
|
emit('update:autoRefresh', enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkListSize(): void {
|
||||||
|
// Find out how many execution card can fit into list
|
||||||
|
// and load more if needed
|
||||||
|
const cards = Object.values(currentWorkflowExecutionsCardRefs.value);
|
||||||
|
if (sidebarContainerRef.value && cards.length) {
|
||||||
|
const cardElement = cards[0].$el as HTMLElement;
|
||||||
|
const listCapacity = Math.ceil(
|
||||||
|
sidebarContainerRef.value.clientHeight / cardElement.clientHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (listCapacity > props.executions.length) {
|
||||||
|
emit('loadMore', listCapacity - props.executions.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToActiveCard(): void {
|
||||||
|
if (
|
||||||
|
executionListRef.value &&
|
||||||
|
executionsStore.activeExecution &&
|
||||||
|
currentWorkflowExecutionsCardRefs.value[executionsStore.activeExecution.id]
|
||||||
|
) {
|
||||||
|
const cardElement =
|
||||||
|
currentWorkflowExecutionsCardRefs.value[executionsStore.activeExecution.id].$el;
|
||||||
|
const cardRect = cardElement.getBoundingClientRect();
|
||||||
|
const LIST_HEADER_OFFSET = 200;
|
||||||
|
if (cardRect.top > executionListRef.value.offsetHeight) {
|
||||||
|
autoScrollDeps.value.scroll = false;
|
||||||
|
executionListRef.value.scrollTo({
|
||||||
|
top: cardRect.top - LIST_HEADER_OFFSET,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="container"
|
ref="sidebarContainerRef"
|
||||||
:class="['executions-sidebar', $style.container]"
|
:class="['executions-sidebar', $style.container]"
|
||||||
data-test-id="executions-sidebar"
|
data-test-id="executions-sidebar"
|
||||||
>
|
>
|
||||||
|
@ -13,14 +177,14 @@
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
v-model="executionsStore.autoRefresh"
|
v-model="executionsStore.autoRefresh"
|
||||||
data-test-id="auto-refresh-checkbox"
|
data-test-id="auto-refresh-checkbox"
|
||||||
@update:model-value="$emit('update:autoRefresh', $event)"
|
@update:model-value="onAutoRefreshChange"
|
||||||
>
|
>
|
||||||
{{ $locale.baseText('executionsList.autoRefresh') }}
|
{{ $locale.baseText('executionsList.autoRefresh') }}
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
<ExecutionsFilter popover-placement="left-start" @filter-changed="onFilterChanged" />
|
<ExecutionsFilter popover-placement="left-start" @filter-changed="onFilterChanged" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref="executionList"
|
ref="executionListRef"
|
||||||
:class="$style.executionList"
|
:class="$style.executionList"
|
||||||
data-test-id="current-executions-list"
|
data-test-id="current-executions-list"
|
||||||
@scroll="loadMore(20)"
|
@scroll="loadMore(20)"
|
||||||
|
@ -39,18 +203,20 @@
|
||||||
</div>
|
</div>
|
||||||
<WorkflowExecutionsCard
|
<WorkflowExecutionsCard
|
||||||
v-else-if="temporaryExecution"
|
v-else-if="temporaryExecution"
|
||||||
:ref="`execution-${temporaryExecution.id}`"
|
:ref="(el) => addCurrentWorkflowExecutionsCardRef(el, temporaryExecution?.id)"
|
||||||
:execution="temporaryExecution"
|
:execution="temporaryExecution"
|
||||||
:data-test-id="`execution-details-${temporaryExecution.id}`"
|
:data-test-id="`execution-details-${temporaryExecution.id}`"
|
||||||
:show-gap="true"
|
:show-gap="true"
|
||||||
|
:workflow-permissions="workflowPermissions"
|
||||||
@retry-execution="onRetryExecution"
|
@retry-execution="onRetryExecution"
|
||||||
/>
|
/>
|
||||||
<TransitionGroup name="executions-list">
|
<TransitionGroup name="executions-list">
|
||||||
<WorkflowExecutionsCard
|
<WorkflowExecutionsCard
|
||||||
v-for="execution in executions"
|
v-for="execution in executions"
|
||||||
:key="execution.id"
|
:key="execution.id"
|
||||||
:ref="`execution-${execution.id}`"
|
:ref="(el) => addCurrentWorkflowExecutionsCardRef(el, execution.id)"
|
||||||
:execution="execution"
|
:execution="execution"
|
||||||
|
:workflow-permissions="workflowPermissions"
|
||||||
:data-test-id="`execution-details-${execution.id}`"
|
:data-test-id="`execution-details-${execution.id}`"
|
||||||
@retry-execution="onRetryExecution"
|
@retry-execution="onRetryExecution"
|
||||||
@mounted="onItemMounted"
|
@mounted="onItemMounted"
|
||||||
|
@ -66,178 +232,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
|
|
||||||
import WorkflowExecutionsInfoAccordion from '@/components/executions/workflow/WorkflowExecutionsInfoAccordion.vue';
|
|
||||||
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
|
|
||||||
import { VIEWS } from '@/constants';
|
|
||||||
import type { ExecutionSummary } from 'n8n-workflow';
|
|
||||||
import type { RouteRecord } from 'vue-router';
|
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useExecutionsStore } from '@/stores/executions.store';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import type { ExecutionFilterType } from '@/Interface';
|
|
||||||
|
|
||||||
type WorkflowExecutionsCardRef = InstanceType<typeof WorkflowExecutionsCard>;
|
|
||||||
type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean };
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'WorkflowExecutionsSidebar',
|
|
||||||
components: {
|
|
||||||
WorkflowExecutionsCard,
|
|
||||||
WorkflowExecutionsInfoAccordion,
|
|
||||||
ExecutionsFilter,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
executions: {
|
|
||||||
type: Array as PropType<ExecutionSummary[]>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
loadingMore: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
temporaryExecution: {
|
|
||||||
type: Object as PropType<ExecutionSummary>,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: {
|
|
||||||
retryExecution: null,
|
|
||||||
loadMore: null,
|
|
||||||
refresh: null,
|
|
||||||
filterUpdated: null,
|
|
||||||
reloadExecutions: null,
|
|
||||||
'update:autoRefresh': null,
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
filter: {} as ExecutionFilterType,
|
|
||||||
mountedItems: [] as string[],
|
|
||||||
autoScrollDeps: {
|
|
||||||
activeExecutionSet: false,
|
|
||||||
cardsMounted: false,
|
|
||||||
scroll: true,
|
|
||||||
} as AutoScrollDeps,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useExecutionsStore, useWorkflowsStore),
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
$route(to: RouteRecord, from: RouteRecord) {
|
|
||||||
if (from.name === VIEWS.EXECUTION_PREVIEW && to.name === VIEWS.EXECUTION_HOME) {
|
|
||||||
// Skip parent route when navigating through executions with back button
|
|
||||||
this.$router.go(-1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'executionsStore.activeExecution'(
|
|
||||||
newValue: ExecutionSummary | null,
|
|
||||||
oldValue: ExecutionSummary | null,
|
|
||||||
) {
|
|
||||||
if (newValue && newValue.id !== oldValue?.id) {
|
|
||||||
this.autoScrollDeps.activeExecutionSet = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
autoScrollDeps: {
|
|
||||||
handler(updatedDeps: AutoScrollDeps) {
|
|
||||||
if (Object.values(updatedDeps).every(Boolean)) {
|
|
||||||
this.scrollToActiveCard();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deep: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onItemMounted(id: string): void {
|
|
||||||
this.mountedItems.push(id);
|
|
||||||
if (this.mountedItems.length === this.executions.length) {
|
|
||||||
this.autoScrollDeps.cardsMounted = true;
|
|
||||||
this.checkListSize();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.executionsStore.activeExecution?.id === id) {
|
|
||||||
this.autoScrollDeps.activeExecutionSet = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
loadMore(limit = 20): void {
|
|
||||||
if (!this.loading) {
|
|
||||||
const executionsListRef = this.$refs.executionList as HTMLElement | undefined;
|
|
||||||
if (executionsListRef) {
|
|
||||||
const diff =
|
|
||||||
executionsListRef.offsetHeight -
|
|
||||||
(executionsListRef.scrollHeight - executionsListRef.scrollTop);
|
|
||||||
if (diff > -10 && diff < 10) {
|
|
||||||
this.$emit('loadMore', limit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRetryExecution(payload: object) {
|
|
||||||
this.$emit('retryExecution', payload);
|
|
||||||
},
|
|
||||||
onRefresh(): void {
|
|
||||||
this.$emit('refresh');
|
|
||||||
},
|
|
||||||
onFilterChanged(filter: ExecutionFilterType) {
|
|
||||||
this.autoScrollDeps.activeExecutionSet = false;
|
|
||||||
this.autoScrollDeps.scroll = true;
|
|
||||||
this.mountedItems = [];
|
|
||||||
this.$emit('filterUpdated', filter);
|
|
||||||
},
|
|
||||||
reloadExecutions(): void {
|
|
||||||
this.$emit('reloadExecutions');
|
|
||||||
},
|
|
||||||
checkListSize(): void {
|
|
||||||
const sidebarContainerRef = this.$refs.container as HTMLElement | undefined;
|
|
||||||
const currentWorkflowExecutionsCardRefs = this.$refs[
|
|
||||||
`execution-${this.mountedItems[this.mountedItems.length - 1]}`
|
|
||||||
] as WorkflowExecutionsCardRef[] | undefined;
|
|
||||||
|
|
||||||
// Find out how many execution card can fit into list
|
|
||||||
// and load more if needed
|
|
||||||
if (sidebarContainerRef && currentWorkflowExecutionsCardRefs?.length) {
|
|
||||||
const cardElement = currentWorkflowExecutionsCardRefs[0].$el as HTMLElement;
|
|
||||||
const listCapacity = Math.ceil(sidebarContainerRef.clientHeight / cardElement.clientHeight);
|
|
||||||
|
|
||||||
if (listCapacity > this.executions.length) {
|
|
||||||
this.$emit('loadMore', listCapacity - this.executions.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollToActiveCard(): void {
|
|
||||||
const executionsListRef = this.$refs.executionList as HTMLElement | undefined;
|
|
||||||
const currentWorkflowExecutionsCardRefs = this.$refs[
|
|
||||||
`execution-${this.executionsStore.activeExecution?.id}`
|
|
||||||
] as WorkflowExecutionsCardRef[] | undefined;
|
|
||||||
|
|
||||||
if (
|
|
||||||
executionsListRef &&
|
|
||||||
currentWorkflowExecutionsCardRefs?.length &&
|
|
||||||
this.executionsStore.activeExecution
|
|
||||||
) {
|
|
||||||
const cardElement = currentWorkflowExecutionsCardRefs[0].$el as HTMLElement;
|
|
||||||
const cardRect = cardElement.getBoundingClientRect();
|
|
||||||
const LIST_HEADER_OFFSET = 200;
|
|
||||||
if (cardRect.top > executionsListRef.offsetHeight) {
|
|
||||||
this.autoScrollDeps.scroll = false;
|
|
||||||
executionsListRef.scrollTo({
|
|
||||||
top: cardRect.top - LIST_HEADER_OFFSET,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.container {
|
.container {
|
||||||
flex: 310px 0 0;
|
flex: 310px 0 0;
|
||||||
|
|
|
@ -23,8 +23,13 @@
|
||||||
:description="i18n.baseText(`${resourceKey}.empty.description` as BaseTextKey)"
|
:description="i18n.baseText(`${resourceKey}.empty.description` as BaseTextKey)"
|
||||||
:button-text="i18n.baseText(`${resourceKey}.empty.button` as BaseTextKey)"
|
:button-text="i18n.baseText(`${resourceKey}.empty.button` as BaseTextKey)"
|
||||||
button-type="secondary"
|
button-type="secondary"
|
||||||
|
:button-disabled="disabled"
|
||||||
@click:button="onAddButtonClick"
|
@click:button="onAddButtonClick"
|
||||||
/>
|
>
|
||||||
|
<template #disabledButtonTooltip>
|
||||||
|
{{ i18n.baseText(`${resourceKey}.empty.button.disabled.tooltip` as BaseTextKey) }}
|
||||||
|
</template>
|
||||||
|
</n8n-action-box>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<PageViewLayoutList v-else :overflow="type !== 'list'">
|
<PageViewLayoutList v-else :overflow="type !== 'list'">
|
||||||
|
|
|
@ -38,6 +38,7 @@ describe('useContextMenu', () => {
|
||||||
|
|
||||||
workflowsStore = useWorkflowsStore();
|
workflowsStore = useWorkflowsStore();
|
||||||
workflowsStore.workflow.nodes = nodes;
|
workflowsStore.workflow.nodes = nodes;
|
||||||
|
workflowsStore.workflow.scopes = ['workflow:update'];
|
||||||
vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockReturnValue({
|
vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockReturnValue({
|
||||||
nodes,
|
nodes,
|
||||||
getNode: (_: string) => {
|
getNode: (_: string) => {
|
||||||
|
@ -127,6 +128,7 @@ describe('useContextMenu', () => {
|
||||||
describe('Read-only mode', () => {
|
describe('Read-only mode', () => {
|
||||||
it('should return the correct actions when right clicking a sticky', () => {
|
it('should return the correct actions when right clicking a sticky', () => {
|
||||||
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
|
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
|
||||||
|
workflowsStore.workflow.scopes = ['workflow:read'];
|
||||||
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||||
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
|
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
|
||||||
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(sticky);
|
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(sticky);
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { getMousePosition } from '../utils/nodeViewUtils';
|
||||||
import { useI18n } from './useI18n';
|
import { useI18n } from './useI18n';
|
||||||
import { usePinnedData } from './usePinnedData';
|
import { usePinnedData } from './usePinnedData';
|
||||||
import { isPresent } from '../utils/typesUtils';
|
import { isPresent } from '../utils/typesUtils';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
export type ContextMenuTarget =
|
export type ContextMenuTarget =
|
||||||
| { source: 'canvas'; nodeIds: string[] }
|
| { source: 'canvas'; nodeIds: string[] }
|
||||||
|
@ -46,8 +47,15 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const workflowPermissions = computed(
|
||||||
|
() => getResourcePermissions(workflowsStore.workflow.scopes).workflow,
|
||||||
|
);
|
||||||
|
|
||||||
const isReadOnly = computed(
|
const isReadOnly = computed(
|
||||||
() => sourceControlStore.preferences.branchReadOnly || uiStore.isReadOnlyView,
|
() =>
|
||||||
|
sourceControlStore.preferences.branchReadOnly ||
|
||||||
|
uiStore.isReadOnlyView ||
|
||||||
|
!workflowPermissions.value.update,
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetNodeIds = computed(() => {
|
const targetNodeIds = computed(() => {
|
||||||
|
|
|
@ -1,120 +1,118 @@
|
||||||
import {
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
getVariablesPermissions,
|
import { getResourcePermissions } from '@/permissions';
|
||||||
getProjectPermissions,
|
import type { Scope } from '@n8n/permissions';
|
||||||
getCredentialPermissions,
|
|
||||||
getWorkflowPermissions,
|
|
||||||
} from '@/permissions';
|
|
||||||
import type { ICredentialsResponse, IUser, IWorkflowDb } from '@/Interface';
|
|
||||||
import type { Project } from '@/types/projects.types';
|
|
||||||
|
|
||||||
describe('permissions', () => {
|
describe('permissions', () => {
|
||||||
it('getVariablesPermissions', () => {
|
it('getResourcePermissions for empty scopes', () => {
|
||||||
expect(getVariablesPermissions(null)).toEqual({
|
expect(getResourcePermissions()).toEqual({
|
||||||
create: false,
|
auditLogs: {},
|
||||||
read: false,
|
banner: {},
|
||||||
update: false,
|
communityPackage: {},
|
||||||
delete: false,
|
credential: {},
|
||||||
list: false,
|
externalSecretsProvider: {},
|
||||||
});
|
externalSecret: {},
|
||||||
|
eventBusDestination: {},
|
||||||
expect(
|
ldap: {},
|
||||||
getVariablesPermissions({
|
license: {},
|
||||||
globalScopes: [
|
logStreaming: {},
|
||||||
'variable:create',
|
orchestration: {},
|
||||||
'variable:read',
|
project: {},
|
||||||
'variable:update',
|
saml: {},
|
||||||
'variable:delete',
|
securityAudit: {},
|
||||||
'variable:list',
|
sourceControl: {},
|
||||||
],
|
tag: {},
|
||||||
} as IUser),
|
user: {},
|
||||||
).toEqual({
|
variable: {},
|
||||||
create: true,
|
workersView: {},
|
||||||
read: true,
|
workflow: {},
|
||||||
update: true,
|
|
||||||
delete: true,
|
|
||||||
list: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
getVariablesPermissions({
|
|
||||||
globalScopes: ['variable:read', 'variable:list'],
|
|
||||||
} as IUser),
|
|
||||||
).toEqual({
|
|
||||||
create: false,
|
|
||||||
read: true,
|
|
||||||
update: false,
|
|
||||||
delete: false,
|
|
||||||
list: true,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('getResourcePermissions', () => {
|
||||||
it('getProjectPermissions', () => {
|
const scopes: Scope[] = [
|
||||||
expect(
|
|
||||||
getProjectPermissions({
|
|
||||||
scopes: [
|
|
||||||
'project:create',
|
|
||||||
'project:read',
|
|
||||||
'project:update',
|
|
||||||
'project:delete',
|
|
||||||
'project:list',
|
|
||||||
],
|
|
||||||
} as Project),
|
|
||||||
).toEqual({
|
|
||||||
create: true,
|
|
||||||
read: true,
|
|
||||||
update: true,
|
|
||||||
delete: true,
|
|
||||||
list: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getCredentialPermissions', () => {
|
|
||||||
expect(
|
|
||||||
getCredentialPermissions({
|
|
||||||
scopes: [
|
|
||||||
'credential:create',
|
'credential:create',
|
||||||
'credential:read',
|
|
||||||
'credential:update',
|
|
||||||
'credential:delete',
|
'credential:delete',
|
||||||
'credential:list',
|
'credential:list',
|
||||||
'credential:share',
|
|
||||||
'credential:move',
|
'credential:move',
|
||||||
],
|
'credential:read',
|
||||||
} as ICredentialsResponse),
|
'credential:share',
|
||||||
).toEqual({
|
'credential:update',
|
||||||
create: true,
|
'eventBusDestination:list',
|
||||||
read: true,
|
'eventBusDestination:test',
|
||||||
update: true,
|
'project:list',
|
||||||
delete: true,
|
'project:read',
|
||||||
list: true,
|
'tag:create',
|
||||||
share: true,
|
'tag:list',
|
||||||
move: true,
|
'tag:read',
|
||||||
});
|
'tag:update',
|
||||||
});
|
'user:list',
|
||||||
|
'variable:list',
|
||||||
it('getWorkflowPermissions', () => {
|
'variable:read',
|
||||||
expect(
|
|
||||||
getWorkflowPermissions({
|
|
||||||
scopes: [
|
|
||||||
'workflow:create',
|
'workflow:create',
|
||||||
'workflow:read',
|
|
||||||
'workflow:update',
|
|
||||||
'workflow:delete',
|
'workflow:delete',
|
||||||
'workflow:list',
|
|
||||||
'workflow:share',
|
|
||||||
'workflow:execute',
|
'workflow:execute',
|
||||||
|
'workflow:list',
|
||||||
'workflow:move',
|
'workflow:move',
|
||||||
],
|
'workflow:read',
|
||||||
} as IWorkflowDb),
|
'workflow:share',
|
||||||
).toEqual({
|
'workflow:update',
|
||||||
|
];
|
||||||
|
|
||||||
|
const permissionRecord: PermissionsRecord = {
|
||||||
|
auditLogs: {},
|
||||||
|
banner: {},
|
||||||
|
communityPackage: {},
|
||||||
|
credential: {
|
||||||
create: true,
|
create: true,
|
||||||
read: true,
|
|
||||||
update: true,
|
|
||||||
delete: true,
|
delete: true,
|
||||||
list: true,
|
list: true,
|
||||||
share: true,
|
|
||||||
execute: true,
|
|
||||||
move: true,
|
move: true,
|
||||||
});
|
read: true,
|
||||||
|
share: true,
|
||||||
|
update: true,
|
||||||
|
},
|
||||||
|
eventBusDestination: {
|
||||||
|
list: true,
|
||||||
|
test: true,
|
||||||
|
},
|
||||||
|
externalSecret: {},
|
||||||
|
externalSecretsProvider: {},
|
||||||
|
ldap: {},
|
||||||
|
license: {},
|
||||||
|
logStreaming: {},
|
||||||
|
orchestration: {},
|
||||||
|
project: {
|
||||||
|
list: true,
|
||||||
|
read: true,
|
||||||
|
},
|
||||||
|
saml: {},
|
||||||
|
securityAudit: {},
|
||||||
|
sourceControl: {},
|
||||||
|
tag: {
|
||||||
|
create: true,
|
||||||
|
list: true,
|
||||||
|
read: true,
|
||||||
|
update: true,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
list: true,
|
||||||
|
},
|
||||||
|
variable: {
|
||||||
|
list: true,
|
||||||
|
read: true,
|
||||||
|
},
|
||||||
|
workersView: {},
|
||||||
|
workflow: {
|
||||||
|
create: true,
|
||||||
|
delete: true,
|
||||||
|
execute: true,
|
||||||
|
list: true,
|
||||||
|
move: true,
|
||||||
|
read: true,
|
||||||
|
share: true,
|
||||||
|
update: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,66 +1,32 @@
|
||||||
import type { IUser, ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
import type { Scope } from '@n8n/permissions';
|
||||||
import type {
|
import { RESOURCES } from '@n8n/permissions';
|
||||||
CredentialScope,
|
|
||||||
ProjectScope,
|
|
||||||
Scope,
|
|
||||||
WorkflowScope,
|
|
||||||
VariableScope,
|
|
||||||
} from '@n8n/permissions';
|
|
||||||
import type { Project } from '@/types/projects.types';
|
|
||||||
|
|
||||||
type ExtractAfterColon<T> = T extends `${infer _Prefix}:${infer Suffix}` ? Suffix : never;
|
type ExtractScopePrefixSuffix<T> = T extends `${infer Prefix}:${infer Suffix}`
|
||||||
export type PermissionsMap<T> = {
|
? [Prefix, Suffix]
|
||||||
[K in ExtractAfterColon<T>]: boolean;
|
: never;
|
||||||
|
type ActionBooleans<T extends readonly string[]> = {
|
||||||
|
[K in T[number]]?: boolean;
|
||||||
|
};
|
||||||
|
export type PermissionsRecord = {
|
||||||
|
[K in keyof typeof RESOURCES]: ActionBooleans<(typeof RESOURCES)[K]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapScopesToPermissions = <T extends Scope>(scopes: T[], scopeSet: Set<T>) =>
|
export const getResourcePermissions = (resourceScopes: Scope[] = []): PermissionsRecord =>
|
||||||
scopes.reduce(
|
Object.keys(RESOURCES).reduce(
|
||||||
(permissions, scope) => ({
|
(permissions, key) => ({
|
||||||
...permissions,
|
...permissions,
|
||||||
[scope.split(':')[1]]: scopeSet.has(scope),
|
[key]: resourceScopes.reduce((resourcePermissions, scope) => {
|
||||||
|
const [prefix, suffix] = scope.split(':') as ExtractScopePrefixSuffix<Scope>;
|
||||||
|
|
||||||
|
if (prefix === key) {
|
||||||
|
return {
|
||||||
|
...resourcePermissions,
|
||||||
|
[suffix]: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return resourcePermissions;
|
||||||
|
}, {}),
|
||||||
}),
|
}),
|
||||||
{} as PermissionsMap<T>,
|
{} as PermissionsRecord,
|
||||||
);
|
|
||||||
|
|
||||||
export const getCredentialPermissions = (
|
|
||||||
credential: ICredentialsResponse,
|
|
||||||
): PermissionsMap<CredentialScope> =>
|
|
||||||
mapScopesToPermissions(
|
|
||||||
[
|
|
||||||
'credential:create',
|
|
||||||
'credential:read',
|
|
||||||
'credential:update',
|
|
||||||
'credential:delete',
|
|
||||||
'credential:list',
|
|
||||||
'credential:share',
|
|
||||||
'credential:move',
|
|
||||||
],
|
|
||||||
new Set(credential?.scopes ?? []),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getWorkflowPermissions = (workflow: IWorkflowDb): PermissionsMap<WorkflowScope> =>
|
|
||||||
mapScopesToPermissions(
|
|
||||||
[
|
|
||||||
'workflow:create',
|
|
||||||
'workflow:read',
|
|
||||||
'workflow:update',
|
|
||||||
'workflow:delete',
|
|
||||||
'workflow:list',
|
|
||||||
'workflow:share',
|
|
||||||
'workflow:execute',
|
|
||||||
'workflow:move',
|
|
||||||
],
|
|
||||||
new Set(workflow?.scopes ?? []),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getProjectPermissions = (project: Project | null): PermissionsMap<ProjectScope> =>
|
|
||||||
mapScopesToPermissions(
|
|
||||||
['project:create', 'project:read', 'project:update', 'project:delete', 'project:list'],
|
|
||||||
new Set(project?.scopes ?? []),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getVariablesPermissions = (user: IUser | null): PermissionsMap<VariableScope> =>
|
|
||||||
mapScopesToPermissions(
|
|
||||||
['variable:create', 'variable:read', 'variable:update', 'variable:delete', 'variable:list'],
|
|
||||||
new Set(user?.globalScopes ?? []),
|
|
||||||
);
|
);
|
||||||
|
|
|
@ -574,6 +574,7 @@
|
||||||
"credentials.empty.heading.userNotSetup": "Set up a credential",
|
"credentials.empty.heading.userNotSetup": "Set up a credential",
|
||||||
"credentials.empty.description": "Credentials let workflows interact with your apps and services",
|
"credentials.empty.description": "Credentials let workflows interact with your apps and services",
|
||||||
"credentials.empty.button": "Add first credential",
|
"credentials.empty.button": "Add first credential",
|
||||||
|
"credentials.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create credentials",
|
||||||
"credentials.item.open": "Open",
|
"credentials.item.open": "Open",
|
||||||
"credentials.item.delete": "Delete",
|
"credentials.item.delete": "Delete",
|
||||||
"credentials.item.move": "Move",
|
"credentials.item.move": "Move",
|
||||||
|
@ -2189,8 +2190,10 @@
|
||||||
"workflows.empty.heading.userNotSetup": "👋 Welcome!",
|
"workflows.empty.heading.userNotSetup": "👋 Welcome!",
|
||||||
"workflows.empty.description": "Create your first workflow",
|
"workflows.empty.description": "Create your first workflow",
|
||||||
"workflows.empty.description.readOnlyEnv": "No workflows here yet",
|
"workflows.empty.description.readOnlyEnv": "No workflows here yet",
|
||||||
|
"workflows.empty.description.noPermission": "There are currently no workflows to view",
|
||||||
"workflows.empty.startFromScratch": "Start from scratch",
|
"workflows.empty.startFromScratch": "Start from scratch",
|
||||||
"workflows.empty.browseTemplates": "Browse {category} templates",
|
"workflows.empty.browseTemplates": "Browse {category} templates",
|
||||||
|
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
|
||||||
"workflows.shareModal.title": "Share '{name}'",
|
"workflows.shareModal.title": "Share '{name}'",
|
||||||
"workflows.shareModal.title.static": "Shared with {projectName}",
|
"workflows.shareModal.title.static": "Shared with {projectName}",
|
||||||
"workflows.shareModal.select.placeholder": "Add users...",
|
"workflows.shareModal.select.placeholder": "Add users...",
|
||||||
|
@ -2241,6 +2244,7 @@
|
||||||
"variables.empty.heading.userNotSetup": "Set up a variable",
|
"variables.empty.heading.userNotSetup": "Set up a variable",
|
||||||
"variables.empty.description": "Variables can be used to store data that can be referenced easily across multiple workflows.",
|
"variables.empty.description": "Variables can be used to store data that can be referenced easily across multiple workflows.",
|
||||||
"variables.empty.button": "Add first variable",
|
"variables.empty.button": "Add first variable",
|
||||||
|
"variables.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create variables",
|
||||||
"variables.empty.notAllowedToCreate.heading": "{name}, start using variables",
|
"variables.empty.notAllowedToCreate.heading": "{name}, start using variables",
|
||||||
"variables.empty.notAllowedToCreate.description": "Ask your n8n instance owner to create the variables you need. Once configured, you can utilize them in your workflows using the syntax $vars.MY_VAR.",
|
"variables.empty.notAllowedToCreate.description": "Ask your n8n instance owner to create the variables you need. Once configured, you can utilize them in your workflows using the syntax $vars.MY_VAR.",
|
||||||
"variables.noResults": "No variables found",
|
"variables.noResults": "No variables found",
|
||||||
|
@ -2444,6 +2448,7 @@
|
||||||
"projects.settings.button.deleteProject": "Delete project",
|
"projects.settings.button.deleteProject": "Delete project",
|
||||||
"projects.settings.role.admin": "Admin",
|
"projects.settings.role.admin": "Admin",
|
||||||
"projects.settings.role.editor": "Editor",
|
"projects.settings.role.editor": "Editor",
|
||||||
|
"projects.settings.role.viewer": "Viewer",
|
||||||
"projects.settings.delete.title": "Delete {projectName}",
|
"projects.settings.delete.title": "Delete {projectName}",
|
||||||
"projects.settings.delete.message": "What should we do with the project data?",
|
"projects.settings.delete.message": "What should we do with the project data?",
|
||||||
"projects.settings.delete.question.transfer.label": "Transfer its workflows and credentials to another project or user",
|
"projects.settings.delete.question.transfer.label": "Transfer its workflows and credentials to another project or user",
|
||||||
|
|
|
@ -74,11 +74,13 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
() => lastSelectedConnection.value,
|
() => lastSelectedConnection.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(readOnlyEnv, (readOnly) => {
|
const setReadOnly = (readOnly: boolean) => {
|
||||||
if (jsPlumbInstanceRef.value) {
|
if (jsPlumbInstanceRef.value) {
|
||||||
jsPlumbInstanceRef.value.elementsDraggable = !readOnly;
|
jsPlumbInstanceRef.value.elementsDraggable = !readOnly;
|
||||||
|
jsPlumbInstanceRef.value.setDragConstrainFunction(((pos: PointXY) =>
|
||||||
|
readOnly ? null : pos) as ConstrainFunction);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
const setLastSelectedConnection = (connection: Connection | undefined) => {
|
const setLastSelectedConnection = (connection: Connection | undefined) => {
|
||||||
lastSelectedConnection.value = connection;
|
lastSelectedConnection.value = connection;
|
||||||
|
@ -255,7 +257,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
if (!nodeName) return;
|
if (!nodeName) return;
|
||||||
const nodeData = workflowStore.getNodeByName(nodeName);
|
const nodeData = workflowStore.getNodeByName(nodeName);
|
||||||
isDragging.value = false;
|
isDragging.value = false;
|
||||||
if (uiStore.isActionActive['dragActive'] && nodeData) {
|
if (uiStore.isActionActive.dragActive && nodeData) {
|
||||||
const moveNodes = uiStore.getSelectedNodes.slice();
|
const moveNodes = uiStore.getSelectedNodes.slice();
|
||||||
const selectedNodeNames = moveNodes.map((node: INodeUi) => node.name);
|
const selectedNodeNames = moveNodes.map((node: INodeUi) => node.name);
|
||||||
if (!selectedNodeNames.includes(nodeData.name)) {
|
if (!selectedNodeNames.includes(nodeData.name)) {
|
||||||
|
@ -300,7 +302,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
if (moveNodes.length > 1) {
|
if (moveNodes.length > 1) {
|
||||||
historyStore.stopRecordingUndo();
|
historyStore.stopRecordingUndo();
|
||||||
}
|
}
|
||||||
if (uiStore.isActionActive['dragActive']) {
|
if (uiStore.isActionActive.dragActive) {
|
||||||
uiStore.removeActiveAction('dragActive');
|
uiStore.removeActiveAction('dragActive');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -319,6 +321,9 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsPlumbInstance = computed(() => jsPlumbInstanceRef.value as BrowserJsPlumbInstance);
|
const jsPlumbInstance = computed(() => jsPlumbInstanceRef.value as BrowserJsPlumbInstance);
|
||||||
|
|
||||||
|
watch(readOnlyEnv, setReadOnly);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDemo,
|
isDemo,
|
||||||
nodeViewScale,
|
nodeViewScale,
|
||||||
|
@ -328,6 +333,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
isLoading: loadingService.isLoading,
|
isLoading: loadingService.isLoading,
|
||||||
aiNodes,
|
aiNodes,
|
||||||
lastSelectedConnection: lastSelectedConnectionComputed,
|
lastSelectedConnection: lastSelectedConnectionComputed,
|
||||||
|
setReadOnly,
|
||||||
setLastSelectedConnection,
|
setLastSelectedConnection,
|
||||||
startLoading: loadingService.startLoading,
|
startLoading: loadingService.startLoading,
|
||||||
setLoadingText: loadingService.setLoadingText,
|
setLoadingText: loadingService.setLoadingText,
|
||||||
|
|
|
@ -101,6 +101,7 @@ describe('roles store', () => {
|
||||||
});
|
});
|
||||||
await rolesStore.fetchRoles();
|
await rolesStore.fetchRoles();
|
||||||
expect(rolesStore.processedProjectRoles.map(({ role }) => role)).toEqual([
|
expect(rolesStore.processedProjectRoles.map(({ role }) => role)).toEqual([
|
||||||
|
'project:viewer',
|
||||||
'project:editor',
|
'project:editor',
|
||||||
'project:admin',
|
'project:admin',
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -13,7 +13,11 @@ export const useRolesStore = defineStore('roles', () => {
|
||||||
credential: [],
|
credential: [],
|
||||||
workflow: [],
|
workflow: [],
|
||||||
});
|
});
|
||||||
const projectRoleOrder = ref<ProjectRole[]>(['project:editor', 'project:admin']);
|
const projectRoleOrder = ref<ProjectRole[]>([
|
||||||
|
'project:viewer',
|
||||||
|
'project:editor',
|
||||||
|
'project:admin',
|
||||||
|
]);
|
||||||
const projectRoleOrderMap = computed<Map<ProjectRole, number>>(
|
const projectRoleOrderMap = computed<Map<ProjectRole, number>>(
|
||||||
() => new Map(projectRoleOrder.value.map((role, idx) => [role, idx])),
|
() => new Map(projectRoleOrder.value.map((role, idx) => [role, idx])),
|
||||||
);
|
);
|
||||||
|
|
|
@ -12,6 +12,7 @@ import type { Connection as VueFlowConnection } from '@vue-flow/core';
|
||||||
import type { RouteLocationRaw } from 'vue-router';
|
import type { RouteLocationRaw } from 'vue-router';
|
||||||
import type { CanvasConnectionMode } from '@/types';
|
import type { CanvasConnectionMode } from '@/types';
|
||||||
import { canvasConnectionModes } from '@/types';
|
import { canvasConnectionModes } from '@/types';
|
||||||
|
import type { ComponentPublicInstance } from 'vue';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Type guards used in editor-ui project
|
Type guards used in editor-ui project
|
||||||
|
@ -103,3 +104,7 @@ export function isRouteLocationRaw(value: unknown): value is RouteLocationRaw {
|
||||||
(typeof value === 'object' && value !== null && ('name' in value || 'path' in value))
|
(typeof value === 'object' && value !== null && ('name' in value || 'path' in value))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isComponentPublicInstance(value: unknown): value is ComponentPublicInstance {
|
||||||
|
return value !== null && typeof value === 'object' && '$props' in value;
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
:additional-filters-handler="onFilter"
|
:additional-filters-handler="onFilter"
|
||||||
:type-props="{ itemSize: 77 }"
|
:type-props="{ itemSize: 77 }"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
:disabled="readOnlyEnv || !projectPermissions.credential.create"
|
||||||
@click:add="addCredential"
|
@click:add="addCredential"
|
||||||
@update:filters="filters = $event"
|
@update:filters="filters = $event"
|
||||||
>
|
>
|
||||||
|
@ -79,6 +80,7 @@ import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||||
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'CredentialsView',
|
name: 'CredentialsView',
|
||||||
|
@ -131,6 +133,14 @@ export default defineComponent({
|
||||||
? this.$locale.baseText('credentials.project.add')
|
? this.$locale.baseText('credentials.project.add')
|
||||||
: this.$locale.baseText('credentials.add');
|
: this.$locale.baseText('credentials.add');
|
||||||
},
|
},
|
||||||
|
readOnlyEnv(): boolean {
|
||||||
|
return this.sourceControlStore.preferences.branchReadOnly;
|
||||||
|
},
|
||||||
|
projectPermissions() {
|
||||||
|
return getResourcePermissions(
|
||||||
|
this.projectsStore.currentProject?.scopes ?? this.projectsStore.personalProject?.scopes,
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
'$route.params.projectId'() {
|
'$route.params.projectId'() {
|
||||||
|
|
|
@ -47,7 +47,11 @@
|
||||||
v-for="nodeData in nodesToRender"
|
v-for="nodeData in nodesToRender"
|
||||||
:key="`${nodeData.id}_node`"
|
:key="`${nodeData.id}_node`"
|
||||||
:name="nodeData.name"
|
:name="nodeData.name"
|
||||||
:is-read-only="isReadOnlyRoute || readOnlyEnv"
|
:is-read-only="
|
||||||
|
isReadOnlyRoute ||
|
||||||
|
readOnlyEnv ||
|
||||||
|
!(workflowPermissions.update ?? projectPermissions.workflow.update)
|
||||||
|
"
|
||||||
:instance="instance"
|
:instance="instance"
|
||||||
:is-active="!!activeNode && activeNode.name === nodeData.name"
|
:is-active="!!activeNode && activeNode.name === nodeData.name"
|
||||||
:hide-actions="pullConnActive"
|
:hide-actions="pullConnActive"
|
||||||
|
@ -75,7 +79,11 @@
|
||||||
:key="`${stickyData.id}_sticky`"
|
:key="`${stickyData.id}_sticky`"
|
||||||
:name="stickyData.name"
|
:name="stickyData.name"
|
||||||
:workflow="currentWorkflowObject"
|
:workflow="currentWorkflowObject"
|
||||||
:is-read-only="isReadOnlyRoute || readOnlyEnv"
|
:is-read-only="
|
||||||
|
isReadOnlyRoute ||
|
||||||
|
readOnlyEnv ||
|
||||||
|
!(workflowPermissions.update ?? projectPermissions.workflow.update)
|
||||||
|
"
|
||||||
:instance="instance"
|
:instance="instance"
|
||||||
:is-active="!!activeNode && activeNode.name === stickyData.name"
|
:is-active="!!activeNode && activeNode.name === stickyData.name"
|
||||||
:node-view-scale="nodeViewScale"
|
:node-view-scale="nodeViewScale"
|
||||||
|
@ -90,7 +98,11 @@
|
||||||
</div>
|
</div>
|
||||||
<NodeDetailsView
|
<NodeDetailsView
|
||||||
:workflow-object="currentWorkflowObject"
|
:workflow-object="currentWorkflowObject"
|
||||||
:read-only="isReadOnlyRoute || readOnlyEnv"
|
:read-only="
|
||||||
|
isReadOnlyRoute ||
|
||||||
|
readOnlyEnv ||
|
||||||
|
!(workflowPermissions.update ?? projectPermissions.workflow.update)
|
||||||
|
"
|
||||||
:renaming="renamingActive"
|
:renaming="renamingActive"
|
||||||
:is-production-execution-preview="isProductionExecutionPreview"
|
:is-production-execution-preview="isProductionExecutionPreview"
|
||||||
@redraw-node="redrawNode"
|
@redraw-node="redrawNode"
|
||||||
|
@ -107,7 +119,11 @@
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<LazyNodeCreation
|
<LazyNodeCreation
|
||||||
v-if="!isReadOnlyRoute && !readOnlyEnv"
|
v-if="
|
||||||
|
!isReadOnlyRoute &&
|
||||||
|
!readOnlyEnv &&
|
||||||
|
(workflowPermissions.update ?? projectPermissions.workflow.update)
|
||||||
|
"
|
||||||
:create-node-active="createNodeActive"
|
:create-node-active="createNodeActive"
|
||||||
:node-view-scale="nodeViewScale"
|
:node-view-scale="nodeViewScale"
|
||||||
@toggle-node-creator="onToggleNodeCreator"
|
@toggle-node-creator="onToggleNodeCreator"
|
||||||
|
@ -120,7 +136,14 @@
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<ContextMenu @action="onContextMenuAction" />
|
<ContextMenu @action="onContextMenuAction" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<div v-if="!isReadOnlyRoute && !readOnlyEnv" class="workflow-execute-wrapper">
|
<div
|
||||||
|
v-if="
|
||||||
|
!isReadOnlyRoute &&
|
||||||
|
!readOnlyEnv &&
|
||||||
|
(workflowPermissions.update ?? projectPermissions.workflow.update)
|
||||||
|
"
|
||||||
|
class="workflow-execute-wrapper"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
v-if="!isManualChatOnly"
|
v-if="!isManualChatOnly"
|
||||||
@mouseenter="showTriggerMissingToltip(true)"
|
@mouseenter="showTriggerMissingToltip(true)"
|
||||||
|
@ -182,13 +205,7 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<n8n-icon-button
|
<n8n-icon-button
|
||||||
v-if="
|
v-if="workflowExecution && !workflowRunning && !allTriggersDisabled"
|
||||||
!isReadOnlyRoute &&
|
|
||||||
!readOnlyEnv &&
|
|
||||||
workflowExecution &&
|
|
||||||
!workflowRunning &&
|
|
||||||
!allTriggersDisabled
|
|
||||||
"
|
|
||||||
:title="$locale.baseText('nodeView.deletesTheCurrentExecutionData')"
|
:title="$locale.baseText('nodeView.deletesTheCurrentExecutionData')"
|
||||||
icon="trash"
|
icon="trash"
|
||||||
size="large"
|
size="large"
|
||||||
|
@ -383,6 +400,7 @@ import type { ProjectSharingData } from '@/types/projects.types';
|
||||||
import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuards';
|
import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuards';
|
||||||
import { usePostHog } from '@/stores/posthog.store';
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
interface AddNodeOptions {
|
interface AddNodeOptions {
|
||||||
position?: XYPosition;
|
position?: XYPosition;
|
||||||
|
@ -538,7 +556,13 @@ export default defineComponent({
|
||||||
return this.$route.name === VIEWS.DEMO;
|
return this.$route.name === VIEWS.DEMO;
|
||||||
},
|
},
|
||||||
showCanvasAddButton(): boolean {
|
showCanvasAddButton(): boolean {
|
||||||
return !this.isLoading && !this.containsTrigger && !this.isDemo && !this.readOnlyEnv;
|
return (
|
||||||
|
!this.isLoading &&
|
||||||
|
!this.containsTrigger &&
|
||||||
|
!this.isDemo &&
|
||||||
|
!this.readOnlyEnv &&
|
||||||
|
!!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
lastSelectedNode(): INodeUi | null {
|
lastSelectedNode(): INodeUi | null {
|
||||||
return this.uiStore.getLastSelectedNode;
|
return this.uiStore.getLastSelectedNode;
|
||||||
|
@ -579,7 +603,8 @@ export default defineComponent({
|
||||||
return NodeViewUtils.getBackgroundStyles(
|
return NodeViewUtils.getBackgroundStyles(
|
||||||
this.nodeViewScale,
|
this.nodeViewScale,
|
||||||
this.uiStore.nodeViewOffsetPosition,
|
this.uiStore.nodeViewOffsetPosition,
|
||||||
this.isExecutionPreview,
|
this.isExecutionPreview ||
|
||||||
|
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
workflowClasses() {
|
workflowClasses() {
|
||||||
|
@ -687,11 +712,22 @@ export default defineComponent({
|
||||||
isProductionExecutionPreview(): boolean {
|
isProductionExecutionPreview(): boolean {
|
||||||
return this.nodeHelpers.isProductionExecutionPreview.value;
|
return this.nodeHelpers.isProductionExecutionPreview.value;
|
||||||
},
|
},
|
||||||
|
workflowPermissions() {
|
||||||
|
return getResourcePermissions(
|
||||||
|
this.workflowsStore.getWorkflowById(this.currentWorkflow)?.scopes,
|
||||||
|
).workflow;
|
||||||
|
},
|
||||||
|
projectPermissions() {
|
||||||
|
const project = this.$route.query?.projectId
|
||||||
|
? this.projectsStore.myProjects.find((p) => p.id === this.$route.query.projectId)
|
||||||
|
: this.projectsStore.currentProject ?? this.projectsStore.personalProject;
|
||||||
|
return getResourcePermissions(project?.scopes);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
// Listen to route changes and load the workflow accordingly
|
// Listen to route changes and load the workflow accordingly
|
||||||
async $route(to: RouteLocation, from: RouteLocation) {
|
async $route(to: RouteLocation, from: RouteLocation) {
|
||||||
this.readOnlyEnvRouteCheck();
|
await this.readOnlyEnvRouteCheck();
|
||||||
|
|
||||||
const currentTab = getNodeViewTab(to);
|
const currentTab = getNodeViewTab(to);
|
||||||
const nodeViewNotInitialized = !this.uiStore.nodeViewInitialized;
|
const nodeViewNotInitialized = !this.uiStore.nodeViewInitialized;
|
||||||
|
@ -858,7 +894,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.readOnlyEnvRouteCheck();
|
await this.readOnlyEnvRouteCheck();
|
||||||
this.canvasStore.isDemo = this.isDemo;
|
this.canvasStore.isDemo = this.isDemo;
|
||||||
},
|
},
|
||||||
activated() {
|
activated() {
|
||||||
|
@ -1005,7 +1041,8 @@ export default defineComponent({
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
return !!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update);
|
||||||
},
|
},
|
||||||
showTriggerMissingToltip(isVisible: boolean) {
|
showTriggerMissingToltip(isVisible: boolean) {
|
||||||
this.showTriggerMissingTooltip = isVisible;
|
this.showTriggerMissingTooltip = isVisible;
|
||||||
|
@ -1405,14 +1442,17 @@ export default defineComponent({
|
||||||
const shiftModifier = e.shiftKey && !e.altKey && !this.deviceSupport.isCtrlKeyPressed(e);
|
const shiftModifier = e.shiftKey && !e.altKey && !this.deviceSupport.isCtrlKeyPressed(e);
|
||||||
const ctrlAltModifier = this.deviceSupport.isCtrlKeyPressed(e) && e.altKey && !e.shiftKey;
|
const ctrlAltModifier = this.deviceSupport.isCtrlKeyPressed(e) && e.altKey && !e.shiftKey;
|
||||||
const noModifierKeys = !this.deviceSupport.isCtrlKeyPressed(e) && !e.shiftKey && !e.altKey;
|
const noModifierKeys = !this.deviceSupport.isCtrlKeyPressed(e) && !e.shiftKey && !e.altKey;
|
||||||
const readOnly = this.isReadOnlyRoute || this.readOnlyEnv;
|
const readOnly =
|
||||||
|
this.isReadOnlyRoute ||
|
||||||
|
this.readOnlyEnv ||
|
||||||
|
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update);
|
||||||
|
|
||||||
if (e.key === 's' && ctrlModifier && !readOnly) {
|
if (e.key === 's' && ctrlModifier && !readOnly) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const workflowIsSaved = !this.uiStore.stateIsDirty;
|
const workflowIsSaved = !this.uiStore.stateIsDirty;
|
||||||
|
|
||||||
if (this.isReadOnlyRoute || this.readOnlyEnv || workflowIsSaved) {
|
if (workflowIsSaved) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1538,7 +1578,9 @@ export default defineComponent({
|
||||||
if (lastSelectedNode !== null) {
|
if (lastSelectedNode !== null) {
|
||||||
if (
|
if (
|
||||||
lastSelectedNode.type === STICKY_NODE_TYPE &&
|
lastSelectedNode.type === STICKY_NODE_TYPE &&
|
||||||
(this.isReadOnlyRoute || this.readOnlyEnv)
|
(this.isReadOnlyRoute ||
|
||||||
|
this.readOnlyEnv ||
|
||||||
|
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update))
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1825,7 +1867,10 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
|
|
||||||
cutNodes(nodes: INode[]) {
|
cutNodes(nodes: INode[]) {
|
||||||
const deleteCopiedNodes = !this.isReadOnlyRoute && !this.readOnlyEnv;
|
const deleteCopiedNodes =
|
||||||
|
!this.isReadOnlyRoute &&
|
||||||
|
!this.readOnlyEnv &&
|
||||||
|
(this.workflowPermissions.update ?? this.projectPermissions.workflow.update);
|
||||||
this.copyNodes(nodes, deleteCopiedNodes);
|
this.copyNodes(nodes, deleteCopiedNodes);
|
||||||
if (deleteCopiedNodes) {
|
if (deleteCopiedNodes) {
|
||||||
this.deleteNodes(nodes);
|
this.deleteNodes(nodes);
|
||||||
|
@ -1959,7 +2004,11 @@ export default defineComponent({
|
||||||
* This method gets called when data got pasted into the window
|
* This method gets called when data got pasted into the window
|
||||||
*/
|
*/
|
||||||
async onClipboardPasteEvent(plainTextData: string): Promise<void> {
|
async onClipboardPasteEvent(plainTextData: string): Promise<void> {
|
||||||
if (this.readOnlyEnv) {
|
if (
|
||||||
|
this.readOnlyEnv ||
|
||||||
|
this.isReadOnlyRoute ||
|
||||||
|
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2704,7 +2753,10 @@ export default defineComponent({
|
||||||
|
|
||||||
this.instance?.connect({
|
this.instance?.connect({
|
||||||
uuids: [targetEndpoint, viableConnection?.uuid || ''],
|
uuids: [targetEndpoint, viableConnection?.uuid || ''],
|
||||||
detachable: !this.isReadOnlyRoute && !this.readOnlyEnv,
|
detachable:
|
||||||
|
!this.isReadOnlyRoute &&
|
||||||
|
!this.readOnlyEnv &&
|
||||||
|
(this.workflowPermissions.update ?? this.projectPermissions.workflow.update),
|
||||||
});
|
});
|
||||||
this.historyStore.stopRecordingUndo();
|
this.historyStore.stopRecordingUndo();
|
||||||
return;
|
return;
|
||||||
|
@ -3000,7 +3052,11 @@ export default defineComponent({
|
||||||
this.dropPrevented = true;
|
this.dropPrevented = true;
|
||||||
this.workflowsStore.addConnection({ connection: connectionData });
|
this.workflowsStore.addConnection({ connection: connectionData });
|
||||||
|
|
||||||
if (!this.isReadOnlyRoute && !this.readOnlyEnv) {
|
if (
|
||||||
|
!this.isReadOnlyRoute &&
|
||||||
|
!this.readOnlyEnv &&
|
||||||
|
(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)
|
||||||
|
) {
|
||||||
NodeViewUtils.hideOutputNameLabel(info.sourceEndpoint);
|
NodeViewUtils.hideOutputNameLabel(info.sourceEndpoint);
|
||||||
NodeViewUtils.addConnectionActionsOverlay(
|
NodeViewUtils.addConnectionActionsOverlay(
|
||||||
info.connection,
|
info.connection,
|
||||||
|
@ -3087,6 +3143,7 @@ export default defineComponent({
|
||||||
|
|
||||||
if (
|
if (
|
||||||
// eslint-disable-next-line no-constant-binary-expression
|
// eslint-disable-next-line no-constant-binary-expression
|
||||||
|
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update) ??
|
||||||
this.isReadOnlyRoute ??
|
this.isReadOnlyRoute ??
|
||||||
this.readOnlyEnv ??
|
this.readOnlyEnv ??
|
||||||
this.enterTimer ??
|
this.enterTimer ??
|
||||||
|
@ -3125,6 +3182,7 @@ export default defineComponent({
|
||||||
|
|
||||||
if (
|
if (
|
||||||
// eslint-disable-next-line no-constant-binary-expression
|
// eslint-disable-next-line no-constant-binary-expression
|
||||||
|
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update) ??
|
||||||
this.isReadOnlyRoute ??
|
this.isReadOnlyRoute ??
|
||||||
this.readOnlyEnv ??
|
this.readOnlyEnv ??
|
||||||
!connection ??
|
!connection ??
|
||||||
|
@ -3537,7 +3595,12 @@ export default defineComponent({
|
||||||
const templateId = this.$route.params.id;
|
const templateId = this.$route.params.id;
|
||||||
await this.openWorkflowTemplate(templateId.toString());
|
await this.openWorkflowTemplate(templateId.toString());
|
||||||
} else {
|
} else {
|
||||||
if (this.uiStore.stateIsDirty && !this.readOnlyEnv) {
|
if (
|
||||||
|
this.uiStore.stateIsDirty &&
|
||||||
|
!this.readOnlyEnv &&
|
||||||
|
!this.isReadOnlyRoute &&
|
||||||
|
(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)
|
||||||
|
) {
|
||||||
const confirmModal = await this.confirm(
|
const confirmModal = await this.confirm(
|
||||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
|
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
|
||||||
{
|
{
|
||||||
|
@ -3604,6 +3667,9 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
this.historyStore.reset();
|
this.historyStore.reset();
|
||||||
|
if (!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)) {
|
||||||
|
this.canvasStore.setReadOnly(true);
|
||||||
|
}
|
||||||
this.uiStore.nodeViewInitialized = true;
|
this.uiStore.nodeViewInitialized = true;
|
||||||
document.addEventListener('keydown', this.keyDown);
|
document.addEventListener('keydown', this.keyDown);
|
||||||
document.addEventListener('keyup', this.keyUp);
|
document.addEventListener('keyup', this.keyUp);
|
||||||
|
@ -4523,17 +4589,16 @@ export default defineComponent({
|
||||||
this.canvasStore.stopLoading();
|
this.canvasStore.stopLoading();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
readOnlyEnvRouteCheck() {
|
async readOnlyEnvRouteCheck() {
|
||||||
if (
|
if (
|
||||||
this.readOnlyEnv &&
|
(this.readOnlyEnv || !this.projectPermissions.workflow.create) &&
|
||||||
(this.$route.name === VIEWS.NEW_WORKFLOW || this.$route.name === VIEWS.TEMPLATE_IMPORT)
|
(this.$route.name === VIEWS.NEW_WORKFLOW || this.$route.name === VIEWS.TEMPLATE_IMPORT)
|
||||||
) {
|
) {
|
||||||
void this.$nextTick(async () => {
|
await this.$nextTick();
|
||||||
this.resetWorkspace();
|
this.resetWorkspace();
|
||||||
this.uiStore.stateIsDirty = false;
|
this.uiStore.stateIsDirty = false;
|
||||||
|
|
||||||
await this.$router.replace({ name: VIEWS.HOMEPAGE });
|
await this.$router.replace({ name: VIEWS.HOMEPAGE });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async checkAndInitDebugMode() {
|
async checkAndInitDebugMode() {
|
||||||
|
@ -4583,7 +4648,10 @@ export default defineComponent({
|
||||||
case 'add_node':
|
case 'add_node':
|
||||||
this.onToggleNodeCreator({
|
this.onToggleNodeCreator({
|
||||||
source: NODE_CREATOR_OPEN_SOURCES.CONTEXT_MENU,
|
source: NODE_CREATOR_OPEN_SOURCES.CONTEXT_MENU,
|
||||||
createNodeActive: !this.isReadOnlyRoute && !this.readOnlyEnv,
|
createNodeActive:
|
||||||
|
!this.isReadOnlyRoute &&
|
||||||
|
!this.readOnlyEnv &&
|
||||||
|
!!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'add_sticky':
|
case 'add_sticky':
|
||||||
|
|
|
@ -43,6 +43,7 @@ const formData = ref<Pick<Project, 'name' | 'relations'>>({
|
||||||
relations: [],
|
relations: [],
|
||||||
});
|
});
|
||||||
const projectRoleTranslations = ref<{ [key: string]: string }>({
|
const projectRoleTranslations = ref<{ [key: string]: string }>({
|
||||||
|
'project:viewer': locale.baseText('projects.settings.role.viewer'),
|
||||||
'project:editor': locale.baseText('projects.settings.role.editor'),
|
'project:editor': locale.baseText('projects.settings.role.editor'),
|
||||||
'project:admin': locale.baseText('projects.settings.role.admin'),
|
'project:admin': locale.baseText('projects.settings.role.admin'),
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,7 +17,7 @@ import VariablesRow from '@/components/VariablesRow.vue';
|
||||||
import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
|
import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
|
||||||
import type { DatatableColumn, EnvironmentVariable } from '@/Interface';
|
import type { DatatableColumn, EnvironmentVariable } from '@/Interface';
|
||||||
import { uid } from 'n8n-design-system/utils';
|
import { uid } from 'n8n-design-system/utils';
|
||||||
import { getVariablesPermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import type { BaseTextKey } from '@/plugins/i18n';
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
@ -39,7 +39,10 @@ const TEMPORARY_VARIABLE_UID_BASE = '@tmpvar';
|
||||||
const allVariables = ref<EnvironmentVariable[]>([]);
|
const allVariables = ref<EnvironmentVariable[]>([]);
|
||||||
const editMode = ref<Record<string, boolean>>({});
|
const editMode = ref<Record<string, boolean>>({});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const permissions = getVariablesPermissions(usersStore.currentUser);
|
|
||||||
|
const permissions = computed(
|
||||||
|
() => getResourcePermissions(usersStore.currentUser?.globalScopes).variable,
|
||||||
|
);
|
||||||
|
|
||||||
const isFeatureEnabled = computed(
|
const isFeatureEnabled = computed(
|
||||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables],
|
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables],
|
||||||
|
@ -49,7 +52,7 @@ const variablesToResources = computed((): IResource[] =>
|
||||||
allVariables.value.map((v) => ({ id: v.id, name: v.key, value: v.value })),
|
allVariables.value.map((v) => ({ id: v.id, name: v.key, value: v.value })),
|
||||||
);
|
);
|
||||||
|
|
||||||
const canCreateVariables = computed(() => isFeatureEnabled.value && permissions.create);
|
const canCreateVariables = computed(() => isFeatureEnabled.value && permissions.value.create);
|
||||||
|
|
||||||
const datatableColumns = computed<DatatableColumn[]>(() => [
|
const datatableColumns = computed<DatatableColumn[]>(() => [
|
||||||
{
|
{
|
||||||
|
|
|
@ -128,7 +128,7 @@ async function fetchWorkflow() {
|
||||||
toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title'));
|
toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
workflow.value = workflowsStore.workflow;
|
workflow.value = workflowsStore.getWorkflowById(workflowId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAutoRefreshToggle(value: boolean) {
|
async function onAutoRefreshToggle(value: boolean) {
|
||||||
|
@ -172,7 +172,10 @@ async function onUpdateFilters(newFilters: ExecutionFilterType) {
|
||||||
await executionsStore.initialize(workflowId.value);
|
await executionsStore.initialize(workflowId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onExecutionStop(id: string) {
|
async function onExecutionStop(id?: string) {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await executionsStore.stopCurrentExecution(id);
|
await executionsStore.stopCurrentExecution(id);
|
||||||
|
|
||||||
|
@ -190,7 +193,10 @@ async function onExecutionStop(id: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onExecutionDelete(id: string) {
|
async function onExecutionDelete(id?: string) {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const executionIndex = executions.value.findIndex((e: ExecutionSummary) => e.id === id);
|
const executionIndex = executions.value.findIndex((e: ExecutionSummary) => e.id === id);
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { telemetry } from '@/plugins/telemetry';
|
import { telemetry } from '@/plugins/telemetry';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
type WorkflowHistoryActionRecord = {
|
type WorkflowHistoryActionRecord = {
|
||||||
[K in Uppercase<WorkflowHistoryActionTypes[number]>]: Lowercase<K>;
|
[K in Uppercase<WorkflowHistoryActionTypes[number]>]: Lowercase<K>;
|
||||||
|
@ -65,10 +66,15 @@ const editorRoute = computed(() => ({
|
||||||
name: workflowId.value,
|
name: workflowId.value,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
const workflowPermissions = computed(
|
||||||
|
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
|
||||||
|
);
|
||||||
const actions = computed<UserAction[]>(() =>
|
const actions = computed<UserAction[]>(() =>
|
||||||
workflowHistoryActionTypes.map((value) => ({
|
workflowHistoryActionTypes.map((value) => ({
|
||||||
label: i18n.baseText(`workflowHistory.item.actions.${value}`),
|
label: i18n.baseText(`workflowHistory.item.actions.${value}`),
|
||||||
disabled: false,
|
disabled:
|
||||||
|
(value === 'clone' && !workflowPermissions.value.create) ||
|
||||||
|
(value === 'restore' && !workflowPermissions.value.update),
|
||||||
value,
|
value,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
:type-props="{ itemSize: 80 }"
|
:type-props="{ itemSize: 80 }"
|
||||||
:shareable="isShareable"
|
:shareable="isShareable"
|
||||||
:initialize="initialize"
|
:initialize="initialize"
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv || !projectPermissions.workflow.create"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@click:add="addWorkflow"
|
@click:add="addWorkflow"
|
||||||
@update:filters="onFiltersUpdated"
|
@update:filters="onFiltersUpdated"
|
||||||
|
@ -61,17 +61,12 @@
|
||||||
: $locale.baseText('workflows.empty.heading.userNotSetup')
|
: $locale.baseText('workflows.empty.heading.userNotSetup')
|
||||||
}}
|
}}
|
||||||
</n8n-heading>
|
</n8n-heading>
|
||||||
<n8n-text size="large" color="text-base">
|
<n8n-text size="large" color="text-base">{{ emptyListDescription }}</n8n-text>
|
||||||
{{
|
|
||||||
$locale.baseText(
|
|
||||||
readOnlyEnv
|
|
||||||
? 'workflows.empty.description.readOnlyEnv'
|
|
||||||
: 'workflows.empty.description',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</n8n-text>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!readOnlyEnv" :class="['text-center', 'mt-2xl', $style.actionsContainer]">
|
<div
|
||||||
|
v-if="!readOnlyEnv && projectPermissions.workflow.create"
|
||||||
|
:class="['text-center', 'mt-2xl', $style.actionsContainer]"
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
v-if="isSalesUser"
|
v-if="isSalesUser"
|
||||||
:href="getTemplateRepositoryURL()"
|
:href="getTemplateRepositoryURL()"
|
||||||
|
@ -162,6 +157,7 @@ import { useTagsStore } from '@/stores/tags.store';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
interface Filters {
|
interface Filters {
|
||||||
search: string;
|
search: string;
|
||||||
|
@ -260,6 +256,20 @@ const WorkflowsView = defineComponent({
|
||||||
? this.$locale.baseText('workflows.project.add')
|
? this.$locale.baseText('workflows.project.add')
|
||||||
: this.$locale.baseText('workflows.add');
|
: this.$locale.baseText('workflows.add');
|
||||||
},
|
},
|
||||||
|
projectPermissions() {
|
||||||
|
return getResourcePermissions(
|
||||||
|
this.projectsStore.currentProject?.scopes ?? this.projectsStore.personalProject?.scopes,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
emptyListDescription() {
|
||||||
|
if (this.readOnlyEnv) {
|
||||||
|
return this.$locale.baseText('workflows.empty.description.readOnlyEnv');
|
||||||
|
} else if (!this.projectPermissions.workflow.create) {
|
||||||
|
return this.$locale.baseText('workflows.empty.description.noPermission');
|
||||||
|
} else {
|
||||||
|
return this.$locale.baseText('workflows.empty.description');
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
filters: {
|
filters: {
|
||||||
|
|
|
@ -39,6 +39,7 @@
|
||||||
"@types/xml2js": "catalog:"
|
"@types/xml2js": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@n8n/permissions": "workspace:*",
|
||||||
"@n8n/tournament": "1.0.3",
|
"@n8n/tournament": "1.0.3",
|
||||||
"@n8n_io/riot-tmpl": "4.0.0",
|
"@n8n_io/riot-tmpl": "4.0.0",
|
||||||
"ast-types": "0.15.2",
|
"ast-types": "0.15.2",
|
||||||
|
|
|
@ -10,6 +10,7 @@ import type { URLSearchParams } from 'url';
|
||||||
import type { RequestBodyMatcher } from 'nock';
|
import type { RequestBodyMatcher } from 'nock';
|
||||||
import type { Client as SSHClient } from 'ssh2';
|
import type { Client as SSHClient } from 'ssh2';
|
||||||
|
|
||||||
|
import type { Scope } from '@n8n/permissions';
|
||||||
import type { AuthenticationMethod } from './Authentication';
|
import type { AuthenticationMethod } from './Authentication';
|
||||||
import type { CODE_EXECUTION_MODES, CODE_LANGUAGES, LOG_LEVELS } from './Constants';
|
import type { CODE_EXECUTION_MODES, CODE_LANGUAGES, LOG_LEVELS } from './Constants';
|
||||||
import type { IDeferredPromise } from './DeferredPromise';
|
import type { IDeferredPromise } from './DeferredPromise';
|
||||||
|
@ -2463,6 +2464,7 @@ export interface ExecutionSummary {
|
||||||
nodeExecutionStatus?: {
|
nodeExecutionStatus?: {
|
||||||
[key: string]: IExecutionSummaryNodeExecutionResult;
|
[key: string]: IExecutionSummaryNodeExecutionResult;
|
||||||
};
|
};
|
||||||
|
scopes?: Scope[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExecutionSummaryNodeExecutionResult {
|
export interface IExecutionSummaryNodeExecutionResult {
|
||||||
|
|
|
@ -1709,6 +1709,9 @@ importers:
|
||||||
|
|
||||||
packages/workflow:
|
packages/workflow:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@n8n/permissions':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../@n8n/permissions
|
||||||
'@n8n/tournament':
|
'@n8n/tournament':
|
||||||
specifier: 1.0.3
|
specifier: 1.0.3
|
||||||
version: 1.0.3
|
version: 1.0.3
|
||||||
|
|
Loading…
Reference in a new issue