dify
This commit is contained in:
16
dify/web/hooks/use-api-access-url.ts
Normal file
16
dify/web/hooks/use-api-access-url.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
|
||||
export const useDatasetApiAccessUrl = () => {
|
||||
const locale = useGetLanguage()
|
||||
|
||||
const apiReferenceUrl = useMemo(() => {
|
||||
if (locale === 'zh_Hans')
|
||||
return 'https://docs.dify.ai/api-reference/%E6%95%B0%E6%8D%AE%E9%9B%86'
|
||||
if (locale === 'ja_JP')
|
||||
return 'https://docs.dify.ai/api-reference/%E3%83%87%E3%83%BC%E3%82%BF%E3%82%BB%E3%83%83%E3%83%88'
|
||||
return 'https://docs.dify.ai/api-reference/datasets'
|
||||
}, [locale])
|
||||
|
||||
return apiReferenceUrl
|
||||
}
|
||||
44
dify/web/hooks/use-app-favicon.ts
Normal file
44
dify/web/hooks/use-app-favicon.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useAsyncEffect } from 'ahooks'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { searchEmoji } from '@/utils/emoji'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
|
||||
type UseAppFaviconOptions = {
|
||||
enable?: boolean
|
||||
icon_type?: AppIconType | null
|
||||
icon?: string
|
||||
icon_background?: string | null
|
||||
icon_url?: string | null
|
||||
}
|
||||
|
||||
export function useAppFavicon(options: UseAppFaviconOptions) {
|
||||
const {
|
||||
enable = true,
|
||||
icon_type = 'emoji',
|
||||
icon,
|
||||
icon_background,
|
||||
icon_url,
|
||||
} = options
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!enable || (icon_type === 'image' && !icon_url) || (icon_type === 'emoji' && !icon))
|
||||
return
|
||||
|
||||
const isValidImageIcon = icon_type === 'image' && icon_url
|
||||
|
||||
const link: HTMLLinkElement = document.querySelector('link[rel*="icon"]') || document.createElement('link')
|
||||
|
||||
link.href = isValidImageIcon
|
||||
? icon_url
|
||||
: 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22>'
|
||||
+ `<rect width=%22100%25%22 height=%22100%25%22 fill=%22${encodeURIComponent(icon_background || appDefaultIconBackground)}%22 rx=%2230%22 ry=%2230%22 />`
|
||||
+ `<text x=%2212.5%22 y=%221em%22 font-size=%2275%22>${
|
||||
icon ? await searchEmoji(icon) : '🤖'
|
||||
}</text>`
|
||||
+ '</svg>'
|
||||
|
||||
link.rel = 'shortcut icon'
|
||||
link.type = 'image/svg'
|
||||
document.getElementsByTagName('head')[0].appendChild(link)
|
||||
}, [enable, icon, icon_background])
|
||||
}
|
||||
133
dify/web/hooks/use-breakpoints.spec.ts
Normal file
133
dify/web/hooks/use-breakpoints.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Test suite for useBreakpoints hook
|
||||
*
|
||||
* This hook provides responsive breakpoint detection based on window width.
|
||||
* It listens to window resize events and returns the current media type.
|
||||
*
|
||||
* Breakpoint definitions:
|
||||
* - mobile: width <= 640px
|
||||
* - tablet: 640px < width <= 768px
|
||||
* - pc: width > 768px
|
||||
*
|
||||
* The hook automatically updates when the window is resized and cleans up
|
||||
* event listeners on unmount to prevent memory leaks.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useBreakpoints, { MediaType } from './use-breakpoints'
|
||||
|
||||
describe('useBreakpoints', () => {
|
||||
const originalInnerWidth = window.innerWidth
|
||||
|
||||
/**
|
||||
* Helper function to simulate window resize events
|
||||
* Updates window.innerWidth and dispatches a resize event
|
||||
*/
|
||||
const fireResize = (width: number) => {
|
||||
window.innerWidth = width
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the original innerWidth after all tests
|
||||
* Ensures tests don't affect each other or the test environment
|
||||
*/
|
||||
afterAll(() => {
|
||||
window.innerWidth = originalInnerWidth
|
||||
})
|
||||
|
||||
/**
|
||||
* Test mobile breakpoint detection
|
||||
* Mobile devices have width <= 640px
|
||||
*/
|
||||
it('should return mobile for width <= 640px', () => {
|
||||
// Mock window.innerWidth for mobile
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 640,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreakpoints())
|
||||
expect(result.current).toBe(MediaType.mobile)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test tablet breakpoint detection
|
||||
* Tablet devices have width between 640px and 768px
|
||||
*/
|
||||
it('should return tablet for width > 640px and <= 768px', () => {
|
||||
// Mock window.innerWidth for tablet
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 768,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreakpoints())
|
||||
expect(result.current).toBe(MediaType.tablet)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test desktop/PC breakpoint detection
|
||||
* Desktop devices have width > 768px
|
||||
*/
|
||||
it('should return pc for width > 768px', () => {
|
||||
// Mock window.innerWidth for pc
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1024,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreakpoints())
|
||||
expect(result.current).toBe(MediaType.pc)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test dynamic breakpoint updates on window resize
|
||||
* The hook should react to window resize events and update the media type
|
||||
*/
|
||||
it('should update media type when window resizes', () => {
|
||||
// Start with desktop
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1024,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreakpoints())
|
||||
expect(result.current).toBe(MediaType.pc)
|
||||
|
||||
// Resize to tablet
|
||||
fireResize(768)
|
||||
expect(result.current).toBe(MediaType.tablet)
|
||||
|
||||
// Resize to mobile
|
||||
fireResize(600)
|
||||
expect(result.current).toBe(MediaType.mobile)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test proper cleanup of event listeners
|
||||
* Ensures no memory leaks by removing resize listeners on unmount
|
||||
*/
|
||||
it('should clean up event listeners on unmount', () => {
|
||||
// Spy on addEventListener and removeEventListener
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener')
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener')
|
||||
|
||||
const { unmount } = renderHook(() => useBreakpoints())
|
||||
|
||||
// Unmount should trigger cleanup
|
||||
unmount()
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||
|
||||
// Clean up spies
|
||||
addEventListenerSpy.mockRestore()
|
||||
removeEventListenerSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
29
dify/web/hooks/use-breakpoints.ts
Normal file
29
dify/web/hooks/use-breakpoints.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
|
||||
export enum MediaType {
|
||||
mobile = 'mobile',
|
||||
tablet = 'tablet',
|
||||
pc = 'pc',
|
||||
}
|
||||
|
||||
const useBreakpoints = () => {
|
||||
const [width, setWidth] = React.useState(globalThis.innerWidth)
|
||||
const media = (() => {
|
||||
if (width <= 640)
|
||||
return MediaType.mobile
|
||||
if (width <= 768)
|
||||
return MediaType.tablet
|
||||
return MediaType.pc
|
||||
})()
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleWindowResize = () => setWidth(window.innerWidth)
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
return () => window.removeEventListener('resize', handleWindowResize)
|
||||
}, [])
|
||||
|
||||
return media
|
||||
}
|
||||
|
||||
export default useBreakpoints
|
||||
111
dify/web/hooks/use-document-title.spec.ts
Normal file
111
dify/web/hooks/use-document-title.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Test suite for useDocumentTitle hook
|
||||
*
|
||||
* This hook manages the browser document title with support for:
|
||||
* - Custom branding (when enabled in system features)
|
||||
* - Default "Dify" branding
|
||||
* - Pending state handling (prevents title flicker during loading)
|
||||
* - Page-specific titles with automatic suffix
|
||||
*
|
||||
* Title format: "[Page Title] - [Brand Name]"
|
||||
* If no page title: "[Brand Name]"
|
||||
*/
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useDocumentTitle from './use-document-title'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
jest.mock('@/service/common', () => ({
|
||||
getSystemFeatures: jest.fn(() => ({ ...defaultSystemFeatures })),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Test behavior when system features are still loading
|
||||
* Title should remain empty to prevent flicker
|
||||
*/
|
||||
describe('title should be empty if systemFeatures is pending', () => {
|
||||
act(() => {
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
|
||||
isGlobalPending: true,
|
||||
})
|
||||
})
|
||||
/**
|
||||
* Test that title stays empty during loading even when a title is provided
|
||||
*/
|
||||
it('document title should be empty if set title', () => {
|
||||
renderHook(() => useDocumentTitle('test'))
|
||||
expect(document.title).toBe('')
|
||||
})
|
||||
/**
|
||||
* Test that title stays empty during loading when no title is provided
|
||||
*/
|
||||
it('document title should be empty if not set title', () => {
|
||||
renderHook(() => useDocumentTitle(''))
|
||||
expect(document.title).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test default Dify branding behavior
|
||||
* When custom branding is disabled, should use "Dify" as the brand name
|
||||
*/
|
||||
describe('use default branding', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useGlobalPublicStore.setState({
|
||||
isGlobalPending: false,
|
||||
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: false } },
|
||||
})
|
||||
})
|
||||
})
|
||||
/**
|
||||
* Test title format with page title and default branding
|
||||
* Format: "[page] - Dify"
|
||||
*/
|
||||
it('document title should be test-Dify if set title', () => {
|
||||
renderHook(() => useDocumentTitle('test'))
|
||||
expect(document.title).toBe('test - Dify')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test title with only default branding (no page title)
|
||||
* Format: "Dify"
|
||||
*/
|
||||
it('document title should be Dify if not set title', () => {
|
||||
renderHook(() => useDocumentTitle(''))
|
||||
expect(document.title).toBe('Dify')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test custom branding behavior
|
||||
* When custom branding is enabled, should use the configured application_title
|
||||
*/
|
||||
describe('use specific branding', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useGlobalPublicStore.setState({
|
||||
isGlobalPending: false,
|
||||
systemFeatures: { ...defaultSystemFeatures, branding: { ...defaultSystemFeatures.branding, enabled: true, application_title: 'Test' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
/**
|
||||
* Test title format with page title and custom branding
|
||||
* Format: "[page] - [Custom Brand]"
|
||||
*/
|
||||
it('document title should be test-Test if set title', () => {
|
||||
renderHook(() => useDocumentTitle('test'))
|
||||
expect(document.title).toBe('test - Test')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test title with only custom branding (no page title)
|
||||
* Format: "[Custom Brand]"
|
||||
*/
|
||||
it('document title should be Test if not set title', () => {
|
||||
renderHook(() => useDocumentTitle(''))
|
||||
expect(document.title).toBe('Test')
|
||||
})
|
||||
})
|
||||
44
dify/web/hooks/use-document-title.ts
Normal file
44
dify/web/hooks/use-document-title.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFavicon, useTitle } from 'ahooks'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function useDocumentTitle(title: string) {
|
||||
const isPending = useGlobalPublicStore(s => s.isGlobalPending)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const prefix = title ? `${title} - ` : ''
|
||||
let titleStr = ''
|
||||
let favicon = ''
|
||||
if (isPending === false) {
|
||||
if (systemFeatures.branding.enabled) {
|
||||
titleStr = `${prefix}${systemFeatures.branding.application_title}`
|
||||
favicon = systemFeatures.branding.favicon
|
||||
}
|
||||
else {
|
||||
titleStr = `${prefix}Dify`
|
||||
favicon = `${basePath}/favicon.ico`
|
||||
}
|
||||
}
|
||||
useTitle(titleStr)
|
||||
useEffect(() => {
|
||||
let apple: HTMLLinkElement | null = null
|
||||
if (systemFeatures.branding.favicon) {
|
||||
document
|
||||
.querySelectorAll(
|
||||
'link[rel=\'icon\'], link[rel=\'shortcut icon\'], link[rel=\'apple-touch-icon\'], link[rel=\'mask-icon\']',
|
||||
)
|
||||
.forEach(n => n.parentNode?.removeChild(n))
|
||||
|
||||
apple = document.createElement('link')
|
||||
apple.rel = 'apple-touch-icon'
|
||||
apple.href = systemFeatures.branding.favicon
|
||||
document.head.appendChild(apple)
|
||||
}
|
||||
|
||||
return () => {
|
||||
apple?.remove()
|
||||
}
|
||||
}, [systemFeatures.branding.favicon])
|
||||
useFavicon(favicon)
|
||||
}
|
||||
376
dify/web/hooks/use-format-time-from-now.spec.ts
Normal file
376
dify/web/hooks/use-format-time-from-now.spec.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Test suite for useFormatTimeFromNow hook
|
||||
*
|
||||
* This hook provides internationalized relative time formatting (e.g., "2 hours ago", "3 days ago")
|
||||
* using dayjs with the relativeTime plugin. It automatically uses the correct locale based on
|
||||
* the user's i18n settings.
|
||||
*
|
||||
* Key features:
|
||||
* - Supports 20+ locales with proper translations
|
||||
* - Automatically syncs with user's interface language
|
||||
* - Uses dayjs for consistent time calculations
|
||||
* - Returns human-readable relative time strings
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useFormatTimeFromNow } from './use-format-time-from-now'
|
||||
|
||||
// Mock the i18n context
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
useI18N: jest.fn(() => ({
|
||||
locale: 'en-US',
|
||||
})),
|
||||
}))
|
||||
|
||||
// Import after mock to get the mocked version
|
||||
import { useI18N } from '@/context/i18n'
|
||||
|
||||
describe('useFormatTimeFromNow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
/**
|
||||
* Test that the hook returns a formatTimeFromNow function
|
||||
* This is the primary interface of the hook
|
||||
*/
|
||||
it('should return formatTimeFromNow function', () => {
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
expect(result.current).toHaveProperty('formatTimeFromNow')
|
||||
expect(typeof result.current.formatTimeFromNow).toBe('function')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test basic relative time formatting with English locale
|
||||
* Should return human-readable relative time strings
|
||||
*/
|
||||
it('should format time from now in English', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Should contain "hour" or "hours" and "ago"
|
||||
expect(formatted).toMatch(/hour|hours/)
|
||||
expect(formatted).toMatch(/ago/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that recent times are formatted as "a few seconds ago"
|
||||
* Very recent timestamps should show seconds
|
||||
*/
|
||||
it('should format very recent times', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const fiveSecondsAgo = now - (5 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(fiveSecondsAgo)
|
||||
|
||||
expect(formatted).toMatch(/second|seconds|few seconds/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test formatting of times in the past (days ago)
|
||||
* Should handle day-level granularity
|
||||
*/
|
||||
it('should format times from days ago', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const threeDaysAgo = now - (3 * 24 * 60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(threeDaysAgo)
|
||||
|
||||
expect(formatted).toMatch(/day|days/)
|
||||
expect(formatted).toMatch(/ago/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test formatting of future times
|
||||
* dayjs fromNow also supports future times (e.g., "in 2 hours")
|
||||
*/
|
||||
it('should format future times', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const twoHoursFromNow = now + (2 * 60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(twoHoursFromNow)
|
||||
|
||||
expect(formatted).toMatch(/in/)
|
||||
expect(formatted).toMatch(/hour|hours/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Locale support', () => {
|
||||
/**
|
||||
* Test Chinese (Simplified) locale formatting
|
||||
* Should use Chinese characters for time units
|
||||
*/
|
||||
it('should format time in Chinese (Simplified)', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'zh-Hans' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Chinese should contain Chinese characters
|
||||
expect(formatted).toMatch(/[\u4E00-\u9FA5]/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Spanish locale formatting
|
||||
* Should use Spanish words for relative time
|
||||
*/
|
||||
it('should format time in Spanish', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Spanish should contain "hace" (ago)
|
||||
expect(formatted).toMatch(/hace/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test French locale formatting
|
||||
* Should use French words for relative time
|
||||
*/
|
||||
it('should format time in French', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'fr-FR' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// French should contain "il y a" (ago)
|
||||
expect(formatted).toMatch(/il y a/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Japanese locale formatting
|
||||
* Should use Japanese characters
|
||||
*/
|
||||
it('should format time in Japanese', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'ja-JP' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Japanese should contain Japanese characters
|
||||
expect(formatted).toMatch(/[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Portuguese (Brazil) locale formatting
|
||||
* Should use pt-br locale mapping
|
||||
*/
|
||||
it('should format time in Portuguese (Brazil)', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'pt-BR' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Portuguese should contain "há" (ago)
|
||||
expect(formatted).toMatch(/há/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test fallback to English for unsupported locales
|
||||
* Unknown locales should default to English
|
||||
*/
|
||||
it('should fallback to English for unsupported locale', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'xx-XX' as any })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Should still return a valid string (in English)
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
/**
|
||||
* Test handling of timestamp 0 (Unix epoch)
|
||||
* Should format as a very old date
|
||||
*/
|
||||
it('should handle timestamp 0', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const formatted = result.current.formatTimeFromNow(0)
|
||||
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted.length).toBeGreaterThan(0)
|
||||
expect(formatted).toMatch(/year|years/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test handling of very large timestamps
|
||||
* Should handle dates far in the future
|
||||
*/
|
||||
it('should handle very large timestamps', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const farFuture = Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year from now
|
||||
const formatted = result.current.formatTimeFromNow(farFuture)
|
||||
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted).toMatch(/in/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the function is memoized based on locale
|
||||
* Changing locale should update the function
|
||||
*/
|
||||
it('should update when locale changes', () => {
|
||||
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
|
||||
// First render with English
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
rerender()
|
||||
const englishResult = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Second render with Spanish
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
|
||||
rerender()
|
||||
const spanishResult = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Results should be different
|
||||
expect(englishResult).not.toBe(spanishResult)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time granularity', () => {
|
||||
/**
|
||||
* Test different time granularities (seconds, minutes, hours, days, months, years)
|
||||
* dayjs should automatically choose the appropriate unit
|
||||
*/
|
||||
it('should use appropriate time units for different durations', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// Seconds
|
||||
const seconds = result.current.formatTimeFromNow(now - 30 * 1000)
|
||||
expect(seconds).toMatch(/second/)
|
||||
|
||||
// Minutes
|
||||
const minutes = result.current.formatTimeFromNow(now - 5 * 60 * 1000)
|
||||
expect(minutes).toMatch(/minute/)
|
||||
|
||||
// Hours
|
||||
const hours = result.current.formatTimeFromNow(now - 3 * 60 * 60 * 1000)
|
||||
expect(hours).toMatch(/hour/)
|
||||
|
||||
// Days
|
||||
const days = result.current.formatTimeFromNow(now - 5 * 24 * 60 * 60 * 1000)
|
||||
expect(days).toMatch(/day/)
|
||||
|
||||
// Months
|
||||
const months = result.current.formatTimeFromNow(now - 60 * 24 * 60 * 60 * 1000)
|
||||
expect(months).toMatch(/month/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Locale mapping', () => {
|
||||
/**
|
||||
* Test that all supported locales in the localeMap are handled correctly
|
||||
* This ensures the mapping from app locales to dayjs locales works
|
||||
*/
|
||||
it('should handle all mapped locales', () => {
|
||||
const locales = [
|
||||
'en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR',
|
||||
'de-DE', 'ja-JP', 'ko-KR', 'ru-RU', 'it-IT', 'th-TH',
|
||||
'id-ID', 'uk-UA', 'vi-VN', 'ro-RO', 'pl-PL', 'hi-IN',
|
||||
'tr-TR', 'fa-IR', 'sl-SI',
|
||||
]
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
|
||||
locales.forEach((locale) => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Should return a non-empty string for each locale
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
/**
|
||||
* Test that the hook doesn't create new functions on every render
|
||||
* The formatTimeFromNow function should be memoized with useCallback
|
||||
*/
|
||||
it('should memoize formatTimeFromNow function', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const firstFunction = result.current.formatTimeFromNow
|
||||
rerender()
|
||||
const secondFunction = result.current.formatTimeFromNow
|
||||
|
||||
// Same locale should return the same function reference
|
||||
expect(firstFunction).toBe(secondFunction)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that changing locale creates a new function
|
||||
* This ensures the memoization dependency on locale works correctly
|
||||
*/
|
||||
it('should create new function when locale changes', () => {
|
||||
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
rerender()
|
||||
const englishFunction = result.current.formatTimeFromNow
|
||||
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
|
||||
rerender()
|
||||
const spanishFunction = result.current.formatTimeFromNow
|
||||
|
||||
// Different locale should return different function reference
|
||||
expect(englishFunction).not.toBe(spanishFunction)
|
||||
})
|
||||
})
|
||||
})
|
||||
61
dify/web/hooks/use-format-time-from-now.ts
Normal file
61
dify/web/hooks/use-format-time-from-now.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { useCallback } from 'react'
|
||||
import { useI18N } from '@/context/i18n'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import 'dayjs/locale/de'
|
||||
import 'dayjs/locale/es'
|
||||
import 'dayjs/locale/fa'
|
||||
import 'dayjs/locale/fr'
|
||||
import 'dayjs/locale/hi'
|
||||
import 'dayjs/locale/id'
|
||||
import 'dayjs/locale/it'
|
||||
import 'dayjs/locale/ja'
|
||||
import 'dayjs/locale/ko'
|
||||
import 'dayjs/locale/pl'
|
||||
import 'dayjs/locale/pt-br'
|
||||
import 'dayjs/locale/ro'
|
||||
import 'dayjs/locale/ru'
|
||||
import 'dayjs/locale/sl'
|
||||
import 'dayjs/locale/th'
|
||||
import 'dayjs/locale/tr'
|
||||
import 'dayjs/locale/uk'
|
||||
import 'dayjs/locale/vi'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/zh-tw'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const localeMap: Record<Locale, string> = {
|
||||
'en-US': 'en',
|
||||
'zh-Hans': 'zh-cn',
|
||||
'zh-Hant': 'zh-tw',
|
||||
'pt-BR': 'pt-br',
|
||||
'es-ES': 'es',
|
||||
'fr-FR': 'fr',
|
||||
'de-DE': 'de',
|
||||
'ja-JP': 'ja',
|
||||
'ko-KR': 'ko',
|
||||
'ru-RU': 'ru',
|
||||
'it-IT': 'it',
|
||||
'th-TH': 'th',
|
||||
'id-ID': 'id',
|
||||
'uk-UA': 'uk',
|
||||
'vi-VN': 'vi',
|
||||
'ro-RO': 'ro',
|
||||
'pl-PL': 'pl',
|
||||
'hi-IN': 'hi',
|
||||
'tr-TR': 'tr',
|
||||
'fa-IR': 'fa',
|
||||
'sl-SI': 'sl',
|
||||
}
|
||||
|
||||
export const useFormatTimeFromNow = () => {
|
||||
const { locale } = useI18N()
|
||||
const formatTimeFromNow = useCallback((time: number) => {
|
||||
const dayjsLocale = localeMap[locale] ?? 'en'
|
||||
return dayjs(time).locale(dayjsLocale).fromNow()
|
||||
}, [locale])
|
||||
|
||||
return { formatTimeFromNow }
|
||||
}
|
||||
9
dify/web/hooks/use-i18n.ts
Normal file
9
dify/web/hooks/use-i18n.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
|
||||
export const useRenderI18nObject = () => {
|
||||
const language = useLanguage()
|
||||
return (obj: Record<string, string>) => {
|
||||
return renderI18nObject(obj, language)
|
||||
}
|
||||
}
|
||||
163
dify/web/hooks/use-import-dsl.ts
Normal file
163
dify/web/hooks/use-import-dsl.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type {
|
||||
DSLImportMode,
|
||||
DSLImportResponse,
|
||||
} from '@/models/app'
|
||||
import { DSLImportStatus } from '@/models/app'
|
||||
import {
|
||||
importDSL,
|
||||
importDSLConfirm,
|
||||
} from '@/service/apps'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
|
||||
type DSLPayload = {
|
||||
mode: DSLImportMode
|
||||
yaml_content?: string
|
||||
yaml_url?: string
|
||||
name?: string
|
||||
icon_type?: AppIconType
|
||||
icon?: string
|
||||
icon_background?: string
|
||||
description?: string
|
||||
}
|
||||
type ResponseCallback = {
|
||||
onSuccess?: () => void
|
||||
onPending?: (payload: DSLImportResponse) => void
|
||||
onFailed?: () => void
|
||||
}
|
||||
export const useImportDSL = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const { handleCheckPluginDependencies } = usePluginDependencies()
|
||||
const isCurrentWorkspaceEditor = useSelector(s => s.isCurrentWorkspaceEditor)
|
||||
const { push } = useRouter()
|
||||
const [versions, setVersions] = useState<{ importedVersion: string; systemVersion: string }>()
|
||||
const importIdRef = useRef<string>('')
|
||||
|
||||
const handleImportDSL = useCallback(async (
|
||||
payload: DSLPayload,
|
||||
{
|
||||
onSuccess,
|
||||
onPending,
|
||||
onFailed,
|
||||
}: ResponseCallback,
|
||||
) => {
|
||||
if (isFetching)
|
||||
return
|
||||
setIsFetching(true)
|
||||
|
||||
try {
|
||||
const response = await importDSL(payload)
|
||||
|
||||
if (!response)
|
||||
return
|
||||
|
||||
const {
|
||||
id,
|
||||
status,
|
||||
app_id,
|
||||
app_mode,
|
||||
imported_dsl_version,
|
||||
current_dsl_version,
|
||||
} = response
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
if (!app_id)
|
||||
return
|
||||
|
||||
notify({
|
||||
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
|
||||
message: t(status === DSLImportStatus.COMPLETED ? 'app.newApp.appCreated' : 'app.newApp.caution'),
|
||||
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('app.newApp.appCreateDSLWarning'),
|
||||
})
|
||||
onSuccess?.()
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
getRedirection(isCurrentWorkspaceEditor, { id: app_id, mode: app_mode }, push)
|
||||
}
|
||||
else if (status === DSLImportStatus.PENDING) {
|
||||
setVersions({
|
||||
importedVersion: imported_dsl_version ?? '',
|
||||
systemVersion: current_dsl_version ?? '',
|
||||
})
|
||||
importIdRef.current = id
|
||||
onPending?.(response)
|
||||
}
|
||||
else {
|
||||
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
|
||||
onFailed?.()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
|
||||
onFailed?.()
|
||||
}
|
||||
finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [t, notify, handleCheckPluginDependencies, isCurrentWorkspaceEditor, push, isFetching])
|
||||
|
||||
const handleImportDSLConfirm = useCallback(async (
|
||||
{
|
||||
onSuccess,
|
||||
onFailed,
|
||||
}: Pick<ResponseCallback, 'onSuccess' | 'onFailed'>,
|
||||
) => {
|
||||
if (isFetching)
|
||||
return
|
||||
setIsFetching(true)
|
||||
if (!importIdRef.current)
|
||||
return
|
||||
|
||||
try {
|
||||
const response = await importDSLConfirm({
|
||||
import_id: importIdRef.current,
|
||||
})
|
||||
|
||||
const { status, app_id, app_mode } = response
|
||||
if (!app_id)
|
||||
return
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
onSuccess?.()
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('app.newApp.appCreated'),
|
||||
})
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
|
||||
}
|
||||
else if (status === DSLImportStatus.FAILED) {
|
||||
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
|
||||
onFailed?.()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
|
||||
onFailed?.()
|
||||
}
|
||||
finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [t, notify, handleCheckPluginDependencies, isCurrentWorkspaceEditor, push, isFetching])
|
||||
|
||||
return {
|
||||
handleImportDSL,
|
||||
handleImportDSLConfirm,
|
||||
versions,
|
||||
isFetching,
|
||||
}
|
||||
}
|
||||
32
dify/web/hooks/use-knowledge.ts
Normal file
32
dify/web/hooks/use-knowledge.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const useKnowledge = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const formatIndexingTechnique = useCallback((indexingTechnique: string) => {
|
||||
return t(`dataset.indexingTechnique.${indexingTechnique}`)
|
||||
}, [t])
|
||||
|
||||
const formatIndexingMethod = useCallback((indexingMethod: string, isEco?: boolean) => {
|
||||
if (isEco)
|
||||
return t('dataset.indexingMethod.invertedIndex')
|
||||
|
||||
return t(`dataset.indexingMethod.${indexingMethod}`)
|
||||
}, [t])
|
||||
|
||||
const formatIndexingTechniqueAndMethod = useCallback((indexingTechnique: string, indexingMethod: string) => {
|
||||
let result = formatIndexingTechnique(indexingTechnique)
|
||||
|
||||
if (indexingMethod)
|
||||
result += ` · ${formatIndexingMethod(indexingMethod, indexingTechnique === 'economy')}`
|
||||
|
||||
return result
|
||||
}, [formatIndexingTechnique, formatIndexingMethod])
|
||||
|
||||
return {
|
||||
formatIndexingTechnique,
|
||||
formatIndexingMethod,
|
||||
formatIndexingTechniqueAndMethod,
|
||||
}
|
||||
}
|
||||
403
dify/web/hooks/use-metadata.ts
Normal file
403
dify/web/hooks/use-metadata.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatFileSize, formatNumber, formatTime } from '@/utils/format'
|
||||
import { ChunkingMode, type DocType } from '@/models/datasets'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
|
||||
export type inputType = 'input' | 'select' | 'textarea'
|
||||
export type metadataType = DocType | 'originInfo' | 'technicalParameters'
|
||||
|
||||
type MetadataMap
|
||||
= Record<
|
||||
metadataType,
|
||||
{
|
||||
text: string
|
||||
allowEdit?: boolean
|
||||
icon?: React.ReactNode
|
||||
iconName?: string
|
||||
subFieldsMap: Record<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
inputType?: inputType
|
||||
field?: string
|
||||
render?: (value: any, total?: number) => React.ReactNode | string
|
||||
}
|
||||
>
|
||||
}
|
||||
>
|
||||
|
||||
const fieldPrefix = 'datasetDocuments.metadata.field'
|
||||
|
||||
export const useMetadataMap = (): MetadataMap => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime: formatTimestamp } = useTimestamp()
|
||||
|
||||
return {
|
||||
book: {
|
||||
text: t('datasetDocuments.metadata.type.book'),
|
||||
iconName: 'bookOpen',
|
||||
subFieldsMap: {
|
||||
title: { label: t(`${fieldPrefix}.book.title`) },
|
||||
language: {
|
||||
label: t(`${fieldPrefix}.book.language`),
|
||||
inputType: 'select',
|
||||
},
|
||||
author: { label: t(`${fieldPrefix}.book.author`) },
|
||||
publisher: { label: t(`${fieldPrefix}.book.publisher`) },
|
||||
publication_date: { label: t(`${fieldPrefix}.book.publicationDate`) },
|
||||
isbn: { label: t(`${fieldPrefix}.book.ISBN`) },
|
||||
category: {
|
||||
label: t(`${fieldPrefix}.book.category`),
|
||||
inputType: 'select',
|
||||
},
|
||||
},
|
||||
},
|
||||
web_page: {
|
||||
text: t('datasetDocuments.metadata.type.webPage'),
|
||||
iconName: 'globe',
|
||||
subFieldsMap: {
|
||||
'title': { label: t(`${fieldPrefix}.webPage.title`) },
|
||||
'url': { label: t(`${fieldPrefix}.webPage.url`) },
|
||||
'language': {
|
||||
label: t(`${fieldPrefix}.webPage.language`),
|
||||
inputType: 'select',
|
||||
},
|
||||
'author/publisher': { label: t(`${fieldPrefix}.webPage.authorPublisher`) },
|
||||
'publish_date': { label: t(`${fieldPrefix}.webPage.publishDate`) },
|
||||
'topic/keywords': { label: t(`${fieldPrefix}.webPage.topicKeywords`) },
|
||||
'description': { label: t(`${fieldPrefix}.webPage.description`) },
|
||||
},
|
||||
},
|
||||
paper: {
|
||||
text: t('datasetDocuments.metadata.type.paper'),
|
||||
iconName: 'graduationHat',
|
||||
subFieldsMap: {
|
||||
'title': { label: t(`${fieldPrefix}.paper.title`) },
|
||||
'language': {
|
||||
label: t(`${fieldPrefix}.paper.language`),
|
||||
inputType: 'select',
|
||||
},
|
||||
'author': { label: t(`${fieldPrefix}.paper.author`) },
|
||||
'publish_date': { label: t(`${fieldPrefix}.paper.publishDate`) },
|
||||
'journal/conference_name': {
|
||||
label: t(`${fieldPrefix}.paper.journalConferenceName`),
|
||||
},
|
||||
'volume/issue/page_numbers': { label: t(`${fieldPrefix}.paper.volumeIssuePage`) },
|
||||
'doi': { label: t(`${fieldPrefix}.paper.DOI`) },
|
||||
'topic/keywords': { label: t(`${fieldPrefix}.paper.topicKeywords`) },
|
||||
'abstract': {
|
||||
label: t(`${fieldPrefix}.paper.abstract`),
|
||||
inputType: 'textarea',
|
||||
},
|
||||
},
|
||||
},
|
||||
social_media_post: {
|
||||
text: t('datasetDocuments.metadata.type.socialMediaPost'),
|
||||
iconName: 'atSign',
|
||||
subFieldsMap: {
|
||||
'platform': { label: t(`${fieldPrefix}.socialMediaPost.platform`) },
|
||||
'author/username': {
|
||||
label: t(`${fieldPrefix}.socialMediaPost.authorUsername`),
|
||||
},
|
||||
'publish_date': { label: t(`${fieldPrefix}.socialMediaPost.publishDate`) },
|
||||
'post_url': { label: t(`${fieldPrefix}.socialMediaPost.postURL`) },
|
||||
'topics/tags': { label: t(`${fieldPrefix}.socialMediaPost.topicsTags`) },
|
||||
},
|
||||
},
|
||||
personal_document: {
|
||||
text: t('datasetDocuments.metadata.type.personalDocument'),
|
||||
iconName: 'file',
|
||||
subFieldsMap: {
|
||||
'title': { label: t(`${fieldPrefix}.personalDocument.title`) },
|
||||
'author': { label: t(`${fieldPrefix}.personalDocument.author`) },
|
||||
'creation_date': {
|
||||
label: t(`${fieldPrefix}.personalDocument.creationDate`),
|
||||
},
|
||||
'last_modified_date': {
|
||||
label: t(`${fieldPrefix}.personalDocument.lastModifiedDate`),
|
||||
},
|
||||
'document_type': {
|
||||
label: t(`${fieldPrefix}.personalDocument.documentType`),
|
||||
inputType: 'select',
|
||||
},
|
||||
'tags/category': {
|
||||
label: t(`${fieldPrefix}.personalDocument.tagsCategory`),
|
||||
},
|
||||
},
|
||||
},
|
||||
business_document: {
|
||||
text: t('datasetDocuments.metadata.type.businessDocument'),
|
||||
iconName: 'briefcase',
|
||||
subFieldsMap: {
|
||||
'title': { label: t(`${fieldPrefix}.businessDocument.title`) },
|
||||
'author': { label: t(`${fieldPrefix}.businessDocument.author`) },
|
||||
'creation_date': {
|
||||
label: t(`${fieldPrefix}.businessDocument.creationDate`),
|
||||
},
|
||||
'last_modified_date': {
|
||||
label: t(`${fieldPrefix}.businessDocument.lastModifiedDate`),
|
||||
},
|
||||
'document_type': {
|
||||
label: t(`${fieldPrefix}.businessDocument.documentType`),
|
||||
inputType: 'select',
|
||||
},
|
||||
'department/team': {
|
||||
label: t(`${fieldPrefix}.businessDocument.departmentTeam`),
|
||||
},
|
||||
},
|
||||
},
|
||||
im_chat_log: {
|
||||
text: t('datasetDocuments.metadata.type.IMChat'),
|
||||
iconName: 'messageTextCircle',
|
||||
subFieldsMap: {
|
||||
'chat_platform': { label: t(`${fieldPrefix}.IMChat.chatPlatform`) },
|
||||
'chat_participants/group_name': {
|
||||
label: t(`${fieldPrefix}.IMChat.chatPartiesGroupName`),
|
||||
},
|
||||
'start_date': { label: t(`${fieldPrefix}.IMChat.startDate`) },
|
||||
'end_date': { label: t(`${fieldPrefix}.IMChat.endDate`) },
|
||||
'participants': { label: t(`${fieldPrefix}.IMChat.participants`) },
|
||||
'topicKeywords': {
|
||||
label: t(`${fieldPrefix}.IMChat.topicKeywords`),
|
||||
inputType: 'textarea',
|
||||
},
|
||||
'fileType': { label: t(`${fieldPrefix}.IMChat.fileType`) },
|
||||
},
|
||||
},
|
||||
wikipedia_entry: {
|
||||
text: t('datasetDocuments.metadata.type.wikipediaEntry'),
|
||||
allowEdit: false,
|
||||
subFieldsMap: {
|
||||
'title': { label: t(`${fieldPrefix}.wikipediaEntry.title`) },
|
||||
'language': {
|
||||
label: t(`${fieldPrefix}.wikipediaEntry.language`),
|
||||
inputType: 'select',
|
||||
},
|
||||
'web_page_url': { label: t(`${fieldPrefix}.wikipediaEntry.webpageURL`) },
|
||||
'editor/contributor': {
|
||||
label: t(`${fieldPrefix}.wikipediaEntry.editorContributor`),
|
||||
},
|
||||
'last_edit_date': {
|
||||
label: t(`${fieldPrefix}.wikipediaEntry.lastEditDate`),
|
||||
},
|
||||
'summary/introduction': {
|
||||
label: t(`${fieldPrefix}.wikipediaEntry.summaryIntroduction`),
|
||||
inputType: 'textarea',
|
||||
},
|
||||
},
|
||||
},
|
||||
synced_from_notion: {
|
||||
text: t('datasetDocuments.metadata.type.notion'),
|
||||
allowEdit: false,
|
||||
subFieldsMap: {
|
||||
'title': { label: t(`${fieldPrefix}.notion.title`) },
|
||||
'language': { label: t(`${fieldPrefix}.notion.lang`), inputType: 'select' },
|
||||
'author/creator': { label: t(`${fieldPrefix}.notion.author`) },
|
||||
'creation_date': { label: t(`${fieldPrefix}.notion.createdTime`) },
|
||||
'last_modified_date': {
|
||||
label: t(`${fieldPrefix}.notion.lastModifiedTime`),
|
||||
},
|
||||
'notion_page_link': { label: t(`${fieldPrefix}.notion.url`) },
|
||||
'category/tags': { label: t(`${fieldPrefix}.notion.tag`) },
|
||||
'description': { label: t(`${fieldPrefix}.notion.desc`) },
|
||||
},
|
||||
},
|
||||
synced_from_github: {
|
||||
text: t('datasetDocuments.metadata.type.github'),
|
||||
allowEdit: false,
|
||||
subFieldsMap: {
|
||||
'repository_name': { label: t(`${fieldPrefix}.github.repoName`) },
|
||||
'repository_description': { label: t(`${fieldPrefix}.github.repoDesc`) },
|
||||
'repository_owner/organization': { label: t(`${fieldPrefix}.github.repoOwner`) },
|
||||
'code_filename': { label: t(`${fieldPrefix}.github.fileName`) },
|
||||
'code_file_path': { label: t(`${fieldPrefix}.github.filePath`) },
|
||||
'programming_language': { label: t(`${fieldPrefix}.github.programmingLang`) },
|
||||
'github_link': { label: t(`${fieldPrefix}.github.url`) },
|
||||
'open_source_license': { label: t(`${fieldPrefix}.github.license`) },
|
||||
'commit_date': { label: t(`${fieldPrefix}.github.lastCommitTime`) },
|
||||
'commit_author': {
|
||||
label: t(`${fieldPrefix}.github.lastCommitAuthor`),
|
||||
},
|
||||
},
|
||||
},
|
||||
originInfo: {
|
||||
text: '',
|
||||
allowEdit: false,
|
||||
subFieldsMap: {
|
||||
'name': { label: t(`${fieldPrefix}.originInfo.originalFilename`) },
|
||||
'data_source_info.upload_file.size': {
|
||||
label: t(`${fieldPrefix}.originInfo.originalFileSize`),
|
||||
render: value => formatFileSize(value),
|
||||
},
|
||||
'created_at': {
|
||||
label: t(`${fieldPrefix}.originInfo.uploadDate`),
|
||||
render: value => formatTimestamp(value, t('datasetDocuments.metadata.dateTimeFormat') as string),
|
||||
},
|
||||
'completed_at': {
|
||||
label: t(`${fieldPrefix}.originInfo.lastUpdateDate`),
|
||||
render: value => formatTimestamp(value, t('datasetDocuments.metadata.dateTimeFormat') as string),
|
||||
},
|
||||
'data_source_type': {
|
||||
label: t(`${fieldPrefix}.originInfo.source`),
|
||||
render: value => t(`datasetDocuments.metadata.source.${value === 'notion_import' ? 'notion' : value}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
technicalParameters: {
|
||||
text: t('datasetDocuments.metadata.type.technicalParameters'),
|
||||
allowEdit: false,
|
||||
subFieldsMap: {
|
||||
'doc_form': {
|
||||
label: t(`${fieldPrefix}.technicalParameters.segmentSpecification`),
|
||||
render: (value) => {
|
||||
if (value === ChunkingMode.text)
|
||||
return t('dataset.chunkingMode.general')
|
||||
if (value === ChunkingMode.qa)
|
||||
return t('dataset.chunkingMode.qa')
|
||||
if (value === ChunkingMode.parentChild)
|
||||
return t('dataset.chunkingMode.parentChild')
|
||||
return '--'
|
||||
},
|
||||
},
|
||||
'dataset_process_rule.rules.segmentation.max_tokens': {
|
||||
label: t(`${fieldPrefix}.technicalParameters.segmentLength`),
|
||||
render: value => formatNumber(value),
|
||||
},
|
||||
'average_segment_length': {
|
||||
label: t(`${fieldPrefix}.technicalParameters.avgParagraphLength`),
|
||||
render: value => `${formatNumber(value)} characters`,
|
||||
},
|
||||
'segment_count': {
|
||||
label: t(`${fieldPrefix}.technicalParameters.paragraphs`),
|
||||
render: value => `${formatNumber(value)} paragraphs`,
|
||||
},
|
||||
'hit_count': {
|
||||
label: t(`${fieldPrefix}.technicalParameters.hitCount`),
|
||||
render: (value, total) => {
|
||||
const v = value || 0
|
||||
return `${!total ? 0 : ((v / total) * 100).toFixed(2)}% (${v}/${total})`
|
||||
},
|
||||
},
|
||||
'indexing_latency': {
|
||||
label: t(`${fieldPrefix}.technicalParameters.embeddingTime`),
|
||||
render: value => formatTime(value),
|
||||
},
|
||||
'tokens': {
|
||||
label: t(`${fieldPrefix}.technicalParameters.embeddedSpend`),
|
||||
render: value => `${formatNumber(value)} tokens`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const langPrefix = 'datasetDocuments.metadata.languageMap.'
|
||||
|
||||
export const useLanguages = () => {
|
||||
const { t } = useTranslation()
|
||||
return {
|
||||
zh: t(`${langPrefix}zh`),
|
||||
en: t(`${langPrefix}en`),
|
||||
es: t(`${langPrefix}es`),
|
||||
fr: t(`${langPrefix}fr`),
|
||||
de: t(`${langPrefix}de`),
|
||||
ja: t(`${langPrefix}ja`),
|
||||
ko: t(`${langPrefix}ko`),
|
||||
ru: t(`${langPrefix}ru`),
|
||||
ar: t(`${langPrefix}ar`),
|
||||
pt: t(`${langPrefix}pt`),
|
||||
it: t(`${langPrefix}it`),
|
||||
nl: t(`${langPrefix}nl`),
|
||||
pl: t(`${langPrefix}pl`),
|
||||
sv: t(`${langPrefix}sv`),
|
||||
tr: t(`${langPrefix}tr`),
|
||||
he: t(`${langPrefix}he`),
|
||||
hi: t(`${langPrefix}hi`),
|
||||
da: t(`${langPrefix}da`),
|
||||
fi: t(`${langPrefix}fi`),
|
||||
no: t(`${langPrefix}no`),
|
||||
hu: t(`${langPrefix}hu`),
|
||||
el: t(`${langPrefix}el`),
|
||||
cs: t(`${langPrefix}cs`),
|
||||
th: t(`${langPrefix}th`),
|
||||
id: t(`${langPrefix}id`),
|
||||
ro: t(`${langPrefix}ro`),
|
||||
}
|
||||
}
|
||||
|
||||
const bookCategoryPrefix = 'datasetDocuments.metadata.categoryMap.book.'
|
||||
|
||||
export const useBookCategories = () => {
|
||||
const { t } = useTranslation()
|
||||
return {
|
||||
fiction: t(`${bookCategoryPrefix}fiction`),
|
||||
biography: t(`${bookCategoryPrefix}biography`),
|
||||
history: t(`${bookCategoryPrefix}history`),
|
||||
science: t(`${bookCategoryPrefix}science`),
|
||||
technology: t(`${bookCategoryPrefix}technology`),
|
||||
education: t(`${bookCategoryPrefix}education`),
|
||||
philosophy: t(`${bookCategoryPrefix}philosophy`),
|
||||
religion: t(`${bookCategoryPrefix}religion`),
|
||||
socialSciences: t(`${bookCategoryPrefix}socialSciences`),
|
||||
art: t(`${bookCategoryPrefix}art`),
|
||||
travel: t(`${bookCategoryPrefix}travel`),
|
||||
health: t(`${bookCategoryPrefix}health`),
|
||||
selfHelp: t(`${bookCategoryPrefix}selfHelp`),
|
||||
businessEconomics: t(`${bookCategoryPrefix}businessEconomics`),
|
||||
cooking: t(`${bookCategoryPrefix}cooking`),
|
||||
childrenYoungAdults: t(`${bookCategoryPrefix}childrenYoungAdults`),
|
||||
comicsGraphicNovels: t(`${bookCategoryPrefix}comicsGraphicNovels`),
|
||||
poetry: t(`${bookCategoryPrefix}poetry`),
|
||||
drama: t(`${bookCategoryPrefix}drama`),
|
||||
other: t(`${bookCategoryPrefix}other`),
|
||||
}
|
||||
}
|
||||
|
||||
const personalDocCategoryPrefix
|
||||
= 'datasetDocuments.metadata.categoryMap.personalDoc.'
|
||||
|
||||
export const usePersonalDocCategories = () => {
|
||||
const { t } = useTranslation()
|
||||
return {
|
||||
notes: t(`${personalDocCategoryPrefix}notes`),
|
||||
blogDraft: t(`${personalDocCategoryPrefix}blogDraft`),
|
||||
diary: t(`${personalDocCategoryPrefix}diary`),
|
||||
researchReport: t(`${personalDocCategoryPrefix}researchReport`),
|
||||
bookExcerpt: t(`${personalDocCategoryPrefix}bookExcerpt`),
|
||||
schedule: t(`${personalDocCategoryPrefix}schedule`),
|
||||
list: t(`${personalDocCategoryPrefix}list`),
|
||||
projectOverview: t(`${personalDocCategoryPrefix}projectOverview`),
|
||||
photoCollection: t(`${personalDocCategoryPrefix}photoCollection`),
|
||||
creativeWriting: t(`${personalDocCategoryPrefix}creativeWriting`),
|
||||
codeSnippet: t(`${personalDocCategoryPrefix}codeSnippet`),
|
||||
designDraft: t(`${personalDocCategoryPrefix}designDraft`),
|
||||
personalResume: t(`${personalDocCategoryPrefix}personalResume`),
|
||||
other: t(`${personalDocCategoryPrefix}other`),
|
||||
}
|
||||
}
|
||||
|
||||
const businessDocCategoryPrefix
|
||||
= 'datasetDocuments.metadata.categoryMap.businessDoc.'
|
||||
|
||||
export const useBusinessDocCategories = () => {
|
||||
const { t } = useTranslation()
|
||||
return {
|
||||
meetingMinutes: t(`${businessDocCategoryPrefix}meetingMinutes`),
|
||||
researchReport: t(`${businessDocCategoryPrefix}researchReport`),
|
||||
proposal: t(`${businessDocCategoryPrefix}proposal`),
|
||||
employeeHandbook: t(`${businessDocCategoryPrefix}employeeHandbook`),
|
||||
trainingMaterials: t(`${businessDocCategoryPrefix}trainingMaterials`),
|
||||
requirementsDocument: t(`${businessDocCategoryPrefix}requirementsDocument`),
|
||||
designDocument: t(`${businessDocCategoryPrefix}designDocument`),
|
||||
productSpecification: t(`${businessDocCategoryPrefix}productSpecification`),
|
||||
financialReport: t(`${businessDocCategoryPrefix}financialReport`),
|
||||
marketAnalysis: t(`${businessDocCategoryPrefix}marketAnalysis`),
|
||||
projectPlan: t(`${businessDocCategoryPrefix}projectPlan`),
|
||||
teamStructure: t(`${businessDocCategoryPrefix}teamStructure`),
|
||||
policiesProcedures: t(`${businessDocCategoryPrefix}policiesProcedures`),
|
||||
contractsAgreements: t(`${businessDocCategoryPrefix}contractsAgreements`),
|
||||
emailCorrespondence: t(`${businessDocCategoryPrefix}emailCorrespondence`),
|
||||
other: t(`${businessDocCategoryPrefix}other`),
|
||||
}
|
||||
}
|
||||
74
dify/web/hooks/use-mitt.ts
Normal file
74
dify/web/hooks/use-mitt.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Emitter, EventType, Handler, WildcardHandler } from 'mitt'
|
||||
import create from 'mitt'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const merge = <T extends Record<string, any>>(
|
||||
...args: Array<T | undefined>
|
||||
): T => {
|
||||
return Object.assign({}, ...args)
|
||||
}
|
||||
|
||||
export type _Events = Record<EventType, unknown>
|
||||
|
||||
export type UseSubscribeOption = {
|
||||
/**
|
||||
* Whether the subscription is enabled.
|
||||
* @default true
|
||||
*/
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type ExtendedOn<Events extends _Events> = {
|
||||
<Key extends keyof Events>(
|
||||
type: Key,
|
||||
handler: Handler<Events[Key]>,
|
||||
options?: UseSubscribeOption,
|
||||
): void;
|
||||
(
|
||||
type: '*',
|
||||
handler: WildcardHandler<Events>,
|
||||
option?: UseSubscribeOption,
|
||||
): void;
|
||||
}
|
||||
|
||||
export type UseMittReturn<Events extends _Events> = {
|
||||
useSubscribe: ExtendedOn<Events>;
|
||||
emit: Emitter<Events>['emit'];
|
||||
}
|
||||
|
||||
const defaultSubscribeOption: UseSubscribeOption = {
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
function useMitt<Events extends _Events>(
|
||||
mitt?: Emitter<Events>,
|
||||
): UseMittReturn<Events> {
|
||||
const emitterRef = useRef<Emitter<Events> | undefined>(undefined)
|
||||
if (!emitterRef.current)
|
||||
emitterRef.current = mitt ?? create<Events>()
|
||||
|
||||
if (mitt && emitterRef.current !== mitt) {
|
||||
emitterRef.current.off('*')
|
||||
emitterRef.current = mitt
|
||||
}
|
||||
const emitter = emitterRef.current
|
||||
const useSubscribe: ExtendedOn<Events> = (
|
||||
type: string,
|
||||
handler: any,
|
||||
option?: UseSubscribeOption,
|
||||
) => {
|
||||
const { enabled } = merge(defaultSubscribeOption, option)
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
emitter.on(type, handler)
|
||||
return () => emitter.off(type, handler)
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
emit: emitter.emit,
|
||||
useSubscribe,
|
||||
}
|
||||
}
|
||||
|
||||
export { useMitt }
|
||||
49
dify/web/hooks/use-moderate.ts
Normal file
49
dify/web/hooks/use-moderate.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ModerationService } from '@/models/common'
|
||||
|
||||
function splitStringByLength(inputString: string, chunkLength: number) {
|
||||
const resultArray = []
|
||||
for (let i = 0; i < inputString.length; i += chunkLength)
|
||||
resultArray.push(inputString.substring(i, i + chunkLength))
|
||||
|
||||
return resultArray
|
||||
}
|
||||
|
||||
export const useModerate = (
|
||||
content: string,
|
||||
stop: boolean,
|
||||
moderationService: (text: string) => ReturnType<ModerationService>,
|
||||
separateLength = 50,
|
||||
) => {
|
||||
const moderatedContentMap = useRef<Map<number, string>>(new Map())
|
||||
const moderatingIndex = useRef<number[]>([])
|
||||
const [contentArr, setContentArr] = useState<string[]>([])
|
||||
|
||||
const handleModerate = () => {
|
||||
const stringArr = splitStringByLength(content, separateLength)
|
||||
|
||||
const lastIndex = stringArr.length - 1
|
||||
stringArr.forEach((item, index) => {
|
||||
if (!(index in moderatingIndex.current) && !moderatedContentMap.current.get(index)) {
|
||||
if (index === lastIndex && !stop)
|
||||
return
|
||||
|
||||
moderatingIndex.current.push(index)
|
||||
moderationService(item).then((res) => {
|
||||
if (res.flagged) {
|
||||
moderatedContentMap.current.set(index, res.text)
|
||||
setContentArr([...stringArr.slice(0, index), res.text, ...stringArr.slice(index + 1)])
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
setContentArr(stringArr)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (content)
|
||||
handleModerate()
|
||||
}, [content, stop])
|
||||
|
||||
return contentArr.map((item, index) => moderatedContentMap.current.get(index) || item).join('')
|
||||
}
|
||||
70
dify/web/hooks/use-oauth.ts
Normal file
70
dify/web/hooks/use-oauth.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
import { useEffect } from 'react'
|
||||
import { validateRedirectUrl } from '@/utils/urlValidation'
|
||||
|
||||
export const useOAuthCallback = () => {
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const subscriptionId = urlParams.get('subscription_id')
|
||||
const error = urlParams.get('error')
|
||||
const errorDescription = urlParams.get('error_description')
|
||||
|
||||
if (window.opener) {
|
||||
if (subscriptionId) {
|
||||
window.opener.postMessage({
|
||||
type: 'oauth_callback',
|
||||
success: true,
|
||||
subscriptionId,
|
||||
}, '*')
|
||||
}
|
||||
else if (error) {
|
||||
window.opener.postMessage({
|
||||
type: 'oauth_callback',
|
||||
success: false,
|
||||
error,
|
||||
errorDescription,
|
||||
}, '*')
|
||||
}
|
||||
else {
|
||||
window.opener.postMessage({
|
||||
type: 'oauth_callback',
|
||||
}, '*')
|
||||
}
|
||||
window.close()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const openOAuthPopup = (url: string, callback: (data?: any) => void) => {
|
||||
const width = 600
|
||||
const height = 600
|
||||
const left = window.screenX + (window.outerWidth - width) / 2
|
||||
const top = window.screenY + (window.outerHeight - height) / 2
|
||||
|
||||
validateRedirectUrl(url)
|
||||
const popup = window.open(
|
||||
url,
|
||||
'OAuth',
|
||||
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes`,
|
||||
)
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'oauth_callback') {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
callback(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
|
||||
// Fallback for window close detection
|
||||
const checkClosed = setInterval(() => {
|
||||
if (popup?.closed) {
|
||||
clearInterval(checkClosed)
|
||||
window.removeEventListener('message', handleMessage)
|
||||
callback()
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return popup
|
||||
}
|
||||
119
dify/web/hooks/use-pay.tsx
Normal file
119
dify/web/hooks/use-pay.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
fetchDataSourceNotionBinding,
|
||||
} from '@/service/common'
|
||||
import type { IConfirm } from '@/app/components/base/confirm'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
|
||||
export type ConfirmType = Pick<IConfirm, 'type' | 'title' | 'content'>
|
||||
|
||||
export const useAnthropicCheckPay = () => {
|
||||
const { t } = useTranslation()
|
||||
const [confirm, setConfirm] = useState<ConfirmType | null>(null)
|
||||
const searchParams = useSearchParams()
|
||||
const providerName = searchParams.get('provider_name')
|
||||
const paymentResult = searchParams.get('payment_result')
|
||||
|
||||
useEffect(() => {
|
||||
if (providerName === 'anthropic' && (paymentResult === 'succeeded' || paymentResult === 'cancelled')) {
|
||||
setConfirm({
|
||||
type: paymentResult === 'succeeded' ? 'info' : 'warning',
|
||||
title: paymentResult === 'succeeded' ? t('common.actionMsg.paySucceeded') : t('common.actionMsg.payCancelled'),
|
||||
})
|
||||
}
|
||||
}, [providerName, paymentResult, t])
|
||||
|
||||
return confirm
|
||||
}
|
||||
|
||||
export const useBillingPay = () => {
|
||||
const { t } = useTranslation()
|
||||
const [confirm, setConfirm] = useState<ConfirmType | null>(null)
|
||||
const searchParams = useSearchParams()
|
||||
const paymentType = searchParams.get('payment_type')
|
||||
const paymentResult = searchParams.get('payment_result')
|
||||
|
||||
useEffect(() => {
|
||||
if (paymentType === 'billing' && (paymentResult === 'succeeded' || paymentResult === 'cancelled')) {
|
||||
setConfirm({
|
||||
type: paymentResult === 'succeeded' ? 'info' : 'warning',
|
||||
title: paymentResult === 'succeeded' ? t('common.actionMsg.paySucceeded') : t('common.actionMsg.payCancelled'),
|
||||
})
|
||||
}
|
||||
}, [paymentType, paymentResult, t])
|
||||
|
||||
return confirm
|
||||
}
|
||||
|
||||
export const useCheckNotion = () => {
|
||||
const router = useRouter()
|
||||
const [confirm, setConfirm] = useState<ConfirmType | null>(null)
|
||||
const [canBinding, setCanBinding] = useState(false)
|
||||
const searchParams = useSearchParams()
|
||||
const type = searchParams.get('type')
|
||||
const notionCode = searchParams.get('code')
|
||||
const notionError = searchParams.get('error')
|
||||
const { data } = useSWR(
|
||||
(canBinding && notionCode)
|
||||
? `/oauth/data-source/binding/notion?code=${notionCode}`
|
||||
: null,
|
||||
fetchDataSourceNotionBinding,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (data)
|
||||
router.replace('/')
|
||||
}, [data, router])
|
||||
useEffect(() => {
|
||||
if (type === 'notion') {
|
||||
if (notionError) {
|
||||
setConfirm({
|
||||
type: 'warning',
|
||||
title: notionError,
|
||||
})
|
||||
}
|
||||
else if (notionCode) {
|
||||
setCanBinding(true)
|
||||
}
|
||||
}
|
||||
}, [type, notionCode, notionError])
|
||||
|
||||
return confirm
|
||||
}
|
||||
|
||||
export const CheckModal = () => {
|
||||
const router = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const [showPayStatusModal, setShowPayStatusModal] = useState(true)
|
||||
const anthropicConfirmInfo = useAnthropicCheckPay()
|
||||
const notionConfirmInfo = useCheckNotion()
|
||||
const billingConfirmInfo = useBillingPay()
|
||||
|
||||
const handleCancelShowPayStatusModal = useCallback(() => {
|
||||
setShowPayStatusModal(false)
|
||||
router.replace('/')
|
||||
}, [router])
|
||||
|
||||
const confirmInfo = anthropicConfirmInfo || notionConfirmInfo || billingConfirmInfo
|
||||
|
||||
if (!confirmInfo || !showPayStatusModal)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Confirm
|
||||
isShow
|
||||
onCancel={handleCancelShowPayStatusModal}
|
||||
onConfirm={handleCancelShowPayStatusModal}
|
||||
showCancel={false}
|
||||
type={confirmInfo.type === 'info' ? 'info' : 'warning' }
|
||||
title={confirmInfo.title}
|
||||
content={(confirmInfo as unknown as { desc: string }).desc || ''}
|
||||
confirmText={(confirmInfo.type === 'info' && t('common.operation.ok')) || ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
543
dify/web/hooks/use-tab-searchparams.spec.ts
Normal file
543
dify/web/hooks/use-tab-searchparams.spec.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* Test suite for useTabSearchParams hook
|
||||
*
|
||||
* This hook manages tab state through URL search parameters, enabling:
|
||||
* - Bookmarkable tab states (users can share URLs with specific tabs active)
|
||||
* - Browser history integration (back/forward buttons work with tabs)
|
||||
* - Configurable routing behavior (push vs replace)
|
||||
* - Optional search parameter syncing (can disable URL updates)
|
||||
*
|
||||
* The hook syncs a local tab state with URL search parameters, making tab
|
||||
* navigation persistent and shareable across sessions.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useTabSearchParams } from './use-tab-searchparams'
|
||||
|
||||
// Mock Next.js navigation hooks
|
||||
const mockPush = jest.fn()
|
||||
const mockReplace = jest.fn()
|
||||
const mockPathname = '/test-path'
|
||||
const mockSearchParams = new URLSearchParams()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: jest.fn(() => mockPathname),
|
||||
useRouter: jest.fn(() => ({
|
||||
push: mockPush,
|
||||
replace: mockReplace,
|
||||
})),
|
||||
useSearchParams: jest.fn(() => mockSearchParams),
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
describe('useTabSearchParams', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockSearchParams.delete('category')
|
||||
mockSearchParams.delete('tab')
|
||||
})
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
/**
|
||||
* Test that the hook returns a tuple with activeTab and setActiveTab
|
||||
* This is the primary interface matching React's useState pattern
|
||||
*/
|
||||
it('should return activeTab and setActiveTab function', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab, setActiveTab] = result.current
|
||||
|
||||
expect(typeof activeTab).toBe('string')
|
||||
expect(typeof setActiveTab).toBe('function')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook initializes with the default tab
|
||||
* When no search param is present, should use defaultTab
|
||||
*/
|
||||
it('should initialize with default tab when no search param exists', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('overview')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook reads from URL search parameters
|
||||
* When a search param exists, it should take precedence over defaultTab
|
||||
*/
|
||||
it('should initialize with search param value when present', () => {
|
||||
mockSearchParams.set('category', 'settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that setActiveTab updates the local state
|
||||
* The active tab should change when setActiveTab is called
|
||||
*/
|
||||
it('should update active tab when setActiveTab is called', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Routing behavior', () => {
|
||||
/**
|
||||
* Test default push routing behavior
|
||||
* By default, tab changes should use router.push (adds to history)
|
||||
*/
|
||||
it('should use push routing by default', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings')
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test replace routing behavior
|
||||
* When routingBehavior is 'replace', should use router.replace (no history)
|
||||
*/
|
||||
it('should use replace routing when specified', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'replace',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/test-path?category=settings')
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that URL encoding is applied to tab values
|
||||
* Special characters in tab names should be properly encoded
|
||||
*/
|
||||
it('should encode special characters in tab values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings & config')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
'/test-path?category=settings%20%26%20config',
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that URL decoding is applied when reading from search params
|
||||
* Encoded values in the URL should be properly decoded
|
||||
*/
|
||||
it('should decode encoded values from search params', () => {
|
||||
mockSearchParams.set('category', 'settings%20%26%20config')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings & config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom search parameter name', () => {
|
||||
/**
|
||||
* Test using a custom search parameter name
|
||||
* Should support different param names instead of default 'category'
|
||||
*/
|
||||
it('should use custom search param name', () => {
|
||||
mockSearchParams.set('tab', 'profile')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
searchParamName: 'tab',
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('profile')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that setActiveTab uses the custom param name in the URL
|
||||
*/
|
||||
it('should update URL with custom param name', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
searchParamName: 'tab',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('profile')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?tab=profile')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled search params mode', () => {
|
||||
/**
|
||||
* Test that disableSearchParams prevents URL updates
|
||||
* When disabled, tab state should be local only
|
||||
*/
|
||||
it('should not update URL when disableSearchParams is true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that local state still updates when search params are disabled
|
||||
* The tab state should work even without URL syncing
|
||||
*/
|
||||
it('should still update local state when search params disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that disabled mode always uses defaultTab
|
||||
* Search params should be ignored when disabled
|
||||
*/
|
||||
it('should use defaultTab when search params disabled even if URL has value', () => {
|
||||
mockSearchParams.set('category', 'settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('overview')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
/**
|
||||
* Test handling of empty string tab values
|
||||
* Empty strings should be handled gracefully
|
||||
*/
|
||||
it('should handle empty string tab values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that special characters in tab names are properly encoded
|
||||
* This ensures URLs remain valid even with unusual tab names
|
||||
*/
|
||||
it('should handle tabs with various special characters', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// Test tab with slashes
|
||||
act(() => result.current[1]('tab/with/slashes'))
|
||||
expect(result.current[0]).toBe('tab/with/slashes')
|
||||
|
||||
// Test tab with question marks
|
||||
act(() => result.current[1]('tab?with?questions'))
|
||||
expect(result.current[0]).toBe('tab?with?questions')
|
||||
|
||||
// Test tab with hash symbols
|
||||
act(() => result.current[1]('tab#with#hash'))
|
||||
expect(result.current[0]).toBe('tab#with#hash')
|
||||
|
||||
// Test tab with equals signs
|
||||
act(() => result.current[1]('tab=with=equals'))
|
||||
expect(result.current[0]).toBe('tab=with=equals')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test fallback when pathname is not available
|
||||
* Should use window.location.pathname as fallback
|
||||
*/
|
||||
it('should fallback to window.location.pathname when hook pathname is null', () => {
|
||||
;(usePathname as jest.Mock).mockReturnValue(null)
|
||||
|
||||
// Mock window.location.pathname
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/fallback-path' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/fallback-path?category=settings')
|
||||
|
||||
// Restore mock
|
||||
;(usePathname as jest.Mock).mockReturnValue(mockPathname)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple instances', () => {
|
||||
/**
|
||||
* Test that multiple instances with different param names work independently
|
||||
* Different hooks should not interfere with each other
|
||||
*/
|
||||
it('should support multiple independent tab states', () => {
|
||||
mockSearchParams.set('category', 'overview')
|
||||
mockSearchParams.set('subtab', 'details')
|
||||
|
||||
const { result: result1 } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'home',
|
||||
searchParamName: 'category',
|
||||
}),
|
||||
)
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'info',
|
||||
searchParamName: 'subtab',
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab1] = result1.current
|
||||
const [activeTab2] = result2.current
|
||||
|
||||
expect(activeTab1).toBe('overview')
|
||||
expect(activeTab2).toBe('details')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
/**
|
||||
* Test typical usage in a tabbed interface
|
||||
* Simulates real-world tab switching behavior
|
||||
*/
|
||||
it('should handle sequential tab changes', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// Change to settings tab
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe('settings')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings')
|
||||
|
||||
// Change to profile tab
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('profile')
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe('profile')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=profile')
|
||||
|
||||
// Verify push was called twice
|
||||
expect(mockPush).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook works with complex pathnames
|
||||
* Should handle nested routes and existing query params
|
||||
*/
|
||||
it('should work with complex pathnames', () => {
|
||||
;(usePathname as jest.Mock).mockReturnValue('/app/123/settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('advanced')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/app/123/settings?category=advanced')
|
||||
|
||||
// Restore mock
|
||||
;(usePathname as jest.Mock).mockReturnValue(mockPathname)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type safety', () => {
|
||||
/**
|
||||
* Test that the return type is a const tuple
|
||||
* TypeScript should infer [string, (tab: string) => void] as const
|
||||
*/
|
||||
it('should return a const tuple type', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// The result should be a tuple with exactly 2 elements
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(typeof result.current[0]).toBe('string')
|
||||
expect(typeof result.current[1]).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
/**
|
||||
* Test that the hook creates a new function on each render
|
||||
* Note: The current implementation doesn't use useCallback,
|
||||
* so setActiveTab is recreated on each render. This could lead to
|
||||
* unnecessary re-renders in child components that depend on this function.
|
||||
* TODO: Consider memoizing setActiveTab with useCallback for better performance.
|
||||
*/
|
||||
it('should create new setActiveTab function on each render', () => {
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [, firstSetActiveTab] = result.current
|
||||
rerender()
|
||||
const [, secondSetActiveTab] = result.current
|
||||
|
||||
// Function reference changes on re-render (not memoized)
|
||||
expect(firstSetActiveTab).not.toBe(secondSetActiveTab)
|
||||
|
||||
// But both functions should work correctly
|
||||
expect(typeof firstSetActiveTab).toBe('function')
|
||||
expect(typeof secondSetActiveTab).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Browser history integration', () => {
|
||||
/**
|
||||
* Test that push behavior adds to browser history
|
||||
* This enables back/forward navigation through tabs
|
||||
*/
|
||||
it('should add to history with push behavior', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'push',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab1')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab2')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab3')
|
||||
})
|
||||
|
||||
// Each tab change should create a history entry
|
||||
expect(mockPush).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that replace behavior doesn't add to history
|
||||
* This prevents cluttering browser history with tab changes
|
||||
*/
|
||||
it('should not add to history with replace behavior', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'replace',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab1')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab2')
|
||||
})
|
||||
|
||||
// Should use replace instead of push
|
||||
expect(mockReplace).toHaveBeenCalledTimes(2)
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
47
dify/web/hooks/use-tab-searchparams.ts
Normal file
47
dify/web/hooks/use-tab-searchparams.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
type UseTabSearchParamsOptions = {
|
||||
defaultTab: string
|
||||
routingBehavior?: 'push' | 'replace'
|
||||
searchParamName?: string
|
||||
disableSearchParams?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage tab state via URL search parameters in a Next.js application.
|
||||
* This hook allows for syncing the active tab with the browser's URL, enabling bookmarking and sharing of URLs with a specific tab activated.
|
||||
*
|
||||
* @param {UseTabSearchParamsOptions} options Configuration options for the hook:
|
||||
* - `defaultTab`: The tab to default to when no tab is specified in the URL.
|
||||
* - `routingBehavior`: Optional. Determines how changes to the active tab update the browser's history ('push' or 'replace'). Default is 'push'.
|
||||
* - `searchParamName`: Optional. The name of the search parameter that holds the tab state in the URL. Default is 'category'.
|
||||
* @returns A tuple where the first element is the active tab and the second element is a function to set the active tab.
|
||||
*/
|
||||
export const useTabSearchParams = ({
|
||||
defaultTab,
|
||||
routingBehavior = 'push',
|
||||
searchParamName = 'category',
|
||||
disableSearchParams = false,
|
||||
}: UseTabSearchParamsOptions) => {
|
||||
const pathnameFromHook = usePathname()
|
||||
const router = useRouter()
|
||||
const pathName = pathnameFromHook || window?.location?.pathname
|
||||
const searchParams = useSearchParams()
|
||||
const searchParamValue = searchParams.has(searchParamName) ? decodeURIComponent(searchParams.get(searchParamName)!) : defaultTab
|
||||
const [activeTab, setTab] = useState<string>(
|
||||
!disableSearchParams
|
||||
? searchParamValue
|
||||
: defaultTab,
|
||||
)
|
||||
|
||||
const setActiveTab = (newActiveTab: string) => {
|
||||
setTab(newActiveTab)
|
||||
if (disableSearchParams)
|
||||
return
|
||||
router[`${routingBehavior}`](`${pathName}?${searchParamName}=${encodeURIComponent(newActiveTab)}`)
|
||||
}
|
||||
|
||||
return [activeTab, setActiveTab] as const
|
||||
}
|
||||
13
dify/web/hooks/use-theme.ts
Normal file
13
dify/web/hooks/use-theme.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Theme } from '@/types/app'
|
||||
import { useTheme as useBaseTheme } from 'next-themes'
|
||||
|
||||
const useTheme = () => {
|
||||
const { theme, resolvedTheme, ...rest } = useBaseTheme()
|
||||
return {
|
||||
// only returns 'light' or 'dark' theme
|
||||
theme: theme === Theme.system ? resolvedTheme as Theme : theme as Theme,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
export default useTheme
|
||||
65
dify/web/hooks/use-timestamp.spec.ts
Normal file
65
dify/web/hooks/use-timestamp.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import useTimestamp from './use-timestamp'
|
||||
|
||||
jest.mock('@/context/app-context', () => ({
|
||||
useAppContext: jest.fn(() => ({
|
||||
userProfile: {
|
||||
id: '8b18e24b-1ac8-4262-aa5c-e9aa95c76846',
|
||||
name: 'test',
|
||||
avatar: null,
|
||||
avatar_url: null,
|
||||
email: 'test@dify.ai',
|
||||
is_password_set: false,
|
||||
interface_language: 'zh-Hans',
|
||||
interface_theme: 'light',
|
||||
timezone: 'Asia/Shanghai',
|
||||
last_login_at: 1744188761,
|
||||
last_login_ip: '127.0.0.1',
|
||||
created_at: 1728444483,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('useTimestamp', () => {
|
||||
describe('formatTime', () => {
|
||||
it('should format unix timestamp correctly', () => {
|
||||
const { result } = renderHook(() => useTimestamp())
|
||||
const timestamp = 1704132000
|
||||
|
||||
expect(result.current.formatTime(timestamp, 'YYYY-MM-DD HH:mm:ss'))
|
||||
.toBe('2024-01-02 02:00:00')
|
||||
})
|
||||
|
||||
it('should format with different patterns', () => {
|
||||
const { result } = renderHook(() => useTimestamp())
|
||||
const timestamp = 1704132000
|
||||
|
||||
expect(result.current.formatTime(timestamp, 'MM/DD/YYYY'))
|
||||
.toBe('01/02/2024')
|
||||
|
||||
expect(result.current.formatTime(timestamp, 'HH:mm'))
|
||||
.toBe('02:00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('should format date string correctly', () => {
|
||||
const { result } = renderHook(() => useTimestamp())
|
||||
const dateString = '2024-01-01T12:00:00Z'
|
||||
|
||||
expect(result.current.formatDate(dateString, 'YYYY-MM-DD HH:mm:ss'))
|
||||
.toBe('2024-01-01 20:00:00')
|
||||
})
|
||||
|
||||
it('should format with different patterns', () => {
|
||||
const { result } = renderHook(() => useTimestamp())
|
||||
const dateString = '2024-01-01T12:00:00Z'
|
||||
|
||||
expect(result.current.formatDate(dateString, 'MM/DD/YYYY'))
|
||||
.toBe('01/01/2024')
|
||||
|
||||
expect(result.current.formatDate(dateString, 'HH:mm'))
|
||||
.toBe('20:00')
|
||||
})
|
||||
})
|
||||
})
|
||||
25
dify/web/hooks/use-timestamp.ts
Normal file
25
dify/web/hooks/use-timestamp.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
import { useCallback } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
const useTimestamp = () => {
|
||||
const { userProfile: { timezone } } = useAppContext()
|
||||
|
||||
const formatTime = useCallback((value: number, format: string) => {
|
||||
return dayjs.unix(value).tz(timezone).format(format)
|
||||
}, [timezone])
|
||||
|
||||
const formatDate = useCallback((value: string, format: string) => {
|
||||
return dayjs(value).tz(timezone).format(format)
|
||||
}, [timezone])
|
||||
|
||||
return { formatTime, formatDate }
|
||||
}
|
||||
|
||||
export default useTimestamp
|
||||
Reference in New Issue
Block a user