Files
n8n-enterprise-unlocked/packages/frontend/editor-ui/src/stores/ui.store.ts

679 lines
18 KiB
TypeScript

import {
ABOUT_MODAL_KEY,
CHAT_EMBED_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
COMMUNITY_PACKAGE_MANAGE_ACTIONS,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
DELETE_USER_MODAL_KEY,
DUPLICATE_MODAL_KEY,
IMPORT_CURL_MODAL_KEY,
INVITE_USER_MODAL_KEY,
LOG_STREAM_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
NODE_PINNING_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY,
VIEWS,
WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
NEW_ASSISTANT_SESSION_MODAL,
PROMPT_MFA_CODE_MODAL_KEY,
COMMUNITY_PLUS_ENROLLMENT_MODAL,
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
DELETE_FOLDER_MODAL_KEY,
MOVE_FOLDER_MODAL_KEY,
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
FROM_AI_PARAMETERS_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
LOCAL_STORAGE_THEME,
WHATS_NEW_MODAL_KEY,
WORKFLOW_DIFF_MODAL_KEY,
} from '@/constants';
import { STORES } from '@n8n/stores';
import type {
XYPosition,
Modals,
NewCredentialsModal,
ThemeOption,
NotificationOptions,
ModalState,
ModalKey,
AppliedThemeOption,
TabOptions,
} from '@/Interface';
import { defineStore } from 'pinia';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSettingsStore } from '@/stores/settings.store';
import { dismissBannerPermanently } from '@n8n/rest-api-client';
import type { BannerName } from '@n8n/api-types';
import { applyThemeToBody, getThemeOverride, isValidTheme } from './ui.utils';
import { computed, ref } from 'vue';
import type { Connection } from '@vue-flow/core';
import { useLocalStorage, useMediaQuery } from '@vueuse/core';
import type { EventBus } from '@n8n/utils/event-bus';
import type { ProjectSharingData } from '@/types/projects.types';
import identity from 'lodash/identity';
let savedTheme: ThemeOption = 'system';
try {
const value = getThemeOverride();
if (value !== null) {
savedTheme = value;
applyThemeToBody(value);
}
} catch (e) {}
type UiStore = ReturnType<typeof useUIStore>;
export const useUIStore = defineStore(STORES.UI, () => {
const activeActions = ref<string[]>([]);
const activeCredentialType = ref<string | null>(null);
const theme = useLocalStorage<ThemeOption>(LOCAL_STORAGE_THEME, savedTheme, {
writeDefaults: false,
serializer: {
read: (value) => (isValidTheme(value) ? value : savedTheme),
write: identity,
},
});
const modalsById = ref<Record<string, ModalState>>({
...Object.fromEntries(
[
ABOUT_MODAL_KEY,
CHAT_EMBED_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
DUPLICATE_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
NODE_PINNING_MODAL_KEY,
INVITE_USER_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
PROMPT_MFA_CODE_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
NEW_ASSISTANT_SESSION_MODAL,
IMPORT_WORKFLOW_URL_MODAL_KEY,
WHATS_NEW_MODAL_KEY,
WORKFLOW_DIFF_MODAL_KEY,
].map((modalKey) => [modalKey, { open: false }]),
),
[DELETE_USER_MODAL_KEY]: {
open: false,
activeId: null,
},
[COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: {
open: false,
mode: '',
activeId: null,
},
[IMPORT_CURL_MODAL_KEY]: {
open: false,
data: {
curlCommands: {},
},
},
[LOG_STREAM_MODAL_KEY]: {
open: false,
data: undefined,
},
[API_KEY_CREATE_OR_EDIT_MODAL_KEY]: {
open: false,
data: {
activeId: null,
mode: '',
},
},
[CREDENTIAL_EDIT_MODAL_KEY]: {
open: false,
mode: '',
activeId: null,
showAuthSelector: false,
} as ModalState,
[DELETE_FOLDER_MODAL_KEY]: {
open: false,
activeId: null,
data: {
workflowListEventBus: undefined,
content: {
workflowCount: 0,
subFolderCount: 0,
},
},
},
[MOVE_FOLDER_MODAL_KEY]: {
open: false,
activeId: null,
data: {
workflowListEventBus: undefined,
},
},
[COMMUNITY_PLUS_ENROLLMENT_MODAL]: {
open: false,
data: {
customHeading: undefined,
},
},
[WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY]: {
open: false,
data: {
triggerType: '',
workflowName: '',
workflowId: '',
webhookPath: '',
node: '',
},
},
[FROM_AI_PARAMETERS_MODAL_KEY]: {
open: false,
data: {
nodeName: undefined,
},
},
[IMPORT_WORKFLOW_URL_MODAL_KEY]: {
open: false,
data: {
url: '',
},
},
[WORKFLOW_EXTRACTION_NAME_MODAL_KEY]: {
open: false,
data: {
workflowName: '',
},
},
[WHATS_NEW_MODAL_KEY]: {
open: false,
data: {
articleId: undefined,
},
},
});
const modalStack = ref<string[]>([]);
const sidebarMenuCollapsedPreference = useLocalStorage<boolean>('sidebar.collapsed', false);
const sidebarMenuCollapsed = ref<boolean>(sidebarMenuCollapsedPreference.value);
const currentView = ref<string>('');
const stateIsDirty = ref<boolean>(false);
const lastSelectedNode = ref<string | null>(null);
const nodeViewOffsetPosition = ref<[number, number]>([0, 0]);
const nodeViewInitialized = ref<boolean>(false);
const addFirstStepOnLoad = ref<boolean>(false);
const bannersHeight = ref<number>(0);
const bannerStack = ref<BannerName[]>([]);
const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({});
const processingExecutionResults = ref<boolean>(false);
/**
* Modules can register their ProjectHeader tabs here
* Since these tabs are specific to the page they are on,
* we add them to separate arrays so pages can pick the right ones
* at render time.
* Module name is also added to the key so that we can check if the module is active
* when tabs are rendered.\
* @example
* uiStore.registerCustomTabs('overview', 'data-store', [
* {
* label: 'Data Store',
* value: 'data-store',
* to: { name: 'data-store' },
* },
* ]);
*/
const moduleTabs = ref<
Record<'overview' | 'project' | 'shared', Record<string, Array<TabOptions<string>>>>
>({
overview: {},
project: {},
shared: {},
});
const appGridDimensions = ref<{ width: number; height: number }>({ width: 0, height: 0 });
// Last interacted with - Canvas v2 specific
const lastInteractedWithNodeConnection = ref<Connection | undefined>();
const lastInteractedWithNodeHandle = ref<string | null>(null);
const lastInteractedWithNodeId = ref<string | undefined>();
const lastCancelledConnectionPosition = ref<XYPosition | undefined>();
const settingsStore = useSettingsStore();
const workflowsStore = useWorkflowsStore();
const rootStore = useRootStore();
const isDarkThemePreferred = useMediaQuery('(prefers-color-scheme: dark)');
const preferredSystemTheme = computed<AppliedThemeOption>(() =>
isDarkThemePreferred.value ? 'dark' : 'light',
);
const appliedTheme = computed(() => {
return theme.value === 'system' ? preferredSystemTheme.value : theme.value;
});
const contextBasedTranslationKeys = computed(() => {
const deploymentType = settingsStore.deploymentType;
let contextKey: '' | '.cloud' = '';
if (deploymentType === 'cloud') {
contextKey = '.cloud';
}
return {
feature: {
unavailable: {
title: `contextual.feature.unavailable.title${contextKey}`,
},
},
credentials: {
sharing: {
unavailable: {
title: `contextual.credentials.sharing.unavailable.title${contextKey}`,
description: `contextual.credentials.sharing.unavailable.description${contextKey}`,
action: `contextual.credentials.sharing.unavailable.action${contextKey}`,
button: `contextual.credentials.sharing.unavailable.button${contextKey}`,
},
},
},
workflows: {
sharing: {
title: 'contextual.workflows.sharing.title',
unavailable: {
title: `contextual.workflows.sharing.unavailable.title${contextKey}`,
description: {
modal: `contextual.workflows.sharing.unavailable.description.modal${contextKey}`,
tooltip: `contextual.workflows.sharing.unavailable.description.tooltip${contextKey}`,
},
action: `contextual.workflows.sharing.unavailable.action${contextKey}`,
button: `contextual.workflows.sharing.unavailable.button${contextKey}`,
},
},
},
variables: {
unavailable: {
title: `contextual.variables.unavailable.title${contextKey}`,
description: 'contextual.variables.unavailable.description',
action: `contextual.variables.unavailable.action${contextKey}`,
button: `contextual.variables.unavailable.button${contextKey}`,
},
},
users: {
settings: {
unavailable: {
title: `contextual.users.settings.unavailable.title${contextKey}`,
description: `contextual.users.settings.unavailable.description${contextKey}`,
button: `contextual.users.settings.unavailable.button${contextKey}`,
},
},
},
} as const;
});
const lastInteractedWithNode = computed(() => {
if (lastInteractedWithNodeId.value) {
return workflowsStore.getNodeById(lastInteractedWithNodeId.value);
}
return null;
});
const isModalActiveById = computed(() =>
Object.keys(modalsById.value).reduce((acc: { [key: string]: boolean }, name) => {
acc[name] = name === modalStack.value[0];
return acc;
}, {}),
);
const activeModals = computed(() => modalStack.value.map((modalName) => modalName));
const isReadOnlyView = computed(() => {
return ![
VIEWS.WORKFLOW.toString(),
VIEWS.NEW_WORKFLOW.toString(),
VIEWS.EXECUTION_DEBUG.toString(),
].includes(currentView.value);
});
const isActionActive = computed(() =>
activeActions.value.reduce((acc: { [action: string]: boolean }, action) => {
acc[action] = true;
return acc;
}, {}),
);
const headerHeight = computed(() => {
const style = getComputedStyle(document.body);
return Number(style.getPropertyValue('--header-height'));
});
const isAnyModalOpen = computed(() => {
return modalStack.value.length > 0;
});
/**
* Whether we are currently in the process of fetching and deserializing
* the full execution data and loading it to the store.
*/
const isProcessingExecutionResults = computed(() => processingExecutionResults.value);
// Methods
const setTheme = (newTheme: ThemeOption): void => {
theme.value = newTheme;
applyThemeToBody(newTheme);
};
const setMode = (name: keyof Modals, mode: string): void => {
modalsById.value[name] = {
...modalsById.value[name],
mode,
};
};
const setActiveId = (name: keyof Modals, activeId: string | null): void => {
modalsById.value[name] = {
...modalsById.value[name],
activeId,
};
};
const setShowAuthSelector = (name: keyof Modals, showAuthSelector: boolean): void => {
modalsById.value[name] = {
...modalsById.value[name],
showAuthSelector,
} as NewCredentialsModal;
};
const setModalData = (payload: { name: keyof Modals; data: Record<string, unknown> }) => {
modalsById.value[payload.name] = {
...modalsById.value[payload.name],
data: payload.data,
};
};
const openModal = (name: ModalKey) => {
modalsById.value[name] = {
...modalsById.value[name],
open: true,
};
modalStack.value = [name].concat(modalStack.value) as string[];
};
const openModalWithData = (payload: { name: ModalKey; data: Record<string, unknown> }) => {
setModalData(payload);
openModal(payload.name);
};
const closeModal = (name: ModalKey) => {
modalsById.value[name] = {
...modalsById.value[name],
open: false,
};
modalStack.value = modalStack.value.filter((openModalName) => name !== openModalName);
};
const openDeleteUserModal = (id: string) => {
setActiveId(DELETE_USER_MODAL_KEY, id);
openModal(DELETE_USER_MODAL_KEY);
};
const openExistingCredential = (id: string) => {
setActiveId(CREDENTIAL_EDIT_MODAL_KEY, id);
setMode(CREDENTIAL_EDIT_MODAL_KEY, 'edit');
openModal(CREDENTIAL_EDIT_MODAL_KEY);
};
const openNewCredential = (type: string, showAuthOptions = false) => {
setActiveId(CREDENTIAL_EDIT_MODAL_KEY, type);
setShowAuthSelector(CREDENTIAL_EDIT_MODAL_KEY, showAuthOptions);
setMode(CREDENTIAL_EDIT_MODAL_KEY, 'new');
openModal(CREDENTIAL_EDIT_MODAL_KEY);
};
const openCommunityPackageUninstallConfirmModal = (packageName: string) => {
setMode(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL);
setActiveId(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, packageName);
openModal(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY);
};
const openCommunityPackageUpdateConfirmModal = (packageName: string) => {
setMode(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_MANAGE_ACTIONS.UPDATE);
setActiveId(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, packageName);
openModal(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY);
};
const openDeleteFolderModal = (
id: string,
workflowListEventBus: EventBus,
content: { workflowCount: number; subFolderCount: number },
) => {
setActiveId(DELETE_FOLDER_MODAL_KEY, id);
openModalWithData({ name: DELETE_FOLDER_MODAL_KEY, data: { workflowListEventBus, content } });
};
const openMoveToFolderModal = (
resourceType: 'folder' | 'workflow',
resource: {
id: string;
name: string;
parentFolderId?: string;
sharedWithProjects?: ProjectSharingData[];
},
workflowListEventBus: EventBus,
) => {
openModalWithData({
name: MOVE_FOLDER_MODAL_KEY,
data: { resourceType, resource, workflowListEventBus },
});
};
const addActiveAction = (action: string) => {
if (!activeActions.value.includes(action)) {
activeActions.value.push(action);
}
};
const removeActiveAction = (action: string) => {
const actionIndex = activeActions.value.indexOf(action);
if (actionIndex !== -1) {
activeActions.value.splice(actionIndex, 1);
}
};
const toggleSidebarMenuCollapse = () => {
const newCollapsedState = !sidebarMenuCollapsed.value;
sidebarMenuCollapsedPreference.value = newCollapsedState;
sidebarMenuCollapsed.value = newCollapsedState;
};
const removeBannerFromStack = (name: BannerName) => {
bannerStack.value = bannerStack.value.filter((bannerName) => bannerName !== name);
};
const dismissBanner = async (name: BannerName, type: 'temporary' | 'permanent' = 'temporary') => {
if (type === 'permanent') {
await dismissBannerPermanently(rootStore.restApiContext, {
bannerName: name,
dismissedBanners: settingsStore.permanentlyDismissedBanners,
});
removeBannerFromStack(name);
return;
}
removeBannerFromStack(name);
};
const updateBannersHeight = (newHeight: number) => {
bannersHeight.value = newHeight;
};
const pushBannerToStack = (name: BannerName) => {
if (bannerStack.value.includes(name)) return;
bannerStack.value.push(name);
};
const clearBannerStack = () => {
bannerStack.value = [];
};
const setNotificationsForView = (view: VIEWS, notifications: NotificationOptions[]) => {
pendingNotificationsForViews.value[view] = notifications;
};
function resetLastInteractedWith() {
lastInteractedWithNodeConnection.value = undefined;
lastInteractedWithNodeHandle.value = null;
lastInteractedWithNodeId.value = undefined;
lastCancelledConnectionPosition.value = undefined;
}
const registerCustomTabs = (
page: 'overview' | 'project' | 'shared',
moduleName: string,
tabs: Array<TabOptions<string>>,
) => {
if (!moduleTabs.value[page]) {
throw new Error(`Invalid page type: ${page}`);
}
moduleTabs.value[page][moduleName] = tabs;
};
/**
* Set whether we are currently in the process of fetching and deserializing
* the full execution data and loading it to the store.
*/
const setProcessingExecutionResults = (value: boolean) => {
processingExecutionResults.value = value;
};
const initialize = (options: { banners: BannerName[] }) => {
options.banners.forEach(pushBannerToStack);
};
return {
appGridDimensions,
appliedTheme,
contextBasedTranslationKeys,
isModalActiveById,
isReadOnlyView,
isActionActive,
activeActions,
headerHeight,
stateIsDirty,
activeCredentialType,
lastSelectedNode,
bannersHeight,
lastInteractedWithNodeConnection,
lastInteractedWithNodeHandle,
lastInteractedWithNodeId,
lastInteractedWithNode,
lastCancelledConnectionPosition,
nodeViewOffsetPosition,
nodeViewInitialized,
addFirstStepOnLoad,
sidebarMenuCollapsed,
sidebarMenuCollapsedPreference,
bannerStack,
theme: computed(() => theme.value),
modalsById,
currentView,
isAnyModalOpen,
pendingNotificationsForViews,
activeModals,
isProcessingExecutionResults,
setTheme,
setModalData,
openModalWithData,
openModal,
closeModal,
openDeleteUserModal,
openExistingCredential,
openNewCredential,
openCommunityPackageUninstallConfirmModal,
openCommunityPackageUpdateConfirmModal,
addActiveAction,
removeActiveAction,
toggleSidebarMenuCollapse,
dismissBanner,
updateBannersHeight,
pushBannerToStack,
clearBannerStack,
setNotificationsForView,
resetLastInteractedWith,
setProcessingExecutionResults,
openDeleteFolderModal,
openMoveToFolderModal,
initialize,
moduleTabs,
registerCustomTabs,
};
});
/**
* Helper function for listening to model opening and closings in the store
*/
export const listenForModalChanges = (opts: {
store: UiStore;
onModalOpened?: (name: keyof Modals) => void;
onModalClosed?: (name: keyof Modals) => void;
}) => {
const { store, onModalClosed, onModalOpened } = opts;
const listeningForActions = ['openModal', 'openModalWithData', 'closeModal'];
return store.$onAction((result) => {
const { name, after, args } = result;
after(async () => {
if (!listeningForActions.includes(name)) {
return;
}
switch (name) {
case 'openModal': {
const modalName = args[0];
onModalOpened?.(modalName);
break;
}
case 'openModalWithData': {
const { name: modalName } = args[0] ?? {};
onModalOpened?.(modalName);
break;
}
case 'closeModal': {
const modalName = args[0];
onModalClosed?.(modalName);
break;
}
}
});
});
};