fix(web): improve card grid responsiveness (#38263)

This commit is contained in:
Stephen Zhou 2026-07-01 17:58:11 +08:00 committed by GitHub
parent 76391b0383
commit befe7ec269
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 125 additions and 46 deletions

View File

@ -222,7 +222,7 @@ const Apps = ({
</div>
<div
className={cn(
'grid shrink-0 grid-cols-1 content-start gap-3 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
'grid shrink-0 grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3',
)}
>
{searchFilteredList.map(app => (

View File

@ -429,17 +429,19 @@ describe('List', () => {
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
})
it('should render sort filter before search and the snippets link', () => {
it('should render search before the right aligned actions', () => {
renderList()
const sortButton = screen.getByRole('button', { name: 'Sort by Last modified' })
const creatorsButton = screen.getByRole('button', { name: 'Creators' })
const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' })
const sortButton = screen.getByRole('button', { name: 'Sort by Last modified' })
const snippetsLink = screen.getByRole('link', { name: 'app.studio.viewSnippets' })
const createButton = screen.getByRole('button', { name: 'common.operation.create' })
expect(snippetsLink).toHaveAttribute('href', '/snippets')
expect(sortButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(creatorsButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(searchInput.compareDocumentPosition(sortButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(sortButton.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
@ -450,6 +452,17 @@ describe('List', () => {
expect(screen.getByTestId('app-card-app-2'))!.toBeInTheDocument()
})
it('should lay out app cards with auto-fill grid columns', () => {
renderList()
const grid = screen.getByTestId('app-card-app-1').parentElement
expect(grid).toHaveClass(
'grid',
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
)
})
it('should hide starred section when there are no starred apps', () => {
renderList()
@ -522,6 +535,24 @@ describe('List', () => {
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
it('should lay out first empty state placeholder cards with auto-fill grid columns', () => {
mockAppData = { pages: [{ data: [], total: 0 }] }
const { container } = renderList()
const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none'))
.find(element => element.className.includes('grid-rows-4'))
if (!placeholderGrid)
throw new Error('Expected first empty state placeholder grid to render')
expect(placeholderGrid).toHaveClass(
'grid',
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
'grid-rows-4',
)
expect(placeholderGrid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-3', 'xl:grid-cols-4')
})
it('should hide learn dify in first empty state when learn app is disabled', () => {
mockAppData = { pages: [{ data: [], total: 0 }] }

View File

@ -1058,11 +1058,20 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement
</div>
</div>
<div className="flex h-[26px] shrink-0 items-start px-3" />
<div className="flex min-w-0 shrink-0 items-center pt-2 pr-4 pb-3 pl-4 system-xs-regular text-text-tertiary">
<div
className={cn(
'flex min-w-0 shrink-0 items-center pt-2 pb-3 pl-4 system-xs-regular text-text-tertiary',
app.access_mode ? 'pr-9' : 'pr-4',
)}
>
<div className="flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap">
<div className="truncate">{app.author_name}</div>
<div className="shrink-0">·</div>
<div className="truncate">{editTimeText}</div>
{app.author_name && (
<>
<div className="min-w-0 truncate">{app.author_name}</div>
<div className="shrink-0">·</div>
</>
)}
<div className="shrink-0">{editTimeText}</div>
</div>
</div>
</>

View File

@ -54,8 +54,8 @@ export function AppListHeaderFilters({
const { t } = useTranslation()
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<AppTypeFilter value={category} onChange={onCategoryChange} />
<TagFilter
type="app"
@ -63,20 +63,21 @@ export function AppListHeaderFilters({
onChange={onTagIDsChange}
onOpenTagManagement={onOpenTagManagement}
showLeadingIcon={false}
triggerClassName="min-w-0"
/>
<CreatorsFilter value={creatorIDs} onChange={onCreatorIDsChange} />
<AppSortFilter value={sortBy} onChange={onSortByChange} />
<SearchInput
className="w-50"
className="w-50 max-w-full"
value={keywords}
onValueChange={onKeywordsChange}
aria-label={t('gotoAnything.actions.searchApplications', { ns: 'app' })}
/>
</div>
<div className="flex items-center gap-2">
<div className="ml-auto flex max-w-full min-w-0 flex-wrap items-center justify-end gap-2">
<AppSortFilter value={sortBy} onChange={onSortByChange} />
<Link
href="/snippets"
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold whitespace-nowrap text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{t('studio.viewSnippets', { ns: 'app' })}
</Link>
@ -87,7 +88,7 @@ export function AppListHeaderFilters({
<Button
variant="primary"
size="medium"
className="gap-0.5 px-2 shadow-xs shadow-shadow-shadow-3"
className="gap-0.5 px-2 whitespace-nowrap shadow-xs shadow-shadow-shadow-3"
>
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
<span className="pl-1">{t('operation.create', { ns: 'common' })}</span>

View File

@ -45,7 +45,7 @@ export function AppSortFilter({
<DropdownMenu>
<DropdownMenuTrigger
aria-label={`${sortByLabel} ${activeOption.text}`}
className="flex h-8 cursor-pointer items-center rounded-lg border-none bg-components-input-bg-normal py-1 pr-2.5 pl-2 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
className="flex h-8 cursor-pointer items-center rounded-lg border-none bg-components-input-bg-normal py-1 pr-2.5 pl-2 text-left whitespace-nowrap outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
>
<span className="flex items-center gap-1 p-1 text-[13px] leading-4 whitespace-nowrap">
<span className="font-normal text-text-tertiary">{sortByLabel}</span>

View File

@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'
import { AppModeEnum } from '@/types/app'
import { isAppListCategory } from './app-type-filter-shared'
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid'
const chipClassName = 'flex h-8 items-center whitespace-nowrap rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid'
type AppTypeFilterProps = {
value: AppListCategory

View File

@ -1,2 +1,2 @@
export const APP_LIST_SEARCH_DEBOUNCE_MS = 500
export const APP_LIST_GRID_CLASS_NAME = 'grid grid-cols-1 gap-2.5 px-8 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5'
export const APP_LIST_GRID_CLASS_NAME = 'grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5 px-8 pt-2'

View File

@ -26,7 +26,7 @@ type CreatorOption = {
isYou: boolean
}
const baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid'
const baseChipClassName = 'flex h-8 items-center whitespace-nowrap rounded-lg border-[0.5px] px-2 text-[13px] leading-4 outline-hidden transition-colors focus-visible:ring-2 focus-visible:ring-state-accent-solid'
const CreatorsFilter = ({
value,

View File

@ -57,7 +57,7 @@ function FirstEmptyState({
return (
<div className="flex grow flex-col overflow-hidden">
<div className="relative min-h-[430px] flex-1 overflow-hidden">
<div className="pointer-events-none absolute inset-x-8 inset-y-2 grid grid-cols-1 grid-rows-4 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="pointer-events-none absolute inset-x-8 inset-y-2 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3">
{EMPTY_PLACEHOLDER_CARD_IDS.map(id => (
<div key={id} className="rounded-xl bg-background-default-lighter opacity-75" />
))}

View File

@ -149,18 +149,8 @@ describe('BuiltInPipelineList', () => {
const { container } = render(<BuiltInPipelineList />)
const grid = container.querySelector('.grid')
expect(grid).toHaveClass('grid-cols-1', 'gap-3', 'py-2')
})
it('should have responsive grid columns', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
const { container } = render(<BuiltInPipelineList />)
const grid = container.querySelector('.grid')
expect(grid).toHaveClass('sm:grid-cols-2', 'md:grid-cols-3', 'lg:grid-cols-4')
expect(grid).toHaveClass('grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', 'gap-3', 'py-2')
expect(grid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'md:grid-cols-3', 'lg:grid-cols-4')
})
})

View File

@ -132,7 +132,8 @@ describe('CustomizedList', () => {
const { container } = render(<CustomizedList />)
const grid = container.querySelector('.grid')
expect(grid).toHaveClass('grid-cols-1', 'gap-3', 'py-2')
expect(grid).toHaveClass('grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', 'gap-3', 'py-2')
expect(grid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'md:grid-cols-3', 'lg:grid-cols-4')
})
})
})

View File

@ -22,7 +22,7 @@ const BuiltInPipelineList = () => {
const list = pipelineList?.pipeline_templates || []
return (
<div className="grid grid-cols-1 gap-3 py-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-3 py-2">
<CreateCard />
{!isLoading && list.map((pipeline, index) => (
<TemplateCard

View File

@ -13,7 +13,7 @@ const CustomizedList = () => {
return (
<>
<div className="pt-2 system-sm-semibold-uppercase text-text-tertiary">{t('templates.customized', { ns: 'datasetPipeline' })}</div>
<div className="grid grid-cols-1 gap-3 py-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-3 py-2">
{list.map((pipeline, index) => (
<TemplateCard
key={index}

View File

@ -17,6 +17,22 @@ describe('DatasetFirstEmptyState', () => {
expect(pipelineLink.querySelector('.i-custom-vender-pipeline-pipeline-line')).toBeInTheDocument()
})
it('lays out placeholder cards with auto-fill grid columns', () => {
const { container } = render(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset />)
const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none'))
.find(element => element.className.includes('grid-rows-4'))
if (!placeholderGrid)
throw new Error('Expected dataset first empty state placeholder grid to render')
expect(placeholderGrid).toHaveClass(
'grid',
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
'grid-rows-4',
)
expect(placeholderGrid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-3', 'xl:grid-cols-4')
})
it('should hide dataset creation actions when dataset.create_and_management is unavailable', () => {
render(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset={false} />)

View File

@ -60,7 +60,7 @@ function DatasetFirstEmptyState({
return (
<div className="flex grow flex-col overflow-hidden">
<div className="relative min-h-[520px] flex-1 overflow-hidden">
<div className="pointer-events-none absolute inset-x-8 inset-y-2 grid grid-cols-1 grid-rows-4 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="pointer-events-none absolute inset-x-8 inset-y-2 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3">
{EMPTY_PLACEHOLDER_CARD_IDS.map(id => (
<div key={id} className="rounded-xl bg-background-default-lighter opacity-75" />
))}

View File

@ -45,7 +45,7 @@ function RecommendationSectionSkeletonBody({
</div>
<SkeletonRectangle className="size-8 shrink-0 animate-pulse rounded-lg" />
</div>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5">
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-4 pt-4 pb-4 shadow-xs">
<div className="flex flex-col items-start gap-2 pb-1">
@ -70,7 +70,7 @@ function RecommendationSectionSkeletonBody({
<SkeletonRectangle className="h-5 w-48 animate-pulse" />
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-3">
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className="rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-4 py-3 shadow-md">
<SkeletonRow>
@ -110,7 +110,7 @@ function ExploreHeaderSkeletonBody() {
function ExploreAppListSkeletonBody() {
return (
<div className="grid shrink-0 grid-cols-1 content-start gap-3 px-8 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid shrink-0 grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3 px-8">
{Array.from({ length: 8 }, (_, index) => (
<ExploreAppCardSkeleton key={index} />
))}

View File

@ -35,7 +35,7 @@ const ContinueWork = ({
<span className="i-ri-arrow-right-line size-3 shrink-0" aria-hidden="true" />
</Link>
</div>
<div className="grid grid-cols-1 gap-2.5 pt-2 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5 pt-2">
{apps.map(app => (
<ContinueWorkItem key={app.id} app={app} />
))}

View File

@ -117,7 +117,7 @@ const LearnDifyContent = ({
</button>
)}
</div>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5">
{visibleItems.map(item => (
<LearnDifyItem
key={item.app_id}

View File

@ -270,6 +270,18 @@ describe('SnippetList', () => {
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
})
it('lays out snippet cards with auto-fill grid columns', () => {
renderList()
const card = screen.getByRole('link', { name: /Sales Snippet/ }).closest('article')
const grid = card?.parentElement
expect(grid).toHaveClass(
'grid',
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
)
})
it('passes creator, tag, and search filters to the snippets list query', () => {
mockQueryState.tagIDs = ['tag-1', 'tag-2']
mockQueryState.keywords = 'sales'

View File

@ -189,7 +189,7 @@ const SnippetList = () => {
</div>
</StudioListHeader>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-8 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
'relative grid grow grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-4 px-8 pt-2',
!hasAnySnippet && 'overflow-hidden',
)}
>

View File

@ -165,11 +165,22 @@ describe('AgentRosterList', () => {
it('renders the Figma-aligned empty roster overlay', () => {
const { container } = renderList([])
const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none'))
.find(element => element.className.includes('grid-rows-4'))
if (!placeholderGrid)
throw new Error('Expected agent roster placeholder grid to render')
expect(screen.getByRole('heading', { name: 'agentV2.roster.empty' })).toHaveClass('system-sm-regular', 'text-text-tertiary')
expect(container.querySelectorAll('.bg-background-default-lighter')).toHaveLength(16)
expect(container.querySelector('.bg-linear-to-b')).toBeInTheDocument()
expect(container.querySelector('.i-ri-robot-2-line')).toHaveClass('size-6', 'text-text-tertiary')
expect(placeholderGrid).toHaveClass(
'grid',
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
'grid-rows-4',
)
expect(placeholderGrid).not.toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-3', 'xl:grid-cols-4')
})
it('uses the same overlay treatment for empty search results', () => {

View File

@ -71,7 +71,7 @@ function AgentRosterPlaceholderState({ title }: { title: string }) {
aria-labelledby="agent-roster-placeholder-title"
className="relative col-span-full min-h-[calc(100vh-142px)] overflow-hidden"
>
<div className="pointer-events-none absolute inset-0 grid grid-cols-1 grid-rows-4 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="pointer-events-none absolute inset-0 grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] grid-rows-4 gap-3">
{emptyPlaceholderCardIds.map(id => (
<div key={id} className="rounded-xl bg-background-default-lighter opacity-75" />
))}
@ -262,7 +262,7 @@ export function AgentRosterList({
const { t } = useTranslation('agentV2')
return (
<section aria-label={label} className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,294px),1fr))] gap-2.5" aria-busy={isFetching || undefined}>
<section aria-label={label} className="grid grid-cols-[repeat(auto-fill,minmax(296px,1fr))] gap-2.5" aria-busy={isFetching || undefined}>
{isPending && <AgentRosterSkeleton />}
{!isPending && isError && (
<AgentRosterPlaceholderState title={t('roster.loadingError')} />

View File

@ -95,6 +95,11 @@ describe('TagFilter', () => {
expect(container.querySelector('svg')).not.toBeInTheDocument()
})
it('should apply custom trigger class names', () => {
render(<TagFilter {...defaultProps} triggerClassName="min-w-0" />)
expect(screen.getByRole('combobox', { name: i18n.placeholder })).toHaveClass('min-w-0')
})
it('should filter tags by type prop', async () => {
const user = userEvent.setup()
render(<TagFilter {...defaultProps} type="knowledge" />)

View File

@ -19,6 +19,7 @@ type TagFilterProps = {
onChange: (v: string[]) => void
onOpenTagManagement?: () => void
showLeadingIcon?: boolean
triggerClassName?: string
}
export const TagFilter = ({
type,
@ -26,6 +27,7 @@ export const TagFilter = ({
onChange,
onOpenTagManagement = () => {},
showLeadingIcon = true,
triggerClassName,
}: TagFilterProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@ -76,8 +78,9 @@ export const TagFilter = ({
aria-label={triggerLabel}
icon={false}
className={cn(
'flex h-8 max-w-60 min-w-28 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-0 text-left select-none hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-components-input-bg-normal',
'flex h-8 max-w-60 min-w-28 cursor-pointer items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 py-0 text-left whitespace-nowrap select-none hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-components-input-bg-normal',
!!value.length && 'pr-6 shadow-xs',
triggerClassName,
)}
>
<span className="flex w-full min-w-0 items-center gap-1">