From 2912cdb7408377811be6b5c360fe1bf4e37fb678 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Mon, 22 Apr 2024 15:45:00 +0300 Subject: [PATCH 01/10] refactor: migrate header WorkflowDetails to composition api --- .../N8nActionDropdown/ActionDropdown.vue | 12 +- .../src/types/action-dropdown.ts | 11 + packages/design-system/src/types/index.ts | 1 + .../src/components/MainHeader/MainHeader.vue | 7 +- .../components/MainHeader/WorkflowDetails.vue | 1112 ++++++++--------- 5 files changed, 562 insertions(+), 581 deletions(-) create mode 100644 packages/design-system/src/types/action-dropdown.ts diff --git a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue index 063b228a35274..3f587e42f0096 100644 --- a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue +++ b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue @@ -61,19 +61,9 @@ import { ref, useCssModule, useAttrs, computed } from 'vue'; import { ElDropdown, ElDropdownMenu, ElDropdownItem, type Placement } from 'element-plus'; import N8nIcon from '../N8nIcon'; import { N8nKeyboardShortcut } from '../N8nKeyboardShortcut'; -import type { KeyboardShortcut } from '../../types'; +import type { IActionDropdownItem } from '../../types'; import type { IconSize } from '@/types/icon'; -interface IActionDropdownItem { - id: string; - label: string; - icon?: string; - divided?: boolean; - disabled?: boolean; - shortcut?: KeyboardShortcut; - customClass?: string; -} - const TRIGGER = ['click', 'hover'] as const; interface ActionDropdownProps { diff --git a/packages/design-system/src/types/action-dropdown.ts b/packages/design-system/src/types/action-dropdown.ts new file mode 100644 index 0000000000000..31a46583b962c --- /dev/null +++ b/packages/design-system/src/types/action-dropdown.ts @@ -0,0 +1,11 @@ +import type { KeyboardShortcut } from '@/types/keyboardshortcut'; + +export interface IActionDropdownItem { + id: string; + label: string; + icon?: string; + divided?: boolean; + disabled?: boolean; + shortcut?: KeyboardShortcut; + customClass?: string; +} diff --git a/packages/design-system/src/types/index.ts b/packages/design-system/src/types/index.ts index eec00e1154eea..1336f17b63149 100644 --- a/packages/design-system/src/types/index.ts +++ b/packages/design-system/src/types/index.ts @@ -1,3 +1,4 @@ +export * from './action-dropdown'; export * from './button'; export * from './datatable'; export * from './form'; diff --git a/packages/editor-ui/src/components/MainHeader/MainHeader.vue b/packages/editor-ui/src/components/MainHeader/MainHeader.vue index 668b8b5d2a580..498cb6d01a860 100644 --- a/packages/editor-ui/src/components/MainHeader/MainHeader.vue +++ b/packages/editor-ui/src/components/MainHeader/MainHeader.vue @@ -2,7 +2,7 @@
- + +import { + DUPLICATE_MODAL_KEY, + EnterpriseEditionFeature, + MAX_WORKFLOW_NAME_LENGTH, + MODAL_CONFIRM, + PLACEHOLDER_EMPTY_WORKFLOW_ID, + SOURCE_CONTROL_PUSH_MODAL_KEY, + VIEWS, + WORKFLOW_MENU_ACTIONS, + WORKFLOW_SETTINGS_MODAL_KEY, + WORKFLOW_SHARE_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 SaveButton from '@/components/SaveButton.vue'; +import TagsDropdown from '@/components/TagsDropdown.vue'; +import InlineTextEdit from '@/components/InlineTextEdit.vue'; +import BreakpointsObserver from '@/components/BreakpointsObserver.vue'; +import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue'; + +import { useRootStore } from '@/stores/n8nRoot.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 { saveAs } from 'file-saver'; +import { useTitleChange } from '@/composables/useTitleChange'; +import { useMessage } from '@/composables/useMessage'; +import { useToast } from '@/composables/useToast'; + +import { getWorkflowPermissions } from '@/permissions'; +import { createEventBus } from 'n8n-design-system/utils'; +import { nodeViewEventBus } from '@/event-bus'; +import { hasPermission } from '@/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 { + IActionDropdownItem, + IWorkflowDataUpdate, + IWorkflowDb, + IWorkflowToShare, +} from '@/Interface'; +import { useI18n } from '@/composables/useI18n'; +import { useTelemetry } from '@/composables/useTelemetry'; +import type { MessageBoxInputData } from 'element-plus'; +import type { BaseTextKey } from '../../plugins/i18n'; + +const props = defineProps<{ + workflow: IWorkflowDb; + readOnly?: boolean; +}>(); + +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 router = useRouter(); +const route = useRoute(); + +const locale = useI18n(); +const telemetry = useTelemetry(); +const message = useMessage(); +const toast = useToast(); +const titleChange = useTitleChange(); +const workflowHelpers = useWorkflowHelpers({ router }); + +const isTagsEditEnabled = ref(false); +const isNameEditEnabled = ref(false); +const appliedTagIds = ref([]); +const tagsEditBus = createEventBus(); +const tagsSaving = ref(false); +const importFileRef = ref(); +const eventBus = 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.workflow.id || + props.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID || + props.workflow.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(() => { + return getWorkflowPermissions(usersStore.currentUser, props.workflow); +}); + +const workflowMenuItems = computed(() => { + const actions: IActionDropdownItem[] = [ + { + id: WORKFLOW_MENU_ACTIONS.DOWNLOAD, + label: locale.baseText('menuActions.download'), + disabled: !onWorkflowPage.value, + }, + ]; + + if (!props.readOnly) { + actions.unshift({ + id: WORKFLOW_MENU_ACTIONS.DUPLICATE, + label: locale.baseText('menuActions.duplicate'), + disabled: !onWorkflowPage.value || !props.workflow.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) { + 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 workflowHistoryRoute = computed<{ name: string; params: { workflowId: string } }>(() => { + return { + name: VIEWS.WORKFLOW_HISTORY, + params: { + workflowId: props.workflow.id, + }, + }; +}); + +const isWorkflowHistoryButtonDisabled = computed(() => { + return isNewWorkflow.value; +}); + +watch( + () => props.workflow, + () => { + isTagsEditEnabled.value = false; + isNameEditEnabled.value = false; + }, +); + +async function onSaveButtonClick() { + // If the workflow is saving, do not allow another save + if (isWorkflowSaving.value) { + return; + } + + let id: string | undefined = undefined; + if (props.workflow.id !== PLACEHOLDER_EMPTY_WORKFLOW_ID) { + id = props.workflow.id; + } else if (route.params.name && route.params.name !== 'new') { + id = route.params.name as string; + } + + const name = props.workflow.name; + const tags = props.workflow.tags as string[]; + + const saved = await workflowHelpers.saveCurrentWorkflow({ + id, + name, + tags, + }); + + if (saved) { + await settingsStore.fetchPromptsData(); + + if (route.name === VIEWS.EXECUTION_DEBUG) { + await router.replace({ + name: VIEWS.WORKFLOW, + params: { name: props.workflow.id }, + }); + } + } +} + +function onShareButtonClick() { + uiStore.openModalWithData({ + name: WORKFLOW_SHARE_MODAL_KEY, + data: { id: props.workflow.id }, + }); + + telemetry.track('User opened sharing modal', { + workflow_id: props.workflow.id, + user_id_sharer: usersStore.currentUser?.id, + sub_view: route.name === VIEWS.WORKFLOWS ? 'Workflows listing' : 'Workflow editor', + }); +} + +function onTagsEditEnable() { + appliedTagIds.value = (props.workflow.tags ?? []) as string[]; + isTagsEditEnabled.value = true; + + setTimeout(() => { + // allow name update to occur before disabling name edit + isNameEditEnabled.value = false; + tagsEditBus.emit('focus'); + }, 0); +} + +async function onTagsBlur() { + const current = (props.workflow.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.workflow.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: cb, +}: { + 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', + }); + + cb(false); + return; + } + + if (newName === props.workflow.name) { + isNameEditEnabled.value = false; + + cb(true); + return; + } + + uiStore.addActiveAction('workflowSaving'); + const saved = await workflowHelpers.saveCurrentWorkflow({ name }); + if (saved) { + isNameEditEnabled.value = false; + } + uiStore.removeActiveAction('workflowSaving'); + cb(saved); +} + +async function handleFileImport(): Promise { + 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: string): Promise { + switch (action) { + case WORKFLOW_MENU_ACTIONS.DUPLICATE: { + uiStore.openModalWithData({ + name: DUPLICATE_MODAL_KEY, + data: { + id: props.workflow.id, + name: props.workflow.name, + tags: props.workflow.tags, + }, + }); + break; + } + case WORKFLOW_MENU_ACTIONS.DOWNLOAD: { + const workflowData = await workflowHelpers.getWorkflowDataToSave(); + const { tags, ...data } = workflowData; + const exportData: IWorkflowToShare = { + ...data, + meta: { + ...(props.workflow.meta ?? {}), + instanceId: rootStore.instanceId, + }, + tags: (tags ?? []).map((tagId) => { + const { usageCount, ...tag } = tagsStore.getTagById(tagId); + + return tag; + }), + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { + type: 'application/json;charset=utf-8', + }); + + let name = props.workflow.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: /^http[s]?:\/\/.*\.json$/i, + }, + )) as MessageBoxInputData; + + if ((promptResponse as unknown as string) === '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, 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.workflow.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.workflow.id); + } catch (error) { + toast.showError(error, locale.baseText('generic.deleteWorkflowError')); + return; + } + uiStore.stateIsDirty = false; + // Reset tab title since workflow is deleted. + titleChange.titleReset(); + toast.showMessage({ + title: locale.baseText('mainSidebar.showMessage.handleSelect1.title'), + type: 'success', + }); + + await router.push({ name: VIEWS.NEW_WORKFLOW }); + break; + } + default: + break; + } +} + +function goToUpgrade() { + void uiStore.goToUpgrade('workflow_sharing', 'upgrade-workflow-sharing'); +} + +