mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -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 { getVisibleSelect } from '../utils';
|
||||
|
||||
const workflowPage = new WorkflowPage();
|
||||
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 getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
|
||||
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 getProjectSettingsCancelButton = () =>
|
||||
cy.getByTestId('project-settings-cancel-button');
|
||||
export const getProjectSettingsDeleteButton = () =>
|
||||
cy.getByTestId('project-settings-delete-button');
|
||||
export const getProjectMembersSelect = () => cy.getByTestId('project-members-select');
|
||||
export const addProjectMember = (email: string) => {
|
||||
export const addProjectMember = (email: string, role?: string) => {
|
||||
getProjectMembersSelect().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 getResourceMoveConfirmModal = () =>
|
||||
cy.getByTestId('project-move-resource-confirm-modal');
|
||||
|
@ -31,12 +39,7 @@ export const getProjectMoveSelect = () => cy.getByTestId('project-move-resource-
|
|||
export function createProject(name: string) {
|
||||
getAddProjectButton().click();
|
||||
|
||||
getProjectNameInput()
|
||||
.should('be.visible')
|
||||
.should('be.focused')
|
||||
.should('have.value', 'My project')
|
||||
.clear()
|
||||
.type(name);
|
||||
getProjectSettingsNameInput().should('be.visible').clear().type(name);
|
||||
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.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
|
|
|
@ -264,6 +264,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
|
|||
describe('Credential Usage in Cross Shared Workflows', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.enableFeature('sharing');
|
||||
cy.enableFeature('advancedPermissions');
|
||||
cy.enableFeature('projectRole:admin');
|
||||
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', () => {
|
||||
cy.enableFeature('advancedPermissions');
|
||||
cy.enableFeature('projectRole:admin');
|
||||
cy.enableFeature('projectRole:editor');
|
||||
cy.changeQuota('maxTeamProjects', -1);
|
||||
|
||||
// Create a notion credential in the home project
|
||||
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||
credentialsModal.actions.createNewCredential('Notion API');
|
||||
|
@ -305,10 +301,36 @@ describe('Credential Usage in Cross Shared Workflows', () => {
|
|||
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', () => {
|
||||
const workflowName = 'Test workflow';
|
||||
cy.enableFeature('sharing');
|
||||
cy.reload();
|
||||
|
||||
// Create a notion credential as the owner and a workflow that is shared
|
||||
// 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", () => {
|
||||
const workflowName = 'Test workflow';
|
||||
cy.enableFeature('sharing');
|
||||
|
||||
// As member 1, create a new notion credential. This should not show up.
|
||||
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', () => {
|
||||
cy.enableFeature('sharing');
|
||||
|
||||
// As member 0, create a new notion credential.
|
||||
cy.signinAsMember();
|
||||
cy.visit(credentialsPage.url);
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
INSTANCE_MEMBERS,
|
||||
INSTANCE_OWNER,
|
||||
MANUAL_TRIGGER_NODE_NAME,
|
||||
NOTION_NODE_NAME,
|
||||
} from '../constants';
|
||||
import { INSTANCE_MEMBERS, MANUAL_TRIGGER_NODE_NAME, NOTION_NODE_NAME } from '../constants';
|
||||
import {
|
||||
WorkflowsPage,
|
||||
WorkflowPage,
|
||||
|
@ -11,9 +6,10 @@ import {
|
|||
CredentialsPage,
|
||||
WorkflowExecutionsTab,
|
||||
NDV,
|
||||
MainSidebar,
|
||||
} from '../pages';
|
||||
import * as projects from '../composables/projects';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
|
||||
|
||||
const workflowsPage = new WorkflowsPage();
|
||||
const workflowPage = new WorkflowPage();
|
||||
|
@ -21,6 +17,7 @@ const credentialsPage = new CredentialsPage();
|
|||
const credentialsModal = new CredentialsModal();
|
||||
const executionsTab = new WorkflowExecutionsTab();
|
||||
const ndv = new NDV();
|
||||
const mainSidebar = new MainSidebar();
|
||||
|
||||
describe('Projects', { disableAutoLogin: true }, () => {
|
||||
before(() => {
|
||||
|
@ -241,6 +238,26 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
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', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
|
@ -257,7 +274,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
|
||||
// Create a project and add a credential to it
|
||||
cy.intercept('POST', '/rest/projects').as('projectCreate');
|
||||
projects.getAddProjectButton().should('contain', 'Add project').should('be.visible').click();
|
||||
projects.getAddProjectButton().click();
|
||||
cy.wait('@projectCreate');
|
||||
projects.getMenuItems().should('have.length', 1);
|
||||
projects.getMenuItems().first().click();
|
||||
|
@ -418,7 +435,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
});
|
||||
|
||||
it('should move resources between projects', () => {
|
||||
cy.signin(INSTANCE_OWNER);
|
||||
cy.signinAsOwner();
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
// Create a workflow and a credential in the Home project
|
||||
|
@ -563,5 +580,80 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects.getProjectTabCredentials().click();
|
||||
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 * from './constants';
|
||||
export * from './hasScope';
|
||||
export * from './combineScopes';
|
||||
|
|
|
@ -1,25 +1,7 @@
|
|||
export type DefaultOperations = 'create' | 'read' | 'update' | 'delete' | 'list';
|
||||
export type Resource =
|
||||
| 'auditLogs'
|
||||
| 'banner'
|
||||
| 'communityPackage'
|
||||
| 'credential'
|
||||
| 'externalSecretsProvider'
|
||||
| 'externalSecret'
|
||||
| 'eventBusDestination'
|
||||
| 'ldap'
|
||||
| 'license'
|
||||
| 'logStreaming'
|
||||
| 'orchestration'
|
||||
| 'project'
|
||||
| 'saml'
|
||||
| 'securityAudit'
|
||||
| 'sourceControl'
|
||||
| 'tag'
|
||||
| 'user'
|
||||
| 'variable'
|
||||
| 'workersView'
|
||||
| 'workflow';
|
||||
import type { DEFAULT_OPERATIONS, RESOURCES } from './constants';
|
||||
|
||||
export type DefaultOperations = (typeof DEFAULT_OPERATIONS)[number];
|
||||
export type Resource = keyof typeof RESOURCES;
|
||||
|
||||
export type ResourceScope<
|
||||
R extends Resource,
|
||||
|
|
|
@ -15,13 +15,19 @@
|
|||
</slot>
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nTooltip :disabled="!buttonDisabled">
|
||||
<template #content>
|
||||
<slot name="disabledButtonTooltip"></slot>
|
||||
</template>
|
||||
<N8nButton
|
||||
v-if="buttonText"
|
||||
:label="buttonText"
|
||||
:type="buttonType"
|
||||
:disabled="buttonDisabled"
|
||||
size="large"
|
||||
@click="$emit('click:button', $event)"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
<N8nCallout
|
||||
v-if="calloutText"
|
||||
:theme="calloutTheme"
|
||||
|
@ -41,12 +47,14 @@ import N8nHeading from '../N8nHeading';
|
|||
import N8nText from '../N8nText';
|
||||
import N8nCallout, { type CalloutTheme } from '../N8nCallout';
|
||||
import type { ButtonType } from 'n8n-design-system/types/button';
|
||||
import N8nTooltip from 'n8n-design-system/components/N8nTooltip/Tooltip.vue';
|
||||
|
||||
interface ActionBoxProps {
|
||||
emoji: string;
|
||||
heading: string;
|
||||
buttonText: string;
|
||||
buttonType: ButtonType;
|
||||
buttonDisabled?: boolean;
|
||||
description: string;
|
||||
calloutText?: string;
|
||||
calloutTheme?: CalloutTheme;
|
||||
|
|
|
@ -9,7 +9,9 @@ exports[`N8NActionBox > should render correctly 1`] = `
|
|||
<div class="description">
|
||||
<n8n-text-stub color="text-base" bold="false" size="medium" compact="false" tag="span"></n8n-text-stub>
|
||||
</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-->
|
||||
</div>"
|
||||
`;
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
:trigger="trigger"
|
||||
:popper-class="popperClass"
|
||||
:teleported="teleported"
|
||||
:disabled="disabled"
|
||||
@command="onSelect"
|
||||
@visible-change="onVisibleChange"
|
||||
>
|
||||
|
@ -76,6 +77,7 @@ interface ActionDropdownProps {
|
|||
trigger?: (typeof TRIGGER)[number];
|
||||
hideArrow?: boolean;
|
||||
teleported?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ActionDropdownProps>(), {
|
||||
|
@ -86,6 +88,7 @@ const props = withDefaults(defineProps<ActionDropdownProps>(), {
|
|||
trigger: 'click',
|
||||
hideArrow: false,
|
||||
teleported: true,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
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 {
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
|
|
|
@ -5,7 +5,7 @@ import type { ICredentialsResponse } from '@/Interface';
|
|||
import { MODAL_CONFIRM, PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import CredentialIcon from '@/components/CredentialIcon.vue';
|
||||
import { getCredentialPermissions } from '@/permissions';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import TimeAgo from '@/components/TimeAgo.vue';
|
||||
|
@ -48,7 +48,7 @@ const projectsStore = useProjectsStore();
|
|||
|
||||
const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase());
|
||||
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 items = [
|
||||
{
|
||||
|
|
|
@ -172,14 +172,13 @@ import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
|
|||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
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 { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { CredentialScope } from '@n8n/permissions';
|
||||
import Banner from '../Banner.vue';
|
||||
import CopyInput from '../CopyInput.vue';
|
||||
import CredentialInputs from './CredentialInputs.vue';
|
||||
|
@ -194,7 +193,7 @@ type Props = {
|
|||
credentialProperties: INodeProperties[];
|
||||
credentialData: ICredentialDataDecryptedObject;
|
||||
credentialId?: string;
|
||||
credentialPermissions?: PermissionsMap<CredentialScope>;
|
||||
credentialPermissions: PermissionsRecord['credential'];
|
||||
parentTypes?: string[];
|
||||
showValidationWarning?: boolean;
|
||||
authError?: string;
|
||||
|
@ -212,7 +211,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
credentialId: '',
|
||||
authError: '',
|
||||
showValidationWarning: false,
|
||||
credentialPermissions: () => ({}) as PermissionsMap<CredentialScope>,
|
||||
credentialPermissions: () => ({}) as PermissionsRecord['credential'],
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
update: [value: IUpdateInformation];
|
||||
|
|
|
@ -145,8 +145,7 @@ import { useMessage } from '@/composables/useMessage';
|
|||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY, EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
|
||||
import type { PermissionsMap } from '@/permissions';
|
||||
import { getCredentialPermissions } from '@/permissions';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
@ -169,7 +168,6 @@ import {
|
|||
updateNodeAuthType,
|
||||
} from '@/utils/nodeTypesUtils';
|
||||
import { isCredentialModalState, isValidCredentialResponse } from '@/utils/typeGuards';
|
||||
import type { CredentialScope } from '@n8n/permissions';
|
||||
|
||||
type Props = {
|
||||
modalName: string;
|
||||
|
@ -395,14 +393,11 @@ const requiredPropertiesFilled = computed(() => {
|
|||
return true;
|
||||
});
|
||||
|
||||
const credentialPermissions = computed<PermissionsMap<CredentialScope>>(() => {
|
||||
if (loading.value) {
|
||||
return {} as PermissionsMap<CredentialScope>;
|
||||
}
|
||||
|
||||
return getCredentialPermissions(
|
||||
(credentialId.value ? currentCredential.value : credentialData.value) as ICredentialsResponse,
|
||||
);
|
||||
const credentialPermissions = computed(() => {
|
||||
return getResourcePermissions(
|
||||
((credentialId.value ? currentCredential.value : credentialData.value) as ICredentialsResponse)
|
||||
?.scopes,
|
||||
).credential;
|
||||
});
|
||||
|
||||
const sidebarItems = computed(() => {
|
||||
|
|
|
@ -68,8 +68,7 @@ import { useProjectsStore } from '@/stores/projects.store';
|
|||
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import type { PermissionsMap } from '@/permissions';
|
||||
import type { CredentialScope } from '@n8n/permissions';
|
||||
import type { PermissionsRecord } from '@/permissions';
|
||||
import type { EventBus } from 'n8n-design-system/utils';
|
||||
import { useRolesStore } from '@/stores/roles.store';
|
||||
import type { RoleMap } from '@/types/roles.types';
|
||||
|
@ -94,7 +93,7 @@ export default defineComponent({
|
|||
required: true,
|
||||
},
|
||||
credentialPermissions: {
|
||||
type: Object as PropType<PermissionsMap<CredentialScope>>,
|
||||
type: Object as PropType<PermissionsRecord['credential']>,
|
||||
required: true,
|
||||
},
|
||||
modalBus: {
|
||||
|
|
|
@ -13,9 +13,6 @@ import {
|
|||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
WORKFLOW_SHARE_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import type { PermissionsMap } from '@/permissions';
|
||||
import type { WorkflowScope } from '@n8n/permissions';
|
||||
|
||||
import ShortenName from '@/components/ShortenName.vue';
|
||||
import TagsContainer from '@/components/TagsContainer.vue';
|
||||
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
|
||||
|
@ -38,8 +35,7 @@ import { saveAs } from 'file-saver';
|
|||
import { useTitleChange } from '@/composables/useTitleChange';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
import { getWorkflowPermissions } from '@/permissions';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { createEventBus } from 'n8n-design-system/utils';
|
||||
import { nodeViewEventBus } from '@/event-bus';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
|
@ -55,7 +51,7 @@ import type {
|
|||
} from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import type { BaseTextKey } from '../../plugins/i18n';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
|
||||
|
@ -140,9 +136,9 @@ const onExecutionsTab = computed(() => {
|
|||
].includes((route.name as string) || '');
|
||||
});
|
||||
|
||||
const workflowPermissions = computed<PermissionsMap<WorkflowScope>>(() => {
|
||||
return getWorkflowPermissions(workflowsStore.getWorkflowById(props.workflow.id));
|
||||
});
|
||||
const workflowPermissions = computed(
|
||||
() => getResourcePermissions(workflowsStore.getWorkflowById(props.workflow.id)?.scopes).workflow,
|
||||
);
|
||||
|
||||
const workflowMenuItems = computed<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({
|
||||
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
|
||||
label: locale.baseText('menuActions.duplicate'),
|
||||
|
@ -631,7 +627,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
|||
:preview-value="shortenedName"
|
||||
:is-edit-enabled="isNameEditEnabled"
|
||||
:max-length="MAX_WORKFLOW_NAME_LENGTH"
|
||||
:disabled="readOnly"
|
||||
:disabled="readOnly || (!isNewWorkflow && !workflowPermissions.update)"
|
||||
placeholder="Enter workflow name"
|
||||
class="name"
|
||||
@toggle="onNameToggle"
|
||||
|
@ -644,7 +640,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
|||
|
||||
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
|
||||
<TagsDropdown
|
||||
v-if="isTagsEditEnabled && !readOnly"
|
||||
v-if="isTagsEditEnabled && !readOnly && (isNewWorkflow || workflowPermissions.update)"
|
||||
ref="dropdown"
|
||||
v-model="appliedTagIds"
|
||||
:event-bus="tagsEventBus"
|
||||
|
@ -654,7 +650,13 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
|||
@blur="onTagsBlur"
|
||||
@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">
|
||||
+ {{ $locale.baseText('workflowDetails.addTag') }}
|
||||
</span>
|
||||
|
@ -673,7 +675,11 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
|||
|
||||
<PushConnectionTracker class="actions">
|
||||
<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>
|
||||
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
|
||||
<div :class="$style.group">
|
||||
|
@ -717,9 +723,11 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
|||
<SaveButton
|
||||
type="primary"
|
||||
:saved="!uiStore.stateIsDirty && !isNewWorkflow"
|
||||
:disabled="isWorkflowSaving || readOnly"
|
||||
:disabled="
|
||||
isWorkflowSaving || readOnly || (!isNewWorkflow && !workflowPermissions.update)
|
||||
"
|
||||
:is-saving="isWorkflowSaving"
|
||||
with-shortcut
|
||||
:with-shortcut="!readOnly && workflowPermissions.update"
|
||||
:shortcut-tooltip="$locale.baseText('saveWorkflowButton.hint')"
|
||||
data-test-id="workflow-save-button"
|
||||
@click="onSaveButtonClick"
|
||||
|
|
|
@ -56,12 +56,7 @@
|
|||
<div :class="$style.userArea">
|
||||
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
||||
<!-- This dropdown is only enabled when sidebar is collapsed -->
|
||||
<el-dropdown
|
||||
:disabled="!isCollapsed"
|
||||
placement="right-end"
|
||||
trigger="click"
|
||||
@command="onUserActionToggle"
|
||||
>
|
||||
<el-dropdown placement="right-end" trigger="click" @command="onUserActionToggle">
|
||||
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
|
||||
<n8n-avatar
|
||||
:first-name="usersStore.currentUser?.firstName"
|
||||
|
@ -69,7 +64,7 @@
|
|||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<template v-if="isCollapsed" #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="settings">
|
||||
{{ $locale.baseText('settings') }}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { useRoute } from 'vue-router';
|
|||
import { VIEWS } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { getProjectPermissions } from '@/permissions';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
const locale = useI18n();
|
||||
const route = useRoute();
|
||||
|
@ -13,6 +13,9 @@ const route = useRoute();
|
|||
const projectsStore = useProjectsStore();
|
||||
|
||||
const selectedTab = ref<RouteRecordName | null | undefined>('');
|
||||
const projectPermissions = computed(
|
||||
() => getResourcePermissions(projectsStore.currentProject?.scopes).project,
|
||||
);
|
||||
const options = computed(() => {
|
||||
const projectId = route?.params?.projectId;
|
||||
const to = projectId
|
||||
|
@ -47,7 +50,7 @@ const options = computed(() => {
|
|||
},
|
||||
];
|
||||
|
||||
if (projectId && getProjectPermissions(projectsStore.currentProject).update) {
|
||||
if (projectId && projectPermissions.value.update) {
|
||||
tabs.push({
|
||||
label: locale.baseText('projects.settings'),
|
||||
value: VIEWS.PROJECT_SETTINGS,
|
||||
|
|
|
@ -8,7 +8,7 @@ import { useClipboard } from '@/composables/useClipboard';
|
|||
import { EnterpriseEditionFeature } from '@/constants';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { getVariablesPermissions } from '@/permissions';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import type { IResource } from './layouts/ResourcesListLayout.vue';
|
||||
|
||||
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 formValidationStatus = ref<Record<string, boolean>>({
|
||||
|
|
|
@ -5,8 +5,14 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import { getActivatableTriggerNodes } from '@/utils/nodeTypesUtils';
|
||||
import { computed } from 'vue';
|
||||
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 workflowActivate = useWorkflowActivate();
|
||||
|
||||
|
@ -35,9 +41,15 @@ const containsTrigger = computed((): boolean => {
|
|||
return foundTriggers.length > 0;
|
||||
});
|
||||
|
||||
const isNewWorkflow = computed(
|
||||
() =>
|
||||
!props.workflowId ||
|
||||
props.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID ||
|
||||
props.workflowId === 'new',
|
||||
);
|
||||
|
||||
const disabled = computed((): boolean => {
|
||||
const isNewWorkflow = !props.workflowId;
|
||||
if (isNewWorkflow || isCurrentWorkflow.value) {
|
||||
if (isNewWorkflow.value || isCurrentWorkflow.value) {
|
||||
return !props.workflowActive && !containsTrigger.value;
|
||||
}
|
||||
|
||||
|
@ -108,7 +120,11 @@ async function displayActivationError() {
|
|||
? i18n.baseText('workflowActivator.deactivateWorkflow')
|
||||
: i18n.baseText('workflowActivator.activateWorkflow')
|
||||
"
|
||||
:disabled="disabled || workflowActivate.updatingWorkflowActivation.value"
|
||||
:disabled="
|
||||
disabled ||
|
||||
workflowActivate.updatingWorkflowActivation.value ||
|
||||
(!isNewWorkflow && !workflowPermissions.update)
|
||||
"
|
||||
:active-color="getActiveColor"
|
||||
inactive-color="#8899AA"
|
||||
data-test-id="workflow-activate-switch"
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '@/constants';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { getWorkflowPermissions } from '@/permissions';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import dateformat from 'dateformat';
|
||||
import WorkflowActivator from '@/components/WorkflowActivator.vue';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
@ -75,7 +75,7 @@ const projectsStore = useProjectsStore();
|
|||
|
||||
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
|
||||
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 items = [
|
||||
{
|
||||
|
@ -88,7 +88,7 @@ const actions = computed(() => {
|
|||
},
|
||||
];
|
||||
|
||||
if (!props.readOnly) {
|
||||
if (workflowPermissions.value.create && !props.readOnly) {
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.duplicate'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
|
||||
|
@ -274,6 +274,7 @@ function moveResource() {
|
|||
class="mr-s"
|
||||
:workflow-active="data.active"
|
||||
:workflow-id="data.id"
|
||||
:workflow-permissions="workflowPermissions"
|
||||
data-test-id="workflow-card-activator"
|
||||
/>
|
||||
|
||||
|
|
|
@ -16,6 +16,8 @@ import { EnterpriseEditionFeature, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constan
|
|||
|
||||
import { nextTick } from 'vue';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import * as permissions from '@/permissions';
|
||||
import type { PermissionsRecord } from '@/permissions';
|
||||
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||
|
@ -52,6 +54,11 @@ describe('WorkflowSettingsVue', () => {
|
|||
updatedAt: 1,
|
||||
versionId: '123',
|
||||
} as IWorkflowDb);
|
||||
vi.spyOn(permissions, 'getResourcePermissions').mockReturnValue({
|
||||
workflow: {
|
||||
update: true,
|
||||
},
|
||||
} as PermissionsRecord);
|
||||
|
||||
uiStore.modalsById[WORKFLOW_SETTINGS_MODAL_KEY] = {
|
||||
open: true,
|
|
@ -23,7 +23,7 @@
|
|||
placeholder="Select Execution Order"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-execution-order"
|
||||
>
|
||||
|
@ -53,7 +53,7 @@
|
|||
v-model="workflowSettings.errorWorkflow"
|
||||
placeholder="Select Workflow"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-error-workflow"
|
||||
>
|
||||
|
@ -82,7 +82,7 @@
|
|||
<el-col :span="14" class="ignore-key-press">
|
||||
<n8n-select
|
||||
v-model="workflowSettings.callerPolicy"
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
filterable
|
||||
:limit-popper-width="true"
|
||||
|
@ -110,7 +110,7 @@
|
|||
<el-col :span="14">
|
||||
<n8n-input
|
||||
v-model="workflowSettings.callerIds"
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:placeholder="$locale.baseText('workflowSettings.callerIds.placeholder')"
|
||||
type="text"
|
||||
data-test-id="workflow-caller-policy-workflow-ids"
|
||||
|
@ -134,7 +134,7 @@
|
|||
v-model="workflowSettings.timezone"
|
||||
placeholder="Select Timezone"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-timezone"
|
||||
>
|
||||
|
@ -163,7 +163,7 @@
|
|||
v-model="workflowSettings.saveDataErrorExecution"
|
||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-failed-executions"
|
||||
>
|
||||
|
@ -192,7 +192,7 @@
|
|||
v-model="workflowSettings.saveDataSuccessExecution"
|
||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-success-executions"
|
||||
>
|
||||
|
@ -221,7 +221,7 @@
|
|||
v-model="workflowSettings.saveManualExecutions"
|
||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-manual-executions"
|
||||
>
|
||||
|
@ -250,7 +250,7 @@
|
|||
v-model="workflowSettings.saveExecutionProgress"
|
||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-execution-progress"
|
||||
>
|
||||
|
@ -278,7 +278,7 @@
|
|||
<div>
|
||||
<el-switch
|
||||
ref="inputField"
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:model-value="(workflowSettings.executionTimeout ?? -1) > -1"
|
||||
active-color="#13ce66"
|
||||
data-test-id="workflow-settings-timeout-workflow"
|
||||
|
@ -303,7 +303,7 @@
|
|||
</el-col>
|
||||
<el-col :span="4">
|
||||
<n8n-input
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:model-value="timeoutHMS.hours"
|
||||
:min="0"
|
||||
@update:model-value="(value: string) => setTimeout('hours', value)"
|
||||
|
@ -313,7 +313,7 @@
|
|||
</el-col>
|
||||
<el-col :span="4" class="timeout-input">
|
||||
<n8n-input
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:model-value="timeoutHMS.minutes"
|
||||
:min="0"
|
||||
:max="60"
|
||||
|
@ -324,7 +324,7 @@
|
|||
</el-col>
|
||||
<el-col :span="4" class="timeout-input">
|
||||
<n8n-input
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:model-value="timeoutHMS.seconds"
|
||||
:min="0"
|
||||
:max="60"
|
||||
|
@ -340,7 +340,7 @@
|
|||
<template #footer>
|
||||
<div class="action-buttons" data-test-id="workflow-settings-save-button">
|
||||
<n8n-button
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !workflowPermissions.update"
|
||||
:label="$locale.baseText('workflowSettings.save')"
|
||||
size="large"
|
||||
float="right"
|
||||
|
@ -379,12 +379,10 @@ import { useRootStore } from '@/stores/root.store';
|
|||
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
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 { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'WorkflowSettings',
|
||||
|
@ -489,8 +487,8 @@ export default defineComponent({
|
|||
|
||||
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflowId}`, fallback);
|
||||
},
|
||||
workflowPermissions(): PermissionsMap<WorkflowScope> {
|
||||
return getWorkflowPermissions(this.workflow);
|
||||
workflowPermissions() {
|
||||
return getResourcePermissions(this.workflow?.scopes).workflow;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
|
@ -515,17 +513,17 @@ export default defineComponent({
|
|||
this.defaultValues.workflowCallerPolicy = this.settingsStore.workflowCallerPolicyDefaultOption;
|
||||
|
||||
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 {
|
||||
await Promise.all(promises);
|
||||
await Promise.all([
|
||||
this.loadWorkflows(),
|
||||
this.loadSaveDataErrorExecutionOptions(),
|
||||
this.loadSaveDataSuccessExecutionOptions(),
|
||||
this.loadSaveExecutionProgressOptions(),
|
||||
this.loadSaveManualOptions(),
|
||||
this.loadTimezones(),
|
||||
this.loadWorkflowCallerPolicyOptions(),
|
||||
]);
|
||||
} catch (error) {
|
||||
this.showError(
|
||||
error,
|
||||
|
|
|
@ -130,9 +130,7 @@ import {
|
|||
WORKFLOW_SHARE_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import type { IUser, IWorkflowDb } from '@/Interface';
|
||||
import type { PermissionsMap } from '@/permissions';
|
||||
import type { WorkflowScope } from '@n8n/permissions';
|
||||
import { getWorkflowPermissions } from '@/permissions';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { nodeViewEventBus } from '@/event-bus';
|
||||
|
@ -224,8 +222,8 @@ export default defineComponent({
|
|||
currentUser(): IUser | null {
|
||||
return this.usersStore.currentUser;
|
||||
},
|
||||
workflowPermissions(): PermissionsMap<WorkflowScope> {
|
||||
return getWorkflowPermissions(this.workflow);
|
||||
workflowPermissions() {
|
||||
return getResourcePermissions(this.workflow?.scopes).workflow;
|
||||
},
|
||||
workflowOwnerName(): string {
|
||||
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflow.id}`);
|
||||
|
|
|
@ -100,6 +100,9 @@ describe('GlobalExecutionsList', () => {
|
|||
const { queryAllByTestId, queryByTestId, getByTestId } = renderComponent({
|
||||
props: {
|
||||
executions: [],
|
||||
filters: {},
|
||||
total: 0,
|
||||
estimated: false,
|
||||
},
|
||||
pinia,
|
||||
});
|
||||
|
@ -121,6 +124,8 @@ describe('GlobalExecutionsList', () => {
|
|||
executions: executionsData[0].results,
|
||||
total: executionsData[0].count,
|
||||
filteredExecutions: executionsData[0].results,
|
||||
filters: {},
|
||||
estimated: false,
|
||||
},
|
||||
pinia,
|
||||
});
|
||||
|
@ -185,6 +190,8 @@ describe('GlobalExecutionsList', () => {
|
|||
executions: executionsData[0].results,
|
||||
total: executionsData[0].count,
|
||||
filteredExecutions: executionsData[0].results,
|
||||
filters: {},
|
||||
estimated: false,
|
||||
},
|
||||
pinia,
|
||||
});
|
||||
|
|
|
@ -11,6 +11,8 @@ import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
|||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import type { PermissionsRecord } from '@/permissions';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
const props = withDefaults(
|
||||
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 {
|
||||
return workflows.value.find((data: IWorkflowDb) => data.id === workflowId)?.name;
|
||||
}
|
||||
|
@ -344,7 +352,9 @@ async function onAutoRefreshToggle(value: boolean) {
|
|||
:key="execution.id"
|
||||
:execution="execution"
|
||||
:workflow-name="getExecutionWorkflowName(execution)"
|
||||
:workflow-permissions="getExecutionWorkflowPermissions(execution)"
|
||||
:selected="selectedItems[execution.id] || allExistingSelected"
|
||||
data-test-id="global-execution-list-item"
|
||||
@stop="stopExecution"
|
||||
@delete="deleteExecution"
|
||||
@select="toggleSelectExecution"
|
||||
|
|
|
@ -55,6 +55,9 @@ describe('GlobalExecutionsListItem', () => {
|
|||
retrySuccessfulId: undefined,
|
||||
waitTill: false,
|
||||
},
|
||||
workflowPermissions: {
|
||||
execute: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -73,6 +76,9 @@ describe('GlobalExecutionsListItem', () => {
|
|||
id: 123,
|
||||
stoppedAt: undefined,
|
||||
},
|
||||
workflowPermissions: {
|
||||
update: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -84,7 +90,10 @@ describe('GlobalExecutionsListItem', () => {
|
|||
global.window.open = vi.fn();
|
||||
|
||||
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'));
|
||||
|
@ -94,7 +103,10 @@ describe('GlobalExecutionsListItem', () => {
|
|||
it('should show formatted start date', () => {
|
||||
const testDate = '2022-01-01T12:00:00Z';
|
||||
const { getByText } = renderComponent({
|
||||
props: { execution: { status: 'success', id: 123, startedAt: testDate } },
|
||||
props: {
|
||||
execution: { status: 'success', id: 123, startedAt: testDate },
|
||||
workflowPermissions: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
|
|
|
@ -8,6 +8,7 @@ import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
|||
import { i18n as locale } from '@/plugins/i18n';
|
||||
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
import type { PermissionsRecord } from '@/permissions';
|
||||
|
||||
type Command = 'retrySaved' | 'retryOriginal' | 'delete';
|
||||
|
||||
|
@ -24,9 +25,11 @@ const props = withDefaults(
|
|||
execution: ExecutionSummary;
|
||||
selected?: boolean;
|
||||
workflowName?: string;
|
||||
workflowPermissions: PermissionsRecord['workflow'];
|
||||
}>(),
|
||||
{
|
||||
selected: false,
|
||||
workflowName: '',
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -266,6 +269,7 @@ async function handleActionItemClick(commandData: Command) {
|
|||
data-test-id="execution-retry-saved-dropdown-item"
|
||||
:class="$style.retryAction"
|
||||
command="retrySaved"
|
||||
:disabled="!workflowPermissions.execute"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
|
||||
</ElDropdownItem>
|
||||
|
@ -274,6 +278,7 @@ async function handleActionItemClick(commandData: Command) {
|
|||
data-test-id="execution-retry-original-dropdown-item"
|
||||
:class="$style.retryAction"
|
||||
command="retryOriginal"
|
||||
:disabled="!workflowPermissions.execute"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.retryWithOriginalWorkflow') }}
|
||||
</ElDropdownItem>
|
||||
|
@ -281,6 +286,7 @@ async function handleActionItemClick(commandData: Command) {
|
|||
data-test-id="execution-delete-dropdown-item"
|
||||
:class="$style.deleteAction"
|
||||
command="delete"
|
||||
:disabled="!workflowPermissions.update"
|
||||
>
|
||||
{{ i18n.baseText('generic.delete') }}
|
||||
</ElDropdownItem>
|
||||
|
|
|
@ -2,6 +2,13 @@ import { createComponentRenderer } from '@/__tests__/render';
|
|||
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
params: {},
|
||||
}),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
|
||||
global: {
|
||||
stubs: {
|
||||
|
@ -25,53 +32,102 @@ describe('WorkflowExecutionsCard', () => {
|
|||
test.each([
|
||||
[
|
||||
{
|
||||
execution: {
|
||||
id: '1',
|
||||
mode: 'manual',
|
||||
status: 'success',
|
||||
retryOf: null,
|
||||
retrySuccessId: null,
|
||||
},
|
||||
workflowPermissions: {
|
||||
execute: true,
|
||||
},
|
||||
},
|
||||
false,
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
execution: {
|
||||
id: '2',
|
||||
mode: 'manual',
|
||||
status: 'error',
|
||||
retryOf: null,
|
||||
retrySuccessId: null,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
id: '3',
|
||||
mode: 'manual',
|
||||
status: 'error',
|
||||
retryOf: '2',
|
||||
retrySuccessId: null,
|
||||
workflowPermissions: {
|
||||
execute: true,
|
||||
},
|
||||
},
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
id: '4',
|
||||
execution: {
|
||||
id: '3',
|
||||
mode: 'manual',
|
||||
status: 'error',
|
||||
retryOf: null,
|
||||
retrySuccessId: '3',
|
||||
},
|
||||
workflowPermissions: {
|
||||
execute: true,
|
||||
},
|
||||
},
|
||||
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(
|
||||
shouldRenderRetryBtn,
|
||||
const retryButton = queryByTestId('retry-execution-button');
|
||||
|
||||
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>
|
||||
<div
|
||||
:class="{
|
||||
|
@ -12,153 +68,74 @@
|
|||
<router-link
|
||||
:class="$style.executionLink"
|
||||
:to="{
|
||||
name: executionPreviewViewName,
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: currentWorkflow, executionId: execution.id },
|
||||
}"
|
||||
:data-test-execution-status="executionUIDetails.name"
|
||||
>
|
||||
<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 }}
|
||||
</n8n-text>
|
||||
</N8nText>
|
||||
<div :class="$style.executionStatus">
|
||||
<n8n-spinner
|
||||
<N8nSpinner
|
||||
v-if="executionUIDetails.name === 'running'"
|
||||
size="small"
|
||||
:class="[$style.spinner, 'mr-4xs']"
|
||||
/>
|
||||
<n8n-text :class="$style.statusLabel" size="small">{{
|
||||
executionUIDetails.label
|
||||
}}</n8n-text>
|
||||
<N8nText :class="$style.statusLabel" size="small">{{ executionUIDetails.label }}</N8nText>
|
||||
{{ ' ' }}
|
||||
<n8n-text
|
||||
<N8nText
|
||||
v-if="executionUIDetails.name === 'running'"
|
||||
:color="isActive ? 'text-dark' : 'text-base'"
|
||||
size="small"
|
||||
>
|
||||
{{ $locale.baseText('executionDetails.runningTimeRunning') }}
|
||||
{{ locale.baseText('executionDetails.runningTimeRunning') }}
|
||||
<ExecutionsTime :start-time="execution.startedAt" />
|
||||
</n8n-text>
|
||||
<n8n-text
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-else-if="executionUIDetails.runningTime !== ''"
|
||||
:color="isActive ? 'text-dark' : 'text-base'"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
$locale.baseText('executionDetails.runningTimeFinished', {
|
||||
locale.baseText('executionDetails.runningTimeFinished', {
|
||||
interpolate: { time: executionUIDetails?.runningTime },
|
||||
})
|
||||
}}
|
||||
</n8n-text>
|
||||
</N8nText>
|
||||
</div>
|
||||
<div v-if="execution.mode === 'retry'">
|
||||
<n8n-text :color="isActive ? 'text-dark' : 'text-base'" size="small">
|
||||
{{ $locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
|
||||
</n8n-text>
|
||||
<N8nText :color="isActive ? 'text-dark' : 'text-base'" size="small">
|
||||
{{ locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.icons">
|
||||
<n8n-action-dropdown
|
||||
<N8nActionDropdown
|
||||
v-if="isRetriable"
|
||||
:class="[$style.icon, $style.retry]"
|
||||
:items="retryExecutionActions"
|
||||
:disabled="!workflowPermissions.execute"
|
||||
activator-icon="redo"
|
||||
data-test-id="retry-execution-button"
|
||||
@select="onRetryMenuItemSelect"
|
||||
/>
|
||||
<n8n-tooltip v-if="execution.mode === 'manual'" placement="top">
|
||||
<N8nTooltip v-if="execution.mode === 'manual'" placement="top">
|
||||
<template #content>
|
||||
<span>{{ $locale.baseText('executionsList.test') }}</span>
|
||||
<span>{{ locale.baseText('executionsList.test') }}</span>
|
||||
</template>
|
||||
<font-awesome-icon
|
||||
<FontAwesomeIcon
|
||||
v-if="execution.mode === 'manual'"
|
||||
:class="[$style.icon, $style.manual]"
|
||||
icon="flask"
|
||||
/>
|
||||
</n8n-tooltip>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</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">
|
||||
@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>
|
||||
<n8n-info-accordion
|
||||
<N8nInfoAccordion
|
||||
:class="[$style.accordion, 'mt-2xl']"
|
||||
:title="$locale.baseText('executionsLandingPage.emptyState.accordion.title')"
|
||||
:items="accordionItems"
|
||||
|
@ -11,232 +200,30 @@
|
|||
<template #customContent>
|
||||
<footer class="mt-2xs">
|
||||
{{ $locale.baseText('executionsLandingPage.emptyState.accordion.footer') }}
|
||||
<n8n-tooltip :disabled="!isNewWorkflow">
|
||||
<N8nTooltip :disabled="!isNewWorkflow">
|
||||
<template #content>
|
||||
<div>
|
||||
<n8n-link @click.prevent="onSaveWorkflowClick">{{
|
||||
<N8nLink @click.prevent="onSaveWorkflowClick">{{
|
||||
$locale.baseText('executionsLandingPage.emptyState.accordion.footer.tooltipLink')
|
||||
}}</n8n-link>
|
||||
}}</N8nLink>
|
||||
{{
|
||||
$locale.baseText('executionsLandingPage.emptyState.accordion.footer.tooltipText')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<n8n-link
|
||||
<N8nLink
|
||||
:class="{ [$style.disabled]: isNewWorkflow }"
|
||||
size="small"
|
||||
@click.prevent="openWorkflowSettings"
|
||||
>
|
||||
{{ $locale.baseText('executionsLandingPage.emptyState.accordion.footer.settingsLink') }}
|
||||
</n8n-link>
|
||||
</n8n-tooltip>
|
||||
</N8nLink>
|
||||
</N8nTooltip>
|
||||
</footer>
|
||||
</template>
|
||||
</n8n-info-accordion>
|
||||
</N8nInfoAccordion>
|
||||
</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">
|
||||
.accordion {
|
||||
background: none;
|
||||
|
|
|
@ -1,67 +1,62 @@
|
|||
<template>
|
||||
<div :class="['workflow-executions-container', $style.container]">
|
||||
<div v-if="executionCount === 0" :class="[$style.messageContainer, $style.noExecutionsMessage]">
|
||||
<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">
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { mapStores } from 'pinia';
|
||||
import { defineComponent } from 'vue';
|
||||
import WorkflowExecutionsInfoAccordion from './WorkflowExecutionsInfoAccordion.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionsLandingPage',
|
||||
components: {
|
||||
WorkflowExecutionsInfoAccordion,
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUIStore, useWorkflowsStore),
|
||||
executionCount(): number {
|
||||
return this.workflowsStore.currentWorkflowExecutions.length;
|
||||
},
|
||||
containsTrigger(): boolean {
|
||||
return this.workflowsStore.workflowTriggerNodes.length > 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onSetupFirstStep(): void {
|
||||
this.uiStore.addFirstStepOnLoad = true;
|
||||
const workflowRoute = this.getWorkflowRoute();
|
||||
void this.$router.push(workflowRoute);
|
||||
},
|
||||
getWorkflowRoute(): { name: string; params: {} } {
|
||||
const workflowId = this.workflowsStore.workflowId || this.$route.params.name;
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const locale = useI18n();
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const executionCount = computed(() => workflowsStore.currentWorkflowExecutions.length);
|
||||
const containsTrigger = computed(() => workflowsStore.workflowTriggerNodes.length > 0);
|
||||
|
||||
function onSetupFirstStep(): void {
|
||||
uiStore.addFirstStepOnLoad = true;
|
||||
const workflowRoute = getWorkflowRoute();
|
||||
void router.push(workflowRoute);
|
||||
}
|
||||
|
||||
function getWorkflowRoute(): { name: string; params: {} } {
|
||||
const workflowId = workflowsStore.workflowId || route.params.name;
|
||||
if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
||||
return { name: VIEWS.NEW_WORKFLOW, params: {} };
|
||||
} else {
|
||||
return { name: VIEWS.WORKFLOW, params: { name: workflowId } };
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
</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">
|
||||
.container {
|
||||
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">
|
||||
import { computed } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { onBeforeRouteLeave, useRouter } from 'vue-router';
|
||||
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
|
||||
import { MAIN_HEADER_TABS, VIEWS } from '@/constants';
|
||||
|
@ -32,7 +7,6 @@ import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
|||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { watch } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
@ -62,10 +36,11 @@ const emit = defineEmits<{
|
|||
const workflowHelpers = useWorkflowHelpers({ router: useRouter() });
|
||||
const router = useRouter();
|
||||
|
||||
const temporaryExecution = computed(() => {
|
||||
const isTemporary = !props.executions.find((execution) => execution.id === props.execution?.id);
|
||||
return isTemporary ? props.execution : undefined;
|
||||
});
|
||||
const temporaryExecution = computed<ExecutionSummary | undefined>(() =>
|
||||
props.executions.find((execution) => execution.id === props.execution?.id)
|
||||
? undefined
|
||||
: props.execution ?? undefined,
|
||||
);
|
||||
|
||||
const hidePreview = computed(() => {
|
||||
return props.loading || (!props.execution && props.executions.length);
|
||||
|
@ -118,6 +93,32 @@ onBeforeRouteLeave(async (to, _, next) => {
|
|||
});
|
||||
</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">
|
||||
.container {
|
||||
display: flex;
|
||||
|
|
|
@ -11,6 +11,8 @@ import { EnterpriseEditionFeature, VIEWS } from '@/constants';
|
|||
import { i18nInstance, I18nPlugin } from '@/plugins/i18n';
|
||||
import { FontAwesomePlugin } from '@/plugins/icons';
|
||||
import { GlobalComponentsPlugin } from '@/plugins/components';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
|
||||
|
@ -59,10 +61,12 @@ const executionDataFactory = (): ExecutionSummary => ({
|
|||
nodeExecutionStatus: {},
|
||||
retryOf: generateUndefinedNullOrString(),
|
||||
retrySuccessId: generateUndefinedNullOrString(),
|
||||
scopes: ['workflow:update'],
|
||||
});
|
||||
|
||||
describe('WorkflowExecutionsPreview.vue', () => {
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||
const executionData: ExecutionSummary = executionDataFactory();
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -70,19 +74,25 @@ describe('WorkflowExecutionsPreview.vue', () => {
|
|||
setActivePinia(pinia);
|
||||
|
||||
settingsStore = useSettingsStore();
|
||||
workflowsStore = useWorkflowsStore();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[false, '/'],
|
||||
[true, `/workflow/${executionData.workflowId}/debug/${executionData.id}`],
|
||||
[false, [], '/'],
|
||||
[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',
|
||||
async (availability, path) => {
|
||||
'when debug enterprise feature is %s with workflow scopes %s it should handle debug link click accordingly',
|
||||
async (availability, scopes, path) => {
|
||||
settingsStore.settings.enterprise = {
|
||||
...(settingsStore.settings.enterprise ?? {}),
|
||||
[EnterpriseEditionFeature.DebugInEditor]: availability,
|
||||
};
|
||||
|
||||
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({ scopes } as IWorkflowDb);
|
||||
|
||||
// Not using createComponentRenderer helper here because this component should not stub `router-link`
|
||||
const { getByTestId } = render(WorkflowExecutionsPreview, {
|
||||
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>
|
||||
<div v-if="executionUIDetails?.name === 'new'" :class="$style.newInfo">
|
||||
<n8n-text :class="$style.newMessage" color="text-light">
|
||||
{{ $locale.baseText('executionDetails.newMessage') }}
|
||||
</n8n-text>
|
||||
<n8n-button class="mt-l" type="tertiary" @click="handleStopClick">
|
||||
{{ $locale.baseText('executionsList.stopExecution') }}
|
||||
</n8n-button>
|
||||
<N8nText :class="$style.newMessage" color="text-light">
|
||||
{{ locale.baseText('executionDetails.newMessage') }}
|
||||
</N8nText>
|
||||
<N8nButton class="mt-l" type="tertiary" @click="handleStopClick">
|
||||
{{ locale.baseText('executionsList.stopExecution') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<div v-else-if="executionUIDetails?.name === 'running'" :class="$style.runningInfo">
|
||||
<div :class="$style.spinner">
|
||||
<n8n-spinner type="ring" />
|
||||
<N8nSpinner type="ring" />
|
||||
</div>
|
||||
<n8n-text :class="$style.runningMessage" color="text-light">
|
||||
{{ $locale.baseText('executionDetails.runningMessage') }}
|
||||
</n8n-text>
|
||||
<n8n-button class="mt-l" type="tertiary" @click="handleStopClick">
|
||||
{{ $locale.baseText('executionsList.stopExecution') }}
|
||||
</n8n-button>
|
||||
<N8nText :class="$style.runningMessage" color="text-light">
|
||||
{{ locale.baseText('executionDetails.runningMessage') }}
|
||||
</N8nText>
|
||||
<N8nButton class="mt-l" type="tertiary" @click="handleStopClick">
|
||||
{{ locale.baseText('executionsList.stopExecution') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<div v-else-if="executionUIDetails" :class="$style.previewContainer">
|
||||
<div
|
||||
|
@ -25,57 +116,53 @@
|
|||
:data-test-id="`execution-preview-details-${executionId}`"
|
||||
>
|
||||
<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
|
||||
}}</n8n-text
|
||||
}}</N8nText
|
||||
><br />
|
||||
<n8n-spinner
|
||||
<N8nSpinner
|
||||
v-if="executionUIDetails?.name === 'running'"
|
||||
size="small"
|
||||
:class="[$style.spinner, 'mr-4xs']"
|
||||
/>
|
||||
<n8n-text
|
||||
<N8nText
|
||||
size="medium"
|
||||
:class="[$style.status, $style[executionUIDetails.name]]"
|
||||
data-test-id="execution-preview-label"
|
||||
>
|
||||
{{ executionUIDetails.label }}
|
||||
</n8n-text>
|
||||
</N8nText>
|
||||
{{ ' ' }}
|
||||
<n8n-text
|
||||
v-if="executionUIDetails?.showTimestamp === false"
|
||||
color="text-base"
|
||||
size="medium"
|
||||
>
|
||||
<N8nText v-if="executionUIDetails?.showTimestamp === false" color="text-base" size="medium">
|
||||
| ID#{{ execution.id }}
|
||||
</n8n-text>
|
||||
<n8n-text v-else-if="executionUIDetails.name === 'running'" color="text-base" size="medium">
|
||||
</N8nText>
|
||||
<N8nText v-else-if="executionUIDetails.name === 'running'" color="text-base" size="medium">
|
||||
{{
|
||||
$locale.baseText('executionDetails.runningTimeRunning', {
|
||||
locale.baseText('executionDetails.runningTimeRunning', {
|
||||
interpolate: { time: executionUIDetails?.runningTime },
|
||||
})
|
||||
}}
|
||||
| ID#{{ execution.id }}
|
||||
</n8n-text>
|
||||
<n8n-text
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-else-if="executionUIDetails.name !== 'waiting'"
|
||||
color="text-base"
|
||||
size="medium"
|
||||
data-test-id="execution-preview-id"
|
||||
>
|
||||
{{
|
||||
$locale.baseText('executionDetails.runningTimeFinished', {
|
||||
locale.baseText('executionDetails.runningTimeFinished', {
|
||||
interpolate: { time: executionUIDetails?.runningTime ?? 'unknown' },
|
||||
})
|
||||
}}
|
||||
| ID#{{ execution.id }}
|
||||
</n8n-text>
|
||||
<br /><n8n-text v-if="execution.mode === 'retry'" color="text-base" size="medium">
|
||||
{{ $locale.baseText('executionDetails.retry') }}
|
||||
</N8nText>
|
||||
<br /><N8nText v-if="execution.mode === 'retry'" color="text-base" size="medium">
|
||||
{{ locale.baseText('executionDetails.retry') }}
|
||||
<router-link
|
||||
:class="$style.executionLink"
|
||||
:to="{
|
||||
name: executionPreviewViewName,
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: {
|
||||
workflowId: execution.workflowId,
|
||||
executionId: execution.retryOf,
|
||||
|
@ -84,24 +171,31 @@
|
|||
>
|
||||
#{{ execution.retryOf }}
|
||||
</router-link>
|
||||
</n8n-text>
|
||||
</N8nText>
|
||||
</div>
|
||||
<div>
|
||||
<n8n-button size="medium" :type="debugButtonData.type" :class="$style.debugLink">
|
||||
<router-link
|
||||
:to="{
|
||||
name: executionDebugViewName,
|
||||
name: VIEWS.EXECUTION_DEBUG,
|
||||
params: {
|
||||
name: execution.workflowId,
|
||||
executionId: execution.id,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<span data-test-id="execution-debug-button" @click="handleDebugLinkClick">{{
|
||||
debugButtonData.text
|
||||
}}</span>
|
||||
<N8nButton
|
||||
size="medium"
|
||||
: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>
|
||||
</n8n-button>
|
||||
|
||||
<ElDropdown
|
||||
v-if="isRetriable"
|
||||
|
@ -111,28 +205,30 @@
|
|||
@command="handleRetryClick"
|
||||
>
|
||||
<span class="retry-button">
|
||||
<n8n-icon-button
|
||||
<N8nIconButton
|
||||
size="medium"
|
||||
type="tertiary"
|
||||
:title="$locale.baseText('executionsList.retryExecution')"
|
||||
:title="locale.baseText('executionsList.retryExecution')"
|
||||
:disabled="!workflowPermissions.update"
|
||||
icon="redo"
|
||||
data-test-id="execution-preview-retry-button"
|
||||
@blur="onRetryButtonBlur"
|
||||
/>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="current-workflow">
|
||||
{{ $locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="original-workflow">
|
||||
{{ $locale.baseText('executionsList.retryWithOriginalWorkflow') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="current-workflow">
|
||||
{{ locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem command="original-workflow">
|
||||
{{ locale.baseText('executionsList.retryWithOriginalWorkflow') }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<n8n-icon-button
|
||||
:title="$locale.baseText('executionDetails.deleteExecution')"
|
||||
<N8nIconButton
|
||||
:title="locale.baseText('executionDetails.deleteExecution')"
|
||||
:disabled="!workflowPermissions.update"
|
||||
icon="trash"
|
||||
size="medium"
|
||||
type="tertiary"
|
||||
|
@ -145,115 +241,11 @@
|
|||
mode="execution"
|
||||
loader-type="spinner"
|
||||
:execution-id="executionId"
|
||||
:execution-mode="executionMode"
|
||||
:execution-mode="execution?.mode || ''"
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
.previewContainer {
|
||||
position: relative;
|
||||
|
@ -321,7 +313,6 @@ export default defineComponent({
|
|||
|
||||
.debugLink {
|
||||
margin-right: var(--spacing-xs);
|
||||
padding: 0;
|
||||
|
||||
a > span {
|
||||
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>
|
||||
<div
|
||||
ref="container"
|
||||
ref="sidebarContainerRef"
|
||||
:class="['executions-sidebar', $style.container]"
|
||||
data-test-id="executions-sidebar"
|
||||
>
|
||||
|
@ -13,14 +177,14 @@
|
|||
<el-checkbox
|
||||
v-model="executionsStore.autoRefresh"
|
||||
data-test-id="auto-refresh-checkbox"
|
||||
@update:model-value="$emit('update:autoRefresh', $event)"
|
||||
@update:model-value="onAutoRefreshChange"
|
||||
>
|
||||
{{ $locale.baseText('executionsList.autoRefresh') }}
|
||||
</el-checkbox>
|
||||
<ExecutionsFilter popover-placement="left-start" @filter-changed="onFilterChanged" />
|
||||
</div>
|
||||
<div
|
||||
ref="executionList"
|
||||
ref="executionListRef"
|
||||
:class="$style.executionList"
|
||||
data-test-id="current-executions-list"
|
||||
@scroll="loadMore(20)"
|
||||
|
@ -39,18 +203,20 @@
|
|||
</div>
|
||||
<WorkflowExecutionsCard
|
||||
v-else-if="temporaryExecution"
|
||||
:ref="`execution-${temporaryExecution.id}`"
|
||||
:ref="(el) => addCurrentWorkflowExecutionsCardRef(el, temporaryExecution?.id)"
|
||||
:execution="temporaryExecution"
|
||||
:data-test-id="`execution-details-${temporaryExecution.id}`"
|
||||
:show-gap="true"
|
||||
:workflow-permissions="workflowPermissions"
|
||||
@retry-execution="onRetryExecution"
|
||||
/>
|
||||
<TransitionGroup name="executions-list">
|
||||
<WorkflowExecutionsCard
|
||||
v-for="execution in executions"
|
||||
:key="execution.id"
|
||||
:ref="`execution-${execution.id}`"
|
||||
:ref="(el) => addCurrentWorkflowExecutionsCardRef(el, execution.id)"
|
||||
:execution="execution"
|
||||
:workflow-permissions="workflowPermissions"
|
||||
:data-test-id="`execution-details-${execution.id}`"
|
||||
@retry-execution="onRetryExecution"
|
||||
@mounted="onItemMounted"
|
||||
|
@ -66,178 +232,6 @@
|
|||
</div>
|
||||
</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">
|
||||
.container {
|
||||
flex: 310px 0 0;
|
||||
|
|
|
@ -23,8 +23,13 @@
|
|||
:description="i18n.baseText(`${resourceKey}.empty.description` as BaseTextKey)"
|
||||
:button-text="i18n.baseText(`${resourceKey}.empty.button` as BaseTextKey)"
|
||||
button-type="secondary"
|
||||
:button-disabled="disabled"
|
||||
@click:button="onAddButtonClick"
|
||||
/>
|
||||
>
|
||||
<template #disabledButtonTooltip>
|
||||
{{ i18n.baseText(`${resourceKey}.empty.button.disabled.tooltip` as BaseTextKey) }}
|
||||
</template>
|
||||
</n8n-action-box>
|
||||
</slot>
|
||||
</div>
|
||||
<PageViewLayoutList v-else :overflow="type !== 'list'">
|
||||
|
|
|
@ -38,6 +38,7 @@ describe('useContextMenu', () => {
|
|||
|
||||
workflowsStore = useWorkflowsStore();
|
||||
workflowsStore.workflow.nodes = nodes;
|
||||
workflowsStore.workflow.scopes = ['workflow:update'];
|
||||
vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockReturnValue({
|
||||
nodes,
|
||||
getNode: (_: string) => {
|
||||
|
@ -127,6 +128,7 @@ describe('useContextMenu', () => {
|
|||
describe('Read-only mode', () => {
|
||||
it('should return the correct actions when right clicking a sticky', () => {
|
||||
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
|
||||
workflowsStore.workflow.scopes = ['workflow:read'];
|
||||
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
|
||||
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
|
||||
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(sticky);
|
||||
|
|
|
@ -10,6 +10,7 @@ import { getMousePosition } from '../utils/nodeViewUtils';
|
|||
import { useI18n } from './useI18n';
|
||||
import { usePinnedData } from './usePinnedData';
|
||||
import { isPresent } from '../utils/typesUtils';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
export type ContextMenuTarget =
|
||||
| { source: 'canvas'; nodeIds: string[] }
|
||||
|
@ -46,8 +47,15 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
|||
|
||||
const i18n = useI18n();
|
||||
|
||||
const workflowPermissions = computed(
|
||||
() => getResourcePermissions(workflowsStore.workflow.scopes).workflow,
|
||||
);
|
||||
|
||||
const isReadOnly = computed(
|
||||
() => sourceControlStore.preferences.branchReadOnly || uiStore.isReadOnlyView,
|
||||
() =>
|
||||
sourceControlStore.preferences.branchReadOnly ||
|
||||
uiStore.isReadOnlyView ||
|
||||
!workflowPermissions.value.update,
|
||||
);
|
||||
|
||||
const targetNodeIds = computed(() => {
|
||||
|
|
|
@ -1,120 +1,118 @@
|
|||
import {
|
||||
getVariablesPermissions,
|
||||
getProjectPermissions,
|
||||
getCredentialPermissions,
|
||||
getWorkflowPermissions,
|
||||
} from '@/permissions';
|
||||
import type { ICredentialsResponse, IUser, IWorkflowDb } from '@/Interface';
|
||||
import type { Project } from '@/types/projects.types';
|
||||
import type { PermissionsRecord } from '@/permissions';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
|
||||
describe('permissions', () => {
|
||||
it('getVariablesPermissions', () => {
|
||||
expect(getVariablesPermissions(null)).toEqual({
|
||||
create: false,
|
||||
read: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
list: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
getVariablesPermissions({
|
||||
globalScopes: [
|
||||
'variable:create',
|
||||
'variable:read',
|
||||
'variable:update',
|
||||
'variable:delete',
|
||||
'variable:list',
|
||||
],
|
||||
} as IUser),
|
||||
).toEqual({
|
||||
create: true,
|
||||
read: true,
|
||||
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 for empty scopes', () => {
|
||||
expect(getResourcePermissions()).toEqual({
|
||||
auditLogs: {},
|
||||
banner: {},
|
||||
communityPackage: {},
|
||||
credential: {},
|
||||
externalSecretsProvider: {},
|
||||
externalSecret: {},
|
||||
eventBusDestination: {},
|
||||
ldap: {},
|
||||
license: {},
|
||||
logStreaming: {},
|
||||
orchestration: {},
|
||||
project: {},
|
||||
saml: {},
|
||||
securityAudit: {},
|
||||
sourceControl: {},
|
||||
tag: {},
|
||||
user: {},
|
||||
variable: {},
|
||||
workersView: {},
|
||||
workflow: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('getProjectPermissions', () => {
|
||||
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: [
|
||||
it('getResourcePermissions', () => {
|
||||
const scopes: Scope[] = [
|
||||
'credential:create',
|
||||
'credential:read',
|
||||
'credential:update',
|
||||
'credential:delete',
|
||||
'credential:list',
|
||||
'credential:share',
|
||||
'credential:move',
|
||||
],
|
||||
} as ICredentialsResponse),
|
||||
).toEqual({
|
||||
create: true,
|
||||
read: true,
|
||||
update: true,
|
||||
delete: true,
|
||||
list: true,
|
||||
share: true,
|
||||
move: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('getWorkflowPermissions', () => {
|
||||
expect(
|
||||
getWorkflowPermissions({
|
||||
scopes: [
|
||||
'credential:read',
|
||||
'credential:share',
|
||||
'credential:update',
|
||||
'eventBusDestination:list',
|
||||
'eventBusDestination:test',
|
||||
'project:list',
|
||||
'project:read',
|
||||
'tag:create',
|
||||
'tag:list',
|
||||
'tag:read',
|
||||
'tag:update',
|
||||
'user:list',
|
||||
'variable:list',
|
||||
'variable:read',
|
||||
'workflow:create',
|
||||
'workflow:read',
|
||||
'workflow:update',
|
||||
'workflow:delete',
|
||||
'workflow:list',
|
||||
'workflow:share',
|
||||
'workflow:execute',
|
||||
'workflow:list',
|
||||
'workflow:move',
|
||||
],
|
||||
} as IWorkflowDb),
|
||||
).toEqual({
|
||||
'workflow:read',
|
||||
'workflow:share',
|
||||
'workflow:update',
|
||||
];
|
||||
|
||||
const permissionRecord: PermissionsRecord = {
|
||||
auditLogs: {},
|
||||
banner: {},
|
||||
communityPackage: {},
|
||||
credential: {
|
||||
create: true,
|
||||
read: true,
|
||||
update: true,
|
||||
delete: true,
|
||||
list: true,
|
||||
share: true,
|
||||
execute: 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 {
|
||||
CredentialScope,
|
||||
ProjectScope,
|
||||
Scope,
|
||||
WorkflowScope,
|
||||
VariableScope,
|
||||
} from '@n8n/permissions';
|
||||
import type { Project } from '@/types/projects.types';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { RESOURCES } from '@n8n/permissions';
|
||||
|
||||
type ExtractAfterColon<T> = T extends `${infer _Prefix}:${infer Suffix}` ? Suffix : never;
|
||||
export type PermissionsMap<T> = {
|
||||
[K in ExtractAfterColon<T>]: boolean;
|
||||
type ExtractScopePrefixSuffix<T> = T extends `${infer Prefix}:${infer Suffix}`
|
||||
? [Prefix, Suffix]
|
||||
: 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>) =>
|
||||
scopes.reduce(
|
||||
(permissions, scope) => ({
|
||||
export const getResourcePermissions = (resourceScopes: Scope[] = []): PermissionsRecord =>
|
||||
Object.keys(RESOURCES).reduce(
|
||||
(permissions, key) => ({
|
||||
...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>,
|
||||
);
|
||||
|
||||
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 ?? []),
|
||||
{} as PermissionsRecord,
|
||||
);
|
||||
|
|
|
@ -574,6 +574,7 @@
|
|||
"credentials.empty.heading.userNotSetup": "Set up a credential",
|
||||
"credentials.empty.description": "Credentials let workflows interact with your apps and services",
|
||||
"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.delete": "Delete",
|
||||
"credentials.item.move": "Move",
|
||||
|
@ -2189,8 +2190,10 @@
|
|||
"workflows.empty.heading.userNotSetup": "👋 Welcome!",
|
||||
"workflows.empty.description": "Create your first workflow",
|
||||
"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.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.static": "Shared with {projectName}",
|
||||
"workflows.shareModal.select.placeholder": "Add users...",
|
||||
|
@ -2241,6 +2244,7 @@
|
|||
"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.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.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",
|
||||
|
@ -2444,6 +2448,7 @@
|
|||
"projects.settings.button.deleteProject": "Delete project",
|
||||
"projects.settings.role.admin": "Admin",
|
||||
"projects.settings.role.editor": "Editor",
|
||||
"projects.settings.role.viewer": "Viewer",
|
||||
"projects.settings.delete.title": "Delete {projectName}",
|
||||
"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",
|
||||
|
|
|
@ -74,11 +74,13 @@ export const useCanvasStore = defineStore('canvas', () => {
|
|||
() => lastSelectedConnection.value,
|
||||
);
|
||||
|
||||
watch(readOnlyEnv, (readOnly) => {
|
||||
const setReadOnly = (readOnly: boolean) => {
|
||||
if (jsPlumbInstanceRef.value) {
|
||||
jsPlumbInstanceRef.value.elementsDraggable = !readOnly;
|
||||
jsPlumbInstanceRef.value.setDragConstrainFunction(((pos: PointXY) =>
|
||||
readOnly ? null : pos) as ConstrainFunction);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setLastSelectedConnection = (connection: Connection | undefined) => {
|
||||
lastSelectedConnection.value = connection;
|
||||
|
@ -255,7 +257,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
|||
if (!nodeName) return;
|
||||
const nodeData = workflowStore.getNodeByName(nodeName);
|
||||
isDragging.value = false;
|
||||
if (uiStore.isActionActive['dragActive'] && nodeData) {
|
||||
if (uiStore.isActionActive.dragActive && nodeData) {
|
||||
const moveNodes = uiStore.getSelectedNodes.slice();
|
||||
const selectedNodeNames = moveNodes.map((node: INodeUi) => node.name);
|
||||
if (!selectedNodeNames.includes(nodeData.name)) {
|
||||
|
@ -300,7 +302,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
|||
if (moveNodes.length > 1) {
|
||||
historyStore.stopRecordingUndo();
|
||||
}
|
||||
if (uiStore.isActionActive['dragActive']) {
|
||||
if (uiStore.isActionActive.dragActive) {
|
||||
uiStore.removeActiveAction('dragActive');
|
||||
}
|
||||
}
|
||||
|
@ -319,6 +321,9 @@ export const useCanvasStore = defineStore('canvas', () => {
|
|||
}
|
||||
|
||||
const jsPlumbInstance = computed(() => jsPlumbInstanceRef.value as BrowserJsPlumbInstance);
|
||||
|
||||
watch(readOnlyEnv, setReadOnly);
|
||||
|
||||
return {
|
||||
isDemo,
|
||||
nodeViewScale,
|
||||
|
@ -328,6 +333,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
|||
isLoading: loadingService.isLoading,
|
||||
aiNodes,
|
||||
lastSelectedConnection: lastSelectedConnectionComputed,
|
||||
setReadOnly,
|
||||
setLastSelectedConnection,
|
||||
startLoading: loadingService.startLoading,
|
||||
setLoadingText: loadingService.setLoadingText,
|
||||
|
|
|
@ -101,6 +101,7 @@ describe('roles store', () => {
|
|||
});
|
||||
await rolesStore.fetchRoles();
|
||||
expect(rolesStore.processedProjectRoles.map(({ role }) => role)).toEqual([
|
||||
'project:viewer',
|
||||
'project:editor',
|
||||
'project:admin',
|
||||
]);
|
||||
|
|
|
@ -13,7 +13,11 @@ export const useRolesStore = defineStore('roles', () => {
|
|||
credential: [],
|
||||
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>>(
|
||||
() => 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 { CanvasConnectionMode } from '@/types';
|
||||
import { canvasConnectionModes } from '@/types';
|
||||
import type { ComponentPublicInstance } from 'vue';
|
||||
|
||||
/*
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
:type-props="{ itemSize: 77 }"
|
||||
:loading="loading"
|
||||
:disabled="readOnlyEnv || !projectPermissions.credential.create"
|
||||
@click:add="addCredential"
|
||||
@update:filters="filters = $event"
|
||||
>
|
||||
|
@ -79,6 +80,7 @@ import { useProjectsStore } from '@/stores/projects.store';
|
|||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CredentialsView',
|
||||
|
@ -131,6 +133,14 @@ export default defineComponent({
|
|||
? this.$locale.baseText('credentials.project.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: {
|
||||
'$route.params.projectId'() {
|
||||
|
|
|
@ -47,7 +47,11 @@
|
|||
v-for="nodeData in nodesToRender"
|
||||
:key="`${nodeData.id}_node`"
|
||||
:name="nodeData.name"
|
||||
:is-read-only="isReadOnlyRoute || readOnlyEnv"
|
||||
:is-read-only="
|
||||
isReadOnlyRoute ||
|
||||
readOnlyEnv ||
|
||||
!(workflowPermissions.update ?? projectPermissions.workflow.update)
|
||||
"
|
||||
:instance="instance"
|
||||
:is-active="!!activeNode && activeNode.name === nodeData.name"
|
||||
:hide-actions="pullConnActive"
|
||||
|
@ -75,7 +79,11 @@
|
|||
:key="`${stickyData.id}_sticky`"
|
||||
:name="stickyData.name"
|
||||
:workflow="currentWorkflowObject"
|
||||
:is-read-only="isReadOnlyRoute || readOnlyEnv"
|
||||
:is-read-only="
|
||||
isReadOnlyRoute ||
|
||||
readOnlyEnv ||
|
||||
!(workflowPermissions.update ?? projectPermissions.workflow.update)
|
||||
"
|
||||
:instance="instance"
|
||||
:is-active="!!activeNode && activeNode.name === stickyData.name"
|
||||
:node-view-scale="nodeViewScale"
|
||||
|
@ -90,7 +98,11 @@
|
|||
</div>
|
||||
<NodeDetailsView
|
||||
:workflow-object="currentWorkflowObject"
|
||||
:read-only="isReadOnlyRoute || readOnlyEnv"
|
||||
:read-only="
|
||||
isReadOnlyRoute ||
|
||||
readOnlyEnv ||
|
||||
!(workflowPermissions.update ?? projectPermissions.workflow.update)
|
||||
"
|
||||
:renaming="renamingActive"
|
||||
:is-production-execution-preview="isProductionExecutionPreview"
|
||||
@redraw-node="redrawNode"
|
||||
|
@ -107,7 +119,11 @@
|
|||
</Suspense>
|
||||
<Suspense>
|
||||
<LazyNodeCreation
|
||||
v-if="!isReadOnlyRoute && !readOnlyEnv"
|
||||
v-if="
|
||||
!isReadOnlyRoute &&
|
||||
!readOnlyEnv &&
|
||||
(workflowPermissions.update ?? projectPermissions.workflow.update)
|
||||
"
|
||||
:create-node-active="createNodeActive"
|
||||
:node-view-scale="nodeViewScale"
|
||||
@toggle-node-creator="onToggleNodeCreator"
|
||||
|
@ -120,7 +136,14 @@
|
|||
<Suspense>
|
||||
<ContextMenu @action="onContextMenuAction" />
|
||||
</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
|
||||
v-if="!isManualChatOnly"
|
||||
@mouseenter="showTriggerMissingToltip(true)"
|
||||
|
@ -182,13 +205,7 @@
|
|||
/>
|
||||
|
||||
<n8n-icon-button
|
||||
v-if="
|
||||
!isReadOnlyRoute &&
|
||||
!readOnlyEnv &&
|
||||
workflowExecution &&
|
||||
!workflowRunning &&
|
||||
!allTriggersDisabled
|
||||
"
|
||||
v-if="workflowExecution && !workflowRunning && !allTriggersDisabled"
|
||||
:title="$locale.baseText('nodeView.deletesTheCurrentExecutionData')"
|
||||
icon="trash"
|
||||
size="large"
|
||||
|
@ -383,6 +400,7 @@ import type { ProjectSharingData } from '@/types/projects.types';
|
|||
import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuards';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
interface AddNodeOptions {
|
||||
position?: XYPosition;
|
||||
|
@ -538,7 +556,13 @@ export default defineComponent({
|
|||
return this.$route.name === VIEWS.DEMO;
|
||||
},
|
||||
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 {
|
||||
return this.uiStore.getLastSelectedNode;
|
||||
|
@ -579,7 +603,8 @@ export default defineComponent({
|
|||
return NodeViewUtils.getBackgroundStyles(
|
||||
this.nodeViewScale,
|
||||
this.uiStore.nodeViewOffsetPosition,
|
||||
this.isExecutionPreview,
|
||||
this.isExecutionPreview ||
|
||||
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update),
|
||||
);
|
||||
},
|
||||
workflowClasses() {
|
||||
|
@ -687,11 +712,22 @@ export default defineComponent({
|
|||
isProductionExecutionPreview(): boolean {
|
||||
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: {
|
||||
// Listen to route changes and load the workflow accordingly
|
||||
async $route(to: RouteLocation, from: RouteLocation) {
|
||||
this.readOnlyEnvRouteCheck();
|
||||
await this.readOnlyEnvRouteCheck();
|
||||
|
||||
const currentTab = getNodeViewTab(to);
|
||||
const nodeViewNotInitialized = !this.uiStore.nodeViewInitialized;
|
||||
|
@ -858,7 +894,7 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
|
||||
this.readOnlyEnvRouteCheck();
|
||||
await this.readOnlyEnvRouteCheck();
|
||||
this.canvasStore.isDemo = this.isDemo;
|
||||
},
|
||||
activated() {
|
||||
|
@ -1005,7 +1041,8 @@ export default defineComponent({
|
|||
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
return !!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update);
|
||||
},
|
||||
showTriggerMissingToltip(isVisible: boolean) {
|
||||
this.showTriggerMissingTooltip = isVisible;
|
||||
|
@ -1405,14 +1442,17 @@ export default defineComponent({
|
|||
const shiftModifier = e.shiftKey && !e.altKey && !this.deviceSupport.isCtrlKeyPressed(e);
|
||||
const ctrlAltModifier = this.deviceSupport.isCtrlKeyPressed(e) && e.altKey && !e.shiftKey;
|
||||
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) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const workflowIsSaved = !this.uiStore.stateIsDirty;
|
||||
|
||||
if (this.isReadOnlyRoute || this.readOnlyEnv || workflowIsSaved) {
|
||||
if (workflowIsSaved) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1538,7 +1578,9 @@ export default defineComponent({
|
|||
if (lastSelectedNode !== null) {
|
||||
if (
|
||||
lastSelectedNode.type === STICKY_NODE_TYPE &&
|
||||
(this.isReadOnlyRoute || this.readOnlyEnv)
|
||||
(this.isReadOnlyRoute ||
|
||||
this.readOnlyEnv ||
|
||||
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
@ -1825,7 +1867,10 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
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);
|
||||
if (deleteCopiedNodes) {
|
||||
this.deleteNodes(nodes);
|
||||
|
@ -1959,7 +2004,11 @@ export default defineComponent({
|
|||
* This method gets called when data got pasted into the window
|
||||
*/
|
||||
async onClipboardPasteEvent(plainTextData: string): Promise<void> {
|
||||
if (this.readOnlyEnv) {
|
||||
if (
|
||||
this.readOnlyEnv ||
|
||||
this.isReadOnlyRoute ||
|
||||
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -2704,7 +2753,10 @@ export default defineComponent({
|
|||
|
||||
this.instance?.connect({
|
||||
uuids: [targetEndpoint, viableConnection?.uuid || ''],
|
||||
detachable: !this.isReadOnlyRoute && !this.readOnlyEnv,
|
||||
detachable:
|
||||
!this.isReadOnlyRoute &&
|
||||
!this.readOnlyEnv &&
|
||||
(this.workflowPermissions.update ?? this.projectPermissions.workflow.update),
|
||||
});
|
||||
this.historyStore.stopRecordingUndo();
|
||||
return;
|
||||
|
@ -3000,7 +3052,11 @@ export default defineComponent({
|
|||
this.dropPrevented = true;
|
||||
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.addConnectionActionsOverlay(
|
||||
info.connection,
|
||||
|
@ -3087,6 +3143,7 @@ export default defineComponent({
|
|||
|
||||
if (
|
||||
// eslint-disable-next-line no-constant-binary-expression
|
||||
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update) ??
|
||||
this.isReadOnlyRoute ??
|
||||
this.readOnlyEnv ??
|
||||
this.enterTimer ??
|
||||
|
@ -3125,6 +3182,7 @@ export default defineComponent({
|
|||
|
||||
if (
|
||||
// eslint-disable-next-line no-constant-binary-expression
|
||||
!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update) ??
|
||||
this.isReadOnlyRoute ??
|
||||
this.readOnlyEnv ??
|
||||
!connection ??
|
||||
|
@ -3537,7 +3595,12 @@ export default defineComponent({
|
|||
const templateId = this.$route.params.id;
|
||||
await this.openWorkflowTemplate(templateId.toString());
|
||||
} 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(
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
|
||||
{
|
||||
|
@ -3604,6 +3667,9 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
this.historyStore.reset();
|
||||
if (!(this.workflowPermissions.update ?? this.projectPermissions.workflow.update)) {
|
||||
this.canvasStore.setReadOnly(true);
|
||||
}
|
||||
this.uiStore.nodeViewInitialized = true;
|
||||
document.addEventListener('keydown', this.keyDown);
|
||||
document.addEventListener('keyup', this.keyUp);
|
||||
|
@ -4523,17 +4589,16 @@ export default defineComponent({
|
|||
this.canvasStore.stopLoading();
|
||||
}
|
||||
},
|
||||
readOnlyEnvRouteCheck() {
|
||||
async readOnlyEnvRouteCheck() {
|
||||
if (
|
||||
this.readOnlyEnv &&
|
||||
(this.readOnlyEnv || !this.projectPermissions.workflow.create) &&
|
||||
(this.$route.name === VIEWS.NEW_WORKFLOW || this.$route.name === VIEWS.TEMPLATE_IMPORT)
|
||||
) {
|
||||
void this.$nextTick(async () => {
|
||||
await this.$nextTick();
|
||||
this.resetWorkspace();
|
||||
this.uiStore.stateIsDirty = false;
|
||||
|
||||
await this.$router.replace({ name: VIEWS.HOMEPAGE });
|
||||
});
|
||||
}
|
||||
},
|
||||
async checkAndInitDebugMode() {
|
||||
|
@ -4583,7 +4648,10 @@ export default defineComponent({
|
|||
case 'add_node':
|
||||
this.onToggleNodeCreator({
|
||||
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;
|
||||
case 'add_sticky':
|
||||
|
|
|
@ -43,6 +43,7 @@ const formData = ref<Pick<Project, 'name' | 'relations'>>({
|
|||
relations: [],
|
||||
});
|
||||
const projectRoleTranslations = ref<{ [key: string]: string }>({
|
||||
'project:viewer': locale.baseText('projects.settings.role.viewer'),
|
||||
'project:editor': locale.baseText('projects.settings.role.editor'),
|
||||
'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 type { DatatableColumn, EnvironmentVariable } from '@/Interface';
|
||||
import { uid } from 'n8n-design-system/utils';
|
||||
import { getVariablesPermissions } from '@/permissions';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
@ -39,7 +39,10 @@ const TEMPORARY_VARIABLE_UID_BASE = '@tmpvar';
|
|||
const allVariables = ref<EnvironmentVariable[]>([]);
|
||||
const editMode = ref<Record<string, boolean>>({});
|
||||
const loading = ref(false);
|
||||
const permissions = getVariablesPermissions(usersStore.currentUser);
|
||||
|
||||
const permissions = computed(
|
||||
() => getResourcePermissions(usersStore.currentUser?.globalScopes).variable,
|
||||
);
|
||||
|
||||
const isFeatureEnabled = computed(
|
||||
() => 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 })),
|
||||
);
|
||||
|
||||
const canCreateVariables = computed(() => isFeatureEnabled.value && permissions.create);
|
||||
const canCreateVariables = computed(() => isFeatureEnabled.value && permissions.value.create);
|
||||
|
||||
const datatableColumns = computed<DatatableColumn[]>(() => [
|
||||
{
|
||||
|
|
|
@ -128,7 +128,7 @@ async function fetchWorkflow() {
|
|||
toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title'));
|
||||
}
|
||||
}
|
||||
workflow.value = workflowsStore.workflow;
|
||||
workflow.value = workflowsStore.getWorkflowById(workflowId.value);
|
||||
}
|
||||
|
||||
async function onAutoRefreshToggle(value: boolean) {
|
||||
|
@ -172,7 +172,10 @@ async function onUpdateFilters(newFilters: ExecutionFilterType) {
|
|||
await executionsStore.initialize(workflowId.value);
|
||||
}
|
||||
|
||||
async function onExecutionStop(id: string) {
|
||||
async function onExecutionStop(id?: string) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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;
|
||||
try {
|
||||
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 { telemetry } from '@/plugins/telemetry';
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
type WorkflowHistoryActionRecord = {
|
||||
[K in Uppercase<WorkflowHistoryActionTypes[number]>]: Lowercase<K>;
|
||||
|
@ -65,10 +66,15 @@ const editorRoute = computed(() => ({
|
|||
name: workflowId.value,
|
||||
},
|
||||
}));
|
||||
const workflowPermissions = computed(
|
||||
() => getResourcePermissions(workflowsStore.getWorkflowById(workflowId.value)?.scopes).workflow,
|
||||
);
|
||||
const actions = computed<UserAction[]>(() =>
|
||||
workflowHistoryActionTypes.map((value) => ({
|
||||
label: i18n.baseText(`workflowHistory.item.actions.${value}`),
|
||||
disabled: false,
|
||||
disabled:
|
||||
(value === 'clone' && !workflowPermissions.value.create) ||
|
||||
(value === 'restore' && !workflowPermissions.value.update),
|
||||
value,
|
||||
})),
|
||||
);
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
:type-props="{ itemSize: 80 }"
|
||||
:shareable="isShareable"
|
||||
:initialize="initialize"
|
||||
:disabled="readOnlyEnv"
|
||||
:disabled="readOnlyEnv || !projectPermissions.workflow.create"
|
||||
:loading="loading"
|
||||
@click:add="addWorkflow"
|
||||
@update:filters="onFiltersUpdated"
|
||||
|
@ -61,17 +61,12 @@
|
|||
: $locale.baseText('workflows.empty.heading.userNotSetup')
|
||||
}}
|
||||
</n8n-heading>
|
||||
<n8n-text size="large" color="text-base">
|
||||
{{
|
||||
$locale.baseText(
|
||||
readOnlyEnv
|
||||
? 'workflows.empty.description.readOnlyEnv'
|
||||
: 'workflows.empty.description',
|
||||
)
|
||||
}}
|
||||
</n8n-text>
|
||||
<n8n-text size="large" color="text-base">{{ emptyListDescription }}</n8n-text>
|
||||
</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
|
||||
v-if="isSalesUser"
|
||||
:href="getTemplateRepositoryURL()"
|
||||
|
@ -162,6 +157,7 @@ import { useTagsStore } from '@/stores/tags.store';
|
|||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
interface Filters {
|
||||
search: string;
|
||||
|
@ -260,6 +256,20 @@ const WorkflowsView = defineComponent({
|
|||
? this.$locale.baseText('workflows.project.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: {
|
||||
filters: {
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
"@types/xml2js": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"@n8n/tournament": "1.0.3",
|
||||
"@n8n_io/riot-tmpl": "4.0.0",
|
||||
"ast-types": "0.15.2",
|
||||
|
|
|
@ -10,6 +10,7 @@ import type { URLSearchParams } from 'url';
|
|||
import type { RequestBodyMatcher } from 'nock';
|
||||
import type { Client as SSHClient } from 'ssh2';
|
||||
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import type { AuthenticationMethod } from './Authentication';
|
||||
import type { CODE_EXECUTION_MODES, CODE_LANGUAGES, LOG_LEVELS } from './Constants';
|
||||
import type { IDeferredPromise } from './DeferredPromise';
|
||||
|
@ -2463,6 +2464,7 @@ export interface ExecutionSummary {
|
|||
nodeExecutionStatus?: {
|
||||
[key: string]: IExecutionSummaryNodeExecutionResult;
|
||||
};
|
||||
scopes?: Scope[];
|
||||
}
|
||||
|
||||
export interface IExecutionSummaryNodeExecutionResult {
|
||||
|
|
|
@ -1709,6 +1709,9 @@ importers:
|
|||
|
||||
packages/workflow:
|
||||
dependencies:
|
||||
'@n8n/permissions':
|
||||
specifier: workspace:*
|
||||
version: link:../@n8n/permissions
|
||||
'@n8n/tournament':
|
||||
specifier: 1.0.3
|
||||
version: 1.0.3
|
||||
|
|
Loading…
Reference in a new issue