Merge branch 'feat/model-plugins-implementing' into deploy/dev

# Conflicts:
#	web/app/components/app/in-site-message/index.spec.tsx
#	web/app/components/app/in-site-message/index.tsx
#	web/app/components/app/in-site-message/notification.spec.tsx
#	web/app/components/app/in-site-message/notification.tsx
#	web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts
#	web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx
#	web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx
#	web/app/components/base/markdown-with-directive/components/with-icon-card-list.tsx
#	web/app/components/base/markdown-with-directive/index.spec.tsx
#	web/app/components/base/markdown-with-directive/index.tsx
#	web/package.json
#	web/pnpm-lock.yaml
This commit is contained in:
yyh
2026-03-11 19:05:09 +08:00
12 changed files with 91 additions and 59 deletions

View File

@ -1,4 +1,3 @@
/* eslint-disable e18e/prefer-static-regex */
import type { InSiteMessageActionItem } from './index'
import { fireEvent, render, screen } from '@testing-library/react'
import InSiteMessage from './index'

View File

@ -88,7 +88,7 @@ function InSiteMessage({
return
}
window.open(linkData.href, target, linkData.rel ?? 'noopener,noreferrer')
window.open(linkData.href, target, linkData.rel || 'noopener,noreferrer')
}
if (!visible)

View File

