mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 11:22:15 +00:00
refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, computed, watch } from 'vue';
|
||||
import { useDocumentVisibility } from '@vueuse/core';
|
||||
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useCollaborationStore } from '@/stores/collaboration.store';
|
||||
|
||||
const collaborationStore = useCollaborationStore();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
const visibility = useDocumentVisibility();
|
||||
watch(visibility, (visibilityState) => {
|
||||
if (visibilityState === 'hidden') {
|
||||
collaborationStore.stopHeartbeat();
|
||||
} else {
|
||||
collaborationStore.startHeartbeat();
|
||||
}
|
||||
});
|
||||
|
||||
const showUserStack = computed(() => collaborationStore.collaborators.length > 1);
|
||||
|
||||
const collaboratorsSorted = computed(() => {
|
||||
const users = collaborationStore.collaborators.map(({ user }) => user);
|
||||
// Move the current user to the first position, if not already there.
|
||||
const index = users.findIndex((user) => user.id === usersStore.currentUser?.id);
|
||||
if (index < 1) return { defaultGroup: users };
|
||||
const [currentUser] = users.splice(index, 1);
|
||||
return { defaultGroup: [currentUser, ...users] };
|
||||
});
|
||||
|
||||
const currentUserEmail = computed(() => usersStore.currentUser?.email);
|
||||
|
||||
onMounted(() => {
|
||||
collaborationStore.initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
collaborationStore.terminate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="`collaboration-pane-container ${$style.container}`"
|
||||
data-test-id="collaboration-pane"
|
||||
>
|
||||
<n8n-user-stack
|
||||
v-if="showUserStack"
|
||||
:users="collaboratorsSorted"
|
||||
:current-user-email="currentUserEmail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
margin: 0 var(--spacing-4xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import TabBar from '@/components/MainHeader/TabBar.vue';
|
||||
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { usePushConnection } from '@/composables/usePushConnection';
|
||||
import {
|
||||
LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON,
|
||||
MAIN_HEADER_TABS,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
STICKY_NODE_TYPE,
|
||||
VIEWS,
|
||||
WORKFLOW_EVALUATION_EXPERIMENT,
|
||||
} from '@/constants';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { computed, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import type { RouteLocation, RouteLocationRaw } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import GithubButton from 'vue-github-button';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const locale = useI18n();
|
||||
const pushConnection = usePushConnection({ router });
|
||||
const ndvStore = useNDVStore();
|
||||
const uiStore = useUIStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const posthogStore = usePostHog();
|
||||
|
||||
const activeHeaderTab = ref(MAIN_HEADER_TABS.WORKFLOW);
|
||||
const workflowToReturnTo = ref('');
|
||||
const executionToReturnTo = ref('');
|
||||
const dirtyState = ref(false);
|
||||
const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON, false);
|
||||
|
||||
// Track the routes that are used for the tabs
|
||||
// This is used to determine which tab to show when the route changes
|
||||
// TODO: It might be easier to manage this in the router config, by passing meta information to the routes
|
||||
// This would allow us to specify it just once on the root route, and then have the tabs be determined for children
|
||||
const testDefinitionRoutes: VIEWS[] = [
|
||||
VIEWS.TEST_DEFINITION,
|
||||
VIEWS.TEST_DEFINITION_EDIT,
|
||||
VIEWS.TEST_DEFINITION_RUNS_DETAIL,
|
||||
VIEWS.TEST_DEFINITION_RUNS_COMPARE,
|
||||
];
|
||||
|
||||
const workflowRoutes: VIEWS[] = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||
|
||||
const executionRoutes: VIEWS[] = [
|
||||
VIEWS.EXECUTION_HOME,
|
||||
VIEWS.WORKFLOW_EXECUTIONS,
|
||||
VIEWS.EXECUTION_PREVIEW,
|
||||
];
|
||||
const tabBarItems = computed(() => {
|
||||
const items = [
|
||||
{ value: MAIN_HEADER_TABS.WORKFLOW, label: locale.baseText('generic.editor') },
|
||||
{ value: MAIN_HEADER_TABS.EXECUTIONS, label: locale.baseText('generic.executions') },
|
||||
];
|
||||
|
||||
if (posthogStore.isFeatureEnabled(WORKFLOW_EVALUATION_EXPERIMENT)) {
|
||||
items.push({
|
||||
value: MAIN_HEADER_TABS.TEST_DEFINITION,
|
||||
label: locale.baseText('generic.tests'),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const activeNode = computed(() => ndvStore.activeNode);
|
||||
const hideMenuBar = computed(() =>
|
||||
Boolean(activeNode.value && activeNode.value.type !== STICKY_NODE_TYPE),
|
||||
);
|
||||
const workflow = computed(() => workflowsStore.workflow);
|
||||
const workflowId = computed(() =>
|
||||
String(router.currentRoute.value.params.name || workflowsStore.workflowId),
|
||||
);
|
||||
const onWorkflowPage = computed(() => !!(route.meta.nodeView || route.meta.keepWorkflowAlive));
|
||||
const readOnly = computed(() => sourceControlStore.preferences.branchReadOnly);
|
||||
const isEnterprise = computed(
|
||||
() => settingsStore.isQueueModeEnabled && settingsStore.isWorkerViewAvailable,
|
||||
);
|
||||
const showGitHubButton = computed(
|
||||
() => !isEnterprise.value && !settingsStore.settings.inE2ETests && !githubButtonHidden.value,
|
||||
);
|
||||
|
||||
watch(route, (to, from) => {
|
||||
syncTabsWithRoute(to, from);
|
||||
});
|
||||
|
||||
onBeforeMount(() => {
|
||||
pushConnection.initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pushConnection.terminate();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
dirtyState.value = uiStore.stateIsDirty;
|
||||
syncTabsWithRoute(route);
|
||||
});
|
||||
|
||||
function isViewRoute(name: unknown): name is VIEWS {
|
||||
return (
|
||||
typeof name === 'string' &&
|
||||
[testDefinitionRoutes, workflowRoutes, executionRoutes].flat().includes(name as VIEWS)
|
||||
);
|
||||
}
|
||||
|
||||
function syncTabsWithRoute(to: RouteLocation, from?: RouteLocation): void {
|
||||
// Map route types to their corresponding tab in the header
|
||||
const routeTabMapping = [
|
||||
{ routes: testDefinitionRoutes, tab: MAIN_HEADER_TABS.TEST_DEFINITION },
|
||||
{ routes: executionRoutes, tab: MAIN_HEADER_TABS.EXECUTIONS },
|
||||
{ routes: workflowRoutes, tab: MAIN_HEADER_TABS.WORKFLOW },
|
||||
];
|
||||
|
||||
// Update the active tab based on the current route
|
||||
if (to.name && isViewRoute(to.name)) {
|
||||
const matchingTab = routeTabMapping.find(({ routes }) => routes.includes(to.name as VIEWS));
|
||||
if (matchingTab) {
|
||||
activeHeaderTab.value = matchingTab.tab;
|
||||
}
|
||||
}
|
||||
|
||||
// Store the current workflow ID, but only if it's not a new workflow
|
||||
if (to.params.name !== 'new' && typeof to.params.name === 'string') {
|
||||
workflowToReturnTo.value = to.params.name;
|
||||
}
|
||||
|
||||
if (
|
||||
from?.name === VIEWS.EXECUTION_PREVIEW &&
|
||||
to.params.name === from.params.name &&
|
||||
typeof from.params.executionId === 'string'
|
||||
) {
|
||||
executionToReturnTo.value = from.params.executionId;
|
||||
}
|
||||
}
|
||||
|
||||
function onTabSelected(tab: MAIN_HEADER_TABS, event: MouseEvent) {
|
||||
const openInNewTab = event.ctrlKey || event.metaKey;
|
||||
|
||||
switch (tab) {
|
||||
case MAIN_HEADER_TABS.WORKFLOW:
|
||||
void navigateToWorkflowView(openInNewTab);
|
||||
break;
|
||||
|
||||
case MAIN_HEADER_TABS.EXECUTIONS:
|
||||
void navigateToExecutionsView(openInNewTab);
|
||||
break;
|
||||
|
||||
case MAIN_HEADER_TABS.TEST_DEFINITION:
|
||||
activeHeaderTab.value = MAIN_HEADER_TABS.TEST_DEFINITION;
|
||||
void router.push({ name: VIEWS.TEST_DEFINITION });
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateToWorkflowView(openInNewTab: boolean) {
|
||||
let routeToNavigateTo: RouteLocationRaw;
|
||||
if (!['', 'new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(workflowToReturnTo.value)) {
|
||||
routeToNavigateTo = {
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: workflowToReturnTo.value },
|
||||
};
|
||||
} else {
|
||||
routeToNavigateTo = { name: VIEWS.NEW_WORKFLOW };
|
||||
}
|
||||
|
||||
if (openInNewTab) {
|
||||
const { href } = router.resolve(routeToNavigateTo);
|
||||
window.open(href, '_blank');
|
||||
} else if (route.name !== routeToNavigateTo.name) {
|
||||
if (route.name === VIEWS.NEW_WORKFLOW) {
|
||||
uiStore.stateIsDirty = dirtyState.value;
|
||||
}
|
||||
activeHeaderTab.value = MAIN_HEADER_TABS.WORKFLOW;
|
||||
await router.push(routeToNavigateTo);
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateToExecutionsView(openInNewTab: boolean) {
|
||||
const routeWorkflowId =
|
||||
workflowId.value === PLACEHOLDER_EMPTY_WORKFLOW_ID ? 'new' : workflowId.value;
|
||||
const executionToReturnToValue = executionsStore.activeExecution?.id || executionToReturnTo.value;
|
||||
const routeToNavigateTo: RouteLocationRaw = executionToReturnToValue
|
||||
? {
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: routeWorkflowId, executionId: executionToReturnToValue },
|
||||
}
|
||||
: {
|
||||
name: VIEWS.EXECUTION_HOME,
|
||||
params: { name: routeWorkflowId },
|
||||
};
|
||||
|
||||
if (openInNewTab) {
|
||||
const { href } = router.resolve(routeToNavigateTo);
|
||||
window.open(href, '_blank');
|
||||
} else if (route.name !== routeToNavigateTo.name) {
|
||||
dirtyState.value = uiStore.stateIsDirty;
|
||||
workflowToReturnTo.value = workflowId.value;
|
||||
activeHeaderTab.value = MAIN_HEADER_TABS.EXECUTIONS;
|
||||
await router.push(routeToNavigateTo);
|
||||
}
|
||||
}
|
||||
|
||||
function hideGithubButton() {
|
||||
githubButtonHidden.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<div :class="{ 'main-header': true, expanded: !uiStore.sidebarMenuCollapsed }">
|
||||
<div v-show="!hideMenuBar" class="top-menu">
|
||||
<WorkflowDetails
|
||||
v-if="workflow?.name"
|
||||
:id="workflow.id"
|
||||
:tags="workflow.tags"
|
||||
:name="workflow.name"
|
||||
:meta="workflow.meta"
|
||||
:scopes="workflow.scopes"
|
||||
:active="workflow.active"
|
||||
:read-only="readOnly"
|
||||
/>
|
||||
</div>
|
||||
<TabBar
|
||||
v-if="onWorkflowPage"
|
||||
:items="tabBarItems"
|
||||
:model-value="activeHeaderTab"
|
||||
@update:model-value="onTabSelected"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showGitHubButton" class="github-button hidden-sm-and-down">
|
||||
<div class="github-button-container">
|
||||
<GithubButton
|
||||
href="https://github.com/n8n-io/n8n"
|
||||
:data-color-scheme="uiStore.appliedTheme"
|
||||
data-size="large"
|
||||
data-show-count="true"
|
||||
aria-label="Star n8n-io/n8n on GitHub"
|
||||
>
|
||||
Star
|
||||
</GithubButton>
|
||||
<N8nIcon
|
||||
class="close-github-button"
|
||||
icon="times-circle"
|
||||
size="medium"
|
||||
@click="hideGithubButton"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
min-height: var(--navbar--height);
|
||||
background-color: var(--color-background-xlight);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-bottom: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
||||
}
|
||||
|
||||
.top-menu {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.9em;
|
||||
font-weight: 400;
|
||||
padding: var(--spacing-xs) var(--spacing-m);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.github-button {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
justify-content: center;
|
||||
min-width: 170px;
|
||||
padding-top: 2px;
|
||||
padding-left: var(--spacing-m);
|
||||
padding-right: var(--spacing-m);
|
||||
background-color: var(--color-background-xlight);
|
||||
border-bottom: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
||||
border-left: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
||||
}
|
||||
|
||||
.close-github-button {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translate(50%, -46%);
|
||||
color: var(--color-foreground-xdark);
|
||||
background-color: var(--color-background-xlight);
|
||||
border-radius: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--prim-color-primary-shade-100);
|
||||
}
|
||||
}
|
||||
.github-button-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.github-button:hover .close-github-button {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import TabBar from '@/components/MainHeader/TabBar.vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(TabBar);
|
||||
|
||||
describe('TabBar', () => {
|
||||
const items = [
|
||||
{ name: 'Workflow', value: 'workflow' },
|
||||
{ name: 'Executions', value: 'executions' },
|
||||
];
|
||||
|
||||
it('should render the correct number of tabs', async () => {
|
||||
const { findAllByRole } = renderComponent({
|
||||
props: {
|
||||
items,
|
||||
modelValue: 'workflow',
|
||||
},
|
||||
});
|
||||
|
||||
const tabs = await findAllByRole('radio');
|
||||
expect(tabs.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should emit update:modelValue event when a tab is clicked', async () => {
|
||||
const { findAllByRole, emitted } = renderComponent({
|
||||
props: {
|
||||
items,
|
||||
modelValue: 'workflow',
|
||||
},
|
||||
});
|
||||
|
||||
const tabs = await findAllByRole('radio');
|
||||
const executionsTab = tabs[1];
|
||||
|
||||
await fireEvent.click(executionsTab);
|
||||
|
||||
expect(emitted()).toHaveProperty('update:modelValue');
|
||||
});
|
||||
|
||||
it('should update the active tab when modelValue prop changes', async () => {
|
||||
const { findAllByRole, rerender } = renderComponent({
|
||||
props: {
|
||||
items,
|
||||
modelValue: 'workflow',
|
||||
},
|
||||
});
|
||||
|
||||
await rerender({ modelValue: 'executions' });
|
||||
|
||||
const tabs = await findAllByRole('radio');
|
||||
const executionsTab = tabs[1];
|
||||
const executionsTabButton = executionsTab.querySelector('.button');
|
||||
|
||||
expect(executionsTabButton).toHaveClass('active');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts" setup>
|
||||
import { MAIN_HEADER_TABS } from '@/constants';
|
||||
import type { ITabBarItem } from '@/Interface';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
items: ITabBarItem[];
|
||||
modelValue?: string;
|
||||
}>(),
|
||||
{
|
||||
modelValue: MAIN_HEADER_TABS.WORKFLOW,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [tab: MAIN_HEADER_TABS, event: MouseEvent];
|
||||
}>();
|
||||
|
||||
function onUpdateModelValue(tab: MAIN_HEADER_TABS, event: MouseEvent): void {
|
||||
emit('update:modelValue', tab, event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="items"
|
||||
:class="{
|
||||
[$style.container]: true,
|
||||
['tab-bar-container']: true,
|
||||
}"
|
||||
>
|
||||
<N8nRadioButtons
|
||||
:model-value="modelValue"
|
||||
:options="items"
|
||||
@update:model-value="onUpdateModelValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(50%);
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
padding: var(--spacing-5xs);
|
||||
background-color: var(--color-foreground-base);
|
||||
border-radius: var(--border-radius-base);
|
||||
transition: all 150ms ease-in-out;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 430px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { EnterpriseEditionFeature, STORES, WORKFLOW_SHARE_MODAL_KEY } from '@/constants';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => vi.fn(),
|
||||
useRouter: () => vi.fn(),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/pushConnection.store', () => ({
|
||||
usePushConnectionStore: vi.fn().mockReturnValue({
|
||||
isConnected: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const initialState = {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: {
|
||||
enterprise: {
|
||||
[EnterpriseEditionFeature.Sharing]: true,
|
||||
[EnterpriseEditionFeature.WorkflowHistory]: true,
|
||||
},
|
||||
},
|
||||
areTagsEnabled: true,
|
||||
},
|
||||
[STORES.TAGS]: {
|
||||
tagsById: {
|
||||
1: {
|
||||
id: '1',
|
||||
name: 'tag1',
|
||||
},
|
||||
2: {
|
||||
id: '2',
|
||||
name: 'tag2',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowDetails, {
|
||||
pinia: createTestingPinia({ initialState }),
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let uiStore: ReturnType<typeof useUIStore>;
|
||||
const workflow = {
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
tags: ['1', '2'],
|
||||
active: false,
|
||||
};
|
||||
|
||||
describe('WorkflowDetails', () => {
|
||||
beforeEach(() => {
|
||||
uiStore = useUIStore();
|
||||
});
|
||||
it('renders workflow name and tags', async () => {
|
||||
const { getByTestId, getByText } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
readOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
const workflowName = getByTestId('workflow-name-input');
|
||||
const workflowNameInput = workflowName.querySelector('input');
|
||||
|
||||
expect(workflowNameInput).toHaveValue('Test Workflow');
|
||||
expect(getByText('tag1')).toBeInTheDocument();
|
||||
expect(getByText('tag2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls save function on save button click', async () => {
|
||||
const onSaveButtonClick = vi.fn();
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
readOnly: false,
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
onSaveButtonClick,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-save-button'));
|
||||
expect(onSaveButtonClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens share modal on share button click', async () => {
|
||||
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
readOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-share-button'));
|
||||
expect(openModalSpy).toHaveBeenCalledWith({
|
||||
name: WORKFLOW_SHARE_MODAL_KEY,
|
||||
data: { id: '1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,802 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
DUPLICATE_MODAL_KEY,
|
||||
EnterpriseEditionFeature,
|
||||
MAX_WORKFLOW_NAME_LENGTH,
|
||||
MODAL_CONFIRM,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||
VALID_WORKFLOW_IMPORT_URL_REGEX,
|
||||
VIEWS,
|
||||
WORKFLOW_MENU_ACTIONS,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
WORKFLOW_SHARE_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import ShortenName from '@/components/ShortenName.vue';
|
||||
import WorkflowTagsContainer from '@/components/WorkflowTagsContainer.vue';
|
||||
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
|
||||
import WorkflowActivator from '@/components/WorkflowActivator.vue';
|
||||
import SaveButton from '@/components/SaveButton.vue';
|
||||
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
|
||||
import InlineTextEdit from '@/components/InlineTextEdit.vue';
|
||||
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
|
||||
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
|
||||
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
|
||||
|
||||
import { useRootStore } from '@/stores/root.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
|
||||
import { saveAs } from 'file-saver';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { nodeViewEventBus } from '@/event-bus';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { computed, ref, useCssModule, watch } from 'vue';
|
||||
import type {
|
||||
ActionDropdownItem,
|
||||
IWorkflowDataUpdate,
|
||||
IWorkflowDb,
|
||||
IWorkflowToShare,
|
||||
} from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
const props = defineProps<{
|
||||
readOnly?: boolean;
|
||||
id: IWorkflowDb['id'];
|
||||
tags: IWorkflowDb['tags'];
|
||||
name: IWorkflowDb['name'];
|
||||
meta: IWorkflowDb['meta'];
|
||||
scopes: IWorkflowDb['scopes'];
|
||||
active: IWorkflowDb['active'];
|
||||
}>();
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const rootStore = useRootStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const tagsStore = useTagsStore();
|
||||
const uiStore = useUIStore();
|
||||
const usersStore = useUsersStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const npsSurveyStore = useNpsSurveyStore();
|
||||
const i18n = useI18n();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const locale = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const message = useMessage();
|
||||
const toast = useToast();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const workflowHelpers = useWorkflowHelpers({ router });
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
|
||||
const isTagsEditEnabled = ref(false);
|
||||
const isNameEditEnabled = ref(false);
|
||||
const appliedTagIds = ref<string[]>([]);
|
||||
const tagsSaving = ref(false);
|
||||
const importFileRef = ref<HTMLInputElement | undefined>();
|
||||
|
||||
const tagsEventBus = createEventBus();
|
||||
const sourceControlModalEventBus = createEventBus();
|
||||
|
||||
const hasChanged = (prev: string[], curr: string[]) => {
|
||||
if (prev.length !== curr.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const set = new Set(prev);
|
||||
return curr.reduce((acc, val) => acc || !set.has(val), false);
|
||||
};
|
||||
|
||||
const isNewWorkflow = computed(() => {
|
||||
return !props.id || props.id === PLACEHOLDER_EMPTY_WORKFLOW_ID || props.id === 'new';
|
||||
});
|
||||
|
||||
const isWorkflowSaving = computed(() => {
|
||||
return uiStore.isActionActive.workflowSaving;
|
||||
});
|
||||
|
||||
const onWorkflowPage = computed(() => {
|
||||
return route.meta && (route.meta.nodeView || route.meta.keepWorkflowAlive === true);
|
||||
});
|
||||
|
||||
const onExecutionsTab = computed(() => {
|
||||
return [
|
||||
VIEWS.EXECUTION_HOME.toString(),
|
||||
VIEWS.WORKFLOW_EXECUTIONS.toString(),
|
||||
VIEWS.EXECUTION_PREVIEW,
|
||||
].includes((route.name as string) || '');
|
||||
});
|
||||
|
||||
const workflowPermissions = computed(() => getResourcePermissions(props.scopes).workflow);
|
||||
|
||||
const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
|
||||
const actions: ActionDropdownItem[] = [
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
|
||||
label: locale.baseText('menuActions.download'),
|
||||
disabled: !onWorkflowPage.value,
|
||||
},
|
||||
];
|
||||
|
||||
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
|
||||
actions.unshift({
|
||||
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
|
||||
label: locale.baseText('menuActions.duplicate'),
|
||||
disabled: !onWorkflowPage.value || !props.id,
|
||||
});
|
||||
|
||||
actions.push(
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL,
|
||||
label: locale.baseText('menuActions.importFromUrl'),
|
||||
disabled: !onWorkflowPage.value || onExecutionsTab.value,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE,
|
||||
label: locale.baseText('menuActions.importFromFile'),
|
||||
disabled: !onWorkflowPage.value || onExecutionsTab.value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission(['rbac'], { rbac: { scope: 'sourceControl:push' } })) {
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.PUSH,
|
||||
label: locale.baseText('menuActions.push'),
|
||||
disabled:
|
||||
!sourceControlStore.isEnterpriseSourceControlEnabled ||
|
||||
!onWorkflowPage.value ||
|
||||
onExecutionsTab.value ||
|
||||
sourceControlStore.preferences.branchReadOnly,
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.SETTINGS,
|
||||
label: locale.baseText('generic.settings'),
|
||||
disabled: !onWorkflowPage.value || isNewWorkflow.value,
|
||||
});
|
||||
|
||||
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.DELETE,
|
||||
label: locale.baseText('menuActions.delete'),
|
||||
disabled: !onWorkflowPage.value || isNewWorkflow.value,
|
||||
customClass: $style.deleteItem,
|
||||
divided: true,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
});
|
||||
|
||||
const isWorkflowHistoryFeatureEnabled = computed(() => {
|
||||
return settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.WorkflowHistory];
|
||||
});
|
||||
|
||||
const workflowTagIds = computed(() => {
|
||||
return (props.tags ?? []).map((tag) => (typeof tag === 'string' ? tag : tag.id));
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.id,
|
||||
() => {
|
||||
isTagsEditEnabled.value = false;
|
||||
isNameEditEnabled.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
function getWorkflowId(): string | undefined {
|
||||
let id: string | undefined = undefined;
|
||||
if (props.id !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
||||
id = props.id;
|
||||
} else if (route.params.name && route.params.name !== 'new') {
|
||||
id = route.params.name as string;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
async function onSaveButtonClick() {
|
||||
// If the workflow is saving, do not allow another save
|
||||
if (isWorkflowSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = getWorkflowId();
|
||||
|
||||
const name = props.name;
|
||||
const tags = props.tags as string[];
|
||||
|
||||
const saved = await workflowHelpers.saveCurrentWorkflow({
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
});
|
||||
|
||||
if (saved) {
|
||||
showCreateWorkflowSuccessToast(id);
|
||||
|
||||
await npsSurveyStore.fetchPromptsData();
|
||||
|
||||
if (route.name === VIEWS.EXECUTION_DEBUG) {
|
||||
await router.replace({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: props.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onShareButtonClick() {
|
||||
uiStore.openModalWithData({
|
||||
name: WORKFLOW_SHARE_MODAL_KEY,
|
||||
data: { id: props.id },
|
||||
});
|
||||
|
||||
telemetry.track('User opened sharing modal', {
|
||||
workflow_id: props.id,
|
||||
user_id_sharer: usersStore.currentUser?.id,
|
||||
sub_view: route.name === VIEWS.WORKFLOWS ? 'Workflows listing' : 'Workflow editor',
|
||||
});
|
||||
}
|
||||
|
||||
function onTagsEditEnable() {
|
||||
appliedTagIds.value = (props.tags ?? []) as string[];
|
||||
isTagsEditEnabled.value = true;
|
||||
|
||||
setTimeout(() => {
|
||||
// allow name update to occur before disabling name edit
|
||||
isNameEditEnabled.value = false;
|
||||
tagsEventBus.emit('focus');
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function onTagsBlur() {
|
||||
const current = (props.tags ?? []) as string[];
|
||||
const tags = appliedTagIds.value;
|
||||
if (!hasChanged(current, tags)) {
|
||||
isTagsEditEnabled.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
if (tagsSaving.value) {
|
||||
return;
|
||||
}
|
||||
tagsSaving.value = true;
|
||||
|
||||
const saved = await workflowHelpers.saveCurrentWorkflow({ tags });
|
||||
telemetry.track('User edited workflow tags', {
|
||||
workflow_id: props.id,
|
||||
new_tag_count: tags.length,
|
||||
});
|
||||
|
||||
tagsSaving.value = false;
|
||||
if (saved) {
|
||||
isTagsEditEnabled.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTagsEditEsc() {
|
||||
isTagsEditEnabled.value = false;
|
||||
}
|
||||
|
||||
function onNameToggle() {
|
||||
isNameEditEnabled.value = !isNameEditEnabled.value;
|
||||
if (isNameEditEnabled.value) {
|
||||
if (isTagsEditEnabled.value) {
|
||||
void onTagsBlur();
|
||||
}
|
||||
|
||||
isTagsEditEnabled.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onNameSubmit({
|
||||
name,
|
||||
onSubmit,
|
||||
}: {
|
||||
name: string;
|
||||
onSubmit: (saved: boolean) => void;
|
||||
}) {
|
||||
const newName = name.trim();
|
||||
if (!newName) {
|
||||
toast.showMessage({
|
||||
title: locale.baseText('workflowDetails.showMessage.title'),
|
||||
message: locale.baseText('workflowDetails.showMessage.message'),
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
onSubmit(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newName === props.name) {
|
||||
isNameEditEnabled.value = false;
|
||||
|
||||
onSubmit(true);
|
||||
return;
|
||||
}
|
||||
|
||||
uiStore.addActiveAction('workflowSaving');
|
||||
const id = getWorkflowId();
|
||||
const saved = await workflowHelpers.saveCurrentWorkflow({ name });
|
||||
if (saved) {
|
||||
isNameEditEnabled.value = false;
|
||||
showCreateWorkflowSuccessToast(id);
|
||||
workflowHelpers.setDocumentTitle(newName, 'IDLE');
|
||||
}
|
||||
uiStore.removeActiveAction('workflowSaving');
|
||||
onSubmit(saved);
|
||||
}
|
||||
|
||||
async function handleFileImport(): Promise<void> {
|
||||
const inputRef = importFileRef.value;
|
||||
if (inputRef?.files && inputRef.files.length !== 0) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
let workflowData: IWorkflowDataUpdate;
|
||||
try {
|
||||
workflowData = JSON.parse(reader.result as string);
|
||||
} catch (error) {
|
||||
toast.showMessage({
|
||||
title: locale.baseText('mainSidebar.showMessage.handleFileImport.title'),
|
||||
message: locale.baseText('mainSidebar.showMessage.handleFileImport.message'),
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
reader.onload = null;
|
||||
inputRef.value = '';
|
||||
}
|
||||
|
||||
nodeViewEventBus.emit('importWorkflowData', { data: workflowData });
|
||||
};
|
||||
reader.readAsText(inputRef.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void> {
|
||||
switch (action) {
|
||||
case WORKFLOW_MENU_ACTIONS.DUPLICATE: {
|
||||
uiStore.openModalWithData({
|
||||
name: DUPLICATE_MODAL_KEY,
|
||||
data: {
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
tags: props.tags,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.DOWNLOAD: {
|
||||
const workflowData = await workflowHelpers.getWorkflowDataToSave();
|
||||
const { tags, ...data } = workflowData;
|
||||
const exportData: IWorkflowToShare = {
|
||||
...data,
|
||||
meta: {
|
||||
...props.meta,
|
||||
instanceId: rootStore.instanceId,
|
||||
},
|
||||
tags: (tags ?? []).map((tagId) => {
|
||||
const { usageCount, ...tag } = tagsStore.tagsById[tagId];
|
||||
|
||||
return tag;
|
||||
}),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
|
||||
let name = props.name || 'unsaved_workflow';
|
||||
name = name.replace(/[^a-z0-9]/gi, '_');
|
||||
|
||||
telemetry.track('User exported workflow', { workflow_id: workflowData.id });
|
||||
saveAs(blob, name + '.json');
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL: {
|
||||
try {
|
||||
const promptResponse = await message.prompt(
|
||||
locale.baseText('mainSidebar.prompt.workflowUrl') + ':',
|
||||
locale.baseText('mainSidebar.prompt.importWorkflowFromUrl') + ':',
|
||||
{
|
||||
confirmButtonText: locale.baseText('mainSidebar.prompt.import'),
|
||||
cancelButtonText: locale.baseText('mainSidebar.prompt.cancel'),
|
||||
inputErrorMessage: locale.baseText('mainSidebar.prompt.invalidUrl'),
|
||||
inputPattern: VALID_WORKFLOW_IMPORT_URL_REGEX,
|
||||
},
|
||||
);
|
||||
|
||||
if (promptResponse.action === 'cancel') {
|
||||
return;
|
||||
}
|
||||
|
||||
nodeViewEventBus.emit('importWorkflowUrl', { url: promptResponse.value });
|
||||
} catch (e) {}
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE: {
|
||||
importFileRef.value?.click();
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.PUSH: {
|
||||
canvasStore.startLoading();
|
||||
try {
|
||||
await onSaveButtonClick();
|
||||
|
||||
const status = await sourceControlStore.getAggregatedStatus();
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||
data: { eventBus: sourceControlModalEventBus, status },
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
switch (error.message) {
|
||||
case 'source_control_not_connected':
|
||||
toast.showError(
|
||||
{ ...error, message: '' },
|
||||
locale.baseText('settings.sourceControl.error.not.connected.title'),
|
||||
locale.baseText('settings.sourceControl.error.not.connected.message'),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
toast.showError(error, locale.baseText('error'));
|
||||
}
|
||||
} finally {
|
||||
canvasStore.stopLoading();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.SETTINGS: {
|
||||
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.DELETE: {
|
||||
const deleteConfirmed = await message.confirm(
|
||||
locale.baseText('mainSidebar.confirmMessage.workflowDelete.message', {
|
||||
interpolate: { workflowName: props.name },
|
||||
}),
|
||||
locale.baseText('mainSidebar.confirmMessage.workflowDelete.headline'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: locale.baseText(
|
||||
'mainSidebar.confirmMessage.workflowDelete.confirmButtonText',
|
||||
),
|
||||
cancelButtonText: locale.baseText(
|
||||
'mainSidebar.confirmMessage.workflowDelete.cancelButtonText',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
if (deleteConfirmed !== MODAL_CONFIRM) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workflowsStore.deleteWorkflow(props.id);
|
||||
} catch (error) {
|
||||
toast.showError(error, locale.baseText('generic.deleteWorkflowError'));
|
||||
return;
|
||||
}
|
||||
uiStore.stateIsDirty = false;
|
||||
// Reset tab title since workflow is deleted.
|
||||
documentTitle.reset();
|
||||
toast.showMessage({
|
||||
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
await router.push({ name: VIEWS.WORKFLOWS });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function goToUpgrade() {
|
||||
void pageRedirectionHelper.goToUpgrade('workflow_sharing', 'upgrade-workflow-sharing');
|
||||
}
|
||||
|
||||
function goToWorkflowHistoryUpgrade() {
|
||||
void pageRedirectionHelper.goToUpgrade('workflow-history', 'upgrade-workflow-history');
|
||||
}
|
||||
|
||||
function showCreateWorkflowSuccessToast(id?: string) {
|
||||
if (!id || ['new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(id)) {
|
||||
let toastTitle = locale.baseText('workflows.create.personal.toast.title');
|
||||
let toastText = locale.baseText('workflows.create.personal.toast.text');
|
||||
|
||||
if (
|
||||
projectsStore.currentProject &&
|
||||
projectsStore.currentProject.id !== projectsStore.personalProject?.id
|
||||
) {
|
||||
toastTitle = locale.baseText('workflows.create.project.toast.title', {
|
||||
interpolate: { projectName: projectsStore.currentProject.name ?? '' },
|
||||
});
|
||||
|
||||
toastText = locale.baseText('workflows.create.project.toast.text', {
|
||||
interpolate: { projectName: projectsStore.currentProject.name ?? '' },
|
||||
});
|
||||
}
|
||||
|
||||
toast.showMessage({
|
||||
title: toastTitle,
|
||||
message: toastText,
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<BreakpointsObserver :value-x-s="15" :value-s-m="25" :value-m-d="50" class="name-container">
|
||||
<template #default="{ value }">
|
||||
<ShortenName :name="name" :limit="value" :custom="true" test-id="workflow-name-input">
|
||||
<template #default="{ shortenedName }">
|
||||
<InlineTextEdit
|
||||
:model-value="name"
|
||||
:preview-value="shortenedName"
|
||||
:is-edit-enabled="isNameEditEnabled"
|
||||
:max-length="MAX_WORKFLOW_NAME_LENGTH"
|
||||
:disabled="readOnly || (!isNewWorkflow && !workflowPermissions.update)"
|
||||
placeholder="Enter workflow name"
|
||||
class="name"
|
||||
@toggle="onNameToggle"
|
||||
@submit="onNameSubmit"
|
||||
/>
|
||||
</template>
|
||||
</ShortenName>
|
||||
</template>
|
||||
</BreakpointsObserver>
|
||||
|
||||
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
|
||||
<WorkflowTagsDropdown
|
||||
v-if="isTagsEditEnabled && !readOnly && (isNewWorkflow || workflowPermissions.update)"
|
||||
ref="dropdown"
|
||||
v-model="appliedTagIds"
|
||||
:event-bus="tagsEventBus"
|
||||
:placeholder="i18n.baseText('workflowDetails.chooseOrCreateATag')"
|
||||
class="tags-edit"
|
||||
data-test-id="workflow-tags-dropdown"
|
||||
@blur="onTagsBlur"
|
||||
@esc="onTagsEditEsc"
|
||||
/>
|
||||
<div
|
||||
v-else-if="
|
||||
(tags ?? []).length === 0 && !readOnly && (isNewWorkflow || workflowPermissions.update)
|
||||
"
|
||||
>
|
||||
<span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable">
|
||||
+ {{ i18n.baseText('workflowDetails.addTag') }}
|
||||
</span>
|
||||
</div>
|
||||
<WorkflowTagsContainer
|
||||
v-else
|
||||
:key="id"
|
||||
:tag-ids="workflowTagIds"
|
||||
:clickable="true"
|
||||
:responsive="true"
|
||||
data-test-id="workflow-tags"
|
||||
@click="onTagsEditEnable"
|
||||
/>
|
||||
</span>
|
||||
<span v-else class="tags"></span>
|
||||
|
||||
<PushConnectionTracker class="actions">
|
||||
<span :class="`activator ${$style.group}`">
|
||||
<WorkflowActivator
|
||||
:workflow-active="active"
|
||||
:workflow-id="id"
|
||||
:workflow-permissions="workflowPermissions"
|
||||
/>
|
||||
</span>
|
||||
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
|
||||
<div :class="$style.group">
|
||||
<CollaborationPane v-if="!isNewWorkflow" />
|
||||
<N8nButton
|
||||
type="secondary"
|
||||
data-test-id="workflow-share-button"
|
||||
@click="onShareButtonClick"
|
||||
>
|
||||
{{ i18n.baseText('workflowDetails.share') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<template #fallback>
|
||||
<N8nTooltip>
|
||||
<N8nButton type="secondary" :class="['mr-2xs', $style.disabledShareButton]">
|
||||
{{ i18n.baseText('workflowDetails.share') }}
|
||||
</N8nButton>
|
||||
<template #content>
|
||||
<i18n-t
|
||||
:keypath="
|
||||
uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable.description
|
||||
.tooltip
|
||||
"
|
||||
tag="span"
|
||||
>
|
||||
<template #action>
|
||||
<a @click="goToUpgrade">
|
||||
{{
|
||||
i18n.baseText(
|
||||
uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable
|
||||
.button as BaseTextKey,
|
||||
)
|
||||
}}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
</EnterpriseEdition>
|
||||
<div :class="$style.group">
|
||||
<SaveButton
|
||||
type="primary"
|
||||
:saved="!uiStore.stateIsDirty && !isNewWorkflow"
|
||||
:disabled="
|
||||
isWorkflowSaving || readOnly || (!isNewWorkflow && !workflowPermissions.update)
|
||||
"
|
||||
:is-saving="isWorkflowSaving"
|
||||
:with-shortcut="!readOnly && workflowPermissions.update"
|
||||
:shortcut-tooltip="i18n.baseText('saveWorkflowButton.hint')"
|
||||
data-test-id="workflow-save-button"
|
||||
@click="onSaveButtonClick"
|
||||
/>
|
||||
<WorkflowHistoryButton
|
||||
:workflow-id="props.id"
|
||||
:is-feature-enabled="isWorkflowHistoryFeatureEnabled"
|
||||
:is-new-workflow="isNewWorkflow"
|
||||
@upgrade="goToWorkflowHistoryUpgrade"
|
||||
/>
|
||||
</div>
|
||||
<div :class="[$style.workflowMenuContainer, $style.group]">
|
||||
<input
|
||||
ref="importFileRef"
|
||||
:class="$style.hiddenInput"
|
||||
type="file"
|
||||
data-test-id="workflow-import-input"
|
||||
@change="handleFileImport()"
|
||||
/>
|
||||
<N8nActionDropdown
|
||||
:items="workflowMenuItems"
|
||||
data-test-id="workflow-menu"
|
||||
@select="onWorkflowMenuSelect"
|
||||
/>
|
||||
</div>
|
||||
</PushConnectionTracker>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$--text-line-height: 24px;
|
||||
$--header-spacing: 20px;
|
||||
|
||||
.name-container {
|
||||
margin-right: $--header-spacing;
|
||||
|
||||
:deep(.el-input) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
color: $custom-font-dark;
|
||||
font-size: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.activator {
|
||||
color: $custom-font-dark;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
line-height: $--text-line-height;
|
||||
align-items: center;
|
||||
|
||||
> span {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-tag {
|
||||
font-size: 12px;
|
||||
padding: 20px 0; // to be more clickable
|
||||
color: $custom-font-very-light;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
margin-right: $--header-spacing;
|
||||
}
|
||||
|
||||
.tags-edit {
|
||||
min-width: 100px;
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
@include mixins.breakpoint('xs-only') {
|
||||
.name {
|
||||
:deep(input) {
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.hiddenInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.deleteItem {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.disabledShareButton {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.closeNodeViewDiscovery {
|
||||
position: absolute;
|
||||
right: var(--spacing-xs);
|
||||
top: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => vi.fn(),
|
||||
useRouter: () => vi.fn(),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowHistoryButton, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: true,
|
||||
'router-link': {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('WorkflowHistoryButton', () => {
|
||||
it('should be disabled if the feature is disabled', async () => {
|
||||
const { getByRole, emitted } = renderComponent({
|
||||
props: {
|
||||
workflowId: '1',
|
||||
isNewWorkflow: false,
|
||||
isFeatureEnabled: false,
|
||||
},
|
||||
});
|
||||
expect(getByRole('button')).toBeDisabled();
|
||||
|
||||
await userEvent.hover(getByRole('button'));
|
||||
expect(getByRole('tooltip')).toBeVisible();
|
||||
|
||||
within(getByRole('tooltip')).getByText('View plans').click();
|
||||
|
||||
expect(emitted()).toHaveProperty('upgrade');
|
||||
});
|
||||
|
||||
it('should be disabled if the feature is enabled but the workflow is new', async () => {
|
||||
const { getByRole } = renderComponent({
|
||||
props: {
|
||||
workflowId: '1',
|
||||
isNewWorkflow: true,
|
||||
isFeatureEnabled: true,
|
||||
},
|
||||
});
|
||||
expect(getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should be enabled if the feature is enabled and the workflow is not new', async () => {
|
||||
const { getByRole } = renderComponent({
|
||||
props: {
|
||||
workflowId: '1',
|
||||
isNewWorkflow: false,
|
||||
isFeatureEnabled: true,
|
||||
},
|
||||
});
|
||||
expect(getByRole('button')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
workflowId: string;
|
||||
isNewWorkflow: boolean;
|
||||
isFeatureEnabled: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
upgrade: [];
|
||||
}>();
|
||||
|
||||
const workflowHistoryRoute = computed<{ name: string; params: { workflowId: string } }>(() => ({
|
||||
name: VIEWS.WORKFLOW_HISTORY,
|
||||
params: {
|
||||
workflowId: props.workflowId,
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nTooltip placement="bottom">
|
||||
<RouterLink :to="workflowHistoryRoute" :class="$style.workflowHistoryButton">
|
||||
<N8nIconButton
|
||||
:disabled="isNewWorkflow || !isFeatureEnabled"
|
||||
data-test-id="workflow-history-button"
|
||||
type="tertiary"
|
||||
icon="history"
|
||||
size="medium"
|
||||
text
|
||||
/>
|
||||
</RouterLink>
|
||||
<template #content>
|
||||
<span v-if="isFeatureEnabled && isNewWorkflow">
|
||||
{{ locale.baseText('workflowHistory.button.tooltip.empty') }}
|
||||
</span>
|
||||
<span v-else-if="isFeatureEnabled">{{
|
||||
locale.baseText('workflowHistory.button.tooltip.enabled')
|
||||
}}</span>
|
||||
<i18n-t v-else keypath="workflowHistory.button.tooltip.disabled">
|
||||
<template #link>
|
||||
<N8nLink size="small" @click="emit('upgrade')">
|
||||
{{ locale.baseText('workflowHistory.button.tooltip.disabled.link') }}
|
||||
</N8nLink>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.workflowHistoryButton {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
color: var(--color-text-dark);
|
||||
border-radius: var(--border-radius-base);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-base);
|
||||
}
|
||||
|
||||
:disabled {
|
||||
background: transparent;
|
||||
border: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user