refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)

This commit is contained in:
Alex Grozav
2025-02-28 14:28:30 +02:00
committed by GitHub
parent 684353436d
commit f5743176e5
1635 changed files with 805 additions and 1079 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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>

View File

@@ -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' },
});
});
});

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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>