@ -22,16 +22,20 @@ vi.mock('@/config', () => ({
}))
vi.mock('@/service/client', () => ({
consoleClient: {
notification: (...args: unknown[]) => mockNotification(...args),
notificationDismiss: (...args: unknown[]) => mockNotificationDismiss(...args),
},
consoleQuery: {
notification: {
queryKey: () => ['console', 'notification'],
queryOptions: (options?: Record<string, unknown>) => ({
queryKey: ['console', 'notification'],
queryFn: (...args: unknown[]) => mockNotification(...args),
...options,
}),
},
notificationDismiss: {
mutationKey: () => ['console', 'notificationDismiss'],
mutationOptions: (options?: Record<string, unknown>) => ({
mutationKey: ['console', 'notificationDismiss'],
mutationFn: (...args: unknown[]) => mockNotificationDismiss(...args),
...options,
}),
},
},
}))
@ -131,11 +135,16 @@ describe('InSiteMessageNotification', () => {
fireEvent.click(screen.getByRole('button', { name: 'Dismiss now' }))
await waitFor(() => {
expect(mockNotificationDismiss).toHaveBeenCalledWith({
body: {
notification_id: 'n-1',
expect(mockNotificationDismiss).toHaveBeenCalledWith(
{
body: {
notification_id: 'n-1',
},
},
})
expect.objectContaining({
mutationKey: ['console', 'notificationDismiss'],
}),
)
})
})
@ -164,11 +173,16 @@ describe('InSiteMessageNotification', () => {
fireEvent.click(closeButton)
await waitFor(() => {
expect(mockNotificationDismiss).toHaveBeenCalledWith({
body: {
notification_id: 'n-2',
expect(mockNotificationDismiss).toHaveBeenCalledWith(
{
body: {
notification_id: 'n-2',
},
},
})
expect.objectContaining({
mutationKey: ['console', 'notificationDismiss'],
}),
)
})
})

View File

@ -4,7 +4,7 @@ import type { InSiteMessageActionItem } from './index'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { consoleClient, consoleQuery } from '@/service/client'
import { consoleQuery } from '@/service/client'
import InSiteMessage from './index'
type NotificationBodyPayload = {
@ -60,24 +60,11 @@ function parseNotificationBody(body: string): NotificationBodyPayload | null {
function InSiteMessageNotification() {
const { t } = useTranslation()
const dismissNotificationMutation = useMutation({
mutationKey: consoleQuery.notificationDismiss.mutationKey(),
mutationFn: async (notificationId: string) => {
return await consoleClient.notificationDismiss({
body: {
notification_id: notificationId,
},
})
},
})
const dismissNotificationMutation = useMutation(consoleQuery.notificationDismiss.mutationOptions())
const { data } = useQuery({
queryKey: consoleQuery.notification.queryKey(),
queryFn: async () => {
return await consoleClient.notification()
},
const { data } = useQuery(consoleQuery.notification.queryOptions({
enabled: IS_CLOUD_EDITION,
})
}))
const notification = data?.notifications?.[0]
const parsedBody = notification ? parseNotificationBody(notification.body) : null
@ -99,7 +86,11 @@ function InSiteMessageNotification() {
if (action.action !== 'close')
return
dismissNotificationMutation.mutate(notification.notification_id)
dismissNotificationMutation.mutate({
body: {
notification_id: notification.notification_id,
},
})
}
return (

View File

@ -1,4 +1,4 @@
import { z } from 'zod'
import * as z from 'zod'
const commonSchema = {
className: z.string().min(1).optional(),

View File

@ -10,20 +10,34 @@ describe('WithIconCardItem', () => {
vi.clearAllMocks()
})
// Verify icon image and content rendering.
describe('rendering', () => {
it('should render icon image and children content', () => {
render(
it('should render a decorative icon and children content by default', () => {
const { container } = render(
<WithIconCardItem icon="https://example.com/icon.png">
<span>Card item content</span>
</WithIconCardItem>,
)
const icon = screen.getByAltText('icon')
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(
<WithIconCardItem icon="https://example.com/icon.png" iconAlt="Card icon">
<span>Accessible card item content</span>
</WithIconCardItem>,
)
const icon = screen.getByAltText('Card icon')
expect(icon).toBeInTheDocument()
expect(icon).not.toHaveAttribute('aria-hidden')
expect(screen.getByText('Accessible card item content')).toBeInTheDocument()
})
})
})

View File

@ -1,19 +1,29 @@
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 }: WithIconItemProps) {
function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) {
return (
<div className="flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2">
<div className={cn('flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2', className)}>
{/*
* unoptimized to "url parameter is not allowed" for external domains despite correct remotePatterns configuration.
* https://github.com/vercel/next.js/issues/88873
*/}
<Image src={icon} className="!border-none object-contain" alt="icon" width={40} height={40} unoptimized />
<Image
src={icon}
className="!border-none object-contain"
alt={iconAlt ?? ''}
aria-hidden={iconAlt ? undefined : true}
width={40}
height={40}
unoptimized
/>
<div className="min-w-0 grow overflow-hidden text-text-secondary system-sm-medium [&_p]:!m-0 [&_p]:block [&_p]:w-full [&_p]:overflow-hidden [&_p]:text-ellipsis [&_p]:whitespace-nowrap">
{children}
</div>

View File

@ -6,7 +6,7 @@ type WithIconListProps = WithIconCardListProps & {
children?: ReactNode
}
function WithIconList({ children, className }: WithIconListProps) {
function WithIconCardList({ children, className }: WithIconListProps) {
return (
<div className={cn('space-y-1', className)}>
{children}
@ -14,4 +14,4 @@ function WithIconList({ children, className }: WithIconListProps) {
)
}
export default WithIconList
export default WithIconCardList

View File

@ -11,6 +11,14 @@ vi.mock('next/image', () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
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()
@ -108,15 +116,13 @@ describe('markdown-with-directive', () => {
// Validate WithIconCardItem rendering and image prop forwarding.
describe('WithIconCardItem component', () => {
it('should render icon image and child content', () => {
render(
const { container } = render(
<WithIconCardItem icon="https://example.com/icon.png">
<span>Card item content</span>
</WithIconCardItem>,
)
const icon = screen.getByAltText('icon')
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute('src', 'https://example.com/icon.png')
expectDecorativeIcon(container, 'https://example.com/icon.png')
expect(screen.getByText('Card item content')).toBeInTheDocument()
})
})
@ -136,7 +142,7 @@ describe('markdown-with-directive', () => {
expect(list).toBeInTheDocument()
expect(list).toHaveClass('space-y-1')
expect(screen.getByText('Card Title')).toBeInTheDocument()
expect(screen.getByAltText('icon')).toHaveAttribute('src', 'https://example.com/icon.png')
expectDecorativeIcon(container, 'https://example.com/icon.png')
})
it('should replace output with invalid content when directive is unknown', () => {
@ -175,14 +181,14 @@ describe('markdown-with-directive', () => {
const sanitizeSpy = vi.spyOn(DOMPurify, 'sanitize')
.mockReturnValue(':withiconcarditem[Sanitized]{icon="https://example.com/safe.png"}')
render(<MarkdownWithDirective markdown="<script>alert(1)</script>" />)
const { container } = render(<MarkdownWithDirective markdown="<script>alert(1)</script>" />)
expect(sanitizeSpy).toHaveBeenCalledWith('<script>alert(1)</script>', {
ALLOWED_ATTR: [],
ALLOWED_TAGS: [],
})
expect(screen.getByText('Sanitized')).toBeInTheDocument()
expect(screen.getByAltText('icon')).toHaveAttribute('src', 'https://example.com/safe.png')
expectDecorativeIcon(container, 'https://example.com/safe.png')
})
it('should render empty output and skip sanitizer when markdown is empty', () => {

View File

@ -1,3 +1,5 @@
'use client'
import type { ReactNode } from 'react'
import type { Components, StreamdownProps } from 'streamdown'
import DOMPurify from 'dompurify'
import remarkDirective from 'remark-directive'
@ -13,7 +15,7 @@ function WithIconCardListAdapter(props: Record<string, unknown>) {
const { children, className } = props
return (
<WithIconCardList
children={children as React.ReactNode}
children={children as ReactNode}
className={typeof className === 'string' ? className : undefined}
/>
)
@ -27,7 +29,7 @@ function WithIconCardItemAdapter(props: Record<string, unknown>) {
icon={typeof icon === 'string' ? icon : ''}
className={typeof className === 'string' ? className : undefined}
>
{children as React.ReactNode}
{children as ReactNode}
</WithIconCardItem>
)
}

View File

@ -156,7 +156,6 @@
"string-ts": "2.3.1",
"tailwind-merge": "2.6.1",
"tldts": "7.0.25",
"ufo": "1.6.3",
"unist-util-visit": "5.1.0",
"use-context-selector": "2.0.0",
"uuid": "13.0.0",

7
web/pnpm-lock.yaml generated
View File

@ -350,9 +350,6 @@ importers:
tldts:
specifier: 7.0.25
version: 7.0.25
ufo:
specifier: 1.6.3
version: 1.6.3
unist-util-visit:
specifier: 5.1.0
version: 5.1.0
@ -8135,7 +8132,7 @@ snapshots:
'@code-inspector/core@1.4.4':
dependencies:
'@vue/compiler-dom': 3.5.30
chalk: 4.1.1
chalk: 4.1.2
dotenv: 16.6.1
launch-ide: 1.4.3
portfinder: 1.0.38
@ -12986,7 +12983,7 @@ snapshots:
launch-ide@1.4.3:
dependencies:
chalk: 4.1.1
chalk: 4.1.2
dotenv: 16.6.1
layout-base@1.0.2: {}