mirror of
https://github.com/langgenius/dify.git
synced 2026-01-01 12:07:11 +00:00
Compare commits
8 Commits
test/rag-p
...
refactor/q
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453b9ae77b | ||
|
|
9ff4d2bbf3 | ||
|
|
7f3437e577 | ||
|
|
fc196df814 | ||
|
|
5e7aa8dd03 | ||
|
|
c1a822b114 | ||
|
|
20d10d42b9 | ||
|
|
e97857ef7f |
200
web/app/components/app/log/index.spec.tsx
Normal file
200
web/app/components/app/log/index.spec.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ChatConversationGeneralDetail, ChatConversationsResponse } from '@/models/log'
|
||||
import type { App, AppIconType } from '@/types/app'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Logs from './index'
|
||||
|
||||
const mockUseChatConversations = vi.fn()
|
||||
const mockUseCompletionConversations = vi.fn()
|
||||
const mockUseAnnotationsCount = vi.fn()
|
||||
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockRouterReplace = vi.fn()
|
||||
|
||||
const mockAppStoreState = {
|
||||
setShowPromptLogModal: vi.fn(),
|
||||
setShowAgentLogModal: vi.fn(),
|
||||
setShowMessageLogModal: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
replace: mockRouterReplace,
|
||||
}),
|
||||
usePathname: () => '/apps/app-123/logs',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useChatConversations: (args: unknown) => mockUseChatConversations(args),
|
||||
useCompletionConversations: (args: unknown) => mockUseCompletionConversations(args),
|
||||
useAnnotationsCount: () => mockUseAnnotationsCount(),
|
||||
useChatConversationDetail: () => ({ data: undefined }),
|
||||
useCompletionConversationDetail: () => ({ data: undefined }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/log', () => ({
|
||||
fetchChatMessages: vi.fn(),
|
||||
updateLogMessageAnnotations: vi.fn(),
|
||||
updateLogMessageFeedbacks: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: { timezone: 'UTC' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: typeof mockAppStoreState) => unknown) => selector(mockAppStoreState),
|
||||
}))
|
||||
|
||||
const renderWithAdapter = (ui: ReactNode, searchParams = '') => {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
{ui}
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
}
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: 'app-123',
|
||||
name: 'Test App',
|
||||
description: 'Test app description',
|
||||
author_name: 'Test Author',
|
||||
icon_type: 'emoji' as AppIconType,
|
||||
icon: ':icon:',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: null,
|
||||
use_icon_as_answer_icon: false,
|
||||
mode: AppModeEnum.CHAT,
|
||||
enable_site: true,
|
||||
enable_api: true,
|
||||
api_rpm: 60,
|
||||
api_rph: 3600,
|
||||
is_demo: false,
|
||||
model_config: {} as App['model_config'],
|
||||
app_model_config: {} as App['app_model_config'],
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
site: {
|
||||
access_token: 'token',
|
||||
app_base_url: 'https://example.com',
|
||||
} as App['site'],
|
||||
api_base_url: 'https://api.example.com',
|
||||
tags: [],
|
||||
access_mode: 'public_access' as App['access_mode'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createChatConversation = (overrides: Partial<ChatConversationGeneralDetail> = {}): ChatConversationGeneralDetail => ({
|
||||
id: 'conversation-1',
|
||||
status: 'normal',
|
||||
from_source: 'api',
|
||||
from_end_user_id: 'user-1',
|
||||
from_end_user_session_id: 'session-1',
|
||||
from_account_id: 'account-1',
|
||||
read_at: new Date(),
|
||||
created_at: 1700000000,
|
||||
updated_at: 1700000001,
|
||||
user_feedback_stats: { like: 0, dislike: 0 },
|
||||
admin_feedback_stats: { like: 0, dislike: 0 },
|
||||
model_config: {
|
||||
provider: 'openai',
|
||||
model_id: 'gpt-4',
|
||||
configs: { prompt_template: '' },
|
||||
},
|
||||
summary: 'Conversation summary',
|
||||
message_count: 1,
|
||||
annotated: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createChatConversationsResponse = (overrides: Partial<ChatConversationsResponse> = {}): ChatConversationsResponse => ({
|
||||
data: [createChatConversation()],
|
||||
has_more: false,
|
||||
limit: APP_PAGE_LIMIT,
|
||||
total: 1,
|
||||
page: 1,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Logs page: loading, empty, and data states.
|
||||
describe('Logs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
globalThis.innerWidth = 1024
|
||||
|
||||
mockUseAnnotationsCount.mockReturnValue({
|
||||
data: { count: 0 },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
mockUseCompletionConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
// Loading behavior when no data yet.
|
||||
describe('Rendering', () => {
|
||||
it('should render loading state when conversations are undefined', () => {
|
||||
// Arrange
|
||||
const appDetail = createMockApp()
|
||||
|
||||
// Act
|
||||
renderWithAdapter(<Logs appDetail={appDetail} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty state when there are no conversations', () => {
|
||||
// Arrange
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: createChatConversationsResponse({ data: [], total: 0 }),
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
const appDetail = createMockApp()
|
||||
|
||||
// Act
|
||||
renderWithAdapter(<Logs appDetail={appDetail} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appLog.table.empty.element.title')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Data rendering behavior.
|
||||
describe('Props', () => {
|
||||
it('should render list with pagination when conversations exist', () => {
|
||||
// Arrange
|
||||
mockUseChatConversations.mockReturnValue({
|
||||
data: createChatConversationsResponse({ total: APP_PAGE_LIMIT + 1 }),
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
const appDetail = createMockApp()
|
||||
|
||||
// Act
|
||||
renderWithAdapter(<Logs appDetail={appDetail} />, '?page=0&limit=0')
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appLog.table.header.summary')).toBeInTheDocument()
|
||||
expect(screen.getByText('25')).toBeInTheDocument()
|
||||
|
||||
const firstCallArgs = mockUseChatConversations.mock.calls[0]?.[0]
|
||||
expect(firstCallArgs.params.page).toBe(1)
|
||||
expect(firstCallArgs.params.limit).toBe(APP_PAGE_LIMIT)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,9 +4,13 @@ import type { App } from '@/types/app'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import { omit } from 'es-toolkit/object'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
useQueryStates,
|
||||
} from 'nuqs'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
@@ -28,53 +32,39 @@ export type QueryParam = {
|
||||
sort_by?: string
|
||||
}
|
||||
|
||||
const defaultQueryParams: QueryParam = {
|
||||
period: '2',
|
||||
annotation_status: 'all',
|
||||
sort_by: '-created_at',
|
||||
}
|
||||
|
||||
const logsStateCache = new Map<string, {
|
||||
queryParams: QueryParam
|
||||
currPage: number
|
||||
limit: number
|
||||
}>()
|
||||
|
||||
const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const getPageFromParams = useCallback(() => {
|
||||
const pageParam = Number.parseInt(searchParams.get('page') || '1', 10)
|
||||
if (Number.isNaN(pageParam) || pageParam < 1)
|
||||
return 0
|
||||
return pageParam - 1
|
||||
}, [searchParams])
|
||||
const cachedState = logsStateCache.get(appDetail.id)
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>(cachedState?.queryParams ?? defaultQueryParams)
|
||||
const [currPage, setCurrPage] = React.useState<number>(() => cachedState?.currPage ?? getPageFromParams())
|
||||
const [limit, setLimit] = React.useState<number>(cachedState?.limit ?? APP_PAGE_LIMIT)
|
||||
|
||||
const [queryParams, setQueryParams] = useQueryStates(
|
||||
{
|
||||
page: parseAsInteger.withDefault(1),
|
||||
limit: parseAsInteger.withDefault(APP_PAGE_LIMIT),
|
||||
period: parseAsString.withDefault('2'),
|
||||
annotation_status: parseAsString.withDefault('all'),
|
||||
keyword: parseAsString,
|
||||
sort_by: parseAsString.withDefault('-created_at'),
|
||||
},
|
||||
{
|
||||
urlKeys: {
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
period: 'period',
|
||||
annotation_status: 'annotation_status',
|
||||
keyword: 'keyword',
|
||||
sort_by: 'sort_by',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
|
||||
|
||||
useEffect(() => {
|
||||
const pageFromParams = getPageFromParams()
|
||||
setCurrPage(prev => (prev === pageFromParams ? prev : pageFromParams))
|
||||
}, [getPageFromParams])
|
||||
|
||||
useEffect(() => {
|
||||
logsStateCache.set(appDetail.id, {
|
||||
queryParams,
|
||||
currPage,
|
||||
limit,
|
||||
})
|
||||
}, [appDetail.id, currPage, limit, queryParams])
|
||||
const page = queryParams.page > 0 ? queryParams.page : 1
|
||||
const limit = queryParams.limit > 0 ? queryParams.limit : APP_PAGE_LIMIT
|
||||
|
||||
// Get the app type first
|
||||
const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION
|
||||
|
||||
const query = {
|
||||
page: currPage + 1,
|
||||
page,
|
||||
limit,
|
||||
...((debouncedQueryParams.period !== '9')
|
||||
? {
|
||||
@@ -83,7 +73,8 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
}
|
||||
: {}),
|
||||
...(isChatMode ? { sort_by: debouncedQueryParams.sort_by } : {}),
|
||||
...omit(debouncedQueryParams, ['period']),
|
||||
...omit(debouncedQueryParams, ['period', 'page', 'limit']),
|
||||
keyword: debouncedQueryParams.keyword || undefined,
|
||||
}
|
||||
|
||||
// When the details are obtained, proceed to the next request
|
||||
@@ -100,27 +91,25 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
const total = isChatMode ? chatConversations?.total : completionConversations?.total
|
||||
|
||||
const handleQueryParamsChange = useCallback((next: QueryParam) => {
|
||||
setCurrPage(0)
|
||||
setQueryParams(next)
|
||||
}, [])
|
||||
setQueryParams({
|
||||
...next,
|
||||
page: 1, // Reset to page 1 on filter change
|
||||
})
|
||||
}, [setQueryParams])
|
||||
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
setCurrPage(page)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
const nextPageValue = page + 1
|
||||
if (nextPageValue === 1)
|
||||
params.delete('page')
|
||||
else
|
||||
params.set('page', String(nextPageValue))
|
||||
const queryString = params.toString()
|
||||
router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false })
|
||||
}, [pathname, router, searchParams])
|
||||
setQueryParams({ page: page + 1 })
|
||||
}, [setQueryParams])
|
||||
|
||||
const handleLimitChange = useCallback((limit: number) => {
|
||||
setQueryParams({ limit, page: 1 })
|
||||
}, [setQueryParams])
|
||||
|
||||
return (
|
||||
<div className="flex h-full grow flex-col">
|
||||
<p className="system-sm-regular shrink-0 text-text-tertiary">{t('description', { ns: 'appLog' })}</p>
|
||||
<div className="flex max-h-[calc(100%-16px)] flex-1 grow flex-col py-4">
|
||||
<Filter isChatMode={isChatMode} appId={appDetail.id} queryParams={queryParams} setQueryParams={handleQueryParamsChange} />
|
||||
<Filter isChatMode={isChatMode} appId={appDetail.id} queryParams={{ ...queryParams, keyword: queryParams.keyword ?? undefined }} setQueryParams={handleQueryParamsChange} />
|
||||
{total === undefined
|
||||
? <Loading type="app" />
|
||||
: total > 0
|
||||
@@ -130,11 +119,11 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? (
|
||||
<Pagination
|
||||
current={currPage}
|
||||
current={page - 1}
|
||||
onChange={handlePageChange}
|
||||
total={total}
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
onLimitChange={handleLimitChange}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChatConfig } from '../types'
|
||||
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { ToastProvider } from '@/app/components/base/toast'
|
||||
import {
|
||||
fetchChatList,
|
||||
@@ -74,9 +75,11 @@ const createQueryClient = () => new QueryClient({
|
||||
|
||||
const createWrapper = (queryClient: QueryClient) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
<NuqsTestingAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { useLocalStorageState } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -82,12 +83,10 @@ export const useEmbeddedChatbot = () => {
|
||||
setConversationId(embeddedConversationId || undefined)
|
||||
}, [embeddedConversationId])
|
||||
|
||||
const [localeParam] = useQueryState('locale', parseAsString)
|
||||
|
||||
useEffect(() => {
|
||||
const setLanguageFromParams = async () => {
|
||||
// Check URL parameters for language override
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const localeParam = urlParams.get('locale')
|
||||
|
||||
// Check for encoded system variables
|
||||
const systemVariables = await getProcessedSystemVariablesFromUrlParams()
|
||||
const localeFromSysVar = systemVariables.locale
|
||||
@@ -107,7 +106,7 @@ export const useEmbeddedChatbot = () => {
|
||||
}
|
||||
|
||||
setLanguageFromParams()
|
||||
}, [appInfo])
|
||||
}, [appInfo, localeParam])
|
||||
|
||||
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
|
||||
defaultValue: {},
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
import usePSInfo from './use-ps-info'
|
||||
|
||||
let searchParamsValues: Record<string, string | null> = {}
|
||||
const setSearchParams = (values: Record<string, string | null>) => {
|
||||
searchParamsValues = values
|
||||
}
|
||||
|
||||
type PartnerStackGlobal = typeof globalThis & {
|
||||
__partnerStackCookieMocks?: {
|
||||
get: ReturnType<typeof vi.fn>
|
||||
@@ -48,11 +45,6 @@ vi.mock('js-cookie', () => {
|
||||
remove,
|
||||
}
|
||||
})
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => searchParamsValues[key] ?? null,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/service/use-billing', () => {
|
||||
const mutateAsync = vi.fn()
|
||||
const globals = getPartnerStackGlobal()
|
||||
@@ -64,6 +56,15 @@ vi.mock('@/service/use-billing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const renderWithAdapter = (searchParams = '') => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
return renderHook(() => usePSInfo(), { wrapper })
|
||||
}
|
||||
|
||||
describe('usePSInfo', () => {
|
||||
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
|
||||
|
||||
@@ -75,7 +76,6 @@ describe('usePSInfo', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setSearchParams({})
|
||||
const { get, set, remove } = ensureCookieMocks()
|
||||
get.mockReset()
|
||||
set.mockReset()
|
||||
@@ -94,12 +94,7 @@ describe('usePSInfo', () => {
|
||||
it('saves partner info when query params change', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue(JSON.stringify({ partnerKey: 'old', clickId: 'old-click' }))
|
||||
setSearchParams({
|
||||
ps_partner_key: 'new-partner',
|
||||
ps_xid: 'new-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderWithAdapter('?ps_partner_key=new-partner&ps_xid=new-click')
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('new-partner')
|
||||
expect(result.current.psClickId).toBe('new-click')
|
||||
@@ -123,17 +118,13 @@ describe('usePSInfo', () => {
|
||||
})
|
||||
|
||||
it('does not overwrite cookie when params do not change', () => {
|
||||
setSearchParams({
|
||||
ps_partner_key: 'existing',
|
||||
ps_xid: 'existing-click',
|
||||
})
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue(JSON.stringify({
|
||||
partnerKey: 'existing',
|
||||
clickId: 'existing-click',
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderWithAdapter('?ps_partner_key=existing&ps_xid=existing-click')
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
@@ -144,12 +135,7 @@ describe('usePSInfo', () => {
|
||||
})
|
||||
|
||||
it('binds partner info and clears cookie once', async () => {
|
||||
setSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderWithAdapter('?ps_partner_key=bind-partner&ps_xid=bind-click')
|
||||
|
||||
const mutate = ensureMutateAsync()
|
||||
const { remove } = ensureCookieMocks()
|
||||
@@ -176,12 +162,7 @@ describe('usePSInfo', () => {
|
||||
it('still removes cookie when bind fails with status 400', async () => {
|
||||
const mutate = ensureMutateAsync()
|
||||
mutate.mockRejectedValueOnce({ status: 400 })
|
||||
setSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
const { result } = renderWithAdapter('?ps_partner_key=bind-partner&ps_xid=bind-click')
|
||||
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useBoolean } from 'ahooks'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
import { useBindPartnerStackInfo } from '@/service/use-billing'
|
||||
|
||||
const usePSInfo = () => {
|
||||
const searchParams = useSearchParams()
|
||||
const [partnerKey] = useQueryState('ps_partner_key', parseAsString)
|
||||
const [clickId] = useQueryState('ps_xid', parseAsString)
|
||||
const psInfoInCookie = (() => {
|
||||
try {
|
||||
return JSON.parse(Cookies.get(PARTNER_STACK_CONFIG.cookieName) || '{}')
|
||||
@@ -16,8 +17,8 @@ const usePSInfo = () => {
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
const psPartnerKey = searchParams.get('ps_partner_key') || psInfoInCookie?.partnerKey
|
||||
const psClickId = searchParams.get('ps_xid') || psInfoInCookie?.clickId
|
||||
const psPartnerKey = partnerKey || psInfoInCookie?.partnerKey
|
||||
const psClickId = clickId || psInfoInCookie?.clickId
|
||||
const isPSChanged = psInfoInCookie?.partnerKey !== psPartnerKey || psInfoInCookie?.clickId !== psClickId
|
||||
const [hasBind, {
|
||||
setTrue: setBind,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import useDocumentListQueryState from './use-document-list-query-state'
|
||||
|
||||
const renderWithAdapter = (searchParams = '') => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
|
||||
return renderHook(() => useDocumentListQueryState(), { wrapper })
|
||||
}
|
||||
|
||||
// Document list query state: defaults, sanitization, and update actions.
|
||||
describe('useDocumentListQueryState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Default query values.
|
||||
describe('Rendering', () => {
|
||||
it('should return default query values when URL params are missing', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
// Act
|
||||
const { query } = result.current
|
||||
|
||||
// Assert
|
||||
expect(query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// URL sanitization behavior.
|
||||
describe('Edge Cases', () => {
|
||||
it('should sanitize invalid URL query values', () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter('?page=0&limit=500&keyword=%20%20&status=invalid&sort=bad')
|
||||
|
||||
// Act
|
||||
const { query } = result.current
|
||||
|
||||
// Assert
|
||||
expect(query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Query update actions.
|
||||
describe('User Interactions', () => {
|
||||
it('should normalize query updates', async () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.updateQuery({
|
||||
page: 0,
|
||||
limit: 200,
|
||||
keyword: ' search ',
|
||||
status: 'invalid',
|
||||
sort: 'hit_count',
|
||||
})
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: ' search ',
|
||||
status: 'all',
|
||||
sort: 'hit_count',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset query values to defaults', async () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter('?page=2&limit=25&keyword=hello&status=enabled&sort=hit_count')
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Callback stability.
|
||||
describe('Performance', () => {
|
||||
it('should keep action callbacks stable across updates', async () => {
|
||||
// Arrange
|
||||
const { result } = renderWithAdapter()
|
||||
const initialUpdate = result.current.updateQuery
|
||||
const initialReset = result.current.resetQuery
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.updateQuery).toBe(initialUpdate)
|
||||
expect(result.current.resetQuery).toBe(initialReset)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReadonlyURLSearchParams } from 'next/navigation'
|
||||
import type { SortType } from '@/service/datasets'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { sanitizeStatusValue } from '../status-filter'
|
||||
|
||||
@@ -21,6 +20,14 @@ export type DocumentListQuery = {
|
||||
sort: SortType
|
||||
}
|
||||
|
||||
type DocumentListQueryInput = {
|
||||
page?: number
|
||||
limit?: number
|
||||
keyword?: string | null
|
||||
status?: string | null
|
||||
sort?: string | null
|
||||
}
|
||||
|
||||
const DEFAULT_QUERY: DocumentListQuery = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
@@ -29,89 +36,60 @@ const DEFAULT_QUERY: DocumentListQuery = {
|
||||
sort: '-created_at',
|
||||
}
|
||||
|
||||
// Parse the query parameters from the URL search string.
|
||||
function parseParams(params: ReadonlyURLSearchParams): DocumentListQuery {
|
||||
const page = Number.parseInt(params.get('page') || '1', 10)
|
||||
const limit = Number.parseInt(params.get('limit') || '10', 10)
|
||||
const keyword = params.get('keyword') || ''
|
||||
const status = sanitizeStatusValue(params.get('status'))
|
||||
const sort = sanitizeSortValue(params.get('sort'))
|
||||
const normalizeKeywordValue = (value?: string | null) => (value && value.trim() ? value : '')
|
||||
|
||||
const normalizeDocumentListQuery = (query: DocumentListQueryInput): DocumentListQuery => {
|
||||
const page = (query.page && query.page > 0) ? query.page : DEFAULT_QUERY.page
|
||||
const limit = (query.limit && query.limit > 0 && query.limit <= 100) ? query.limit : DEFAULT_QUERY.limit
|
||||
const keyword = normalizeKeywordValue(query.keyword ?? DEFAULT_QUERY.keyword)
|
||||
const status = sanitizeStatusValue(query.status ?? DEFAULT_QUERY.status)
|
||||
const sort = sanitizeSortValue(query.sort ?? DEFAULT_QUERY.sort)
|
||||
|
||||
return {
|
||||
page: page > 0 ? page : 1,
|
||||
limit: (limit > 0 && limit <= 100) ? limit : 10,
|
||||
keyword: keyword ? decodeURIComponent(keyword) : '',
|
||||
page,
|
||||
limit,
|
||||
keyword,
|
||||
status,
|
||||
sort,
|
||||
}
|
||||
}
|
||||
|
||||
// Update the URL search string with the given query parameters.
|
||||
function updateSearchParams(query: DocumentListQuery, searchParams: URLSearchParams) {
|
||||
const { page, limit, keyword, status, sort } = query || {}
|
||||
|
||||
const hasNonDefaultParams = (page && page > 1) || (limit && limit !== 10) || (keyword && keyword.trim())
|
||||
|
||||
if (hasNonDefaultParams) {
|
||||
searchParams.set('page', (page || 1).toString())
|
||||
searchParams.set('limit', (limit || 10).toString())
|
||||
}
|
||||
else {
|
||||
searchParams.delete('page')
|
||||
searchParams.delete('limit')
|
||||
}
|
||||
|
||||
if (keyword && keyword.trim())
|
||||
searchParams.set('keyword', encodeURIComponent(keyword))
|
||||
else
|
||||
searchParams.delete('keyword')
|
||||
|
||||
const sanitizedStatus = sanitizeStatusValue(status)
|
||||
if (sanitizedStatus && sanitizedStatus !== 'all')
|
||||
searchParams.set('status', sanitizedStatus)
|
||||
else
|
||||
searchParams.delete('status')
|
||||
|
||||
const sanitizedSort = sanitizeSortValue(sort)
|
||||
if (sanitizedSort !== '-created_at')
|
||||
searchParams.set('sort', sanitizedSort)
|
||||
else
|
||||
searchParams.delete('sort')
|
||||
}
|
||||
|
||||
function useDocumentListQueryState() {
|
||||
const searchParams = useSearchParams()
|
||||
const query = useMemo(() => parseParams(searchParams), [searchParams])
|
||||
const [query, setQuery] = useQueryStates(
|
||||
{
|
||||
page: parseAsInteger.withDefault(DEFAULT_QUERY.page),
|
||||
limit: parseAsInteger.withDefault(DEFAULT_QUERY.limit),
|
||||
keyword: parseAsString.withDefault(DEFAULT_QUERY.keyword),
|
||||
status: parseAsString.withDefault(DEFAULT_QUERY.status),
|
||||
sort: parseAsString.withDefault(DEFAULT_QUERY.sort),
|
||||
},
|
||||
{
|
||||
history: 'push',
|
||||
urlKeys: {
|
||||
page: 'page',
|
||||
limit: 'limit',
|
||||
keyword: 'keyword',
|
||||
status: 'status',
|
||||
sort: 'sort',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const finalQuery = useMemo(() => normalizeDocumentListQuery(query), [query])
|
||||
|
||||
// Helper function to update specific query parameters
|
||||
const updateQuery = useCallback((updates: Partial<DocumentListQuery>) => {
|
||||
const newQuery = { ...query, ...updates }
|
||||
newQuery.status = sanitizeStatusValue(newQuery.status)
|
||||
newQuery.sort = sanitizeSortValue(newQuery.sort)
|
||||
const params = new URLSearchParams()
|
||||
updateSearchParams(newQuery, params)
|
||||
const search = params.toString()
|
||||
const queryString = search ? `?${search}` : ''
|
||||
router.push(`${pathname}${queryString}`, { scroll: false })
|
||||
}, [query, router, pathname])
|
||||
setQuery(prev => normalizeDocumentListQuery({ ...prev, ...updates }))
|
||||
}, [setQuery])
|
||||
|
||||
// Helper function to reset query to defaults
|
||||
const resetQuery = useCallback(() => {
|
||||
const params = new URLSearchParams()
|
||||
updateSearchParams(DEFAULT_QUERY, params)
|
||||
const search = params.toString()
|
||||
const queryString = search ? `?${search}` : ''
|
||||
router.push(`${pathname}${queryString}`, { scroll: false })
|
||||
}, [router, pathname])
|
||||
setQuery(DEFAULT_QUERY)
|
||||
}, [setQuery])
|
||||
|
||||
return useMemo(() => ({
|
||||
query,
|
||||
query: finalQuery,
|
||||
updateQuery,
|
||||
resetQuery,
|
||||
}), [query, updateQuery, resetQuery])
|
||||
}), [finalQuery, updateQuery, resetQuery])
|
||||
}
|
||||
|
||||
export default useDocumentListQueryState
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useEffect } from 'react'
|
||||
import usePSInfo from '../components/billing/partner-stack/use-ps-info'
|
||||
import NormalForm from './normal-form'
|
||||
import OneMoreStep from './one-more-step'
|
||||
|
||||
const SignIn = () => {
|
||||
const searchParams = useSearchParams()
|
||||
const step = searchParams.get('step')
|
||||
const [step] = useQueryState('step', parseAsString)
|
||||
const { saveOrUpdate } = usePSInfo()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user