mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
chore: use semantic tags for interactive elements (#41813)
This commit is contained in:
parent
dcb7b36ad4
commit
6f37f0661d
@ -191,12 +191,6 @@
|
||||
"eslint-react/set-state-in-effect": {
|
||||
"count": 5
|
||||
},
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -295,12 +289,6 @@
|
||||
"eslint-react/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
@ -618,14 +606,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/base/chat/chat-with-history/sidebar/item.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/chat/chat/__tests__/hooks.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -1209,14 +1189,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/base/image-gallery/index.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-noninteractive-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx": {
|
||||
"erasable-syntax-only/parameter-properties": {
|
||||
"count": 1
|
||||
@ -1240,14 +1212,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/image-uploader/image-list.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-noninteractive-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/image-uploader/image-preview.tsx": {
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
@ -1309,12 +1273,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/base/markdown-blocks/link.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-noninteractive-element-interactions": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -1628,14 +1586,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/tab-slider-plain/index.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/tag-input/index.stories.tsx": {
|
||||
"jsx-a11y/label-has-associated-control": {
|
||||
"count": 12
|
||||
@ -1698,11 +1648,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/chunk.tsx": {
|
||||
"jsx-a11y/label-has-associated-control": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/common/document-status-with-action/status-with-action.tsx": {
|
||||
"eslint-react/static-components": {
|
||||
"count": 2
|
||||
@ -1794,14 +1739,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-noninteractive-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -1963,14 +1900,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-noninteractive-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -2160,12 +2089,6 @@
|
||||
"web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx": {
|
||||
"eslint-react/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 3
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx": {
|
||||
@ -3369,14 +3292,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/form-input-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -3754,14 +3669,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/http/components/authorization/radio-group.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
|
||||
@ -294,13 +294,16 @@ const ViewAnnotationModal: FC<Props> = ({ appId, isShow, onHide, item, onSave, o
|
||||
</div>
|
||||
{id && (
|
||||
<div className="flex h-16 shrink-0 items-center justify-between rounded-b-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary">
|
||||
<div
|
||||
className="flex cursor-pointer items-center space-x-2 pl-3"
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer appearance-none items-center space-x-2 border-0 bg-transparent py-0 pr-0 pl-3 text-left"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<MessageCheckRemove />
|
||||
<div>{t(($) => $['editModal.removeThisCache'], { ns: 'appAnnotation' })}</div>
|
||||
</div>
|
||||
<span>
|
||||
{t(($) => $['editModal.removeThisCache'], { ns: 'appAnnotation' })}
|
||||
</span>
|
||||
</button>
|
||||
<div>
|
||||
{t(($) => $['editModal.createdAt'], { ns: 'appAnnotation' })}
|
||||
|
||||
|
||||
@ -197,13 +197,14 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
</IconButton>
|
||||
</div>
|
||||
{showBackButton && (
|
||||
<div
|
||||
className="mb-2 flex cursor-pointer items-center gap-1 system-xs-semibold-uppercase text-text-accent-secondary"
|
||||
<button
|
||||
type="button"
|
||||
className="mb-2 flex cursor-pointer appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left system-xs-semibold-uppercase text-text-accent-secondary"
|
||||
onClick={onHide}
|
||||
>
|
||||
<RiArrowLeftLine className="size-4" />
|
||||
{t(($) => $['detailPanel.operation.back'], { ns: 'plugin' })}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon size="tiny" className="size-6" src={collection.icon} />
|
||||
|
||||
@ -28,16 +28,20 @@ const Item: FC<ItemProps> = ({
|
||||
ref={ref}
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'group flex cursor-pointer rounded-lg p-1 pl-3 system-sm-medium text-components-menu-item-text hover:bg-state-base-hover',
|
||||
'group flex rounded-lg p-1 pl-3 system-sm-medium text-components-menu-item-text hover:bg-state-base-hover',
|
||||
isSelected && 'bg-state-accent-active text-text-accent hover:bg-state-accent-active',
|
||||
)}
|
||||
onClick={() => onChangeConversation(item.id)}
|
||||
>
|
||||
<div className="grow truncate p-1 pl-0" title={item.name}>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 grow cursor-pointer appearance-none truncate border-0 bg-transparent p-1 pl-0 text-left"
|
||||
title={item.name}
|
||||
onClick={() => onChangeConversation(item.id)}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
</button>
|
||||
{item.id !== '' && (
|
||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="shrink-0">
|
||||
<Operation
|
||||
isActive={isSelected}
|
||||
isPinned={!!isPin}
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import ImageGallery from '..'
|
||||
|
||||
vi.mock('react-i18next', () =>
|
||||
createReactI18nextMock({
|
||||
'imageGallery.previewImage': 'Preview image {{index}} of {{total}}',
|
||||
}),
|
||||
)
|
||||
|
||||
describe('ImageGallery', () => {
|
||||
it('previews the selected image and closes on Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
@ -9,7 +16,7 @@ describe('ImageGallery', () => {
|
||||
<ImageGallery srcs={['https://example.com/first.png', 'https://example.com/second.png']} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getAllByTestId('gallery-image')[1]!)
|
||||
await user.click(screen.getByRole('button', { name: 'Preview image 2 of 2' }))
|
||||
|
||||
expect(screen.getByTestId('image-preview-container').querySelector('img')).toHaveAttribute(
|
||||
'src',
|
||||
|
||||
@ -3,6 +3,7 @@ import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
import s from './style.module.css'
|
||||
|
||||
@ -29,6 +30,7 @@ const getWidthStyle = (imgNum: number) => {
|
||||
}
|
||||
|
||||
const ImageGallery: FC<Props> = ({ srcs }) => {
|
||||
const { t } = useTranslation('common')
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
|
||||
|
||||
const imgNum = srcs.length
|
||||
@ -37,16 +39,25 @@ const ImageGallery: FC<Props> = ({ srcs }) => {
|
||||
<div className={cn(s[`img-${imgNum}`], 'flex flex-wrap')} data-testid="image-gallery">
|
||||
{srcs.map((src, index) =>
|
||||
!src ? null : (
|
||||
<img
|
||||
<button
|
||||
type="button"
|
||||
key={index}
|
||||
className={s.item}
|
||||
style={imgStyle}
|
||||
src={src}
|
||||
alt=""
|
||||
data-testid="gallery-image" // Added for testing
|
||||
aria-label={t(($) => $['imageGallery.previewImage'], {
|
||||
index: index + 1,
|
||||
total: imgNum,
|
||||
})}
|
||||
onClick={() => setImagePreviewUrl(src)}
|
||||
onError={(e) => e.currentTarget.remove()}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
className={s.image}
|
||||
src={src}
|
||||
alt=""
|
||||
data-testid="gallery-image" // Added for testing
|
||||
onError={(e) => e.currentTarget.parentElement?.remove()}
|
||||
/>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{imagePreviewUrl && (
|
||||
|
||||
@ -2,12 +2,29 @@
|
||||
max-height: 200px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.img-1 .image {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.item:nth-child(3n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ImageList from '../image-list'
|
||||
|
||||
vi.mock('react-i18next', () =>
|
||||
createReactI18nextMock({
|
||||
'imageGallery.previewImage': 'Preview image {{index}} of {{total}}',
|
||||
}),
|
||||
)
|
||||
|
||||
const previewButtonName = 'Preview image 1 of 1'
|
||||
|
||||
const createLocalFile = (overrides: Partial<ImageFile> = {}): ImageFile => ({
|
||||
type: TransferMethod.local_file,
|
||||
_id: `local-${Date.now()}-${Math.random()}`,
|
||||
@ -31,32 +40,32 @@ describe('ImageList', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render images for each item in the list', () => {
|
||||
const list = [createLocalFile({ _id: 'file-1' }), createLocalFile({ _id: 'file-2' })]
|
||||
render(<ImageList list={list} />)
|
||||
const { container } = render(<ImageList list={list} />)
|
||||
|
||||
const images = screen.getAllByRole('img')
|
||||
const images = container.querySelectorAll('img')
|
||||
expect(images).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should use base64Url as src for local files', () => {
|
||||
const list = [createLocalFile({ _id: 'file-1', base64Url: 'data:image/png;base64,xyz' })]
|
||||
render(<ImageList list={list} />)
|
||||
const { container } = render(<ImageList list={list} />)
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'data:image/png;base64,xyz')
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'data:image/png;base64,xyz')
|
||||
})
|
||||
|
||||
it('should use url as src for remote files', () => {
|
||||
const list = [createRemoteFile({ _id: 'file-1', url: 'https://example.com/img.jpg' })]
|
||||
render(<ImageList list={list} />)
|
||||
const { container } = render(<ImageList list={list} />)
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://example.com/img.jpg')
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'https://example.com/img.jpg')
|
||||
})
|
||||
|
||||
it('should set alt attribute from file name', () => {
|
||||
it('should use the file name for a non-interactive local thumbnail', () => {
|
||||
const file = new File(['test'], 'my-image.png', { type: 'image/png' })
|
||||
const list = [createLocalFile({ _id: 'file-1', file })]
|
||||
const list = [createLocalFile({ _id: 'file-1', file, progress: 50 })]
|
||||
render(<ImageList list={list} />)
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('alt', 'my-image.png')
|
||||
expect(screen.getByRole('img', { name: 'my-image.png' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -72,7 +81,9 @@ describe('ImageList', () => {
|
||||
const list = [createLocalFile({ _id: 'file-1' })]
|
||||
render(<ImageList list={list} readonly onRemove={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.remove' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -94,9 +105,10 @@ describe('ImageList', () => {
|
||||
it('should show retry icon when local file upload fails (progress -1)', () => {
|
||||
const onReUpload = vi.fn()
|
||||
const list = [createLocalFile({ _id: 'file-1', progress: -1 })]
|
||||
render(<ImageList list={list} onReUpload={onReUpload} />)
|
||||
render(<ImageList list={list} onReUpload={onReUpload} readonly />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('listitem')).getAllByRole('button')).toHaveLength(1)
|
||||
expect(screen.queryByText(/\d+\s*%/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -131,7 +143,7 @@ describe('ImageList', () => {
|
||||
const list = [createLocalFile({ _id: 'file-1' })]
|
||||
render(<ImageList list={list} onRemove={onRemove} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
expect(onRemove).toHaveBeenCalledWith('file-1')
|
||||
@ -153,19 +165,17 @@ describe('ImageList', () => {
|
||||
]
|
||||
render(<ImageList list={list} />)
|
||||
|
||||
await user.click(screen.getByRole('img'))
|
||||
await user.click(screen.getByRole('button', { name: previewButtonName }))
|
||||
|
||||
const preview = screen.getByTestId('image-preview-container')
|
||||
expect(preview).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open image preview when clicking an in-progress image', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('should not expose a preview action for an in-progress image', () => {
|
||||
const list = [createLocalFile({ _id: 'file-1', progress: 50 })]
|
||||
render(<ImageList list={list} />)
|
||||
|
||||
await user.click(screen.getByRole('img'))
|
||||
render(<ImageList list={list} readonly />)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -175,7 +185,7 @@ describe('ImageList', () => {
|
||||
render(<ImageList list={list} />)
|
||||
|
||||
// Open preview
|
||||
await user.click(screen.getByRole('img'))
|
||||
await user.click(screen.getByRole('button', { name: previewButtonName }))
|
||||
expect(screen.queryByTestId('image-preview-container')).toBeInTheDocument()
|
||||
|
||||
// Close preview
|
||||
@ -195,7 +205,7 @@ describe('ImageList', () => {
|
||||
]
|
||||
render(<ImageList list={list} />)
|
||||
|
||||
await user.click(screen.getByRole('img'))
|
||||
await user.click(screen.getByRole('button', { name: previewButtonName }))
|
||||
|
||||
const previewImage = screen.getByTestId('image-preview-image')
|
||||
expect(previewImage).toBeInTheDocument()
|
||||
@ -207,9 +217,11 @@ describe('ImageList', () => {
|
||||
it('should call onImageLinkLoadSuccess for remote URL on load when progress is not -1', () => {
|
||||
const onImageLinkLoadSuccess = vi.fn()
|
||||
const list = [createRemoteFile({ _id: 'file-1', progress: 0 })]
|
||||
render(<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />)
|
||||
const { container } = render(
|
||||
<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />,
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
const img = container.querySelector('img')!
|
||||
fireEvent.load(img)
|
||||
expect(onImageLinkLoadSuccess).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
@ -217,9 +229,11 @@ describe('ImageList', () => {
|
||||
it('should not call onImageLinkLoadSuccess for remote URL when progress is -1', () => {
|
||||
const onImageLinkLoadSuccess = vi.fn()
|
||||
const list = [createRemoteFile({ _id: 'file-1', progress: -1 })]
|
||||
render(<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />)
|
||||
const { container } = render(
|
||||
<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />,
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
const img = container.querySelector('img')!
|
||||
fireEvent.load(img)
|
||||
|
||||
expect(onImageLinkLoadSuccess).not.toHaveBeenCalled()
|
||||
@ -228,9 +242,11 @@ describe('ImageList', () => {
|
||||
it('should not call onImageLinkLoadSuccess for local file type', () => {
|
||||
const onImageLinkLoadSuccess = vi.fn()
|
||||
const list = [createLocalFile({ _id: 'file-1', progress: 50 })]
|
||||
render(<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />)
|
||||
const { container } = render(
|
||||
<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />,
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
const img = container.querySelector('img')!
|
||||
fireEvent.load(img)
|
||||
|
||||
expect(onImageLinkLoadSuccess).not.toHaveBeenCalled()
|
||||
@ -239,9 +255,11 @@ describe('ImageList', () => {
|
||||
it('should call onImageLinkLoadError for remote URL on error', () => {
|
||||
const onImageLinkLoadError = vi.fn()
|
||||
const list = [createRemoteFile({ _id: 'file-1', progress: 0 })]
|
||||
render(<ImageList list={list} onImageLinkLoadError={onImageLinkLoadError} />)
|
||||
const { container } = render(
|
||||
<ImageList list={list} onImageLinkLoadError={onImageLinkLoadError} />,
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
const img = container.querySelector('img')!
|
||||
fireEvent.error(img)
|
||||
|
||||
expect(onImageLinkLoadError).toHaveBeenCalledWith('file-1')
|
||||
@ -250,9 +268,11 @@ describe('ImageList', () => {
|
||||
it('should not call onImageLinkLoadError for local file type', () => {
|
||||
const onImageLinkLoadError = vi.fn()
|
||||
const list = [createLocalFile({ _id: 'file-1', progress: 50 })]
|
||||
render(<ImageList list={list} onImageLinkLoadError={onImageLinkLoadError} />)
|
||||
const { container } = render(
|
||||
<ImageList list={list} onImageLinkLoadError={onImageLinkLoadError} />,
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
const img = container.querySelector('img')!
|
||||
fireEvent.error(img)
|
||||
|
||||
expect(onImageLinkLoadError).not.toHaveBeenCalled()
|
||||
@ -262,17 +282,16 @@ describe('ImageList', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle list with mixed local and remote files', () => {
|
||||
const list = [createLocalFile({ _id: 'local-1' }), createRemoteFile({ _id: 'remote-1' })]
|
||||
render(<ImageList list={list} />)
|
||||
const { container } = render(<ImageList list={list} />)
|
||||
|
||||
expect(screen.getAllByRole('img')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('img')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle item without file property for alt attribute', () => {
|
||||
const list = [createLocalFile({ _id: 'file-1', file: undefined })]
|
||||
render(<ImageList list={list} />)
|
||||
const { container } = render(<ImageList list={list} />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(container.querySelector('img')).toHaveAttribute('alt', '')
|
||||
})
|
||||
|
||||
it('should handle onRemove not provided gracefully', async () => {
|
||||
@ -280,8 +299,8 @@ describe('ImageList', () => {
|
||||
const list = [createLocalFile({ _id: 'file-1' })]
|
||||
render(<ImageList list={list} />)
|
||||
|
||||
// Button exists, clicking it should not throw
|
||||
await user.click(screen.getByRole('button'))
|
||||
// Remove button exists, clicking it should not throw
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -37,6 +37,15 @@ const ImageList: FC<ImageListProps> = ({
|
||||
if (item.type === TransferMethod.remote_url && onImageLinkLoadError)
|
||||
onImageLinkLoadError(item._id)
|
||||
}
|
||||
const renderImage = (item: ImageFile) => (
|
||||
<img
|
||||
className="block h-16 w-16 rounded-lg border-[0.5px] border-black/5 object-cover"
|
||||
alt={item.progress === 100 ? '' : (item.file?.name ?? '')}
|
||||
onLoad={() => handleImageLinkLoadSuccess(item)}
|
||||
onError={() => handleImageLinkLoadError(item)}
|
||||
src={item.type === TransferMethod.remote_url ? item.url : item.base64Url}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -47,7 +56,7 @@ const ImageList: FC<ImageListProps> = ({
|
||||
role="list"
|
||||
aria-label={t(($) => $['imageUploader.imageList'], { ns: 'common' })}
|
||||
>
|
||||
{list.map((item) => (
|
||||
{list.map((item, index) => (
|
||||
<li
|
||||
key={item._id}
|
||||
className="group relative mr-1 rounded-lg border-[0.5px] border-black/5"
|
||||
@ -104,19 +113,26 @@ const ImageList: FC<ImageListProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
className="h-16 w-16 cursor-pointer rounded-lg border-[0.5px] border-black/5 object-cover"
|
||||
alt={item.file?.name}
|
||||
onLoad={() => handleImageLinkLoadSuccess(item)}
|
||||
onError={() => handleImageLinkLoadError(item)}
|
||||
src={item.type === TransferMethod.remote_url ? item.url : item.base64Url}
|
||||
onClick={() =>
|
||||
item.progress === 100 &&
|
||||
setImagePreviewUrl(
|
||||
(item.type === TransferMethod.remote_url ? item.url : item.base64Url) as string,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{item.progress === 100 ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['imageGallery.previewImage'], {
|
||||
ns: 'common',
|
||||
index: index + 1,
|
||||
total: list.length,
|
||||
})}
|
||||
className="block cursor-pointer appearance-none rounded-lg border-0 bg-transparent p-0 leading-none"
|
||||
onClick={() =>
|
||||
setImagePreviewUrl(
|
||||
(item.type === TransferMethod.remote_url ? item.url : item.base64Url) as string,
|
||||
)
|
||||
}
|
||||
>
|
||||
{renderImage(item)}
|
||||
</button>
|
||||
) : (
|
||||
renderImage(item)
|
||||
)}
|
||||
{!readonly && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -23,9 +23,9 @@ describe('Link component', () => {
|
||||
})
|
||||
|
||||
// --------------------------
|
||||
// ABBR LINK
|
||||
// ABBR ACTION
|
||||
// --------------------------
|
||||
it('renders abbr link and calls onSend when clicked', () => {
|
||||
it('renders abbr action as a button and calls onSend when clicked', () => {
|
||||
const node = {
|
||||
properties: {
|
||||
href: 'abbr:hello%20world',
|
||||
@ -35,15 +35,15 @@ describe('Link component', () => {
|
||||
|
||||
render(<Link node={node} />)
|
||||
|
||||
const abbr = screen.getByText('Tooltip text')
|
||||
expect(abbr.tagName).toBe('ABBR')
|
||||
const button = screen.getByRole('button', { name: 'Tooltip text' })
|
||||
expect(button.tagName).toBe('BUTTON')
|
||||
|
||||
fireEvent.click(abbr)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledWith('hello world')
|
||||
})
|
||||
|
||||
it('renders abbr with empty fallback title/value when child value is missing', () => {
|
||||
it('renders abbr action with empty fallback title/value when child value is missing', () => {
|
||||
const node = {
|
||||
properties: {
|
||||
href: 'abbr:hi',
|
||||
@ -53,10 +53,10 @@ describe('Link component', () => {
|
||||
|
||||
const { container } = render(<Link node={node} />)
|
||||
|
||||
const abbr = container.querySelector('abbr')
|
||||
expect(abbr).toBeTruthy()
|
||||
expect(abbr?.tagName).toBe('ABBR')
|
||||
fireEvent.click(abbr as HTMLElement)
|
||||
const button = container.querySelector('button')
|
||||
expect(button).toBeTruthy()
|
||||
expect(button?.tagName).toBe('BUTTON')
|
||||
fireEvent.click(button as HTMLElement)
|
||||
expect(mockOnSend).toHaveBeenCalledWith('hi')
|
||||
})
|
||||
|
||||
|
||||
@ -26,13 +26,14 @@ const Link = ({ node, children, ...props }: any) => {
|
||||
const hidden_text = decodeURIComponent(node.properties.href.toString().split('abbr:')[1])
|
||||
|
||||
return (
|
||||
<abbr
|
||||
className={commonClassName}
|
||||
<button
|
||||
type="button"
|
||||
className={`${commonClassName} font-inherit inline appearance-none border-0 bg-transparent p-0 text-inherit`}
|
||||
onClick={() => onSend?.(hidden_text)}
|
||||
title={node.children[0]?.value || ''}
|
||||
>
|
||||
{node.children[0]?.value || ''}
|
||||
</abbr>
|
||||
</button>
|
||||
)
|
||||
} else {
|
||||
const rawHref = props.href || node.properties?.href
|
||||
|
||||
@ -17,30 +17,31 @@ type ItemProps = Readonly<{
|
||||
}>
|
||||
const Item: FC<ItemProps> = ({ className, isActive, onClick, option, smallItem }) => {
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
data-testid={`tab-slider-item-${option.value}`}
|
||||
className={cn(
|
||||
'relative pb-2.5',
|
||||
'relative appearance-none border-0 bg-transparent px-0 pt-0 pb-2.5 text-left',
|
||||
!isActive && 'cursor-pointer',
|
||||
smallItem ? 'system-sm-semibold-uppercase' : 'system-xl-semibold',
|
||||
className,
|
||||
)}
|
||||
onClick={() => !isActive && onClick(option.value)}
|
||||
>
|
||||
<div
|
||||
<span
|
||||
data-testid="tab-slider-item-text"
|
||||
className={cn(isActive ? 'text-text-primary' : 'text-text-tertiary')}
|
||||
className={cn('block', isActive ? 'text-text-primary' : 'text-text-tertiary')}
|
||||
>
|
||||
{option.text}
|
||||
</div>
|
||||
</span>
|
||||
{isActive && (
|
||||
<div
|
||||
<span
|
||||
data-testid="tab-active-indicator"
|
||||
className="absolute inset-x-0 bottom-0 h-0.5 bg-util-colors-blue-brand-blue-brand-600"
|
||||
></div>
|
||||
></span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -42,11 +42,11 @@ export const QAPreview: FC<QAPreviewProps> = (props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex gap-x-1">
|
||||
<label className="shrink-0 text-[13px] leading-5 font-medium text-text-tertiary">Q</label>
|
||||
<span className="shrink-0 text-[13px] leading-5 font-medium text-text-tertiary">Q</span>
|
||||
<p className="body-md-regular text-text-secondary">{qa.question}</p>
|
||||
</div>
|
||||
<div className="flex gap-x-1">
|
||||
<label className="shrink-0 text-[13px] leading-5 font-medium text-text-tertiary">A</label>
|
||||
<span className="shrink-0 text-[13px] leading-5 font-medium text-text-tertiary">A</span>
|
||||
<p className="body-md-regular text-text-secondary">{qa.answer}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -60,14 +60,18 @@ describe('UploadDropzone', () => {
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render browse label when extensions are allowed', () => {
|
||||
it('should render browse button when extensions are allowed', () => {
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'datasetCreation.stepOne.uploader.browse' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render browse label when no extensions allowed', () => {
|
||||
it('should not render browse button when no extensions allowed', () => {
|
||||
render(<UploadDropzone {...defaultProps} acceptTypes={[]} />)
|
||||
expect(screen.queryByText('datasetCreation.stepOne.uploader.browse')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'datasetCreation.stepOne.uploader.browse' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file size and count limits', () => {
|
||||
@ -195,12 +199,14 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
|
||||
describe('event handlers', () => {
|
||||
it('should call onSelectFile when browse label is clicked', () => {
|
||||
it('should call onSelectFile when browse button is clicked', () => {
|
||||
const onSelectFile = vi.fn()
|
||||
render(<UploadDropzone {...defaultProps} onSelectFile={onSelectFile} />)
|
||||
|
||||
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
|
||||
fireEvent.click(browseLabel)
|
||||
const browseButton = screen.getByRole('button', {
|
||||
name: 'datasetCreation.stepOne.uploader.browse',
|
||||
})
|
||||
fireEvent.click(browseButton)
|
||||
|
||||
expect(onSelectFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@ -252,10 +258,12 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should have cursor-pointer on browse label', () => {
|
||||
it('should have cursor-pointer on browse button', () => {
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
|
||||
expect(browseLabel).toHaveClass('cursor-pointer')
|
||||
const browseButton = screen.getByRole('button', {
|
||||
name: 'datasetCreation.stepOne.uploader.browse',
|
||||
})
|
||||
expect(browseButton).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -58,9 +58,13 @@ const UploadDropzone = ({
|
||||
? t(($) => $['stepOne.uploader.button'], { ns: 'datasetCreation' })
|
||||
: t(($) => $['stepOne.uploader.buttonSingleFile'], { ns: 'datasetCreation' })}
|
||||
{acceptTypes.length > 0 && (
|
||||
<label className="ml-1 cursor-pointer text-text-accent" onClick={onSelectFile}>
|
||||
<button
|
||||
type="button"
|
||||
className="font-inherit ml-1 inline cursor-pointer appearance-none border-0 bg-transparent p-0 text-text-accent"
|
||||
onClick={onSelectFile}
|
||||
>
|
||||
{t(($) => $['stepOne.uploader.browse'], { ns: 'datasetCreation' })}
|
||||
</label>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -10,9 +10,9 @@ describe('DelimiterInput', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render separator label', () => {
|
||||
it('should associate the separator label with its input', () => {
|
||||
render(<DelimiterInput />)
|
||||
expect(screen.getByText(`${ns}.stepTwo.separator`))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: `${ns}.stepTwo.separator` })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text input with placeholder', () => {
|
||||
|
||||
@ -15,20 +15,12 @@ import {
|
||||
NumberFieldInput,
|
||||
NumberFieldUnit,
|
||||
} from '@langgenius/dify-ui/number-field'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { env } from '@/env'
|
||||
|
||||
const TextLabel: FC<PropsWithChildren> = (props) => {
|
||||
return (
|
||||
<label className="text-xs leading-none font-semibold text-text-secondary">
|
||||
{props.children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
const FormField: FC<PropsWithChildren<{ label: ReactNode }>> = (props) => {
|
||||
return (
|
||||
// Reflow on the container (a @container/chunkfields ancestor), not the
|
||||
@ -37,7 +29,7 @@ const FormField: FC<PropsWithChildren<{ label: ReactNode }>> = (props) => {
|
||||
// At/above 552px this restores flex-1 with no cap, so three columns resolve
|
||||
// to (container - gaps)/3 — pixel-identical to the stock flex-1 layout.
|
||||
<div className="max-w-[288px] space-y-2 @min-[552px]/chunkfields:max-w-none @min-[552px]/chunkfields:flex-1">
|
||||
<TextLabel>{props.label}</TextLabel>
|
||||
<div className="text-xs leading-none font-semibold text-text-secondary">{props.label}</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
@ -50,6 +42,8 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({
|
||||
...rest
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const generatedInputId = useId()
|
||||
const inputId = rest.id ?? generatedInputId
|
||||
const isComposing = useRef(false)
|
||||
const [compositionValue, setCompositionValue] = useState('')
|
||||
|
||||
@ -57,9 +51,9 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({
|
||||
<FormField
|
||||
label={
|
||||
<div className="mb-1 flex items-center">
|
||||
<span className="mr-0.5 system-sm-semibold">
|
||||
<label htmlFor={inputId} className="mr-0.5 system-sm-semibold">
|
||||
{t(($) => $['stepTwo.separator'], { ns: 'datasetCreation' })}
|
||||
</span>
|
||||
</label>
|
||||
<Infotip
|
||||
aria-label={tooltip || t(($) => $['stepTwo.separatorTip'], { ns: 'datasetCreation' })}
|
||||
popupClassName="max-w-[200px]"
|
||||
@ -70,6 +64,7 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({
|
||||
}
|
||||
>
|
||||
<Input
|
||||
id={inputId}
|
||||
type="text"
|
||||
className="h-9"
|
||||
placeholder={t(($) => $['stepTwo.separatorPlaceholder'], { ns: 'datasetCreation' })!}
|
||||
|
||||
@ -64,16 +64,20 @@ describe('UploadDropzone', () => {
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render browse label when extensions are allowed', () => {
|
||||
it('should render browse button when extensions are allowed', () => {
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'datasetCreation.stepOne.uploader.browse' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render browse label when no extensions allowed', () => {
|
||||
it('should not render browse button when no extensions allowed', () => {
|
||||
render(<UploadDropzone {...defaultProps} allowedExtensions={[]} />)
|
||||
|
||||
expect(screen.queryByText('datasetCreation.stepOne.uploader.browse')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'datasetCreation.stepOne.uploader.browse' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file size and count limits', () => {
|
||||
@ -209,12 +213,14 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
|
||||
describe('event handlers', () => {
|
||||
it('should call onSelectFile when browse label is clicked', () => {
|
||||
it('should call onSelectFile when browse button is clicked', () => {
|
||||
const onSelectFile = vi.fn()
|
||||
render(<UploadDropzone {...defaultProps} onSelectFile={onSelectFile} />)
|
||||
|
||||
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
|
||||
fireEvent.click(browseLabel)
|
||||
const browseButton = screen.getByRole('button', {
|
||||
name: 'datasetCreation.stepOne.uploader.browse',
|
||||
})
|
||||
fireEvent.click(browseButton)
|
||||
|
||||
expect(onSelectFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@ -269,11 +275,13 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should have cursor-pointer on browse label', () => {
|
||||
it('should have cursor-pointer on browse button', () => {
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
|
||||
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
|
||||
expect(browseLabel).toHaveClass('cursor-pointer')
|
||||
const browseButton = screen.getByRole('button', {
|
||||
name: 'datasetCreation.stepOne.uploader.browse',
|
||||
})
|
||||
expect(browseButton).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -64,9 +64,13 @@ const UploadDropzone = ({
|
||||
? t(($) => $['stepOne.uploader.button'], { ns: 'datasetCreation' })
|
||||
: t(($) => $['stepOne.uploader.buttonSingleFile'], { ns: 'datasetCreation' })}
|
||||
{allowedExtensions.length > 0 && (
|
||||
<label className="ml-1 cursor-pointer text-text-accent" onClick={onSelectFile}>
|
||||
<button
|
||||
type="button"
|
||||
className="font-inherit ml-1 inline cursor-pointer appearance-none border-0 bg-transparent p-0 text-text-accent"
|
||||
onClick={onSelectFile}
|
||||
>
|
||||
{t(($) => $['stepOne.uploader.browse'], { ns: 'datasetCreation' })}
|
||||
</label>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -15,12 +15,18 @@ type ApiItem = {
|
||||
}
|
||||
|
||||
type ExternalApiSelectProps = {
|
||||
'aria-labelledby'?: string
|
||||
items: ApiItem[]
|
||||
value?: string
|
||||
onSelect: (item: ApiItem) => void
|
||||
}
|
||||
|
||||
const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onSelect }) => {
|
||||
const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
items,
|
||||
value,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [selectedItem, setSelectedItem] = useState<ApiItem | null>(
|
||||
@ -57,19 +63,21 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className={`flex cursor-pointer items-center justify-between gap-0.5 self-stretch rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt ${isOpen && 'bg-state-base-hover-alt'}`}
|
||||
<button
|
||||
type="button"
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={`flex w-full cursor-pointer appearance-none items-center justify-between gap-0.5 self-stretch rounded-lg border-0 bg-components-input-bg-normal px-2 py-1 text-left hover:bg-state-base-hover-alt ${isOpen && 'bg-state-base-hover-alt'}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{selectedItem ? (
|
||||
<div className="flex items-center gap-2 self-stretch rounded-lg p-1">
|
||||
<span className="flex items-center gap-2 self-stretch rounded-lg p-1">
|
||||
<ApiConnectionMod className="size-4 text-text-secondary" />
|
||||
<div className="flex grow items-center">
|
||||
<span className="flex grow items-center">
|
||||
<span className="overflow-hidden system-sm-regular text-ellipsis text-components-input-text-filled">
|
||||
{selectedItem.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="system-sm-regular text-components-input-text-placeholder">
|
||||
{t(($) => $['selectExternalKnowledgeAPI.placeholder'], { ns: 'dataset' })}
|
||||
@ -78,16 +86,17 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS
|
||||
<RiArrowDownSLine
|
||||
className={`size-4 text-text-quaternary transition-transform ${isOpen ? 'text-text-secondary' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={item.value}
|
||||
className="flex cursor-pointer items-center p-1"
|
||||
className="flex w-full cursor-pointer appearance-none items-center border-0 bg-transparent p-1 text-left"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2 self-stretch rounded-lg p-2 hover:bg-state-base-hover">
|
||||
<span className="flex w-full items-center gap-2 self-stretch rounded-lg p-2 hover:bg-state-base-hover">
|
||||
<ApiConnectionMod className="size-4 text-text-secondary" />
|
||||
<span className="grow overflow-hidden system-sm-medium text-ellipsis text-text-secondary">
|
||||
{item.name}
|
||||
@ -95,19 +104,20 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS
|
||||
<span className="overflow-hidden text-right system-xs-regular text-ellipsis text-text-tertiary">
|
||||
{item.url}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex flex-col items-start self-stretch p-1">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-2 self-stretch rounded-lg p-2 hover:bg-state-base-hover"
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer appearance-none items-center gap-2 self-stretch rounded-lg border-0 bg-transparent p-2 text-left hover:bg-state-base-hover"
|
||||
onClick={handleAddNewAPI}
|
||||
>
|
||||
<RiAddLine className="size-4 text-text-secondary" />
|
||||
<span className="grow overflow-hidden system-sm-medium text-ellipsis text-text-secondary">
|
||||
{t(($) => $.createNewExternalAPI, { ns: 'dataset' })}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -30,6 +30,7 @@ const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({
|
||||
consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({ input: {} })
|
||||
const { data } = useQuery(externalKnowledgeApiQueryOptions)
|
||||
const externalKnowledgeApiList = data?.data ?? []
|
||||
const externalKnowledgeApiLabelId = useId()
|
||||
const externalKnowledgeIdInputId = useId()
|
||||
const [selectedApiId, setSelectedApiId] = useState(external_knowledge_api_id)
|
||||
const { setShowExternalKnowledgeAPIModal } = useModalContext()
|
||||
@ -71,12 +72,13 @@ const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({
|
||||
<form className="flex flex-col gap-4 self-stretch">
|
||||
<div className="flex flex-col gap-1 self-stretch">
|
||||
<div className="flex flex-col self-stretch">
|
||||
<label className="system-sm-semibold text-text-secondary">
|
||||
<span id={externalKnowledgeApiLabelId} className="system-sm-semibold text-text-secondary">
|
||||
{t(($) => $.externalAPIPanelTitle, { ns: 'dataset' })}
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
{apiItems.length > 0 ? (
|
||||
<ExternalApiSelect
|
||||
aria-labelledby={externalKnowledgeApiLabelId}
|
||||
items={apiItems}
|
||||
value={selectedApiId}
|
||||
onSelect={(e) => {
|
||||
|
||||
@ -64,6 +64,21 @@ describe('ExternalApiSelect', () => {
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('uses the referenced visible label to name the trigger', () => {
|
||||
render(
|
||||
<>
|
||||
<span id="external-api-label">External API</span>
|
||||
<ExternalApiSelect
|
||||
aria-labelledby="external-api-label"
|
||||
items={items}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'External API' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show placeholder when no value selected', () => {
|
||||
render(<ExternalApiSelect items={items} onSelect={onSelect} />)
|
||||
expect(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')).toBeInTheDocument()
|
||||
|
||||
@ -53,11 +53,14 @@ vi.mock('../ExternalApiSelect', () => ({
|
||||
default: ({
|
||||
items,
|
||||
onSelect,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
}: {
|
||||
items: Array<{ value: string; name: string }>
|
||||
onSelect: (item: { value: string; name: string }) => void
|
||||
'aria-labelledby'?: string
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" aria-labelledby={ariaLabelledBy} />
|
||||
{items.map((item) => (
|
||||
<button type="button" key={item.value} onClick={() => onSelect(item)}>
|
||||
{item.name}
|
||||
@ -95,6 +98,14 @@ describe('ExternalApiSelection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('associates the external API label with its selector', () => {
|
||||
render(<ExternalApiSelection {...defaultProps} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'dataset.externalAPIPanelTitle' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates the external knowledge ID', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
@ -10,9 +10,10 @@ type Props = Readonly<{
|
||||
const FormInputBoolean: FC<Props> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="flex w-full space-x-1">
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 grow cursor-default items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
|
||||
'flex h-8 grow cursor-default appearance-none items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
|
||||
!value &&
|
||||
'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
value &&
|
||||
@ -21,10 +22,11 @@ const FormInputBoolean: FC<Props> = ({ value, onChange }) => {
|
||||
onClick={() => onChange(true)}
|
||||
>
|
||||
True
|
||||
</div>
|
||||
<div
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 grow cursor-default items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
|
||||
'flex h-8 grow cursor-default appearance-none items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
|
||||
value &&
|
||||
'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
!value &&
|
||||
@ -33,7 +35,7 @@ const FormInputBoolean: FC<Props> = ({ value, onChange }) => {
|
||||
onClick={() => onChange(false)}
|
||||
>
|
||||
False
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -16,9 +16,10 @@ type ItemProps = {
|
||||
}
|
||||
const Item: FC<ItemProps> = ({ title, onClick, isSelected }) => {
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 grow cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
|
||||
'flex h-8 grow cursor-default appearance-none items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-left system-sm-regular text-text-secondary',
|
||||
!isSelected &&
|
||||
'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
isSelected &&
|
||||
@ -27,7 +28,7 @@ const Item: FC<ItemProps> = ({ title, onClick, isSelected }) => {
|
||||
onClick={onClick}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,14 @@ import userEvent from '@testing-library/user-event'
|
||||
import MonthlyDaysSelector from '../monthly-days-selector'
|
||||
|
||||
describe('trigger-schedule/monthly-days-selector', () => {
|
||||
it('names the monthly day selection group', () => {
|
||||
render(<MonthlyDaysSelector selectedDays={[1]} onChange={vi.fn()} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('group', { name: 'workflow.nodes.triggerSchedule.days' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles monthly days and shows the day-31 warning', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
@ -3,6 +3,14 @@ import userEvent from '@testing-library/user-event'
|
||||
import WeekdaySelector from '../weekday-selector'
|
||||
|
||||
describe('trigger-schedule/weekday-selector', () => {
|
||||
it('names the weekday selection group', () => {
|
||||
render(<WeekdaySelector selectedDays={['mon']} onChange={vi.fn()} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('group', { name: 'workflow.nodes.triggerSchedule.weekdays' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps at least one weekday selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
@ -29,10 +30,10 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="mb-2 block text-xs font-medium text-text-tertiary">
|
||||
<Fieldset className="space-y-2">
|
||||
<FieldsetLegend className="mb-2 py-0 text-xs font-medium text-text-tertiary">
|
||||
{t(($) => $['nodes.triggerSchedule.days'], { ns: 'workflow' })}
|
||||
</label>
|
||||
</FieldsetLegend>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row, rowIndex) => (
|
||||
@ -96,7 +97,7 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -30,10 +31,10 @@ const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
|
||||
const isDaySelected = (dayKey: string) => selectedDays.includes(dayKey)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="mb-2 block text-xs font-medium text-text-tertiary">
|
||||
<Fieldset className="space-y-2">
|
||||
<FieldsetLegend className="mb-2 py-0 text-xs font-medium text-text-tertiary">
|
||||
{t(($) => $['nodes.triggerSchedule.weekdays'], { ns: 'workflow' })}
|
||||
</label>
|
||||
</FieldsetLegend>
|
||||
<div className="flex gap-1.5">
|
||||
{weekdays.map((day) => (
|
||||
<button
|
||||
@ -50,7 +51,7 @@ const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "تحميل {{type}} لا يمكن أن يتجاوز {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "فشل قراءة الملف، يرجى المحاولة مرة أخرى.",
|
||||
"fileUploader.uploadFromComputerUploadError": "فشل تحميل الملف، يرجى التحميل مرة أخرى.",
|
||||
"imageGallery.previewImage": "معاينة الصورة {{index}} من {{total}}",
|
||||
"imageInput.browse": "تصفح",
|
||||
"imageInput.dropImageHere": "أسقط صورتك هنا، أو",
|
||||
"imageInput.supportedFormats": "يدعم PNG و JPG و JPEG و WEBP و GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Der Upload von {{type}} darf {{size}} nicht überschreiten",
|
||||
"fileUploader.uploadFromComputerReadError": "Lesen der Datei fehlgeschlagen, bitte versuchen Sie es erneut.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Datei-Upload fehlgeschlagen, bitte erneut hochladen.",
|
||||
"imageGallery.previewImage": "Bild {{index}} von {{total}} in der Vorschau anzeigen",
|
||||
"imageInput.browse": "blättern",
|
||||
"imageInput.dropImageHere": "Laden Sie Ihr Bild hierher hoch oder",
|
||||
"imageInput.supportedFormats": "Unterstützt PNG, JPG, JPEG, WEBP und GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Upload {{type}} cannot exceed {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "File reading failed, please try again.",
|
||||
"fileUploader.uploadFromComputerUploadError": "File upload failed, please upload again.",
|
||||
"imageGallery.previewImage": "Preview image {{index}} of {{total}}",
|
||||
"imageInput.browse": "browse",
|
||||
"imageInput.dropImageHere": "Drop your image here, or",
|
||||
"imageInput.supportedFormats": "Supports PNG, JPG, JPEG, WEBP and GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "La carga de {{type}} no puede exceder {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "Error en la lectura del archivo, inténtelo de nuevo.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Error en la carga del archivo, vuelva a cargarlo.",
|
||||
"imageGallery.previewImage": "Vista previa de la imagen {{index}} de {{total}}",
|
||||
"imageInput.browse": "navegar",
|
||||
"imageInput.dropImageHere": "Deja tu imagen aquí, o",
|
||||
"imageInput.supportedFormats": "Soporta PNG, JPG, JPEG, WEBP y GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "آپلود {{type}} نمی تواند از {{size}} تجاوز کند",
|
||||
"fileUploader.uploadFromComputerReadError": "خواندن فایل انجام نشد، لطفا دوباره امتحان کنید.",
|
||||
"fileUploader.uploadFromComputerUploadError": "آپلود فایل انجام نشد، لطفا دوباره آپلود کنید.",
|
||||
"imageGallery.previewImage": "پیشنمایش تصویر {{index}} از {{total}}",
|
||||
"imageInput.browse": "مرورگر",
|
||||
"imageInput.dropImageHere": "عکس خود را اینجا رها کنید، یا",
|
||||
"imageInput.supportedFormats": "از فرمتهای PNG، JPG، JPEG، WEBP و GIF پشتیبانی میکند",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Le téléchargement de {{type}} ne peut pas dépasser {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "Échec de la lecture du fichier, veuillez réessayer.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Le téléchargement du fichier a échoué, veuillez le télécharger à nouveau.",
|
||||
"imageGallery.previewImage": "Prévisualiser l’image {{index}} sur {{total}}",
|
||||
"imageInput.browse": "naviguer",
|
||||
"imageInput.dropImageHere": "Déposez votre image ici, ou",
|
||||
"imageInput.supportedFormats": "Prend en charge PNG, JPG, JPEG, WEBP et GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "{{type}} अपलोड {{size}} से ज़्यादा नहीं हो सकता",
|
||||
"fileUploader.uploadFromComputerReadError": "फ़ाइल पढ़ना विफल रहा, कृपया पुनः प्रयास करें.",
|
||||
"fileUploader.uploadFromComputerUploadError": "फ़ाइल अपलोड विफल रही, कृपया फिर से अपलोड करें।",
|
||||
"imageGallery.previewImage": "{{total}} में से चित्र {{index}} का पूर्वावलोकन करें",
|
||||
"imageInput.browse": "ब्राउज़ करें",
|
||||
"imageInput.dropImageHere": "अपनी छवि यहाँ छोड़ें, या",
|
||||
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP और GIF का समर्थन करता है",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Unggahan {{type}} tidak boleh melebihi {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "Pembacaan file gagal, silakan coba lagi.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Unggahan file gagal, silakan unggah lagi.",
|
||||
"imageGallery.previewImage": "Pratinjau gambar {{index}} dari {{total}}",
|
||||
"imageInput.browse": "Telusuri",
|
||||
"imageInput.dropImageHere": "Letakkan gambar Anda di sini, atau",
|
||||
"imageInput.supportedFormats": "Mendukung PNG, JPG, JPEG, WEBP dan GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Il caricamento di {{type}} non può superare {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "Lettura del file non riuscita, riprovare.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Caricamento del file non riuscito, carica di nuovo.",
|
||||
"imageGallery.previewImage": "Visualizza l'anteprima dell'immagine {{index}} di {{total}}",
|
||||
"imageInput.browse": "sfogliare",
|
||||
"imageInput.dropImageHere": "Trascina la tua immagine qui, oppure",
|
||||
"imageInput.supportedFormats": "Supporta PNG, JPG, JPEG, WEBP e GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "{{type}}のアップロードは{{size}}を超えてはなりません",
|
||||
"fileUploader.uploadFromComputerReadError": "ファイルの読み取りに失敗しました。もう一度やり直してください。",
|
||||
"fileUploader.uploadFromComputerUploadError": "ファイルのアップロードに失敗しました。再度アップロードしてください。",
|
||||
"imageGallery.previewImage": "{{total}}枚中{{index}}枚目の画像をプレビュー",
|
||||
"imageInput.browse": "ブラウズする",
|
||||
"imageInput.dropImageHere": "ここに画像をドロップするか、",
|
||||
"imageInput.supportedFormats": "PNG、JPG、JPEG、WEBP、および GIF をサポートしています。",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "{{type}} 업로드는 {{size}}를 초과할 수 없습니다.",
|
||||
"fileUploader.uploadFromComputerReadError": "파일 읽기에 실패했습니다. 다시 시도하십시오.",
|
||||
"fileUploader.uploadFromComputerUploadError": "파일 업로드에 실패했습니다. 다시 업로드하십시오.",
|
||||
"imageGallery.previewImage": "이미지 {{total}}개 중 {{index}}번째 미리보기",
|
||||
"imageInput.browse": "찾아보기",
|
||||
"imageInput.dropImageHere": "여기에 이미지를 드롭하거나",
|
||||
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP 및 GIF 를 지원합니다.",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "ອັບໂຫຼດ {{type}} ຕ້ອງບໍ່ເກີນ {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "ອ່ານໄຟລ໌ບໍ່ສຳເລັດ, ກະລຸນາລອງໃໝ່.",
|
||||
"fileUploader.uploadFromComputerUploadError": "ອັບໂຫຼດໄຟລ໌ບໍ່ສຳເລັດ, ກະລຸນາອັບໂຫຼດໃໝ່.",
|
||||
"imageGallery.previewImage": "ສະແດງຕົວຢ່າງຮູບທີ {{index}} ຈາກ {{total}}",
|
||||
"imageInput.browse": "ເລືອກເບິ່ງ",
|
||||
"imageInput.dropImageHere": "ວາງຮູບຂອງທ່ານທີ່ນີ້, ຫຼື",
|
||||
"imageInput.supportedFormats": "ຮອງຮັບ PNG, JPG, JPEG, WEBP ແລະ GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Upload {{type}} cannot exceed {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "File reading failed, please try again.",
|
||||
"fileUploader.uploadFromComputerUploadError": "File upload failed, please upload again.",
|
||||
"imageGallery.previewImage": "Afbeelding {{index}} van {{total}} bekijken",
|
||||
"imageInput.browse": "browse",
|
||||
"imageInput.dropImageHere": "Drop your image here, or",
|
||||
"imageInput.supportedFormats": "Supports PNG, JPG, JPEG, WEBP and GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Przesyłanie {{type}} nie może przekraczać {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "Odczyt pliku nie powiódł się, spróbuj ponownie.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Przesyłanie pliku nie powiodło się, prześlij ponownie.",
|
||||
"imageGallery.previewImage": "Wyświetl podgląd obrazu {{index}} z {{total}}",
|
||||
"imageInput.browse": "przeglądaj",
|
||||
"imageInput.dropImageHere": "Upuść swój obraz tutaj, lub",
|
||||
"imageInput.supportedFormats": "Obsługuje PNG, JPG, JPEG, WEBP i GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "O upload de {{type}} não pode exceder {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "Falha na leitura do arquivo, tente novamente.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Falha no upload do arquivo, faça o upload novamente.",
|
||||
"imageGallery.previewImage": "Visualizar imagem {{index}} de {{total}}",
|
||||
"imageInput.browse": "navegar",
|
||||
"imageInput.dropImageHere": "Arraste sua imagem aqui, ou",
|
||||
"imageInput.supportedFormats": "Suporta PNG, JPG, JPEG, WEBP e GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Încărcarea {{type}} nu poate depăși {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "Citirea fișierului a eșuat, vă rugăm să încercați din nou.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Încărcarea fișierului a eșuat, vă rugăm să încărcați din nou.",
|
||||
"imageGallery.previewImage": "Previzualizează imaginea {{index}} din {{total}}",
|
||||
"imageInput.browse": "naviga",
|
||||
"imageInput.dropImageHere": "Trageți imaginea aici sau",
|
||||
"imageInput.supportedFormats": "Suportă PNG, JPG, JPEG, WEBP și GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Загрузка {{type}} не может превышать {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "Чтение файла не удалось, пожалуйста, повторите попытку.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Загрузка файла не удалась, пожалуйста, загрузите еще раз.",
|
||||
"imageGallery.previewImage": "Просмотреть изображение {{index}} из {{total}}",
|
||||
"imageInput.browse": "просмотр",
|
||||
"imageInput.dropImageHere": "Перетащите ваше изображение сюда или",
|
||||
"imageInput.supportedFormats": "Поддерживает PNG, JPG, JPEG, WEBP и GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Nalaganje {{type}} ne sme presegati {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "Branje datoteke ni uspelo, poskusite znova.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Nalaganje datoteke ni uspelo, naložite ga znova.",
|
||||
"imageGallery.previewImage": "Prikaži predogled slike {{index}} od {{total}}",
|
||||
"imageInput.browse": "brskati",
|
||||
"imageInput.dropImageHere": "Tukaj spustite svojo sliko ali",
|
||||
"imageInput.supportedFormats": "Podpira PNG, JPG, JPEG, WEBP in GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "อัปโหลด {{type}} ต้องไม่เกิน {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "การอ่านไฟล์ล้มเหลว โปรดลองอีกครั้ง",
|
||||
"fileUploader.uploadFromComputerUploadError": "อัปโหลดไฟล์ล้มเหลว โปรดอัปโหลดอีกครั้ง",
|
||||
"imageGallery.previewImage": "ดูตัวอย่างรูปภาพที่ {{index}} จาก {{total}}",
|
||||
"imageInput.browse": "ท่องเว็บ",
|
||||
"imageInput.dropImageHere": "วางภาพของคุณที่นี่ หรือ",
|
||||
"imageInput.supportedFormats": "รองรับ PNG, JPG, JPEG, WEBP และ GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "{{type}} yüklemesi {{size}}'ı aşamaz",
|
||||
"fileUploader.uploadFromComputerReadError": "Dosya okuma başarısız oldu, lütfen tekrar deneyin.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Dosya yükleme başarısız oldu, lütfen tekrar yükleyin.",
|
||||
"imageGallery.previewImage": "{{total}} görselden {{index}}. görseli önizle",
|
||||
"imageInput.browse": "göz atın",
|
||||
"imageInput.dropImageHere": "Görüntünüzü buraya bırakın veya",
|
||||
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP ve GIF'i destekler",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Завантаження {{type}} не може перевищувати {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "Не вдалося прочитати файл, будь ласка, спробуйте ще раз.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Не вдалося завантажити файл, будь ласка, завантажте ще раз.",
|
||||
"imageGallery.previewImage": "Переглянути зображення {{index}} із {{total}}",
|
||||
"imageInput.browse": "перегляд",
|
||||
"imageInput.dropImageHere": "Перетягніть зображення сюди або",
|
||||
"imageInput.supportedFormats": "Підтримує PNG, JPG, JPEG, WEBP і GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "Tải lên {{type}} không được vượt quá {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "Đọc tệp không thành công, vui lòng thử lại.",
|
||||
"fileUploader.uploadFromComputerUploadError": "Tải lên tệp không thành công, vui lòng tải lên lại.",
|
||||
"imageGallery.previewImage": "Xem trước ảnh {{index}} trên {{total}}",
|
||||
"imageInput.browse": "duyệt",
|
||||
"imageInput.dropImageHere": "Kéo hình ảnh của bạn vào đây, hoặc",
|
||||
"imageInput.supportedFormats": "Hỗ trợ PNG, JPG, JPEG, WEBP và GIF",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "上传 {{type}} 不能超过 {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "文件读取失败,请重新选择。",
|
||||
"fileUploader.uploadFromComputerUploadError": "文件上传失败,请重新上传。",
|
||||
"imageGallery.previewImage": "预览第 {{index}} 张图片,共 {{total}} 张",
|
||||
"imageInput.browse": "浏览",
|
||||
"imageInput.dropImageHere": "将图片拖放到此处,或",
|
||||
"imageInput.supportedFormats": "支持 PNG、JPG、JPEG、WEBP 和 GIF 格式",
|
||||
|
||||
@ -177,6 +177,7 @@
|
||||
"fileUploader.uploadFromComputerLimit": "上傳 {{type}} 不能超過 {{size}}",
|
||||
"fileUploader.uploadFromComputerReadError": "檔案讀取失敗,請重試。",
|
||||
"fileUploader.uploadFromComputerUploadError": "檔案上傳失敗,請重新上傳。",
|
||||
"imageGallery.previewImage": "預覽第 {{index}} 張圖片,共 {{total}} 張",
|
||||
"imageInput.browse": "瀏覽",
|
||||
"imageInput.dropImageHere": "將您的圖片放在這裡,或",
|
||||
"imageInput.supportedFormats": "支援 PNG、JPG、JPEG、WEBP 和 GIF",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user