diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index db2786f6cf..5ac39f1e39 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import * as React from 'react' import { AppInitializer } from '@/app/components/app-initializer' +import InSiteMessageNotification from '@/app/components/app/in-site-message/notification' import AmplitudeProvider from '@/app/components/base/amplitude' import GA, { GaType } from '@/app/components/base/ga' import Zendesk from '@/app/components/base/zendesk' @@ -32,6 +33,7 @@ const Layout = ({ children }: { children: ReactNode }) => { {children} + diff --git a/web/app/components/app/in-site-message/index.spec.tsx b/web/app/components/app/in-site-message/index.spec.tsx new file mode 100644 index 0000000000..69f036da17 --- /dev/null +++ b/web/app/components/app/in-site-message/index.spec.tsx @@ -0,0 +1,132 @@ +import type { InSiteMessageActionItem } from './index' +import { fireEvent, render, screen } from '@testing-library/react' +import InSiteMessage from './index' + +describe('InSiteMessage', () => { + const originalLocation = window.location + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('open', vi.fn()) + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + configurable: true, + }) + vi.unstubAllGlobals() + }) + + const renderComponent = (actions: InSiteMessageActionItem[], props?: Partial>) => { + return render( + , + ) + } + + // Validate baseline rendering and content normalization. + describe('Rendering', () => { + it('should render title, subtitle, markdown content, and action buttons', () => { + const actions: InSiteMessageActionItem[] = [ + { action: 'close', text: 'Close', type: 'default' }, + { action: 'link', text: 'Learn more', type: 'primary', data: 'https://example.com' }, + ] + + renderComponent(actions, { className: 'custom-message' }) + + const closeButton = screen.getByRole('button', { name: 'Close' }) + const learnMoreButton = screen.getByRole('button', { name: 'Learn more' }) + const panel = closeButton.closest('div.fixed') + const titleElement = panel?.querySelector('.title-3xl-bold') + const subtitleElement = panel?.querySelector('.body-md-regular') + expect(panel).toHaveClass('custom-message') + expect(titleElement).toHaveTextContent(/Title.*Line/s) + expect(subtitleElement).toHaveTextContent(/Subtitle.*Line/s) + expect(titleElement?.textContent).not.toContain('\\n') + expect(subtitleElement?.textContent).not.toContain('\\n') + expect(screen.getByText('Main content')).toBeInTheDocument() + expect(closeButton).toBeInTheDocument() + expect(learnMoreButton).toBeInTheDocument() + }) + + it('should fallback to default header background when headerBgUrl is empty string', () => { + const actions: InSiteMessageActionItem[] = [{ action: 'close', text: 'Close', type: 'default' }] + + const { container } = renderComponent(actions, { headerBgUrl: '' }) + const header = container.querySelector('div[style]') + expect(header).toHaveStyle({ backgroundImage: 'url(/in-site-message/header-bg.svg)' }) + }) + }) + + // Validate action handling for close and link actions. + describe('Actions', () => { + it('should call onAction and hide component when close action is clicked', () => { + const onAction = vi.fn() + const closeAction: InSiteMessageActionItem = { action: 'close', text: 'Close', type: 'default' } + + renderComponent([closeAction], { onAction }) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + + expect(onAction).toHaveBeenCalledWith(closeAction) + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() + }) + + it('should open a new tab when link action data is a string', () => { + const linkAction: InSiteMessageActionItem = { + action: 'link', + text: 'Open link', + type: 'primary', + data: 'https://example.com', + } + + renderComponent([linkAction]) + fireEvent.click(screen.getByRole('button', { name: 'Open link' })) + + expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer') + }) + + it('should navigate with location.assign when link action target is _self', () => { + const assignSpy = vi.fn() + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + assign: assignSpy, + }, + configurable: true, + }) + + const linkAction: InSiteMessageActionItem = { + action: 'link', + text: 'Open self', + type: 'primary', + data: { href: 'https://example.com/self', target: '_self' }, + } + + renderComponent([linkAction]) + fireEvent.click(screen.getByRole('button', { name: 'Open self' })) + + expect(assignSpy).toHaveBeenCalledWith('https://example.com/self') + expect(window.open).not.toHaveBeenCalled() + }) + + it('should not trigger navigation when link data is invalid', () => { + const linkAction: InSiteMessageActionItem = { + action: 'link', + text: 'Broken link', + type: 'primary', + data: { rel: 'noopener' }, + } + + renderComponent([linkAction]) + fireEvent.click(screen.getByRole('button', { name: 'Broken link' })) + + expect(window.open).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/app/in-site-message/index.tsx b/web/app/components/app/in-site-message/index.tsx new file mode 100644 index 0000000000..9225eb8a15 --- /dev/null +++ b/web/app/components/app/in-site-message/index.tsx @@ -0,0 +1,134 @@ +'use client' + +import { useMemo, useState } from 'react' +import Button from '@/app/components/base/button' +import { MarkdownWithDirective } from '@/app/components/base/markdown-with-directive' +import { cn } from '@/utils/classnames' + +type InSiteMessageAction = 'link' | 'close' +type InSiteMessageButtonType = 'primary' | 'default' + +export type InSiteMessageActionItem = { + action: InSiteMessageAction + data?: unknown + text: string + type: InSiteMessageButtonType +} + +type InSiteMessageProps = { + actions: InSiteMessageActionItem[] + className?: string + headerBgUrl?: string + main: string + onAction?: (action: InSiteMessageActionItem) => void + subtitle: string + title: string +} + +const LINE_BREAK_REGEX = /\\n/g + +function normalizeLineBreaks(text: string): string { + return text.replace(LINE_BREAK_REGEX, '\n') +} + +function normalizeLinkData(data: unknown): { href: string, rel?: string, target?: string } | null { + if (typeof data === 'string') + return { href: data, target: '_blank' } + + if (!data || typeof data !== 'object') + return null + + const candidate = data as { href?: unknown, rel?: unknown, target?: unknown } + if (typeof candidate.href !== 'string' || !candidate.href) + return null + + return { + href: candidate.href, + rel: typeof candidate.rel === 'string' ? candidate.rel : undefined, + target: typeof candidate.target === 'string' ? candidate.target : '_blank', + } +} + +const DEFAULT_HEADER_BG_URL = '/in-site-message/header-bg.svg' + +function InSiteMessage({ + actions, + className, + headerBgUrl = DEFAULT_HEADER_BG_URL, + main, + onAction, + subtitle, + title, +}: InSiteMessageProps) { + const [visible, setVisible] = useState(true) + const normalizedTitle = normalizeLineBreaks(title) + const normalizedSubtitle = normalizeLineBreaks(subtitle) + + const headerStyle = useMemo(() => { + return { + backgroundImage: `url(${headerBgUrl || DEFAULT_HEADER_BG_URL})`, + } + }, [headerBgUrl]) + + const handleAction = (item: InSiteMessageActionItem) => { + onAction?.(item) + + if (item.action === 'close') { + setVisible(false) + return + } + + const linkData = normalizeLinkData(item.data) + if (!linkData) + return + + const target = linkData.target ?? '_blank' + if (target === '_self') { + window.location.assign(linkData.href) + return + } + + window.open(linkData.href, target, linkData.rel || 'noopener,noreferrer') + } + + if (!visible) + return null + + return ( +
+
+
+ {normalizedTitle} +
+
+ {normalizedSubtitle} +
+
+ +
+ +
+ +
+ {actions.map(item => ( + + ))} +
+
+ ) +} + +export default InSiteMessage diff --git a/web/app/components/app/in-site-message/notification.spec.tsx b/web/app/components/app/in-site-message/notification.spec.tsx new file mode 100644 index 0000000000..84fe3aebc7 --- /dev/null +++ b/web/app/components/app/in-site-message/notification.spec.tsx @@ -0,0 +1,216 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import InSiteMessageNotification from './notification' + +const { + mockConfig, + mockNotification, + mockNotificationDismiss, +} = vi.hoisted(() => ({ + mockConfig: { + isCloudEdition: true, + }, + mockNotification: vi.fn(), + mockNotificationDismiss: vi.fn(), +})) + +vi.mock('@/config', () => ({ + get IS_CLOUD_EDITION() { + return mockConfig.isCloudEdition + }, +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + notification: { + queryOptions: (options?: Record) => ({ + queryKey: ['console', 'notification'], + queryFn: (...args: unknown[]) => mockNotification(...args), + ...options, + }), + }, + notificationDismiss: { + mutationOptions: (options?: Record) => ({ + mutationKey: ['console', 'notificationDismiss'], + mutationFn: (...args: unknown[]) => mockNotificationDismiss(...args), + ...options, + }), + }, + }, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }) + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + + return Wrapper +} + +describe('InSiteMessageNotification', () => { + beforeEach(() => { + vi.clearAllMocks() + mockConfig.isCloudEdition = true + vi.stubGlobal('open', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + // Validate query gating and empty state rendering. + describe('Rendering', () => { + it('should render null and skip query when not cloud edition', async () => { + mockConfig.isCloudEdition = false + const Wrapper = createWrapper() + const { container } = render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(mockNotification).not.toHaveBeenCalled() + }) + expect(container).toBeEmptyDOMElement() + }) + + it('should render null when notification list is empty', async () => { + mockNotification.mockResolvedValue({ notifications: [] }) + const Wrapper = createWrapper() + const { container } = render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(mockNotification).toHaveBeenCalledTimes(1) + }) + expect(container).toBeEmptyDOMElement() + }) + }) + + // Validate parsed-body behavior and action handling. + describe('Notification body parsing and actions', () => { + it('should render parsed main/actions and dismiss only on close action', async () => { + mockNotification.mockResolvedValue({ + notifications: [ + { + notification_id: 'n-1', + title: 'Update title', + subtitle: 'Update subtitle', + title_pic_url: 'https://example.com/bg.png', + body: JSON.stringify({ + main: 'Parsed body main', + actions: [ + { action: 'link', data: 'https://example.com/docs', text: 'Visit docs', type: 'primary' }, + { action: 'close', text: 'Dismiss now', type: 'default' }, + { action: 'link', data: 'https://example.com/invalid', text: 100, type: 'primary' }, + ], + }), + }, + ], + }) + mockNotificationDismiss.mockResolvedValue({ success: true }) + + const Wrapper = createWrapper() + render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(screen.getByText('Parsed body main')).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: 'Visit docs' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Dismiss now' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Invalid' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Visit docs' })) + expect(mockNotificationDismiss).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss now' })) + await waitFor(() => { + expect(mockNotificationDismiss).toHaveBeenCalledWith( + { + body: { + notification_id: 'n-1', + }, + }, + expect.objectContaining({ + mutationKey: ['console', 'notificationDismiss'], + }), + ) + }) + }) + + it('should fallback to raw body and default close action when body is invalid json', async () => { + mockNotification.mockResolvedValue({ + notifications: [ + { + notification_id: 'n-2', + title: 'Fallback title', + subtitle: 'Fallback subtitle', + title_pic_url: 'https://example.com/bg-2.png', + body: 'raw body text', + }, + ], + }) + mockNotificationDismiss.mockResolvedValue({ success: true }) + + const Wrapper = createWrapper() + render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(screen.getByText('raw body text')).toBeInTheDocument() + }) + + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) + fireEvent.click(closeButton) + + await waitFor(() => { + expect(mockNotificationDismiss).toHaveBeenCalledWith( + { + body: { + notification_id: 'n-2', + }, + }, + expect.objectContaining({ + mutationKey: ['console', 'notificationDismiss'], + }), + ) + }) + }) + + it('should fallback to default close action when parsed actions are all invalid', async () => { + mockNotification.mockResolvedValue({ + notifications: [ + { + notification_id: 'n-3', + title: 'Invalid action title', + subtitle: 'Invalid action subtitle', + title_pic_url: 'https://example.com/bg-3.png', + body: JSON.stringify({ + main: 'Main from parsed body', + actions: [ + { action: 'link', type: 'primary', text: 100, data: 'https://example.com' }, + ], + }), + }, + ], + }) + + const Wrapper = createWrapper() + render(, { wrapper: Wrapper }) + + await waitFor(() => { + expect(screen.getByText('Main from parsed body')).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/in-site-message/notification.tsx b/web/app/components/app/in-site-message/notification.tsx new file mode 100644 index 0000000000..de256a4663 --- /dev/null +++ b/web/app/components/app/in-site-message/notification.tsx @@ -0,0 +1,109 @@ +'use client' + +import type { InSiteMessageActionItem } from './index' +import { useMutation, useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { IS_CLOUD_EDITION } from '@/config' +import { consoleQuery } from '@/service/client' +import InSiteMessage from './index' + +type NotificationBodyPayload = { + actions: InSiteMessageActionItem[] + main: string +} + +function isValidActionItem(value: unknown): value is InSiteMessageActionItem { + if (!value || typeof value !== 'object') + return false + + const candidate = value as { + action?: unknown + data?: unknown + text?: unknown + type?: unknown + } + + return ( + typeof candidate.text === 'string' + && (candidate.type === 'primary' || candidate.type === 'default') + && (candidate.action === 'link' || candidate.action === 'close') + && (candidate.data === undefined || typeof candidate.data !== 'function') + ) +} + +function parseNotificationBody(body: string): NotificationBodyPayload | null { + try { + const parsed = JSON.parse(body) as { + actions?: unknown + main?: unknown + } + + if (!parsed || typeof parsed !== 'object') + return null + + if (typeof parsed.main !== 'string') + return null + + const actions = Array.isArray(parsed.actions) + ? parsed.actions.filter(isValidActionItem) + : [] + + return { + main: parsed.main, + actions, + } + } + catch { + return null + } +} + +function InSiteMessageNotification() { + const { t } = useTranslation() + const dismissNotificationMutation = useMutation(consoleQuery.notificationDismiss.mutationOptions()) + + const { data } = useQuery(consoleQuery.notification.queryOptions({ + enabled: IS_CLOUD_EDITION, + })) + + const notification = data?.notifications?.[0] + const parsedBody = notification ? parseNotificationBody(notification.body) : null + + if (!IS_CLOUD_EDITION || !notification) + return null + + const fallbackActions: InSiteMessageActionItem[] = [ + { + type: 'default', + text: t('operation.close', { ns: 'common' }), + action: 'close', + }, + ] + + const actions = parsedBody?.actions?.length ? parsedBody.actions : fallbackActions + const main = parsedBody?.main ?? notification.body + const handleAction = (action: InSiteMessageActionItem) => { + if (action.action !== 'close') + return + + dismissNotificationMutation.mutate({ + body: { + notification_id: notification.notification_id, + }, + }) + } + + return ( + + ) +} + +export default InSiteMessageNotification diff --git a/web/app/components/base/audio-gallery/AudioPlayer.tsx b/web/app/components/base/audio-gallery/AudioPlayer.tsx index 331dd06c67..cbf50ddc13 100644 --- a/web/app/components/base/audio-gallery/AudioPlayer.tsx +++ b/web/app/components/base/audio-gallery/AudioPlayer.tsx @@ -65,7 +65,7 @@ const AudioPlayer: React.FC = ({ src, srcs }) => { if (primarySrc) { // Delayed generation of waveform data // eslint-disable-next-line ts/no-use-before-define - const timer = setTimeout(() => generateWaveformData(primarySrc), 1000) + const timer = setTimeout(generateWaveformData, 1000, primarySrc) return () => { audio.removeEventListener('loadedmetadata', setAudioData) audio.removeEventListener('timeupdate', setAudioTime) diff --git a/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.spec.ts b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.spec.ts new file mode 100644 index 0000000000..9e74ed43b4 --- /dev/null +++ b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.spec.ts @@ -0,0 +1,73 @@ +import { validateDirectiveProps } from './markdown-with-directive-schema' + +describe('markdown-with-directive-schema', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Validate the happy path for known directives. + describe('valid props', () => { + it('should return true for withiconcardlist when className is provided', () => { + expect(validateDirectiveProps('withiconcardlist', { className: 'custom-list' })).toBe(true) + }) + + it('should return true for withiconcarditem when icon is https URL', () => { + expect(validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' })).toBe(true) + }) + }) + + // Validate strict schema constraints and error branches. + describe('invalid props', () => { + it('should return false and log error for unknown directive name', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('unknown-directive', { className: 'custom-list' }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Unknown directive name.', + expect.objectContaining({ + attributes: { className: 'custom-list' }, + directive: 'unknown-directive', + }), + ) + }) + + it('should return false and log error for non-http icon URL', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('withiconcarditem', { icon: 'ftp://example.com/icon.png' }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Invalid directive props.', + expect.objectContaining({ + attributes: { icon: 'ftp://example.com/icon.png' }, + directive: 'withiconcarditem', + issues: expect.arrayContaining([ + expect.objectContaining({ + path: 'icon', + }), + ]), + }), + ) + }) + + it('should return false when extra field is provided to strict list schema', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('withiconcardlist', { + className: 'custom-list', + extra: 'not-allowed', + }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Invalid directive props.', + expect.objectContaining({ + directive: 'withiconcardlist', + }), + ) + }) + }) +}) diff --git a/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts new file mode 100644 index 0000000000..5e31a7afa9 --- /dev/null +++ b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts @@ -0,0 +1,56 @@ +import * as z from 'zod' + +const commonSchema = { + className: z.string().min(1).optional(), +} +export const withIconCardListPropsSchema = z.object(commonSchema).strict() + +const HTTP_URL_REGEX = /^https?:\/\//i + +export const withIconCardItemPropsSchema = z.object({ + ...commonSchema, + icon: z.string().trim().url().refine( + value => HTTP_URL_REGEX.test(value), + 'icon must be a http/https URL', + ), +}).strict() + +export const directivePropsSchemas = { + withiconcardlist: withIconCardListPropsSchema, + withiconcarditem: withIconCardItemPropsSchema, +} as const + +export type DirectiveName = keyof typeof directivePropsSchemas + +function isDirectiveName(name: string): name is DirectiveName { + return Object.hasOwn(directivePropsSchemas, name) +} + +export function validateDirectiveProps(name: string, attributes: Record): boolean { + if (!isDirectiveName(name)) { + console.error('[markdown-with-directive] Unknown directive name.', { + attributes, + directive: name, + }) + return false + } + + const parsed = directivePropsSchemas[name].safeParse(attributes) + if (!parsed.success) { + console.error('[markdown-with-directive] Invalid directive props.', { + attributes, + directive: name, + issues: parsed.error.issues.map(issue => ({ + code: issue.code, + message: issue.message, + path: issue.path.join('.'), + })), + }) + return false + } + + return true +} + +export type WithIconCardListProps = z.infer +export type WithIconCardItemProps = z.infer diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx new file mode 100644 index 0000000000..58eb24d75e --- /dev/null +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react' +import WithIconCardItem from './with-icon-card-item' + +vi.mock('next/image', () => ({ + default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes & { unoptimized?: boolean }) => , +})) + +describe('WithIconCardItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should render a decorative icon and children content by default', () => { + const { container } = render( + + Card item content + , + ) + + const icon = container.querySelector('img') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('src', 'https://example.com/icon.png') + expect(icon).toHaveAttribute('alt', '') + expect(icon).toHaveAttribute('aria-hidden', 'true') + expect(icon).toHaveClass('object-contain') + expect(screen.getByText('Card item content')).toBeInTheDocument() + }) + + it('should expose alt text when iconAlt is provided', () => { + render( + + Accessible card item content + , + ) + + const icon = screen.getByAltText('Card icon') + expect(icon).toBeInTheDocument() + expect(icon).not.toHaveAttribute('aria-hidden') + expect(screen.getByText('Accessible card item content')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx new file mode 100644 index 0000000000..915c31f160 --- /dev/null +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react' +import type { WithIconCardItemProps } from './markdown-with-directive-schema' +import Image from 'next/image' +import { cn } from '@/utils/classnames' + +type WithIconItemProps = WithIconCardItemProps & { + children?: ReactNode + iconAlt?: string +} + +function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) { + return ( +
+ {/* + * unoptimized to "url parameter is not allowed" for external domains despite correct remotePatterns configuration. + * https://github.com/vercel/next.js/issues/88873 + */} + {iconAlt +
+ {children} +
+
+ ) +} + +export default WithIconCardItem diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-list.spec.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-list.spec.tsx new file mode 100644 index 0000000000..d5b701b01c --- /dev/null +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-list.spec.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react' +import WithIconCardList from './with-icon-card-list' + +describe('WithIconCardList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify baseline rendering and className merge behavior. + describe('rendering', () => { + it('should render children and merge custom className with base class', () => { + const { container } = render( + + List child + , + ) + + expect(screen.getByText('List child')).toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('space-y-1') + expect(container.firstElementChild).toHaveClass('custom-list-class') + }) + + it('should keep base class when className is not provided', () => { + const { container } = render( + + Only base class + , + ) + + expect(screen.getByText('Only base class')).toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('space-y-1') + }) + }) +}) diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-list.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-list.tsx new file mode 100644 index 0000000000..750bfe5dc9 --- /dev/null +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-list.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' +import type { WithIconCardListProps } from './markdown-with-directive-schema' +import { cn } from '@/utils/classnames' + +type WithIconListProps = WithIconCardListProps & { + children?: ReactNode +} + +function WithIconCardList({ children, className }: WithIconListProps) { + return ( +
+ {children} +
+ ) +} + +export default WithIconCardList diff --git a/web/app/components/base/markdown-with-directive/index.spec.tsx b/web/app/components/base/markdown-with-directive/index.spec.tsx new file mode 100644 index 0000000000..0ca608727f --- /dev/null +++ b/web/app/components/base/markdown-with-directive/index.spec.tsx @@ -0,0 +1,202 @@ +import { render, screen } from '@testing-library/react' +import DOMPurify from 'dompurify' +import { validateDirectiveProps } from './components/markdown-with-directive-schema' +import WithIconCardItem from './components/with-icon-card-item' +import WithIconCardList from './components/with-icon-card-list' +import { MarkdownWithDirective } from './index' + +const FOUR_COLON_RE = /:{4}/ + +vi.mock('next/image', () => ({ + default: (props: React.ImgHTMLAttributes) => , +})) + +function expectDecorativeIcon(container: HTMLElement, src: string) { + const icon = container.querySelector('img') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('src', src) + expect(icon).toHaveAttribute('alt', '') + expect(icon).toHaveAttribute('aria-hidden', 'true') +} + +describe('markdown-with-directive', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Validate directive prop schemas and error paths. + describe('Directive schema validation', () => { + it('should return true when withiconcardlist props are valid', () => { + expect(validateDirectiveProps('withiconcardlist', { className: 'custom-list' })).toBe(true) + }) + + it('should return true when withiconcarditem props are valid', () => { + expect(validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' })).toBe(true) + }) + + it('should return false and log when directive name is unknown', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('unknown-directive', { className: 'custom-list' }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Unknown directive name.', + expect.objectContaining({ + attributes: { className: 'custom-list' }, + directive: 'unknown-directive', + }), + ) + }) + + it('should return false and log when withiconcarditem icon is not http/https', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('withiconcarditem', { icon: 'ftp://example.com/icon.png' }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Invalid directive props.', + expect.objectContaining({ + attributes: { icon: 'ftp://example.com/icon.png' }, + directive: 'withiconcarditem', + issues: expect.arrayContaining([ + expect.objectContaining({ + path: 'icon', + }), + ]), + }), + ) + }) + + it('should return false when extra props are provided to strict schema', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const isValid = validateDirectiveProps('withiconcardlist', { + className: 'custom-list', + extra: 'not-allowed', + }) + + expect(isValid).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[markdown-with-directive] Invalid directive props.', + expect.objectContaining({ + directive: 'withiconcardlist', + }), + ) + }) + }) + + // Validate WithIconCardList rendering and class merge behavior. + describe('WithIconCardList component', () => { + it('should render children and merge className with base class', () => { + const { container } = render( + + List child + , + ) + + expect(screen.getByText('List child')).toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('space-y-1') + expect(container.firstElementChild).toHaveClass('custom-list-class') + }) + + it('should render base class when className is not provided', () => { + const { container } = render( + + Only base class + , + ) + + expect(screen.getByText('Only base class')).toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('space-y-1') + }) + }) + + // Validate WithIconCardItem rendering and image prop forwarding. + describe('WithIconCardItem component', () => { + it('should render icon image and child content', () => { + const { container } = render( + + Card item content + , + ) + + expectDecorativeIcon(container, 'https://example.com/icon.png') + expect(screen.getByText('Card item content')).toBeInTheDocument() + }) + }) + + // Validate markdown parsing pipeline, sanitizer usage, and invalid fallback. + describe('MarkdownWithDirective component', () => { + it('should render directives when markdown is valid', () => { + const markdown = [ + '::withiconcardlist {className="custom-list"}', + ':withiconcarditem[Card Title] {icon="https://example.com/icon.png"} {className="custom-item"}', + '::', + ].join('\n') + + const { container } = render() + + const list = container.querySelector('.custom-list') + expect(list).toBeInTheDocument() + expect(list).toHaveClass('space-y-1') + expect(screen.getByText('Card Title')).toBeInTheDocument() + expectDecorativeIcon(container, 'https://example.com/icon.png') + }) + + it('should replace output with invalid content when directive is unknown', () => { + const markdown = ':unknown[Bad Content]{foo="bar"}' + + render() + + expect(screen.getByText('invalid content')).toBeInTheDocument() + expect(screen.queryByText('Bad Content')).not.toBeInTheDocument() + }) + + it('should replace output with invalid content when directive props are invalid', () => { + const markdown = ':withiconcarditem[Invalid Icon]{icon="not-a-url"}' + + render() + + expect(screen.getByText('invalid content')).toBeInTheDocument() + expect(screen.queryByText('Invalid Icon')).not.toBeInTheDocument() + }) + + it('should not render trailing fence text for four-colon container directives', () => { + const markdown = [ + '::::withiconcardlist {className="custom-list"}', + ':withiconcarditem[Card Title]{icon="https://example.com/icon.png"}', + '::::', + ].join('\n') + + const { container } = render() + + expect(screen.getByText('Card Title')).toBeInTheDocument() + expect(screen.queryByText(FOUR_COLON_RE)).not.toBeInTheDocument() + expect(container.textContent).not.toContain('::::') + }) + + it('should call sanitizer and render based on sanitized markdown', () => { + const sanitizeSpy = vi.spyOn(DOMPurify, 'sanitize') + .mockReturnValue(':withiconcarditem[Sanitized]{icon="https://example.com/safe.png"}') + + const { container } = render() + + expect(sanitizeSpy).toHaveBeenCalledWith('', { + ALLOWED_ATTR: [], + ALLOWED_TAGS: [], + }) + expect(screen.getByText('Sanitized')).toBeInTheDocument() + expectDecorativeIcon(container, 'https://example.com/safe.png') + }) + + it('should render empty output and skip sanitizer when markdown is empty', () => { + const sanitizeSpy = vi.spyOn(DOMPurify, 'sanitize') + const { container } = render() + + expect(sanitizeSpy).not.toHaveBeenCalled() + expect(container).toBeEmptyDOMElement() + }) + }) +}) diff --git a/web/app/components/base/markdown-with-directive/index.tsx b/web/app/components/base/markdown-with-directive/index.tsx new file mode 100644 index 0000000000..8b5f5162c0 --- /dev/null +++ b/web/app/components/base/markdown-with-directive/index.tsx @@ -0,0 +1,287 @@ +'use client' +import type { ReactNode } from 'react' +import type { Components, StreamdownProps } from 'streamdown' +import DOMPurify from 'dompurify' +import remarkDirective from 'remark-directive' +import { defaultRehypePlugins, Streamdown } from 'streamdown' +import { visit } from 'unist-util-visit' +import { validateDirectiveProps } from './components/markdown-with-directive-schema' +import WithIconCardItem from './components/with-icon-card-item' +import WithIconCardList from './components/with-icon-card-list' + +// Adapter to map generic props to WithIconListProps +function WithIconCardListAdapter(props: Record) { + // Extract expected props, fallback to undefined if not present + const { children, className } = props + return ( + + ) +} + +// Adapter to map generic props to WithIconCardItemProps +function WithIconCardItemAdapter(props: Record) { + const { icon, className, children } = props + return ( + + {children as ReactNode} + + ) +} + +type DirectiveNode = { + type?: string + name?: string + attributes?: Record + data?: { + hName?: string + hProperties?: Record + } +} + +type MdastRoot = { + type: 'root' + children: Array<{ + type: string + children?: Array<{ type: string, value?: string }> + value?: string + }> +} + +function isMdastRoot(node: Parameters[0]): node is MdastRoot { + if (typeof node !== 'object' || node === null) + return false + + const candidate = node as { type?: unknown, children?: unknown } + return candidate.type === 'root' && Array.isArray(candidate.children) +} + +// Move the regex to module scope to avoid recompilation +const DIRECTIVE_ATTRIBUTE_BLOCK_REGEX = /^(\s*:+[a-z][\w-]*(?:\[[^\]\n]*\])?)\s+((?:\{[^}\n]*\}\s*)+)$/i +const ATTRIBUTE_BLOCK_REGEX = /\{([^}\n]*)\}/g +type PluggableList = NonNullable +type Pluggable = PluggableList[number] +type AttributeDefinition = string | [string, ...(string | boolean | RegExp)[]] +type SanitizeSchema = { + tagNames?: string[] + attributes?: Record + required?: Record> + clobber?: string[] + clobberPrefix?: string + [key: string]: unknown +} + +const DIRECTIVE_ALLOWED_TAGS: Record = { + withiconcardlist: ['className'], + withiconcarditem: ['icon', 'className'], +} + +function buildDirectiveRehypePlugins(): PluggableList { + const [sanitizePlugin, defaultSanitizeSchema] + = defaultRehypePlugins.sanitize as [Pluggable, SanitizeSchema] + + const tagNames = new Set([ + ...(defaultSanitizeSchema.tagNames ?? []), + ...Object.keys(DIRECTIVE_ALLOWED_TAGS), + ]) + + const attributes: Record = { + ...(defaultSanitizeSchema.attributes ?? {}), + } + + for (const [tagName, allowedAttributes] of Object.entries(DIRECTIVE_ALLOWED_TAGS)) + attributes[tagName] = [...(attributes[tagName] ?? []), ...allowedAttributes] + + const sanitizeSchema: SanitizeSchema = { + ...defaultSanitizeSchema, + tagNames: [...tagNames], + attributes, + } + + return [ + defaultRehypePlugins.raw, + [sanitizePlugin, sanitizeSchema] as Pluggable, + defaultRehypePlugins.harden, + ] +} + +const directiveRehypePlugins = buildDirectiveRehypePlugins() + +function normalizeDirectiveAttributeBlocks(markdown: string): string { + const lines = markdown.split('\n') + + return lines.map((line) => { + const match = line.match(DIRECTIVE_ATTRIBUTE_BLOCK_REGEX) + if (!match) + return line + + const directivePrefix = match[1] + const attributeBlocks = match[2] + const attrMatches = [...attributeBlocks.matchAll(ATTRIBUTE_BLOCK_REGEX)] + if (attrMatches.length === 0) + return line + + const mergedAttributes = attrMatches + .map(result => result[1].trim()) + .filter(Boolean) + .join(' ') + + return mergedAttributes + ? `${directivePrefix}{${mergedAttributes}}` + : directivePrefix + }).join('\n') +} + +function normalizeDirectiveAttributes(attributes?: Record): Record { + const normalized: Record = {} + + if (!attributes) + return normalized + + for (const [key, value] of Object.entries(attributes)) { + if (typeof value === 'string') + normalized[key] = value + } + + return normalized +} + +function isValidDirectiveAst(tree: Parameters[0]): boolean { + let isValid = true + + visit( + tree, + ['textDirective', 'leafDirective', 'containerDirective'], + (node) => { + if (!isValid) + return + + const directiveNode = node as DirectiveNode + const directiveName = directiveNode.name?.toLowerCase() + if (!directiveName) { + isValid = false + return + } + + const attributes = normalizeDirectiveAttributes(directiveNode.attributes) + if (!validateDirectiveProps(directiveName, attributes)) + isValid = false + }, + ) + + return isValid +} + +const UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX = /^\s*:{2,}[a-z][\w-]*/im + +function hasUnparsedDirectiveLikeText(tree: Parameters[0]): boolean { + let hasInvalidText = false + + visit(tree, 'text', (node) => { + if (hasInvalidText) + return + + const textNode = node as { value?: string } + const value = textNode.value || '' + if (UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX.test(value)) + hasInvalidText = true + }) + + return hasInvalidText +} + +function replaceWithInvalidContent(tree: Parameters[0]) { + if (!isMdastRoot(tree)) + return + + const root = tree + root.children = [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'invalid content', + }, + ], + }, + ] +} + +function directivePlugin() { + return (tree: Parameters[0]) => { + if (!isValidDirectiveAst(tree) || hasUnparsedDirectiveLikeText(tree)) { + replaceWithInvalidContent(tree) + return + } + + visit( + tree, + ['textDirective', 'leafDirective', 'containerDirective'], + (node) => { + const directiveNode = node as DirectiveNode + const attributes = normalizeDirectiveAttributes(directiveNode.attributes) + const hProperties: Record = { ...attributes } + + if (hProperties.class) { + hProperties.className = hProperties.class + delete hProperties.class + } + + const data = directiveNode.data || (directiveNode.data = {}) + data.hName = directiveNode.name?.toLowerCase() + data.hProperties = hProperties + }, + ) + } +} + +const directiveComponents = { + withiconcardlist: WithIconCardListAdapter, + withiconcarditem: WithIconCardItemAdapter, +} satisfies Components + +type MarkdownWithDirectiveProps = { + markdown: string +} + +function sanitizeMarkdownInput(markdown: string): string { + if (!markdown) + return '' + + if (typeof DOMPurify.sanitize === 'function') { + return DOMPurify.sanitize(markdown, { + ALLOWED_ATTR: [], + ALLOWED_TAGS: [], + }) + } + + return markdown +} + +export function MarkdownWithDirective({ markdown }: MarkdownWithDirectiveProps) { + const sanitizedMarkdown = sanitizeMarkdownInput(markdown) + const normalizedMarkdown = normalizeDirectiveAttributeBlocks(sanitizedMarkdown) + + if (!normalizedMarkdown) + return null + + return ( +
+ + {normalizedMarkdown} + +
+ + ) +} diff --git a/web/contract/console/notification.ts b/web/contract/console/notification.ts new file mode 100644 index 0000000000..f82d45efa5 --- /dev/null +++ b/web/contract/console/notification.ts @@ -0,0 +1,36 @@ +import { type } from '@orpc/contract' +import { base } from '../base' + +export type ConsoleNotification = { + body: string + frequency: 'once' | 'always' + lang: string + notification_id: string + subtitle: string + title: string + title_pic_url?: string +} + +export type ConsoleNotificationResponse = { + notifications: ConsoleNotification[] + should_show: boolean +} + +export const notificationContract = base + .route({ + path: '/notification', + method: 'GET', + }) + .output(type()) + +export const notificationDismissContract = base + .route({ + path: '/notification/dismiss', + method: 'POST', + }) + .input(type<{ + body: { + notification_id: string + } + }>()) + .output(type()) diff --git a/web/contract/router.ts b/web/contract/router.ts index 79a95be55a..0518378aa0 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -12,6 +12,7 @@ import { exploreInstalledAppsContract, exploreInstalledAppUninstallContract, } from './console/explore' +import { notificationContract, notificationDismissContract } from './console/notification' import { systemFeaturesContract } from './console/system' import { triggerOAuthConfigContract, @@ -67,6 +68,8 @@ export const consoleRouterContract = { invoices: invoicesContract, bindPartnerStack: bindPartnerStackContract, }, + notification: notificationContract, + notificationDismiss: notificationDismissContract, triggers: { list: triggersContract, providerInfo: triggerProviderInfoContract, diff --git a/web/package.json b/web/package.json index 917266b978..5b844527ee 100644 --- a/web/package.json +++ b/web/package.json @@ -147,6 +147,7 @@ "react-window": "1.8.11", "reactflow": "11.11.4", "remark-breaks": "4.0.0", + "remark-directive": "4.0.0", "scheduler": "0.27.0", "semver": "7.7.4", "sharp": "0.34.5", @@ -155,6 +156,7 @@ "string-ts": "2.3.1", "tailwind-merge": "2.6.1", "tldts": "7.0.25", + "unist-util-visit": "5.1.0", "use-context-selector": "2.0.0", "uuid": "13.0.0", "zod": "4.3.6", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index c7e3961d77..48caf3d8dc 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -323,6 +323,9 @@ importers: remark-breaks: specifier: 4.0.0 version: 4.0.0 + remark-directive: + specifier: 4.0.0 + version: 4.0.0 scheduler: specifier: 0.27.0 version: 0.27.0 @@ -347,6 +350,9 @@ importers: tldts: specifier: 7.0.25 version: 7.0.25 + unist-util-visit: + specifier: 5.1.0 + version: 5.1.0 use-context-selector: specifier: 2.0.0 version: 2.0.0(react@19.2.4)(scheduler@0.27.0) @@ -5605,6 +5611,9 @@ packages: engines: {node: '>= 20'} hasBin: true + mdast-util-directive@3.1.0: + resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -5690,6 +5699,9 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + micromark-extension-frontmatter@2.0.0: resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} @@ -6533,6 +6545,9 @@ packages: remark-breaks@4.0.0: resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + remark-directive@4.0.0: + resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -13138,6 +13153,20 @@ snapshots: marked@17.0.4: {} + mdast-util-directive@3.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -13386,6 +13415,16 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + micromark-extension-frontmatter@2.0.0: dependencies: fault: 2.0.1 @@ -14442,6 +14481,15 @@ snapshots: mdast-util-newline-to-break: 2.0.0 unified: 11.0.5 + remark-directive@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.1.0 + micromark-extension-directive: 4.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 diff --git a/web/public/in-site-message/header-bg.svg b/web/public/in-site-message/header-bg.svg new file mode 100644 index 0000000000..da1f0ca5e8 --- /dev/null +++ b/web/public/in-site-message/header-bg.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +