dify/web/app/components/snippet-list/index.tsx
Joel c25b439d8b
fix: improve snippets list accessibility (#41579)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-09-02 04:39:17 +00:00

267 lines
9.5 KiB
TypeScript

'use client'
import type { SnippetPublishStatus } from './components/snippet-publish-status-filter'
import type { SnippetListItem } from '@/types/snippet'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useDebounce } from 'ahooks'
import { useAtomValue } from 'jotai'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { currentWorkspaceLoadingAtom } from '@/context/workspace-state'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import useDocumentTitle from '@/hooks/use-document-title'
import dynamic from '@/next/dynamic'
import Link from '@/next/link'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import CreatorsFilter from '../apps/creators-filter'
import Empty from '../apps/empty'
import { StudioListHeader } from '../apps/studio-list-header'
import { canAccessSnippets } from '../snippets/utils/permission'
import SnippetCard from './components/snippet-card'
import SnippetCreateButton from './components/snippet-create-button'
import SnippetPublishStatusFilter from './components/snippet-publish-status-filter'
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from './constants'
import { useSnippetsQueryState } from './hooks/use-snippets-query-state'
const TagManagementModal = dynamic(
() =>
import('@/features/tag-management/components/tag-management-modal').then(
(mod) => mod.TagManagementModal,
),
{
ssr: false,
},
)
const SNIPPET_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
const toSnippetPublishedQuery = (publishStatus: SnippetPublishStatus) => {
if (publishStatus === 'published') return true
if (publishStatus === 'draft') return false
return undefined
}
type SnippetCardSkeletonProps = {
count: number
}
const SnippetCardSkeleton = ({ count }: SnippetCardSkeletonProps) => {
return (
<>
{SNIPPET_CARD_SKELETON_KEYS.slice(0, count).map((key) => (
<div
key={key}
className="col-span-1 h-55 animate-pulse rounded-xl bg-background-default-lighter"
/>
))}
</>
)
}
const SnippetList = () => {
const { t } = useTranslation()
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
// oxlint-disable-next-line eslint-react/use-state -- custom URL query hook, not React.useState
const {
query: { tagIDs, keywords, creatorIDs },
setKeywords,
setTagIDs,
setCreatorIDs,
} = useSnippetsQueryState()
const debouncedKeywords = useDebounce(keywords, { wait: SNIPPET_LIST_SEARCH_DEBOUNCE_MS })
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const [publishStatus, setPublishStatus] = useState<SnippetPublishStatus>('all')
useDocumentTitle(t(($) => $['tabs.snippets'], { ns: 'workflow' }))
const snippetListQuery = useMemo(() => {
const isPublished = toSnippetPublishedQuery(publishStatus)
return {
page: 1,
limit: 30,
keyword: debouncedKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
...(typeof isPublished === 'boolean' ? { is_published: isPublished } : {}),
}
}, [creatorIDs, debouncedKeywords, publishStatus, tagIDs])
const canQuerySnippetList = canAccessSnippets(workspacePermissionKeys)
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
error,
refetch,
} = useInfiniteSnippetList(snippetListQuery, {
enabled: canQuerySnippetList,
})
useEffect(() => {
if (!canQuerySnippetList) return
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined
if (error) {
if (observer) observer.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver(
(entries) => {
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
fetchNextPage()
},
{
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1,
},
)
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [canQuerySnippetList, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading])
const pages = useMemo(
() => (canQuerySnippetList ? (data?.pages ?? []) : []),
[canQuerySnippetList, data?.pages],
)
const snippets = useMemo<SnippetListItem[]>(
() => pages.flatMap(({ data: pageSnippets }) => pageSnippets),
[pages],
)
const totalSnippetCount = pages[0]?.total ?? 0
const hasAnySnippet = totalSnippetCount > 0
const hasInitialQueryError = canQuerySnippetList && Boolean(error) && pages.length === 0
const showSkeleton =
!hasInitialQueryError &&
(isLoadingCurrentWorkspace ||
(canQuerySnippetList && (isLoading || (isFetching && pages.length === 0))))
const isListBusy =
isLoadingCurrentWorkspace || (canQuerySnippetList && (isFetching || isFetchingNextPage))
const initialQueryErrorMessage = hasInitialQueryError
? t(($) => $['errorBoundary.title'], { ns: 'common' })
: ''
const listStatus = showSkeleton
? ''
: isListBusy
? t(($) => $.loading, { ns: 'common' })
: hasInitialQueryError
? initialQueryErrorMessage
: hasAnySnippet
? t(($) => $['operation.searchCount'], {
ns: 'common',
count: totalSnippetCount,
content: t(($) => $['tabs.snippets'], { ns: 'workflow' }),
})
: t(($) => $['tabs.noSnippetsFound'], { ns: 'workflow' })
return (
<div
ref={containerRef}
className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body"
>
<StudioListHeader
title={
<>
<Link
href="/apps"
className="min-w-0 truncate text-[18px]/[21.6px] font-semibold text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{t(($) => $['menus.apps'], { ns: 'common' })}
</Link>
<span className="mx-1.5 shrink-0 font-light text-divider-deep">/</span>
<h1 className="min-w-0 truncate text-[18px]/[21.6px] font-semibold text-text-primary">
{t(($) => $['tabs.snippets'], { ns: 'workflow' })}
</h1>
</>
}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<CreatorsFilter value={creatorIDs} onChange={setCreatorIDs} />
<SnippetPublishStatusFilter value={publishStatus} onChange={setPublishStatus} />
<TagFilter
type="snippet"
value={tagIDs}
onChange={setTagIDs}
onOpenTagManagement={() => setShowTagManagementModal(true)}
/>
<SearchInput
name="snippet-query"
className="w-full sm:w-50"
value={keywords}
onValueChange={setKeywords}
placeholder={t(($) => $['tabs.searchSnippets'], { ns: 'workflow' })}
aria-label={t(($) => $['tabs.searchSnippets'], { ns: 'workflow' })}
/>
</div>
<SnippetCreateButton />
</div>
</StudioListHeader>
<div className="sr-only" role="status" aria-live="polite" aria-atomic="true">
{listStatus}
</div>
<div
aria-busy={isListBusy}
className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-4 pt-2 sm:grid-cols-[repeat(auto-fill,minmax(296px,1fr))] sm:px-8',
!hasAnySnippet && 'overflow-hidden',
)}
>
{showSkeleton ? (
<SnippetCardSkeleton count={6} />
) : hasInitialQueryError ? (
<div className="col-span-full flex min-h-55 flex-col items-center justify-center gap-3 text-center">
<p className="system-md-medium text-text-secondary">{initialQueryErrorMessage}</p>
<Button variant="secondary" loading={isFetching} onClick={() => refetch()}>
{t(($) => $['operation.retry'], { ns: 'common' })}
</Button>
</div>
) : hasAnySnippet ? (
snippets.map((snippet) => (
<SnippetCard
key={snippet.id}
snippet={snippet}
onOpenTagManagement={() => setShowTagManagementModal(true)}
onRefresh={refetch}
onTagsChange={refetch}
/>
))
) : (
<Empty message={t(($) => $['tabs.noSnippetsFound'], { ns: 'workflow' })} />
)}
{isFetchingNextPage && <SnippetCardSkeleton count={3} />}
</div>
<div ref={anchorRef} className="h-0">
{' '}
</div>
<TagManagementModal
type="snippet"
show={showTagManagementModal}
onClose={() => setShowTagManagementModal(false)}
onTagsChange={refetch}
/>
</div>
)
}
export default SnippetList