<template> <div class="container" v-if="workflowName"> <BreakpointsObserver :valueXS="15" :valueSM="25" :valueMD="50" class="name-container"> <template v-slot="{ value }"> <ShortenName :name="workflowName" :limit="value" :custom="true" > <template v-slot="{ shortenedName }"> <InlineTextEdit :value="workflowName" :previewValue="shortenedName" :isEditEnabled="isNameEditEnabled" :maxLength="MAX_WORKFLOW_NAME_LENGTH" @toggle="onNameToggle" @submit="onNameSubmit" placeholder="Enter workflow name" class="name" /> </template> </ShortenName> </template> </BreakpointsObserver> <span v-if="settingsStore.areTagsEnabled" class="tags"> <div v-if="isTagsEditEnabled"> <TagsDropdown :createEnabled="true" :currentTagIds="appliedTagIds" :eventBus="tagsEditBus" @blur="onTagsBlur" @update="onTagsUpdate" @esc="onTagsEditEsc" :placeholder="$locale.baseText('workflowDetails.chooseOrCreateATag')" ref="dropdown" class="tags-edit" /> </div> <div v-else-if="currentWorkflowTagIds.length === 0" > <span class="add-tag clickable" @click="onTagsEditEnable" > + {{ $locale.baseText('workflowDetails.addTag') }} </span> </div> <TagsContainer v-else :tagIds="currentWorkflowTagIds" :clickable="true" :responsive="true" :key="currentWorkflowId" @click="onTagsEditEnable" /> </span> <span v-else class="tags"></span> <PushConnectionTracker class="actions"> <template> <span class="activator"> <WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" /> </span> <SaveButton type="secondary" :saved="!this.isDirty && !this.isNewWorkflow" :disabled="isWorkflowSaving" @click="onSaveButtonClick" /> <div :class="$style.workflowMenuContainer"> <input :class="$style.hiddenInput" type="file" ref="importFile" @change="handleFileImport()"> <n8n-action-dropdown :items="workflowMenuItems" @select="onWorkflowMenuSelect" /> </div> </template> </PushConnectionTracker> </div> </template> <script lang="ts"> import Vue from "vue"; import mixins from "vue-typed-mixins"; import { DUPLICATE_MODAL_KEY, MAX_WORKFLOW_NAME_LENGTH, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS, WORKFLOW_MENU_ACTIONS, WORKFLOW_SETTINGS_MODAL_KEY, } from "@/constants"; import ShortenName from "@/components/ShortenName.vue"; import TagsContainer from "@/components/TagsContainer.vue"; import PushConnectionTracker from "@/components/PushConnectionTracker.vue"; import WorkflowActivator from "@/components/WorkflowActivator.vue"; import { workflowHelpers } from "@/components/mixins/workflowHelpers"; import SaveButton from "@/components/SaveButton.vue"; import TagsDropdown from "@/components/TagsDropdown.vue"; import InlineTextEdit from "@/components/InlineTextEdit.vue"; import BreakpointsObserver from "@/components/BreakpointsObserver.vue"; import { IWorkflowDataUpdate, IWorkflowToShare } from "@/Interface"; import { saveAs } from 'file-saver'; import { titleChange } from "../mixins/titleChange"; import type { MessageBoxInputData } from 'element-ui/types/message-box'; import { mapStores } from "pinia"; import { useUIStore } from "@/stores/ui"; import { useSettingsStore } from "@/stores/settings"; import { useWorkflowsStore } from "@/stores/workflows"; import { useRootStore } from "@/stores/n8nRootStore"; import { useTagsStore } from "@/stores/tags"; const hasChanged = (prev: string[], curr: string[]) => { if (prev.length !== curr.length) { return true; } const set = new Set(prev); return curr.reduce((accu, val) => accu || !set.has(val), false); }; export default mixins(workflowHelpers, titleChange).extend({ name: "WorkflowDetails", components: { TagsContainer, PushConnectionTracker, ShortenName, WorkflowActivator, SaveButton, TagsDropdown, InlineTextEdit, BreakpointsObserver, }, data() { return { isTagsEditEnabled: false, isNameEditEnabled: false, appliedTagIds: [], tagsEditBus: new Vue(), MAX_WORKFLOW_NAME_LENGTH, tagsSaving: false, }; }, computed: { ...mapStores( useTagsStore, useRootStore, useSettingsStore, useUIStore, useWorkflowsStore, ), isWorkflowActive(): boolean { return this.workflowsStore.isWorkflowActive; }, workflowName(): string { return this.workflowsStore.workflowName; }, isDirty(): boolean { return this.uiStore.stateIsDirty; }, currentWorkflowTagIds(): string[] { return this.workflowsStore.workflowTags; }, isNewWorkflow(): boolean { return !this.currentWorkflowId || (this.currentWorkflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID || this.currentWorkflowId === 'new'); }, isWorkflowSaving(): boolean { return this.uiStore.isActionActive('workflowSaving'); }, currentWorkflowId(): string { return this.workflowsStore.workflowId; }, onWorkflowPage(): boolean { return this.$route.meta && (this.$route.meta.nodeView || this.$route.meta.keepWorkflowAlive === true); }, onExecutionsTab(): boolean { return [ VIEWS.EXECUTION_HOME.toString(), VIEWS.EXECUTIONS.toString(), VIEWS.EXECUTION_PREVIEW ].includes(this.$route.name || ''); }, workflowMenuItems(): Array<{}> { return [ { id: WORKFLOW_MENU_ACTIONS.DUPLICATE, label: this.$locale.baseText('menuActions.duplicate'), disabled: !this.onWorkflowPage || !this.currentWorkflowId, }, { id: WORKFLOW_MENU_ACTIONS.DOWNLOAD, label: this.$locale.baseText('menuActions.download'), disabled: !this.onWorkflowPage, }, { id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL, label: this.$locale.baseText('menuActions.importFromUrl'), disabled: !this.onWorkflowPage || this.onExecutionsTab, }, { id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE, label: this.$locale.baseText('menuActions.importFromFile'), disabled: !this.onWorkflowPage || this.onExecutionsTab, }, { id: WORKFLOW_MENU_ACTIONS.SETTINGS, label: this.$locale.baseText('generic.settings'), disabled: !this.onWorkflowPage || this.isNewWorkflow, }, { id: WORKFLOW_MENU_ACTIONS.DELETE, label: this.$locale.baseText('menuActions.delete'), disabled: !this.onWorkflowPage || this.isNewWorkflow, customClass: this.$style.deleteItem, divided: true, }, ]; }, }, methods: { async onSaveButtonClick () { let currentId = undefined; if (this.currentWorkflowId !== PLACEHOLDER_EMPTY_WORKFLOW_ID) { currentId = this.currentWorkflowId; } else if (this.$route.params.name && this.$route.params.name !== 'new') { currentId = this.$route.params.name; } const saved = await this.saveCurrentWorkflow({ id: currentId, name: this.workflowName, tags: this.currentWorkflowTagIds }); if (saved) await this.settingsStore.fetchPromptsData(); }, onTagsEditEnable() { this.$data.appliedTagIds = this.currentWorkflowTagIds; this.$data.isTagsEditEnabled = true; setTimeout(() => { // allow name update to occur before disabling name edit this.$data.isNameEditEnabled = false; this.$data.tagsEditBus.$emit('focus'); }, 0); }, async onTagsUpdate(tags: string[]) { this.$data.appliedTagIds = tags; }, async onTagsBlur() { const current = this.currentWorkflowTagIds; const tags = this.$data.appliedTagIds; if (!hasChanged(current, tags)) { this.$data.isTagsEditEnabled = false; return; } if (this.$data.tagsSaving) { return; } this.$data.tagsSaving = true; const saved = await this.saveCurrentWorkflow({ tags }); this.$telemetry.track('User edited workflow tags', { workflow_id: this.currentWorkflowId as string, new_tag_count: tags.length }); this.$data.tagsSaving = false; if (saved) { this.$data.isTagsEditEnabled = false; } }, onTagsEditEsc() { this.$data.isTagsEditEnabled = false; }, onNameToggle() { this.$data.isNameEditEnabled = !this.$data.isNameEditEnabled; if (this.$data.isNameEditEnabled) { if (this.$data.isTagsEditEnabled) { // @ts-ignore this.onTagsBlur(); } this.$data.isTagsEditEnabled = false; } }, async onNameSubmit(name: string, cb: (saved: boolean) => void) { const newName = name.trim(); if (!newName) { this.$showMessage({ title: this.$locale.baseText('workflowDetails.showMessage.title'), message: this.$locale.baseText('workflowDetails.showMessage.message'), type: "error", }); cb(false); return; } if (newName === this.workflowName) { this.$data.isNameEditEnabled = false; cb(true); return; } const saved = await this.saveCurrentWorkflow({ name }); if (saved) { this.$data.isNameEditEnabled = false; } cb(saved); }, async handleFileImport(): Promise<void> { const reader = new FileReader(); reader.onload = (event: ProgressEvent) => { const data = (event.target as FileReader).result; let workflowData: IWorkflowDataUpdate; try { workflowData = JSON.parse(data as string); } catch (error) { this.$showMessage({ title: this.$locale.baseText('mainSidebar.showMessage.handleFileImport.title'), message: this.$locale.baseText('mainSidebar.showMessage.handleFileImport.message'), type: 'error', }); return; } this.$root.$emit('importWorkflowData', { data: workflowData }); }; const input = this.$refs.importFile as HTMLInputElement; if (input !== null && input.files !== null && input.files.length !== 0) { reader.readAsText(input!.files[0]!); } }, async onWorkflowMenuSelect(action: string): Promise<void> { switch (action) { case WORKFLOW_MENU_ACTIONS.DUPLICATE: { this.uiStore.openModalWithData({ name: DUPLICATE_MODAL_KEY, data: { id: this.workflowsStore.workflowId, name: this.workflowsStore.workflowName, tags: this.workflowsStore.workflowTags, }, }); break; } case WORKFLOW_MENU_ACTIONS.DOWNLOAD: { const workflowData = await this.getWorkflowDataToSave(); const {tags, ...data} = workflowData; if (data.id && typeof data.id === 'string') { data.id = parseInt(data.id, 10); } const exportData: IWorkflowToShare = { ...data, meta: { instanceId: this.rootStore.instanceId, }, tags: (tags || []).map(tagId => { const {usageCount, ...tag} = this.tagsStore.getTagById(tagId); return tag; }), }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json;charset=utf-8', }); let workflowName = this.workflowName || 'unsaved_workflow'; workflowName = workflowName.replace(/[^a-z0-9]/gi, '_'); this.$telemetry.track('User exported workflow', { workflow_id: workflowData.id }); saveAs(blob, workflowName + '.json'); break; } case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL: { try { const promptResponse = await this.$prompt( this.$locale.baseText('mainSidebar.prompt.workflowUrl') + ':', this.$locale.baseText('mainSidebar.prompt.importWorkflowFromUrl') + ':', { confirmButtonText: this.$locale.baseText('mainSidebar.prompt.import'), cancelButtonText: this.$locale.baseText('mainSidebar.prompt.cancel'), inputErrorMessage: this.$locale.baseText('mainSidebar.prompt.invalidUrl'), inputPattern: /^http[s]?:\/\/.*\.json$/i, }, ) as MessageBoxInputData; this.$root.$emit('importWorkflowUrl', { url: promptResponse.value }); } catch (e) {} break; } case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE: { (this.$refs.importFile as HTMLInputElement).click(); break; } case WORKFLOW_MENU_ACTIONS.SETTINGS: { this.uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY); break; } case WORKFLOW_MENU_ACTIONS.DELETE: { const deleteConfirmed = await this.confirmMessage( this.$locale.baseText( 'mainSidebar.confirmMessage.workflowDelete.message', { interpolate: { workflowName: this.workflowName } }, ), this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.headline'), 'warning', this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.confirmButtonText'), this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.cancelButtonText'), ); if (deleteConfirmed === false) { return; } try { await this.restApi().deleteWorkflow(this.currentWorkflowId); } catch (error) { this.$showError( error, this.$locale.baseText('mainSidebar.showError.stopExecution.title'), ); return; } this.uiStore.stateIsDirty = false; // Reset tab title since workflow is deleted. this.$titleReset(); this.$showMessage({ title: this.$locale.baseText('mainSidebar.showMessage.handleSelect1.title'), type: 'success', }); this.$router.push({ name: VIEWS.NEW_WORKFLOW }); break; } default: break; } }, }, watch: { currentWorkflowId() { this.$data.isTagsEditEnabled = false; this.$data.isNameEditEnabled = false; }, }, }); </script> <style scoped lang="scss"> $--text-line-height: 24px; $--header-spacing: 20px; .container { position: relative; top: -1px; width: 100%; display: flex; align-items: center; } .name-container { margin-right: $--header-spacing; } .name { color: $custom-font-dark; font-size: 15px; } .activator { color: $custom-font-dark; font-weight: 400; font-size: 13px; line-height: $--text-line-height; display: flex; align-items: center; margin-right: 30px; > 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 { flex: 1; margin-right: $--header-spacing; } .tags-edit { min-width: 100px; max-width: 460px; } .actions { display: flex; align-items: center; } </style> <style module lang="scss"> .workflowMenuContainer { margin-left: var(--spacing-2xs); } .hiddenInput { display: none; } .deleteItem { color: var(--color-danger); } </style>