chore: remove next img (#33517)

This commit is contained in:
Stephen Zhou 2026-03-16 16:48:22 +08:00 committed by GitHub
parent 041d7ffe3d
commit 4822d550b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 206 additions and 287 deletions

View File

@ -4,7 +4,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks' import { useDebounceFn, useKeyPress } from 'ahooks'
import Image from 'next/image'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -117,10 +116,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
<div className="px-10"> <div className="px-10">
<div className="h-6 w-full 2xl:h-[139px]" /> <div className="h-6 w-full 2xl:h-[139px]" />
<div className="pb-6 pt-1"> <div className="pb-6 pt-1">
<span className="title-2xl-semi-bold text-text-primary">{t('newApp.startFromBlank', { ns: 'app' })}</span> <span className="text-text-primary title-2xl-semi-bold">{t('newApp.startFromBlank', { ns: 'app' })}</span>
</div> </div>
<div className="mb-2 leading-6"> <div className="mb-2 leading-6">
<span className="system-sm-semibold text-text-secondary">{t('newApp.chooseAppType', { ns: 'app' })}</span> <span className="text-text-secondary system-sm-semibold">{t('newApp.chooseAppType', { ns: 'app' })}</span>
</div> </div>
<div className="flex w-[660px] flex-col gap-4"> <div className="flex w-[660px] flex-col gap-4">
<div> <div>
@ -160,7 +159,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
className="flex cursor-pointer items-center border-0 bg-transparent p-0" className="flex cursor-pointer items-center border-0 bg-transparent p-0"
onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)} onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)}
> >
<span className="system-2xs-medium-uppercase text-text-tertiary">{t('newApp.forBeginners', { ns: 'app' })}</span> <span className="text-text-tertiary system-2xs-medium-uppercase">{t('newApp.forBeginners', { ns: 'app' })}</span>
<RiArrowRightSLine className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} /> <RiArrowRightSLine className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} />
</button> </button>
</div> </div>
@ -212,7 +211,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="flex-1"> <div className="flex-1">
<div className="mb-1 flex h-6 items-center"> <div className="mb-1 flex h-6 items-center">
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionName', { ns: 'app' })}</label> <label className="text-text-secondary system-sm-semibold">{t('newApp.captionName', { ns: 'app' })}</label>
</div> </div>
<Input <Input
value={name} value={name}
@ -243,8 +242,8 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div> </div>
<div> <div>
<div className="mb-1 flex h-6 items-center"> <div className="mb-1 flex h-6 items-center">
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionDescription', { ns: 'app' })}</label> <label className="text-text-secondary system-sm-semibold">{t('newApp.captionDescription', { ns: 'app' })}</label>
<span className="system-xs-regular ml-1 text-text-tertiary"> <span className="ml-1 text-text-tertiary system-xs-regular">
( (
{t('newApp.optional', { ns: 'app' })} {t('newApp.optional', { ns: 'app' })}
) )
@ -260,7 +259,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div> </div>
{isAppsFull && <AppsFull className="mt-4" loc="app-create" />} {isAppsFull && <AppsFull className="mt-4" loc="app-create" />}
<div className="flex items-center justify-between pb-10 pt-5"> <div className="flex items-center justify-between pb-10 pt-5">
<div className="system-xs-regular flex cursor-pointer items-center gap-1 text-text-tertiary" onClick={onCreateFromTemplate}> <div className="flex cursor-pointer items-center gap-1 text-text-tertiary system-xs-regular" onClick={onCreateFromTemplate}>
<span>{t('newApp.noIdeaTip', { ns: 'app' })}</span> <span>{t('newApp.noIdeaTip', { ns: 'app' })}</span>
<div className="p-[1px]"> <div className="p-[1px]">
<RiArrowRightLine className="h-3.5 w-3.5" /> <RiArrowRightLine className="h-3.5 w-3.5" />
@ -334,8 +333,8 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
onClick={onClick} onClick={onClick}
> >
{icon} {icon}
<div className="system-sm-semibold mb-0.5 mt-2 text-text-secondary">{title}</div> <div className="mb-0.5 mt-2 text-text-secondary system-sm-semibold">{title}</div>
<div className="system-xs-regular line-clamp-2 text-text-tertiary" title={description}>{description}</div> <div className="line-clamp-2 text-text-tertiary system-xs-regular" title={description}>{description}</div>
</div> </div>
) )
} }
@ -367,8 +366,8 @@ function AppPreview({ mode }: { mode: AppModeEnum }) {
const previewInfo = modeToPreviewInfoMap[mode] const previewInfo = modeToPreviewInfoMap[mode]
return ( return (
<div className="px-8 py-4"> <div className="px-8 py-4">
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4> <h4 className="text-text-secondary system-sm-semibold-uppercase">{previewInfo.title}</h4>
<div className="system-xs-regular mt-1 min-h-8 max-w-96 text-text-tertiary"> <div className="mt-1 min-h-8 max-w-96 text-text-tertiary system-xs-regular">
<span>{previewInfo.description}</span> <span>{previewInfo.description}</span>
</div> </div>
</div> </div>
@ -389,7 +388,7 @@ function AppScreenShot({ mode, show }: { mode: AppModeEnum, show: boolean }) {
<source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} /> <source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} />
<source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@2x.png`} /> <source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@2x.png`} />
<source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@3x.png`} /> <source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@3x.png`} />
<Image <img
className={show ? '' : 'hidden'} className={show ? '' : 'hidden'}
src={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} src={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`}
alt="App Screen Shot" alt="App Screen Shot"

View File

@ -1,5 +1,3 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import type { EmbeddedChatbotContextValue } from '../../context' import type { EmbeddedChatbotContextValue } from '../../context'
import type { AppData } from '@/models/share' import type { AppData } from '@/models/share'
import type { SystemFeatures } from '@/types/feature' import type { SystemFeatures } from '@/types/feature'
@ -22,15 +20,6 @@ vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropd
default: () => <div data-testid="view-form-dropdown" />, default: () => <div data-testid="view-form-dropdown" />,
})) }))
// Mock next/image to render a normal img tag for testing
vi.mock('next/image', () => ({
__esModule: true,
default: (props: ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => {
const { unoptimized: _, ...rest } = props
return <img {...rest} />
},
}))
type GlobalPublicStoreMock = { type GlobalPublicStoreMock = {
systemFeatures: SystemFeatures systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void setSystemFeatures: (systemFeatures: SystemFeatures) => void

View File

@ -1,13 +1,7 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import CheckboxList from '..' import CheckboxList from '..'
vi.mock('next/image', () => ({
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
describe('checkbox list component', () => { describe('checkbox list component', () => {
const options = [ const options = [
{ label: 'Option 1', value: 'option1' }, { label: 'Option 1', value: 'option1' },

View File

@ -1,6 +1,5 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import Image from 'next/image'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge' import Badge from '@/app/components/base/badge'
@ -169,7 +168,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
{searchQuery {searchQuery
? ( ? (
<div className="flex flex-col items-center justify-center gap-2"> <div className="flex flex-col items-center justify-center gap-2">
<Image alt="search menu" src={SearchMenu} width={32} /> <img alt="search menu" src={SearchMenu.src} width={32} />
<span className="text-text-secondary system-sm-regular">{t('operation.noSearchResults', { ns: 'common', content: title })}</span> <span className="text-text-secondary system-sm-regular">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button> <Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
</div> </div>

View File

@ -1,14 +1,7 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import FileThumb from '../index' import FileThumb from '../index'
vi.mock('next/image', () => ({
__esModule: true,
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
describe('FileThumb Component', () => { describe('FileThumb Component', () => {
const mockImageFile = { const mockImageFile = {
name: 'test-image.jpg', name: 'test-image.jpg',

View File

@ -1,10 +1,6 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import WithIconCardItem from './with-icon-card-item' import WithIconCardItem from './with-icon-card-item'
vi.mock('next/image', () => ({
default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => <img {...props} />,
}))
describe('WithIconCardItem', () => { describe('WithIconCardItem', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()

View File

@ -1,6 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { WithIconCardItemProps } from './markdown-with-directive-schema' import type { WithIconCardItemProps } from './markdown-with-directive-schema'
import Image from 'next/image'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
type WithIconItemProps = WithIconCardItemProps & { type WithIconItemProps = WithIconCardItemProps & {
@ -11,18 +10,13 @@ type WithIconItemProps = WithIconCardItemProps & {
function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) { function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) {
return ( return (
<div className={cn('flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2', className)}> <div className={cn('flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2', className)}>
{/* <img
* 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} src={icon}
className="!border-none object-contain" className="!border-none object-contain"
alt={iconAlt ?? ''} alt={iconAlt ?? ''}
aria-hidden={iconAlt ? undefined : true} aria-hidden={iconAlt ? undefined : true}
width={40} width={40}
height={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"> <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} {children}

View File

@ -7,10 +7,6 @@ import { MarkdownWithDirective } from './index'
const FOUR_COLON_RE = /:{4}/ const FOUR_COLON_RE = /:{4}/
vi.mock('next/image', () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
function expectDecorativeIcon(container: HTMLElement, src: string) { function expectDecorativeIcon(container: HTMLElement, src: string) {
const icon = container.querySelector('img') const icon = container.querySelector('img')
expect(icon).toBeInTheDocument() expect(icon).toBeInTheDocument()

View File

@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import CredentialSelector from '../index' import CredentialSelector from '../index'
// Mock CredentialIcon since it's likely a complex component or uses next/image // Mock CredentialIcon since it's likely a complex component.
vi.mock('@/app/components/datasets/common/credential-icon', () => ({ vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: ({ name }: { name: string }) => <div data-testid="credential-icon">{name}</div>, CredentialIcon: ({ name }: { name: string }) => <div data-testid="credential-icon">{name}</div>,
})) }))

View File

@ -4,13 +4,6 @@ import { RETRIEVE_METHOD } from '@/types/app'
import { retrievalIcon } from '../../../create/icons' import { retrievalIcon } from '../../../create/icons'
import RetrievalMethodInfo, { getIcon } from '../index' import RetrievalMethodInfo, { getIcon } from '../index'
// Override global next/image auto-mock: tests assert on rendered <img> src attributes via data-testid
vi.mock('next/image', () => ({
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
<img src={src} alt={alt || ''} className={className} data-testid="method-icon" />
),
}))
// Mock RadioCard // Mock RadioCard
vi.mock('@/app/components/base/radio-card', () => ({ vi.mock('@/app/components/base/radio-card', () => ({
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => ( default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
@ -50,7 +43,7 @@ describe('RetrievalMethodInfo', () => {
}) })
it('should render correctly with full config', () => { it('should render correctly with full config', () => {
render(<RetrievalMethodInfo value={defaultConfig} />) const { container } = render(<RetrievalMethodInfo value={defaultConfig} />)
expect(screen.getByTestId('radio-card')).toBeInTheDocument() expect(screen.getByTestId('radio-card')).toBeInTheDocument()
@ -59,7 +52,7 @@ describe('RetrievalMethodInfo', () => {
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description') expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
// Check Icon // Check Icon
const icon = screen.getByTestId('method-icon') const icon = container.querySelector('img')
expect(icon).toHaveAttribute('src', 'vector-icon.png') expect(icon).toHaveAttribute('src', 'vector-icon.png')
// Check Config Details // Check Config Details
@ -87,18 +80,18 @@ describe('RetrievalMethodInfo', () => {
it('should handle different retrieval methods', () => { it('should handle different retrieval methods', () => {
// Test Hybrid // Test Hybrid
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid } const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
const { unmount } = render(<RetrievalMethodInfo value={hybridConfig} />) const { container, unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title') expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png') expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png')
unmount() unmount()
// Test FullText // Test FullText
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText } const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
render(<RetrievalMethodInfo value={fullTextConfig} />) const { container: fullTextContainer } = render(<RetrievalMethodInfo value={fullTextConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title') expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png') expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png')
}) })
describe('getIcon utility', () => { describe('getIcon utility', () => {
@ -132,17 +125,17 @@ describe('RetrievalMethodInfo', () => {
it('should render correctly with invertedIndex search method', () => { it('should render correctly with invertedIndex search method', () => {
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex } const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
render(<RetrievalMethodInfo value={invertedIndexConfig} />) const { container } = render(<RetrievalMethodInfo value={invertedIndexConfig} />)
// invertedIndex uses vector icon // invertedIndex uses vector icon
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png') expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
}) })
it('should render correctly with keywordSearch search method', () => { it('should render correctly with keywordSearch search method', () => {
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch } const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
render(<RetrievalMethodInfo value={keywordSearchConfig} />) const { container } = render(<RetrievalMethodInfo value={keywordSearchConfig} />)
// keywordSearch uses vector icon // keywordSearch uses vector icon
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png') expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
}) })
}) })

View File

@ -1,7 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import type { RetrievalConfig } from '@/types/app' import type { RetrievalConfig } from '@/types/app'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RadioCard from '@/app/components/base/radio-card' import RadioCard from '@/app/components/base/radio-card'
@ -28,7 +27,7 @@ const EconomicalRetrievalMethodConfig: FC<Props> = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const type = value.search_method const type = value.search_method
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" /> const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<RadioCard <RadioCard

View File

@ -1,7 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import type { RetrievalConfig } from '@/types/app' import type { RetrievalConfig } from '@/types/app'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
@ -127,7 +126,7 @@ const RetrievalParamConfig: FC<Props> = ({
/> />
)} )}
<div className="flex items-center"> <div className="flex items-center">
<span className="system-sm-semibold mr-0.5 text-text-secondary">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span> <span className="mr-0.5 text-text-secondary system-sm-semibold">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span>
<Tooltip <Tooltip
popupContent={ popupContent={
<div className="w-[200px]">{t('modelProvider.rerankModel.tip', { ns: 'common' })}</div> <div className="w-[200px]">{t('modelProvider.rerankModel.tip', { ns: 'common' })}</div>
@ -157,7 +156,7 @@ const RetrievalParamConfig: FC<Props> = ({
<div className="p-1"> <div className="p-1">
<AlertTriangle className="size-4 text-text-warning-secondary" /> <AlertTriangle className="size-4 text-text-warning-secondary" />
</div> </div>
<span className="system-xs-medium text-text-primary"> <span className="text-text-primary system-xs-medium">
{t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })} {t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
</span> </span>
</div> </div>
@ -215,11 +214,11 @@ const RetrievalParamConfig: FC<Props> = ({
isChosen={value.reranking_mode === option.value} isChosen={value.reranking_mode === option.value}
onChosen={() => handleChangeRerankMode(option.value)} onChosen={() => handleChangeRerankMode(option.value)}
icon={( icon={(
<Image <img
src={ src={
option.value === RerankingModeEnum.WeightedScore option.value === RerankingModeEnum.WeightedScore
? ProgressIndicator ? ProgressIndicator.src
: Reranking : Reranking.src
} }
alt="" alt=""
/> />
@ -281,7 +280,7 @@ const RetrievalParamConfig: FC<Props> = ({
<div className="p-1"> <div className="p-1">
<AlertTriangle className="size-4 text-text-warning-secondary" /> <AlertTriangle className="size-4 text-text-warning-secondary" />
</div> </div>
<span className="system-xs-medium text-text-primary"> <span className="text-text-primary system-xs-medium">
{t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })} {t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
</span> </span>
</div> </div>

View File

@ -20,14 +20,6 @@ vi.mock('next/navigation', () => ({
useRouter: () => mockRouter, useRouter: () => mockRouter,
})) }))
// Override global next/image auto-mock: test asserts on data-testid="next-image"
vi.mock('next/image', () => ({
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
// eslint-disable-next-line next/no-img-element
<img src={src} alt={alt} className={className} data-testid="next-image" />
),
}))
// Mock API service // Mock API service
const mockFetchIndexingStatusBatch = vi.fn() const mockFetchIndexingStatusBatch = vi.fn()
vi.mock('@/service/datasets', () => ({ vi.mock('@/service/datasets', () => ({
@ -979,9 +971,9 @@ describe('RuleDetail', () => {
}) })
it('should render correct icon for indexing type', () => { it('should render correct icon for indexing type', () => {
render(<RuleDetail indexingType="high_quality" />) const { container } = render(<RuleDetail indexingType="high_quality" />)
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images.length).toBeGreaterThan(0) expect(images.length).toBeGreaterThan(0)
}) })
}) })

View File

@ -1,6 +1,5 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { ProcessRuleResponse } from '@/models/datasets' import type { ProcessRuleResponse } from '@/models/datasets'
import Image from 'next/image'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata' import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata'
@ -119,12 +118,12 @@ const RuleDetail: FC<RuleDetailProps> = ({ sourceData, indexingType, retrievalMe
<FieldInfo <FieldInfo
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })} label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={indexModeLabel} displayedValue={indexModeLabel}
valueIcon={<Image className="size-4" src={indexMethodIconSrc} alt="" />} valueIcon={<img className="size-4" src={indexMethodIconSrc} alt="" />}
/> />
<FieldInfo <FieldInfo
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })} label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={retrievalLabel} displayedValue={retrievalLabel}
valueIcon={<Image className="size-4" src={retrievalIconSrc} alt="" />} valueIcon={<img className="size-4" src={retrievalIconSrc} alt="" />}
/> />
</div> </div>
) )

View File

@ -5,12 +5,12 @@ import Research from './assets/research-mod.svg'
import Selection from './assets/selection-mod.svg' import Selection from './assets/selection-mod.svg'
export const indexMethodIcon = { export const indexMethodIcon = {
high_quality: GoldIcon, high_quality: GoldIcon.src,
economical: Piggybank, economical: Piggybank.src,
} }
export const retrievalIcon = { export const retrievalIcon = {
vector: Selection, vector: Selection.src,
fullText: Research, fullText: Research.src,
hybrid: PatternRecognition, hybrid: PatternRecognition.src,
} }

View File

@ -2,13 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OptionCard, OptionCardHeader } from '../option-card' import { OptionCard, OptionCardHeader } from '../option-card'
// Override global next/image auto-mock: tests assert on rendered <img> elements
vi.mock('next/image', () => ({
default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => (
<img src={src} alt={alt} {...props} />
),
}))
describe('OptionCardHeader', () => { describe('OptionCardHeader', () => {
const defaultProps = { const defaultProps = {
icon: <span data-testid="icon">icon</span>, icon: <span data-testid="icon">icon</span>,

View File

@ -6,7 +6,6 @@ import {
RiAlertFill, RiAlertFill,
RiSearchEyeLine, RiSearchEyeLine,
} from '@remixicon/react' } from '@remixicon/react'
import Image from 'next/image'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox' import Checkbox from '@/app/components/base/checkbox'
@ -26,7 +25,7 @@ type TextLabelProps = {
} }
const TextLabel: FC<TextLabelProps> = ({ children }) => { const TextLabel: FC<TextLabelProps> = ({ children }) => {
return <label className="system-sm-semibold text-text-secondary">{children}</label> return <label className="text-text-secondary system-sm-semibold">{children}</label>
} }
type GeneralChunkingOptionsProps = { type GeneralChunkingOptionsProps = {
@ -97,7 +96,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
<OptionCard <OptionCard
className="mb-2 bg-background-section" className="mb-2 bg-background-section"
title={t('stepTwo.general', { ns: 'datasetCreation' })} title={t('stepTwo.general', { ns: 'datasetCreation' })}
icon={<Image width={20} height={20} src={SettingCog} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />} icon={<img width={20} height={20} src={SettingCog.src} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
activeHeaderClassName="bg-dataset-option-card-blue-gradient" activeHeaderClassName="bg-dataset-option-card-blue-gradient"
description={t('stepTwo.generalTip', { ns: 'datasetCreation' })} description={t('stepTwo.generalTip', { ns: 'datasetCreation' })}
isActive={isActive} isActive={isActive}
@ -148,7 +147,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
onClick={() => onRuleToggle(rule.id)} onClick={() => onRuleToggle(rule.id)}
> >
<Checkbox checked={rule.enabled} /> <Checkbox checked={rule.enabled} />
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary"> <label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
{getRuleName(rule.id)} {getRuleName(rule.id)}
</label> </label>
</div> </div>
@ -183,7 +182,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
checked={currentDocForm === ChunkingMode.qa} checked={currentDocForm === ChunkingMode.qa}
disabled={hasCurrentDatasetDocForm} disabled={hasCurrentDatasetDocForm}
/> />
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary"> <label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
{t('stepTwo.useQALanguage', { ns: 'datasetCreation' })} {t('stepTwo.useQALanguage', { ns: 'datasetCreation' })}
</label> </label>
</div> </div>
@ -202,7 +201,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
className="mt-2 flex h-10 items-center gap-2 rounded-xl border border-components-panel-border px-3 text-xs shadow-xs backdrop-blur-[5px]" className="mt-2 flex h-10 items-center gap-2 rounded-xl border border-components-panel-border px-3 text-xs shadow-xs backdrop-blur-[5px]"
> >
<RiAlertFill className="size-4 text-text-warning-secondary" /> <RiAlertFill className="size-4 text-text-warning-secondary" />
<span className="system-xs-medium text-text-primary"> <span className="text-text-primary system-xs-medium">
{t('stepTwo.QATip', { ns: 'datasetCreation' })} {t('stepTwo.QATip', { ns: 'datasetCreation' })}
</span> </span>
</div> </div>

View File

@ -3,7 +3,6 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app' import type { RetrievalConfig } from '@/types/app'
import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge' import Badge from '@/app/components/base/badge'
@ -70,7 +69,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
return ( return (
<> <>
{/* Index Mode */} {/* Index Mode */}
<div className="system-md-semibold mb-1 text-text-secondary"> <div className="mb-1 text-text-secondary system-md-semibold">
{t('stepTwo.indexMode', { ns: 'datasetCreation' })} {t('stepTwo.indexMode', { ns: 'datasetCreation' })}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -98,7 +97,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
</div> </div>
)} )}
description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })} description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })}
icon={<Image src={indexMethodIcon.high_quality} alt="" />} icon={<img src={indexMethodIcon.high_quality} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED} isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED}
disabled={hasSetIndexType} disabled={hasSetIndexType}
onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)} onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)}
@ -143,7 +142,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
className="h-full" className="h-full"
title={t('stepTwo.economical', { ns: 'datasetCreation' })} title={t('stepTwo.economical', { ns: 'datasetCreation' })}
description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })} description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
icon={<Image src={indexMethodIcon.economical} alt="" />} icon={<img src={indexMethodIcon.economical} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
disabled={hasSetIndexType || docForm !== ChunkingMode.text} disabled={hasSetIndexType || docForm !== ChunkingMode.text}
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)} onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
@ -160,7 +159,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
<div className="p-1"> <div className="p-1">
<AlertTriangle className="size-4 text-text-warning-secondary" /> <AlertTriangle className="size-4 text-text-warning-secondary" />
</div> </div>
<span className="system-xs-medium text-text-primary"> <span className="text-text-primary system-xs-medium">
{t('stepTwo.highQualityTip', { ns: 'datasetCreation' })} {t('stepTwo.highQualityTip', { ns: 'datasetCreation' })}
</span> </span>
</div> </div>
@ -168,7 +167,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
{/* Economical index setting tip */} {/* Economical index setting tip */}
{hasSetIndexType && indexType === IndexingType.ECONOMICAL && ( {hasSetIndexType && indexType === IndexingType.ECONOMICAL && (
<div className="system-xs-medium mt-2 text-text-tertiary"> <div className="mt-2 text-text-tertiary system-xs-medium">
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}> <Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
@ -179,7 +178,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
{/* Embedding model */} {/* Embedding model */}
{indexType === IndexingType.QUALIFIED && ( {indexType === IndexingType.QUALIFIED && (
<div className="mt-5"> <div className="mt-5">
<div className={cn('system-md-semibold mb-1 text-text-secondary', datasetId && 'flex items-center justify-between')}> <div className={cn('mb-1 text-text-secondary system-md-semibold', datasetId && 'flex items-center justify-between')}>
{t('form.embeddingModel', { ns: 'datasetSettings' })} {t('form.embeddingModel', { ns: 'datasetSettings' })}
</div> </div>
<ModelSelector <ModelSelector
@ -190,7 +189,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
onSelect={onEmbeddingModelChange} onSelect={onEmbeddingModelChange}
/> />
{isModelAndRetrievalConfigDisabled && ( {isModelAndRetrievalConfigDisabled && (
<div className="system-xs-medium mt-2 text-text-tertiary"> <div className="mt-2 text-text-tertiary system-xs-medium">
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}> <Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
@ -207,10 +206,10 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
{!isModelAndRetrievalConfigDisabled {!isModelAndRetrievalConfigDisabled
? ( ? (
<div className="mb-1"> <div className="mb-1">
<div className="system-md-semibold mb-0.5 text-text-secondary"> <div className="mb-0.5 text-text-secondary system-md-semibold">
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })} {t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
</div> </div>
<div className="body-xs-regular text-text-tertiary"> <div className="text-text-tertiary body-xs-regular">
<a <a
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@ -224,7 +223,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
</div> </div>
) )
: ( : (
<div className={cn('system-md-semibold mb-0.5 text-text-secondary', 'flex items-center justify-between')}> <div className={cn('mb-0.5 text-text-secondary system-md-semibold', 'flex items-center justify-between')}>
<div>{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div> <div>{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div>
</div> </div>
)} )}

View File

@ -1,5 +1,4 @@
import type { ComponentProps, FC, ReactNode } from 'react' import type { ComponentProps, FC, ReactNode } from 'react'
import Image from 'next/image'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
const TriangleArrow: FC<ComponentProps<'svg'>> = props => ( const TriangleArrow: FC<ComponentProps<'svg'>> = props => (
@ -23,7 +22,7 @@ export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
return ( return (
<div className={cn('relative flex h-full overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}> <div className={cn('relative flex h-full overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}>
<div className="relative flex size-14 items-center justify-center overflow-hidden"> <div className="relative flex size-14 items-center justify-center overflow-hidden">
{isActive && effectImg && <Image src={effectImg} className="absolute left-0 top-0 h-full w-full" alt="" width={56} height={56} />} {isActive && effectImg && <img src={effectImg} className="absolute left-0 top-0 h-full w-full" alt="" width={56} height={56} />}
<div className="p-1"> <div className="p-1">
<div className="flex size-8 justify-center rounded-lg border border-components-panel-border-subtle bg-background-default-dodge p-1.5 shadow-md"> <div className="flex size-8 justify-center rounded-lg border border-components-panel-border-subtle bg-background-default-dodge p-1.5 shadow-md">
{icon} {icon}
@ -34,8 +33,8 @@ export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
className={cn('absolute -bottom-1.5 left-4 text-transparent', isActive && 'text-components-panel-bg')} className={cn('absolute -bottom-1.5 left-4 text-transparent', isActive && 'text-components-panel-bg')}
/> />
<div className="flex-1 space-y-0.5 py-3 pr-4"> <div className="flex-1 space-y-0.5 py-3 pr-4">
<div className="system-md-semibold text-text-secondary">{title}</div> <div className="text-text-secondary system-md-semibold">{title}</div>
<div className="system-xs-regular text-text-tertiary">{description}</div> <div className="text-text-tertiary system-xs-regular">{description}</div>
</div> </div>
</div> </div>
) )

View File

@ -4,7 +4,6 @@ import type { FC } from 'react'
import type { ParentChildConfig } from '../hooks' import type { ParentChildConfig } from '../hooks'
import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import { RiSearchEyeLine } from '@remixicon/react' import { RiSearchEyeLine } from '@remixicon/react'
import Image from 'next/image'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox' import Checkbox from '@/app/components/base/checkbox'
@ -26,7 +25,7 @@ type TextLabelProps = {
} }
const TextLabel: FC<TextLabelProps> = ({ children }) => { const TextLabel: FC<TextLabelProps> = ({ children }) => {
return <label className="system-sm-semibold text-text-secondary">{children}</label> return <label className="text-text-secondary system-sm-semibold">{children}</label>
} }
type ParentChildOptionsProps = { type ParentChildOptionsProps = {
@ -118,7 +117,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
</div> </div>
<RadioCard <RadioCard
className="mt-1" className="mt-1"
icon={<Image src={Note} alt="" />} icon={<img src={Note.src} alt="" />}
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })} title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })} description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
isChosen={parentChildConfig.chunkForContext === 'paragraph'} isChosen={parentChildConfig.chunkForContext === 'paragraph'}
@ -140,7 +139,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
/> />
<RadioCard <RadioCard
className="mt-2" className="mt-2"
icon={<Image src={FileList} alt="" />} icon={<img src={FileList.src} alt="" />}
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })} title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })} description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
onChosen={() => onChunkForContextChange('full-doc')} onChosen={() => onChunkForContextChange('full-doc')}
@ -186,7 +185,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
onClick={() => onRuleToggle(rule.id)} onClick={() => onRuleToggle(rule.id)}
> >
<Checkbox checked={rule.enabled} /> <Checkbox checked={rule.enabled} />
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary"> <label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
{getRuleName(rule.id)} {getRuleName(rule.id)}
</label> </label>
</div> </div>

View File

@ -6,14 +6,6 @@ import { ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app' import { RETRIEVE_METHOD } from '@/types/app'
import RuleDetail from '../rule-detail' import RuleDetail from '../rule-detail'
// Override global next/image auto-mock: tests assert on data-testid="next-image" and src attributes
vi.mock('next/image', () => ({
default: function MockImage({ src, alt, className }: { src: string, alt: string, className?: string }) {
// eslint-disable-next-line next/no-img-element
return <img src={src} alt={alt} className={className} data-testid="next-image" />
},
}))
// Mock FieldInfo component // Mock FieldInfo component
vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({ vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({
FieldInfo: ({ label, displayedValue, valueIcon }: { label: string, displayedValue: string, valueIcon?: React.ReactNode }) => ( FieldInfo: ({ label, displayedValue, valueIcon }: { label: string, displayedValue: string, valueIcon?: React.ReactNode }) => (
@ -184,16 +176,16 @@ describe('RuleDetail', () => {
}) })
it('should show high_quality icon for qualified indexing', () => { it('should show high_quality icon for qualified indexing', () => {
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />) const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg') expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg')
}) })
it('should show economical icon for economical indexing', () => { it('should show economical icon for economical indexing', () => {
render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />) const { container } = render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images[0]).toHaveAttribute('src', '/icons/economical.svg') expect(images[0]).toHaveAttribute('src', '/icons/economical.svg')
}) })
}) })
@ -256,38 +248,38 @@ describe('RuleDetail', () => {
}) })
it('should show vector icon for semantic search', () => { it('should show vector icon for semantic search', () => {
render( const { container } = render(
<RuleDetail <RuleDetail
indexingType={IndexingType.QUALIFIED} indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.semantic} retrievalMethod={RETRIEVE_METHOD.semantic}
/>, />,
) )
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
}) })
it('should show fullText icon for full text search', () => { it('should show fullText icon for full text search', () => {
render( const { container } = render(
<RuleDetail <RuleDetail
indexingType={IndexingType.QUALIFIED} indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.fullText} retrievalMethod={RETRIEVE_METHOD.fullText}
/>, />,
) )
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg') expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg')
}) })
it('should show hybrid icon for hybrid search', () => { it('should show hybrid icon for hybrid search', () => {
render( const { container } = render(
<RuleDetail <RuleDetail
indexingType={IndexingType.QUALIFIED} indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.hybrid} retrievalMethod={RETRIEVE_METHOD.hybrid}
/>, />,
) )
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg') expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg')
}) })
}) })
@ -308,9 +300,9 @@ describe('RuleDetail', () => {
}) })
it('should handle undefined retrievalMethod with defined indexingType', () => { it('should handle undefined retrievalMethod with defined indexingType', () => {
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />) const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
// When retrievalMethod is undefined, vector icon is used as default // When retrievalMethod is undefined, vector icon is used as default
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
}) })

View File

@ -1,5 +1,4 @@
import type { ProcessRuleResponse } from '@/models/datasets' import type { ProcessRuleResponse } from '@/models/datasets'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -50,7 +49,7 @@ const RuleDetail = ({
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })} label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string} displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
valueIcon={( valueIcon={(
<Image <img
className="size-4" className="size-4"
src={ src={
indexingType === IndexingType.ECONOMICAL indexingType === IndexingType.ECONOMICAL
@ -65,7 +64,7 @@ const RuleDetail = ({
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })} label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })} displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
valueIcon={( valueIcon={(
<Image <img
className="size-4" className="size-4"
src={ src={
retrievalMethod === RETRIEVE_METHOD.fullText retrievalMethod === RETRIEVE_METHOD.fullText

View File

@ -1,7 +1,6 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { ProcessRuleResponse } from '@/models/datasets' import type { ProcessRuleResponse } from '@/models/datasets'
import type { RETRIEVE_METHOD } from '@/types/app' import type { RETRIEVE_METHOD } from '@/types/app'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -101,7 +100,7 @@ const RuleDetail: FC<RuleDetailProps> = React.memo(({
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })} label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string} displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
valueIcon={( valueIcon={(
<Image <img
className="size-4" className="size-4"
src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality} src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality}
alt="" alt=""
@ -112,7 +111,7 @@ const RuleDetail: FC<RuleDetailProps> = React.memo(({
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })} label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })} displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
valueIcon={( valueIcon={(
<Image <img
className="size-4" className="size-4"
src={getRetrievalIcon(retrievalMethod)} src={getRetrievalIcon(retrievalMethod)}
alt="" alt=""

View File

@ -14,7 +14,6 @@ import {
RiEqualizer2Line, RiEqualizer2Line,
RiPlayCircleLine, RiPlayCircleLine,
} from '@remixicon/react' } from '@remixicon/react'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -178,7 +177,7 @@ const QueryInput = ({
}, [text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult]) }, [text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult])
const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" /> const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
const TextAreaComp = useMemo(() => { const TextAreaComp = useMemo(() => {
return ( return (
<Textarea <Textarea
@ -206,7 +205,7 @@ const QueryInput = ({
<div className={cn('relative flex h-80 shrink-0 flex-col overflow-hidden rounded-xl bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs')}> <div className={cn('relative flex h-80 shrink-0 flex-col overflow-hidden rounded-xl bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs')}>
<div className="flex h-full flex-col overflow-hidden rounded-[10px] bg-background-section-burn"> <div className="flex h-full flex-col overflow-hidden rounded-[10px] bg-background-section-burn">
<div className="relative flex shrink-0 items-center justify-between p-1.5 pb-1 pl-3"> <div className="relative flex shrink-0 items-center justify-between p-1.5 pb-1 pl-3">
<span className="system-sm-semibold-uppercase text-text-secondary"> <span className="text-text-secondary system-sm-semibold-uppercase">
{t('input.title', { ns: 'datasetHitTesting' })} {t('input.title', { ns: 'datasetHitTesting' })}
</span> </span>
{isExternal {isExternal
@ -218,7 +217,7 @@ const QueryInput = ({
> >
<RiEqualizer2Line className="h-3.5 w-3.5 text-components-button-secondary-text" /> <RiEqualizer2Line className="h-3.5 w-3.5 text-components-button-secondary-text" />
<div className="flex items-center justify-center gap-1 px-[3px]"> <div className="flex items-center justify-center gap-1 px-[3px]">
<span className="system-xs-medium text-components-button-secondary-text">{t('settingTitle', { ns: 'datasetHitTesting' })}</span> <span className="text-components-button-secondary-text system-xs-medium">{t('settingTitle', { ns: 'datasetHitTesting' })}</span>
</div> </div>
</Button> </Button>
) )

View File

@ -1,4 +1,3 @@
import type { ImgHTMLAttributes } from 'react'
import type { TryAppInfo } from '@/service/try-app' import type { TryAppInfo } from '@/service/try-app'
import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react' import * as React from 'react'
@ -11,21 +10,6 @@ vi.mock('../use-get-requirements', () => ({
default: (...args: unknown[]) => mockUseGetRequirements(...args), default: (...args: unknown[]) => mockUseGetRequirements(...args),
})) }))
vi.mock('next/image', () => ({
default: ({
src,
alt,
unoptimized: _unoptimized,
...rest
}: {
src: string
alt: string
unoptimized?: boolean
} & ImgHTMLAttributes<HTMLImageElement>) => (
React.createElement('img', { src, alt, ...rest })
),
}))
const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({ const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({
id: 'test-app-id', id: 'test-app-id',
name: 'Test App Name', name: 'Test App Name',

View File

@ -1,7 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import type { TryAppInfo } from '@/service/try-app' import type { TryAppInfo } from '@/service/try-app'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector' import { AppTypeIcon } from '@/app/components/app/type-selector'
@ -38,14 +37,13 @@ const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => {
} }
return ( return (
<Image <img
className="size-5 rounded-md object-cover shadow-xs" className="size-5 rounded-md object-cover shadow-xs"
src={iconUrl} src={iconUrl}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
width={requirementIconSize} width={requirementIconSize}
height={requirementIconSize} height={requirementIconSize}
unoptimized
onError={() => setFailedSource(iconUrl)} onError={() => setFailedSource(iconUrl)}
/> />
) )

View File

@ -1,6 +1,5 @@
import type { Form, ValidateValue } from '../key-validator/declarations' import type { Form, ValidateValue } from '../key-validator/declarations'
import type { PluginProvider } from '@/models/common' import type { PluginProvider } from '@/models/common'
import Image from 'next/image'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast/context' import { useToastContext } from '@/app/components/base/toast/context'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
@ -64,7 +63,7 @@ const SerpapiPlugin = ({
return ( return (
<KeyValidator <KeyValidator
type="serpapi" type="serpapi"
title={<Image alt="serpapi logo" src={SerpapiLogo} width={64} />} title={<img alt="serpapi logo" src={SerpapiLogo.src} width={64} />}
status={plugin.credentials?.api_key ? 'success' : 'add'} status={plugin.credentials?.api_key ? 'success' : 'add'}
forms={forms} forms={forms}
keyFrom={{ keyFrom={{

View File

@ -1,4 +1,3 @@
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
import { basePath } from '@/utils/var' import { basePath } from '@/utils/var'
@ -11,7 +10,7 @@ const PipelineScreenShot = () => {
<source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline.png`} /> <source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline.png`} />
<source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline@2x.png`} /> <source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline@2x.png`} />
<source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline@3x.png`} /> <source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline@3x.png`} />
<Image <img
src={`${basePath}/screenshots/${theme}/Pipeline.png`} src={`${basePath}/screenshots/${theme}/Pipeline.png`}
alt="Pipeline Screenshot" alt="Pipeline Screenshot"
width={692} width={692}

View File

@ -30,7 +30,7 @@ pnpm test path/to/file.spec.tsx
## Project Test Setup ## Project Test Setup
- **Configuration**: `vitest.config.ts` sets the `jsdom` environment, loads the Testing Library presets, and respects our path aliases (`@/...`). Check this file before adding new transformers or module name mappers. - **Configuration**: `vitest.config.ts` sets the `jsdom` environment, loads the Testing Library presets, and respects our path aliases (`@/...`). Check this file before adding new transformers or module name mappers.
- **Global setup**: `vitest.setup.ts` already imports `@testing-library/jest-dom`, runs `cleanup()` after every test, and defines shared mocks (for example `react-i18next`, `next/image`). Add any environment-level mocks (for example `ResizeObserver`, `matchMedia`, `IntersectionObserver`, `TextEncoder`, `crypto`) here so they are shared consistently. - **Global setup**: `vitest.setup.ts` already imports `@testing-library/jest-dom`, runs `cleanup()` after every test, and defines shared mocks (for example `react-i18next`). Add any environment-level mocks (for example `ResizeObserver`, `matchMedia`, `IntersectionObserver`, `TextEncoder`, `crypto`) here so they are shared consistently.
- **Reusable mocks**: Place shared mock factories inside `web/__mocks__/` and use `vi.mock('module-name')` to point to them rather than redefining mocks in every spec. - **Reusable mocks**: Place shared mock factories inside `web/__mocks__/` and use `vi.mock('module-name')` to point to them rather than redefining mocks in every spec.
- **Mocking behavior**: Modules are not mocked automatically. Use `vi.mock(...)` in tests, or place global mocks in `vitest.setup.ts`. - **Mocking behavior**: Modules are not mocked automatically. Use `vi.mock(...)` in tests, or place global mocks in `vitest.setup.ts`.
- **Script utilities**: `web/scripts/analyze-component.js` analyzes component complexity and generates test prompts for AI assistants. Commands: - **Script utilities**: `web/scripts/analyze-component.js` analyzes component complexity and generates test prompts for AI assistants. Commands:

View File

@ -1119,9 +1119,6 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": { "react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1 "count": 1
}, },
"tailwindcss/enforce-consistent-class-order": {
"count": 11
},
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 1 "count": 1
} }
@ -2993,9 +2990,6 @@
"app/components/datasets/common/retrieval-param-config/index.tsx": { "app/components/datasets/common/retrieval-param-config/index.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
} }
}, },
"app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx": { "app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx": {
@ -3139,17 +3133,11 @@
"app/components/datasets/create/step-two/components/general-chunking-options.tsx": { "app/components/datasets/create/step-two/components/general-chunking-options.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 4
} }
}, },
"app/components/datasets/create/step-two/components/indexing-mode-section.tsx": { "app/components/datasets/create/step-two/components/indexing-mode-section.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 2 "count": 2
},
"tailwindcss/enforce-consistent-class-order": {
"count": 8
} }
}, },
"app/components/datasets/create/step-two/components/inputs.tsx": { "app/components/datasets/create/step-two/components/inputs.tsx": {
@ -3160,16 +3148,6 @@
"count": 2 "count": 2
} }
}, },
"app/components/datasets/create/step-two/components/option-card.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/datasets/create/step-two/components/parent-child-options.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/datasets/create/step-two/hooks/use-indexing-config.ts": { "app/components/datasets/create/step-two/hooks/use-indexing-config.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": { "react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3 "count": 3
@ -3884,11 +3862,6 @@
"count": 3 "count": 3
} }
}, },
"app/components/datasets/hit-testing/components/query-input/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/datasets/hit-testing/components/query-input/textarea.tsx": { "app/components/datasets/hit-testing/components/query-input/textarea.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1

View File

@ -14,6 +14,79 @@ process.env.TAILWIND_MODE ??= 'ESLINT'
const disableRuleAutoFix = !(isInEditorEnv() || isInGitHooksOrLintStaged()) const disableRuleAutoFix = !(isInEditorEnv() || isInGitHooksOrLintStaged())
const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [
{
group: ['next/image'],
message: 'Do not import next/image. Use native img tags instead.',
},
{
group: ['next/font', 'next/font/*'],
message: 'Do not import next/font. Use the project font styles instead.',
},
]
const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
{
group: [
'**/portal-to-follow-elem',
'**/portal-to-follow-elem/index',
],
message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.',
},
{
group: [
'**/base/tooltip',
'**/base/tooltip/index',
],
message: 'Deprecated: use @/app/components/base/ui/tooltip instead. See issue #32767.',
},
{
group: [
'**/base/modal',
'**/base/modal/index',
'**/base/modal/modal',
],
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
},
{
group: [
'**/base/select',
'**/base/select/index',
'**/base/select/custom',
'**/base/select/pure',
],
message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.',
},
{
group: [
'**/base/confirm',
'**/base/confirm/index',
],
message: 'Deprecated: use @/app/components/base/ui/alert-dialog instead. See issue #32767.',
},
{
group: [
'**/base/popover',
'**/base/popover/index',
],
message: 'Deprecated: use @/app/components/base/ui/popover instead. See issue #32767.',
},
{
group: [
'**/base/dropdown',
'**/base/dropdown/index',
],
message: 'Deprecated: use @/app/components/base/ui/dropdown-menu instead. See issue #32767.',
},
{
group: [
'**/base/dialog',
'**/base/dialog/index',
],
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
},
]
export default antfu( export default antfu(
{ {
react: { react: {
@ -53,6 +126,7 @@ export default antfu(
{ {
rules: { rules: {
'node/prefer-global/process': 'off', 'node/prefer-global/process': 'off',
'next/no-img-element': 'off',
}, },
}, },
{ {
@ -156,6 +230,15 @@ export default antfu(
'react-refresh/only-export-components': 'off', 'react-refresh/only-export-components': 'off',
}, },
}, },
{
name: 'dify/no-next-image-or-font',
files: [GLOB_TS, GLOB_TSX],
rules: {
'no-restricted-imports': ['error', {
patterns: NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
}],
},
},
{ {
name: 'dify/overlay-migration', name: 'dify/overlay-migration',
files: [GLOB_TS, GLOB_TSX], files: [GLOB_TS, GLOB_TSX],
@ -165,58 +248,10 @@ export default antfu(
], ],
rules: { rules: {
'no-restricted-imports': ['error', { 'no-restricted-imports': ['error', {
patterns: [{ patterns: [
group: [ ...NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
'**/portal-to-follow-elem', ...OVERLAY_RESTRICTED_IMPORT_PATTERNS,
'**/portal-to-follow-elem/index', ],
],
message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.',
}, {
group: [
'**/base/tooltip',
'**/base/tooltip/index',
],
message: 'Deprecated: use @/app/components/base/ui/tooltip instead. See issue #32767.',
}, {
group: [
'**/base/modal',
'**/base/modal/index',
'**/base/modal/modal',
],
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
}, {
group: [
'**/base/select',
'**/base/select/index',
'**/base/select/custom',
'**/base/select/pure',
],
message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.',
}, {
group: [
'**/base/confirm',
'**/base/confirm/index',
],
message: 'Deprecated: use @/app/components/base/ui/alert-dialog instead. See issue #32767.',
}, {
group: [
'**/base/popover',
'**/base/popover/index',
],
message: 'Deprecated: use @/app/components/base/ui/popover instead. See issue #32767.',
}, {
group: [
'**/base/dropdown',
'**/base/dropdown/index',
],
message: 'Deprecated: use @/app/components/base/ui/dropdown-menu instead. See issue #32767.',
}, {
group: [
'**/base/dialog',
'**/base/dialog/index',
],
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
}],
}], }],
}, },
}, },

View File

@ -6,12 +6,6 @@ import { env } from './env'
const isDev = process.env.NODE_ENV === 'development' const isDev = process.env.NODE_ENV === 'development'
const withMDX = createMDX() const withMDX = createMDX()
// the default url to prevent parse url error when running jest
const hasSetWebPrefix = env.NEXT_PUBLIC_WEB_PREFIX
const port = env.PORT
const locImageURLs = !hasSetWebPrefix ? [new URL(`http://localhost:${port}/**`), new URL(`http://127.0.0.1:${port}/**`)] : []
const remoteImageURLs = ([hasSetWebPrefix ? new URL(`${env.NEXT_PUBLIC_WEB_PREFIX}/**`) : '', ...locImageURLs].filter(item => !!item)) as URL[]
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
basePath: env.NEXT_PUBLIC_BASE_PATH, basePath: env.NEXT_PUBLIC_BASE_PATH,
transpilePackages: ['@t3-oss/env-core', '@t3-oss/env-nextjs', 'echarts', 'zrender'], transpilePackages: ['@t3-oss/env-core', '@t3-oss/env-nextjs', 'echarts', 'zrender'],
@ -23,16 +17,6 @@ const nextConfig: NextConfig = {
productionBrowserSourceMaps: false, // enable browser source map generation during the production build productionBrowserSourceMaps: false, // enable browser source map generation during the production build
// Configure pageExtensions to include md and mdx // Configure pageExtensions to include md and mdx
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'], pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
// https://nextjs.org/docs/messages/next-image-unconfigured-host
images: {
remotePatterns: remoteImageURLs.map(remoteImageURL => ({
protocol: remoteImageURL.protocol.replace(':', '') as 'http' | 'https',
hostname: remoteImageURL.hostname,
port: remoteImageURL.port,
pathname: remoteImageURL.pathname,
search: '',
})),
},
typescript: { typescript: {
// https://nextjs.org/docs/api-reference/next.config.js/ignoring-typescript-errors // https://nextjs.org/docs/api-reference/next.config.js/ignoring-typescript-errors
ignoreBuildErrors: true, ignoreBuildErrors: true,

View File

@ -0,0 +1,30 @@
import type { Plugin } from 'vite'
import path from 'node:path'
import { normalizeViteModuleId } from './utils'
type NextStaticImageTestPluginOptions = {
projectRoot: string
}
const STATIC_ASSET_RE = /\.(?:svg|png|jpe?g|gif)$/i
const EXCLUDED_QUERY_RE = /[?&](?:raw|url)\b/
export const nextStaticImageTestPlugin = ({ projectRoot }: NextStaticImageTestPluginOptions): Plugin => {
return {
name: 'next-static-image-test',
enforce: 'pre',
load(id) {
if (EXCLUDED_QUERY_RE.test(id))
return null
const cleanId = normalizeViteModuleId(id)
if (!cleanId.startsWith(projectRoot) || !STATIC_ASSET_RE.test(cleanId))
return null
const relativePath = path.relative(projectRoot, cleanId).split(path.sep).join('/')
const src = `/__static__/${relativePath}`
return `export default { src: ${JSON.stringify(src)} }\n`
},
}
}

View File

@ -72,12 +72,10 @@ export const config = {
* Match all request paths except for the ones starting with: * Match all request paths except for the ones starting with:
* - api (API routes) * - api (API routes)
* - _next/static (static files) * - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file) * - favicon.ico (favicon file)
*/ */
{ {
// source: '/((?!api|_next/static|_next/image|favicon.ico).*)', source: '/((?!_next/static|favicon.ico).*)',
source: '/((?!_next/static|_next/image|favicon.ico).*)',
// source: '/(.*)', // source: '/(.*)',
// missing: [ // missing: [
// { type: 'header', key: 'next-router-prefetch' }, // { type: 'header', key: 'next-router-prefetch' },

View File

@ -6,6 +6,7 @@ import Inspect from 'vite-plugin-inspect'
import { defineConfig } from 'vite-plus' import { defineConfig } from 'vite-plus'
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector' import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr' import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test'
import { collectComponentCoverageExcludedFiles } from './scripts/component-coverage-filters.mjs' import { collectComponentCoverageExcludedFiles } from './scripts/component-coverage-filters.mjs'
import { EXCLUDED_COMPONENT_MODULES } from './scripts/components-coverage-thresholds.mjs' import { EXCLUDED_COMPONENT_MODULES } from './scripts/components-coverage-thresholds.mjs'
@ -28,6 +29,7 @@ export default defineConfig(({ mode }) => {
return { return {
plugins: isTest plugins: isTest
? [ ? [
nextStaticImageTestPlugin({ projectRoot }),
react(), react(),
{ {
// Stub .mdx files so components importing them can be unit-tested // Stub .mdx files so components importing them can be unit-tested

View File

@ -100,9 +100,6 @@ afterEach(async () => {
}) })
}) })
// mock next/image to avoid width/height requirements for data URLs
vi.mock('next/image')
// mock foxact/use-clipboard - not available in test environment // mock foxact/use-clipboard - not available in test environment
vi.mock('foxact/use-clipboard', () => ({ vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({ useClipboard: () => ({