Files
n8n-enterprise-unlocked/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.vue

982 lines
26 KiB
Vue

<script lang="ts" setup>
import {
DUPLICATE_MODAL_KEY,
EnterpriseEditionFeature,
IMPORT_WORKFLOW_URL_MODAL_KEY,
MAX_WORKFLOW_NAME_LENGTH,
MODAL_CONFIRM,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
PROJECT_MOVE_RESOURCE_MODAL,
SOURCE_CONTROL_PUSH_MODAL_KEY,
VIEWS,
WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
} from '@/constants';
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 BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
import { ResourceType } from '@/utils/projects.utils';
import { useProjectsStore } from '@/stores/projects.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 { useRootStore } from '@n8n/stores/useRootStore';
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, useTemplateRef, watch } from 'vue';
import type {
ActionDropdownItem,
FolderShortInfo,
IWorkflowDataUpdate,
IWorkflowDb,
IWorkflowToShare,
} from '@/Interface';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useTelemetry } from '@/composables/useTelemetry';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { N8nInlineTextEdit } from '@n8n/design-system';
import { useFoldersStore } from '@/stores/folders.store';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { ProjectTypes } from '@/types/projects.types';
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
const props = defineProps<{
readOnly?: boolean;
id: IWorkflowDb['id'];
tags: IWorkflowDb['tags'];
name: IWorkflowDb['name'];
meta: IWorkflowDb['meta'];
scopes: IWorkflowDb['scopes'];
active: IWorkflowDb['active'];
currentFolder?: FolderShortInfo;
isArchived: IWorkflowDb['isArchived'];
}>();
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 foldersStore = useFoldersStore();
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 workflowSaving = useWorkflowSaving({ router });
const workflowHelpers = useWorkflowHelpers();
const pageRedirectionHelper = usePageRedirectionHelper();
const isTagsEditEnabled = ref(false);
const appliedTagIds = ref<string[]>([]);
const tagsSaving = ref(false);
const importFileRef = ref<HTMLInputElement | undefined>();
const tagsEventBus = createEventBus();
const sourceControlModalEventBus = createEventBus();
const changeOwnerEventBus = 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.move && projectsStore.isTeamProjectFeatureEnabled) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.CHANGE_OWNER,
label: locale.baseText('workflows.item.changeOwner'),
disabled: isNewWorkflow.value,
});
}
if (!props.readOnly && !props.isArchived) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.RENAME,
label: locale.baseText('generic.rename'),
disabled: !onWorkflowPage.value || workflowPermissions.value.update !== true,
});
}
if (
(workflowPermissions.value.delete === true && !props.readOnly && !props.isArchived) ||
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 === true && !props.readOnly) || isNewWorkflow.value) {
if (props.isArchived) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.UNARCHIVE,
label: locale.baseText('menuActions.unarchive'),
disabled: !onWorkflowPage.value || isNewWorkflow.value,
});
actions.push({
id: WORKFLOW_MENU_ACTIONS.DELETE,
label: locale.baseText('menuActions.delete'),
disabled: !onWorkflowPage.value || isNewWorkflow.value,
customClass: $style.deleteItem,
divided: true,
});
} else {
actions.push({
id: WORKFLOW_MENU_ACTIONS.ARCHIVE,
label: locale.baseText('menuActions.archive'),
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));
});
const currentProjectName = computed(() => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return locale.baseText('projects.menu.personal');
}
return projectsStore.currentProject?.name;
});
const currentFolderForBreadcrumbs = computed(() => {
if (!isNewWorkflow.value && props.currentFolder) {
return props.currentFolder;
}
const folderId = route.query.parentFolderId as string;
if (folderId) {
return foldersStore.getCachedFolder(folderId);
}
return null;
});
watch(
() => props.id,
() => {
isTagsEditEnabled.value = false;
renameInput.value?.forceCancel();
},
);
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 workflowSaving.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
renameInput.value?.forceCancel();
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 workflowSaving.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;
}
const renameInput = useTemplateRef('renameInput');
function onNameToggle() {
if (renameInput.value?.forceFocus) {
renameInput.value.forceFocus();
}
}
async function onNameSubmit(name: string) {
const newName = name.trim();
if (!newName) {
toast.showMessage({
title: locale.baseText('renameAction.emptyName.title'),
message: locale.baseText('renameAction.emptyName.message'),
type: 'error',
});
renameInput.value?.forceCancel();
return;
}
if (newName === props.name) {
renameInput.value?.forceCancel();
return;
}
uiStore.addActiveAction('workflowSaving');
const id = getWorkflowId();
const saved = await workflowSaving.saveCurrentWorkflow({ name });
if (saved) {
showCreateWorkflowSuccessToast(id);
workflowHelpers.setDocumentTitle(newName, 'IDLE');
}
uiStore.removeActiveAction('workflowSaving');
renameInput.value?.forceCancel();
}
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,
parentFolderId: props.currentFolder?.id,
},
});
break;
}
case WORKFLOW_MENU_ACTIONS.RENAME: {
onNameToggle();
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: {
uiStore.openModal(IMPORT_WORKFLOW_URL_MODAL_KEY);
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.ARCHIVE: {
if (props.active) {
const archiveConfirmed = await message.confirm(
locale.baseText('mainSidebar.confirmMessage.workflowArchive.message', {
interpolate: { workflowName: props.name },
}),
locale.baseText('mainSidebar.confirmMessage.workflowArchive.headline'),
{
type: 'warning',
confirmButtonText: locale.baseText(
'mainSidebar.confirmMessage.workflowArchive.confirmButtonText',
),
cancelButtonText: locale.baseText(
'mainSidebar.confirmMessage.workflowArchive.cancelButtonText',
),
},
);
if (archiveConfirmed !== MODAL_CONFIRM) {
return;
}
}
try {
await workflowsStore.archiveWorkflow(props.id);
} catch (error) {
toast.showError(error, locale.baseText('generic.archiveWorkflowError'));
return;
}
uiStore.stateIsDirty = false;
toast.showMessage({
title: locale.baseText('mainSidebar.showMessage.handleArchive.title', {
interpolate: { workflowName: props.name },
}),
type: 'success',
});
await router.push({ name: VIEWS.WORKFLOWS });
break;
}
case WORKFLOW_MENU_ACTIONS.UNARCHIVE: {
await workflowsStore.unarchiveWorkflow(props.id);
toast.showMessage({
title: locale.baseText('mainSidebar.showMessage.handleUnarchive.title', {
interpolate: { workflowName: props.name },
}),
type: 'success',
});
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', {
interpolate: { workflowName: props.name },
}),
type: 'success',
});
await router.push({ name: VIEWS.WORKFLOWS });
break;
}
case WORKFLOW_MENU_ACTIONS.CHANGE_OWNER: {
const workflowId = getWorkflowId();
if (!workflowId) {
return;
}
changeOwnerEventBus.once(
'resource-moved',
async () => await router.push({ name: VIEWS.WORKFLOWS }),
);
uiStore.openModalWithData({
name: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resource: workflowsStore.workflowsById[workflowId],
resourceType: ResourceType.Workflow,
resourceTypeLabel: locale.baseText('generic.workflow').toLowerCase(),
eventBus: changeOwnerEventBus,
},
});
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) {
if (props.currentFolder) {
toastTitle = locale.baseText('workflows.create.folder.toast.title', {
interpolate: {
projectName: currentProjectName.value ?? '',
folderName: props.currentFolder.name ?? '',
},
});
} else if (projectsStore.currentProject.id !== projectsStore.personalProject?.id) {
toastTitle = locale.baseText('workflows.create.project.toast.title', {
interpolate: { projectName: currentProjectName.value ?? '' },
});
}
toastText = locale.baseText('workflows.create.project.toast.text', {
interpolate: { projectName: currentProjectName.value ?? '' },
});
}
toast.showMessage({
title: toastTitle,
message: toastText,
type: 'success',
});
}
}
const onBreadcrumbsItemSelected = (item: PathItem) => {
if (item.href) {
void router.push(item.href).catch((error) => {
toast.showError(error, i18n.baseText('folders.open.error.title'));
});
}
};
</script>
<template>
<div :class="$style.container">
<BreakpointsObserver
:value-x-s="15"
:value-s-m="25"
:value-m-d="50"
class="name-container"
data-test-id="canvas-breadcrumbs"
>
<template #default>
<FolderBreadcrumbs
:current-folder="currentFolderForBreadcrumbs"
:current-folder-as-link="true"
@item-selected="onBreadcrumbsItemSelected"
>
<template #append>
<span
v-if="projectsStore.currentProject ?? projectsStore.personalProject"
:class="$style['path-separator']"
>/</span
>
<N8nInlineTextEdit
ref="renameInput"
:key="id"
placeholder="Workflow name"
data-test-id="workflow-name-input"
class="name"
:model-value="name"
:max-length="MAX_WORKFLOW_NAME_LENGTH"
:read-only="readOnly || isArchived || (!isNewWorkflow && !workflowPermissions.update)"
:disabled="readOnly || isArchived || (!isNewWorkflow && !workflowPermissions.update)"
@update:model-value="onNameSubmit"
/>
</template>
</FolderBreadcrumbs>
</template>
</BreakpointsObserver>
<span class="tags" data-test-id="workflow-tags-container">
<template v-if="settingsStore.areTagsEnabled">
<WorkflowTagsDropdown
v-if="
isTagsEditEnabled &&
!(readOnly || isArchived) &&
(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 || isArchived) &&
(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"
/>
</template>
<span class="archived">
<N8nBadge
v-if="isArchived"
class="ml-3xs"
theme="tertiary"
bold
data-test-id="workflow-archived-tag"
>
{{ locale.baseText('workflows.item.archived') }}
</N8nBadge>
</span>
</span>
<PushConnectionTracker class="actions">
<span :class="`activator ${$style.group}`">
<WorkflowActivator
:is-archived="isArchived"
: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 ||
isArchived ||
(!isNewWorkflow && !workflowPermissions.update)
"
:is-saving="isWorkflowSaving"
:with-shortcut="!readOnly && !isArchived && 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: var(--spacing-s);
:deep(.el-input) {
padding: 0;
}
}
.name {
color: $custom-font-dark;
font-size: var(--font-size-s);
padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs);
}
.activator {
color: $custom-font-dark;
font-weight: var(--font-weight-regular);
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: var(--font-weight-bold);
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;
}
.archived {
display: flex;
align-items: center;
width: 100%;
flex: 1;
margin-right: $--header-spacing;
}
.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%;
padding: var(--spacing-xs) var(--spacing-m);
display: flex;
align-items: center;
flex-wrap: nowrap;
}
.path-separator {
font-size: var(--font-size-xl);
color: var(--color-foreground-base);
padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs);
}
.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>