mirror of
https://github.com/langgenius/dify.git
synced 2026-04-29 06:58:05 +08:00
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:
@ -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'
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
const commonSchema = {
|
||||
className: z.string().min(1).optional(),
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
7
web/pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user