diff --git a/cypress/composables/versions.ts b/cypress/composables/versions.ts index f96ea8152f..be7c5bfee0 100644 --- a/cypress/composables/versions.ts +++ b/cypress/composables/versions.ts @@ -1,9 +1,13 @@ +import { MainSidebar } from '../pages/sidebar/main-sidebar'; + +const mainSidebar = new MainSidebar(); + /** * Getters */ export function getVersionUpdatesPanelOpenButton() { - return cy.getByTestId('version-updates-panel-button'); + return cy.getByTestId('version-update-next-versions-link'); } export function getVersionUpdatesPanel() { @@ -22,9 +26,13 @@ export function getVersionCard() { * Actions */ +export function openWhatsNewMenu() { + mainSidebar.getters.whatsNew().should('be.visible'); + mainSidebar.getters.whatsNew().click(); +} + export function openVersionUpdatesPanel() { - getVersionUpdatesPanelOpenButton().click(); - getVersionUpdatesPanel().should('be.visible'); + getVersionUpdatesPanelOpenButton().should('be.visible').click(); } export function closeVersionUpdatesPanel() { diff --git a/cypress/e2e/36-versions.cy.ts b/cypress/e2e/36-versions.cy.ts index d749ae4537..0c1bd25bc5 100644 --- a/cypress/e2e/36-versions.cy.ts +++ b/cypress/e2e/36-versions.cy.ts @@ -2,6 +2,7 @@ import { closeVersionUpdatesPanel, getVersionCard, getVersionUpdatesPanelOpenButton, + openWhatsNewMenu, openVersionUpdatesPanel, } from '../composables/versions'; import { WorkflowsPage } from '../pages/workflows'; @@ -16,6 +17,8 @@ describe('Versions', () => { versionNotifications: { enabled: true, endpoint: 'https://api.n8n.io/api/versions/', + whatsNewEnabled: true, + whatsNewEndpoint: 'https://api.n8n.io/api/whats-new', infoUrl: 'https://docs.n8n.io/getting-started/installation/updating.html', }, }); @@ -23,7 +26,8 @@ describe('Versions', () => { cy.visit(workflowsPage.url); cy.wait('@loadSettings'); - getVersionUpdatesPanelOpenButton().should('contain', '2 updates'); + openWhatsNewMenu(); + getVersionUpdatesPanelOpenButton().should('contain', '2 versions behind'); openVersionUpdatesPanel(); getVersionCard().should('have.length', 2); closeVersionUpdatesPanel(); diff --git a/cypress/pages/sidebar/main-sidebar.ts b/cypress/pages/sidebar/main-sidebar.ts index 7824a6bebb..cafcbd179b 100644 --- a/cypress/pages/sidebar/main-sidebar.ts +++ b/cypress/pages/sidebar/main-sidebar.ts @@ -19,6 +19,7 @@ export class MainSidebar extends BasePage { credentials: () => this.getters.menuItem('credentials'), executions: () => this.getters.menuItem('executions'), adminPanel: () => this.getters.menuItem('cloud-admin'), + whatsNew: () => this.getters.menuItem('whats-new'), userMenu: () => cy.getByTestId('user-menu'), logo: () => cy.getByTestId('n8n-logo'), }; diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index dfd0d2c233..0c8751209a 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -5,6 +5,8 @@ import { type InsightsDateRange } from './schemas/insights.schema'; export interface IVersionNotificationSettings { enabled: boolean; endpoint: string; + whatsNewEnabled: boolean; + whatsNewEndpoint: string; infoUrl: string; } diff --git a/packages/@n8n/config/src/configs/version-notifications.config.ts b/packages/@n8n/config/src/configs/version-notifications.config.ts index 313e264a2d..1adea161d9 100644 --- a/packages/@n8n/config/src/configs/version-notifications.config.ts +++ b/packages/@n8n/config/src/configs/version-notifications.config.ts @@ -10,6 +10,14 @@ export class VersionNotificationsConfig { @Env('N8N_VERSION_NOTIFICATIONS_ENDPOINT') endpoint: string = 'https://api.n8n.io/api/versions/'; + /** Whether to request What's New articles. Also requires `N8N_VERSION_NOTIFICATIONS_ENABLED` to be enabled */ + @Env('N8N_VERSION_NOTIFICATIONS_WHATS_NEW_ENABLED') + whatsNewEnabled: boolean = true; + + /** Endpoint to retrieve "What's New" articles from */ + @Env('N8N_VERSION_NOTIFICATIONS_WHATS_NEW_ENDPOINT') + whatsNewEndpoint: string = 'https://api.n8n.io/api/whats-new'; + /** URL for versions panel to page instructing user on how to update n8n instance */ @Env('N8N_VERSION_NOTIFICATIONS_INFO_URL') infoUrl: string = 'https://docs.n8n.io/hosting/installation/updating/'; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 35bd70dfca..931e784979 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -162,6 +162,8 @@ describe('GlobalConfig', () => { versionNotifications: { enabled: true, endpoint: 'https://api.n8n.io/api/versions/', + whatsNewEnabled: true, + whatsNewEndpoint: 'https://api.n8n.io/api/whats-new', infoUrl: 'https://docs.n8n.io/hosting/installation/updating/', }, workflows: { diff --git a/packages/@n8n/constants/src/instance.ts b/packages/@n8n/constants/src/instance.ts index df4cbf6357..807d41c46f 100644 --- a/packages/@n8n/constants/src/instance.ts +++ b/packages/@n8n/constants/src/instance.ts @@ -1,4 +1,5 @@ export const INSTANCE_ID_HEADER = 'n8n-instance-id'; +export const INSTANCE_VERSION_HEADER = 'n8n-version'; export const INSTANCE_TYPES = ['main', 'webhook', 'worker'] as const; export type InstanceType = (typeof INSTANCE_TYPES)[number]; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 31fba140eb..a92a39d2ce 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -122,6 +122,8 @@ export class FrontendService { versionNotifications: { enabled: this.globalConfig.versionNotifications.enabled, endpoint: this.globalConfig.versionNotifications.endpoint, + whatsNewEnabled: this.globalConfig.versionNotifications.whatsNewEnabled, + whatsNewEndpoint: this.globalConfig.versionNotifications.whatsNewEndpoint, infoUrl: this.globalConfig.versionNotifications.infoUrl, }, instanceId: this.instanceSettings.instanceId, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue index 8979f87df5..85f69eab4f 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nMarkdown/Markdown.vue @@ -8,7 +8,7 @@ import { computed, ref } from 'vue'; import xss, { friendlyAttrValue, whiteList } from 'xss'; import { markdownYoutubeEmbed, YOUTUBE_EMBED_SRC_REGEX, type YoutubeEmbedConfig } from './youtube'; -import { escapeMarkdown, toggleCheckbox } from '../../utils/markdown'; +import { toggleCheckbox } from '../../utils/markdown'; import N8nLoading from '../N8nLoading'; interface IImage { @@ -44,7 +44,7 @@ const props = withDefaults(defineProps(), { theme: 'markdown', options: () => ({ markdown: { - html: true, + html: false, linkify: true, typographer: true, breaks: true, @@ -110,7 +110,8 @@ const htmlContent = computed(() => { if (props.withMultiBreaks) { contentToRender = contentToRender.replaceAll('\n\n', '\n \n'); } - const html = md.render(escapeMarkdown(contentToRender)); + const html = md.render(contentToRender); + const safeHtml = xss(html, { onTagAttr(tag, name, value) { if (tag === 'img' && name === 'src') { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMenu/Menu.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nMenu/Menu.stories.ts index b5bf903a4d..cf25a63431 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMenu/Menu.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nMenu/Menu.stories.ts @@ -2,6 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { StoryFn } from '@storybook/vue3'; import N8nMenu from './Menu.vue'; +import N8nCallout from '../N8nCallout'; import N8nIcon from '../N8nIcon'; import N8nText from '../N8nText'; @@ -159,3 +160,69 @@ withHeaderAndFooter.args = { items: menuItems }; export const withAllSlots = templateWithAllSlots.bind({}); withAllSlots.args = { items: menuItems }; + +export const withCustomComponent = templateWithHeaderAndFooter.bind({}); +withCustomComponent.args = { + items: [ + ...menuItems, + { + id: 'custom', + icon: 'bell', + label: "What's New", + position: 'bottom', + children: [ + { + id: 'custom-callout', + component: N8nCallout, + available: true, + props: { + theme: 'warning', + icon: 'bell', + }, + }, + ], + }, + ], +}; + +export const withNotification = templateWithHeaderAndFooter.bind({}); +withNotification.args = { + items: [ + ...menuItems, + { + id: 'notification', + icon: 'bell', + label: 'Notification', + position: 'top', + notification: true, + }, + ], +}; + +export const withSmallMenu = templateWithHeaderAndFooter.bind({}); +withSmallMenu.args = { + items: [ + ...menuItems, + { + id: 'notification', + icon: { + type: 'icon', + value: 'bell', + color: 'primary', + }, + label: 'Small items', + position: 'top', + children: [ + { icon: 'info', label: 'About n8n', id: 'about', size: 'small' }, + { icon: 'book', label: 'Documentation', id: 'docs', size: 'small' }, + { + icon: 'bell', + label: 'Notification', + id: 'notification', + notification: true, + size: 'small', + }, + ], + }, + ], +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMenu/Menu.vue b/packages/frontend/@n8n/design-system/src/components/N8nMenu/Menu.vue index 04d25f0864..2ced3c5a90 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMenu/Menu.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nMenu/Menu.vue @@ -3,7 +3,8 @@ import { ElMenu } from 'element-plus'; import { computed, onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; -import type { IMenuItem } from '../../types'; +import type { IMenuItem, IMenuElement } from '../../types'; +import { isCustomMenuItem } from '../../types'; import N8nMenuItem from '../N8nMenuItem'; import { doesMenuItemMatchCurrentRoute } from '../N8nMenuItem/routerUtil'; @@ -14,7 +15,7 @@ interface MenuProps { transparentBackground?: boolean; mode?: 'router' | 'tabs'; tooltipDelay?: number; - items?: IMenuItem[]; + items?: IMenuElement[]; modelValue?: string; } @@ -36,11 +37,13 @@ const emit = defineEmits<{ const activeTab = ref(props.modelValue); const upperMenuItems = computed(() => - props.items.filter((item: IMenuItem) => item.position === 'top' && item.available !== false), + props.items.filter((item: IMenuElement) => item.position === 'top' && item.available !== false), ); const lowerMenuItems = computed(() => - props.items.filter((item: IMenuItem) => item.position === 'bottom' && item.available !== false), + props.items.filter( + (item: IMenuElement) => item.position === 'bottom' && item.available !== false, + ), ); const currentRoute = computed(() => { @@ -89,31 +92,35 @@ const onSelect = (item: IMenuItem): void => { - +
- +
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue b/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue index cdba4dd4ff..5bfc33005a 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nMenuItem/MenuItem.vue @@ -4,11 +4,14 @@ import { computed, useCssModule, getCurrentInstance } from 'vue'; import { useRoute } from 'vue-router'; import { doesMenuItemMatchCurrentRoute } from './routerUtil'; -import type { IMenuItem } from '../../types'; +import type { IMenuItem, IMenuElement } from '../../types'; +import { isCustomMenuItem } from '../../types'; +import type { IconColor } from '../../types/icon'; import { getInitials } from '../../utils/labelUtil'; import ConditionalRouterLink from '../ConditionalRouterLink'; import N8nIcon from '../N8nIcon'; import N8nSpinner from '../N8nSpinner'; +import N8nText from '../N8nText'; import N8nTooltip from '../N8nTooltip'; interface MenuItemProps { @@ -26,12 +29,14 @@ const props = withDefaults(defineProps(), { tooltipDelay: 300, popperClass: '', mode: 'router', + activeTab: undefined, + handleSelect: undefined, }); const $style = useCssModule(); const $route = useRoute(); -const availableChildren = computed((): IMenuItem[] => +const availableChildren = computed((): IMenuElement[] => Array.isArray(props.item.children) ? props.item.children.filter((child) => child.available !== false) : [], @@ -49,7 +54,7 @@ const submenuPopperClass = computed((): string => { return popperClass.join(' '); }); -const isActive = (item: IMenuItem): boolean => { +const isActive = (item: IMenuElement): boolean => { if (props.mode === 'router') { return doesMenuItemMatchCurrentRoute(item, currentRoute.value); } else { @@ -65,6 +70,14 @@ const isItemActive = (item: IMenuItem): boolean => { // Get self component to avoid dependency cycle const N8nMenuItem = getCurrentInstance()?.type; + +const getIconColor = (item: IMenuItem): IconColor | undefined => { + if (typeof item.icon === 'string') { + return undefined; + } + + return item.icon?.color; +};