mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
Merge branch 'main' into jzh
This commit is contained in:
commit
c15e437ff7
@ -3182,24 +3182,11 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": {
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/plugins/plugin-auth/authorize/index.tsx": {
|
"web/app/components/plugins/plugin-auth/authorize/index.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/plugins/plugin-auth/authorized-in-node.tsx": {
|
"web/app/components/plugins/plugin-auth/authorized-in-node.tsx": {
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import type { OAuthClientSettingsProps } from '../oauth-client-settings'
|
||||||
|
import type { FormSchema } from '@/app/components/base/form/types'
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { AuthCategory } from '../../types'
|
import { AuthCategory } from '../../types'
|
||||||
|
|
||||||
const mockGetPluginOAuthUrl = vi.fn().mockResolvedValue({ authorization_url: 'https://auth.example.com' })
|
const mockGetPluginOAuthUrl = vi.fn().mockResolvedValue({ authorization_url: 'https://auth.example.com' })
|
||||||
const mockOpenOAuthPopup = vi.fn()
|
const mockOpenOAuthPopup = vi.fn()
|
||||||
|
const mockWriteText = vi.fn()
|
||||||
|
const mockOAuthClientSettingsProps: OAuthClientSettingsProps[] = []
|
||||||
|
|
||||||
vi.mock('@/hooks/use-i18n', () => ({
|
vi.mock('@/hooks/use-i18n', () => ({
|
||||||
useRenderI18nObject: () => (obj: Record<string, string> | string) => typeof obj === 'string' ? obj : obj.en_US || '',
|
useRenderI18nObject: () => (obj: Record<string, string> | string) => typeof obj === 'string' ? obj : obj.en_US || '',
|
||||||
@ -31,11 +35,37 @@ vi.mock('../../hooks/use-credential', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../oauth-client-settings', () => ({
|
vi.mock('../oauth-client-settings', () => ({
|
||||||
default: ({ onClose }: { onClose: () => void }) => (
|
default: (props: OAuthClientSettingsProps) => {
|
||||||
<div data-testid="oauth-settings-modal">
|
mockOAuthClientSettingsProps.push(props)
|
||||||
<button data-testid="oauth-settings-close" onClick={onClose}>Close</button>
|
const {
|
||||||
</div>
|
open = true,
|
||||||
),
|
onClose,
|
||||||
|
onOpenChange,
|
||||||
|
schemas,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
if (!open)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange?.(false)
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="oauth-settings-modal">
|
||||||
|
<button data-testid="oauth-settings-close" onClick={handleClose}>Close</button>
|
||||||
|
{schemas.map(schema => (
|
||||||
|
<div key={schema.name} data-testid={`oauth-schema-${schema.name}`}>
|
||||||
|
<div data-testid={`oauth-schema-label-${schema.name}`}>
|
||||||
|
{React.isValidElement(schema.label) ? schema.label : String(schema.label || '')}
|
||||||
|
</div>
|
||||||
|
{String(schema.default || '')}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/form/types', () => ({
|
vi.mock('@/app/components/base/form/types', () => ({
|
||||||
@ -56,6 +86,11 @@ describe('AddOAuthButton', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockOAuthClientSettingsProps.length = 0
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText: mockWriteText },
|
||||||
|
})
|
||||||
const mod = await import('../add-oauth-button')
|
const mod = await import('../add-oauth-button')
|
||||||
AddOAuthButton = mod.default
|
AddOAuthButton = mod.default
|
||||||
})
|
})
|
||||||
@ -72,6 +107,7 @@ describe('AddOAuthButton', () => {
|
|||||||
fireEvent.click(screen.getByTestId('oauth-settings-button'))
|
fireEvent.click(screen.getByTestId('oauth-settings-button'))
|
||||||
|
|
||||||
expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument()
|
expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument()
|
||||||
|
expect(mockOAuthClientSettingsProps.at(-1)?.open).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should close OAuth settings modal', () => {
|
it('should close OAuth settings modal', () => {
|
||||||
@ -84,13 +120,37 @@ describe('AddOAuthButton', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should trigger OAuth flow on main button click', async () => {
|
it('should trigger OAuth flow on main button click', async () => {
|
||||||
|
const mockOnUpdate = vi.fn()
|
||||||
|
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" onUpdate={mockOnUpdate} />)
|
||||||
|
|
||||||
|
const button = screen.getByText('Use OAuth').closest('button')
|
||||||
|
if (button)
|
||||||
|
fireEvent.click(button)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOpenOAuthPopup).toHaveBeenCalledWith('https://auth.example.com', expect.any(Function))
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleOAuthSuccess = mockOpenOAuthPopup.mock.calls[0]?.[1]
|
||||||
|
expect(handleOAuthSuccess).toBeTypeOf('function')
|
||||||
|
if (typeof handleOAuthSuccess === 'function')
|
||||||
|
handleOAuthSuccess()
|
||||||
|
|
||||||
|
expect(mockOnUpdate).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not open OAuth popup when authorization URL is missing', async () => {
|
||||||
|
mockGetPluginOAuthUrl.mockResolvedValueOnce({})
|
||||||
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />)
|
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />)
|
||||||
|
|
||||||
const button = screen.getByText('Use OAuth').closest('button')
|
const button = screen.getByText('Use OAuth').closest('button')
|
||||||
if (button)
|
if (button)
|
||||||
fireEvent.click(button)
|
fireEvent.click(button)
|
||||||
|
|
||||||
expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
|
await waitFor(() => {
|
||||||
|
expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
expect(mockOpenOAuthPopup).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should be disabled when disabled prop is true', () => {
|
it('should be disabled when disabled prop is true', () => {
|
||||||
@ -99,4 +159,96 @@ describe('AddOAuthButton', () => {
|
|||||||
const button = screen.getByText('Use OAuth').closest('button')
|
const button = screen.getByText('Use OAuth').closest('button')
|
||||||
expect(button).toBeDisabled()
|
expect(button).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should open OAuth settings from setup entry when OAuth is not configured', () => {
|
||||||
|
render(
|
||||||
|
<AddOAuthButton
|
||||||
|
pluginPayload={basePayload}
|
||||||
|
oAuthData={{
|
||||||
|
schema: [],
|
||||||
|
is_oauth_custom_client_enabled: false,
|
||||||
|
is_system_oauth_params_exists: false,
|
||||||
|
client_params: {},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
|
||||||
|
|
||||||
|
expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument()
|
||||||
|
expect(mockOAuthClientSettingsProps.at(-1)?.editValues).toMatchObject({
|
||||||
|
__oauth_client__: 'custom',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show custom badge when OAuth custom client is enabled', () => {
|
||||||
|
render(
|
||||||
|
<AddOAuthButton
|
||||||
|
pluginPayload={basePayload}
|
||||||
|
buttonText="Use OAuth"
|
||||||
|
oAuthData={{
|
||||||
|
schema: [],
|
||||||
|
is_oauth_custom_client_enabled: true,
|
||||||
|
is_system_oauth_params_exists: true,
|
||||||
|
client_params: {},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('plugin.auth.custom')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should build custom OAuth schema and edit values for settings modal', () => {
|
||||||
|
const schema = [
|
||||||
|
{
|
||||||
|
name: 'client_id',
|
||||||
|
label: { en_US: 'Client ID' },
|
||||||
|
type: 'text-input',
|
||||||
|
required: true,
|
||||||
|
default: 'schema-client-id',
|
||||||
|
},
|
||||||
|
] as FormSchema[]
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AddOAuthButton
|
||||||
|
pluginPayload={basePayload}
|
||||||
|
buttonText="Use OAuth"
|
||||||
|
oAuthData={{
|
||||||
|
schema,
|
||||||
|
is_oauth_custom_client_enabled: true,
|
||||||
|
is_system_oauth_params_exists: true,
|
||||||
|
client_params: { client_id: 'stored-client-id' },
|
||||||
|
redirect_uri: 'https://redirect.example.com',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('oauth-settings-button'))
|
||||||
|
|
||||||
|
const settingsProps = mockOAuthClientSettingsProps.at(-1)
|
||||||
|
expect(settingsProps?.editValues).toMatchObject({
|
||||||
|
__oauth_client__: 'custom',
|
||||||
|
client_id: 'stored-client-id',
|
||||||
|
})
|
||||||
|
expect(settingsProps?.hasOriginalClientParams).toBe(true)
|
||||||
|
expect(settingsProps?.schemas[0]).toMatchObject({
|
||||||
|
name: '__oauth_client__',
|
||||||
|
default: 'custom',
|
||||||
|
})
|
||||||
|
expect(settingsProps?.schemas[1]).toMatchObject({
|
||||||
|
name: 'client_id',
|
||||||
|
default: 'stored-client-id',
|
||||||
|
show_on: [
|
||||||
|
{
|
||||||
|
variable: '__oauth_client__',
|
||||||
|
value: 'custom',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(screen.getByText('https://redirect.example.com')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(within(screen.getByTestId('oauth-schema-label-client_id')).getByRole('button'))
|
||||||
|
|
||||||
|
expect(mockWriteText).toHaveBeenCalledWith('https://redirect.example.com')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { ApiKeyModalProps } from '../api-key-modal'
|
import type { ApiKeyModalProps } from '../api-key-modal'
|
||||||
import type { FormSchema } from '@/app/components/base/form/types'
|
import type { FormSchema } from '@/app/components/base/form/types'
|
||||||
|
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
@ -384,6 +385,29 @@ describe('ApiKeyModal', () => {
|
|||||||
expect(mockOnClose).toHaveBeenCalled()
|
expect(mockOnClose).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should close on backdrop click when nested inside another dialog', async () => {
|
||||||
|
const mockOnClose = vi.fn()
|
||||||
|
render(
|
||||||
|
<Dialog open>
|
||||||
|
<DialogContent backdropClassName="bg-transparent">
|
||||||
|
<ControlledModalHarness ApiKeyModal={ApiKeyModal} onClose={mockOnClose} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const backdrop = document.querySelector('.bg-background-overlay')
|
||||||
|
expect(backdrop).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.pointerDown(backdrop!)
|
||||||
|
fireEvent.mouseDown(backdrop!)
|
||||||
|
fireEvent.click(backdrop!)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
|
||||||
|
})
|
||||||
|
expect(mockOnClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should render readme entrance when detail is provided', () => {
|
it('should render readme entrance when detail is provided', () => {
|
||||||
const payload = { ...basePayload, detail: { name: 'Test' } as never }
|
const payload = { ...basePayload, detail: { name: 'Test' } as never }
|
||||||
render(<ApiKeyModal pluginPayload={payload} />)
|
render(<ApiKeyModal pluginPayload={payload} />)
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
|
import type { OAuthClientSettingsProps } from '../oauth-client-settings'
|
||||||
|
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { AuthCategory } from '../../types'
|
import { AuthCategory } from '../../types'
|
||||||
@ -20,7 +24,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
|||||||
const mockSetPluginOAuthCustomClient = vi.fn().mockResolvedValue({})
|
const mockSetPluginOAuthCustomClient = vi.fn().mockResolvedValue({})
|
||||||
const mockDeletePluginOAuthCustomClient = vi.fn().mockResolvedValue({})
|
const mockDeletePluginOAuthCustomClient = vi.fn().mockResolvedValue({})
|
||||||
const mockInvalidPluginOAuthClientSchema = vi.fn()
|
const mockInvalidPluginOAuthClientSchema = vi.fn()
|
||||||
const mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
|
let mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
|
||||||
|
let mockAuthFormProps: Record<string, unknown> | undefined
|
||||||
|
|
||||||
vi.mock('../../hooks/use-credential', () => ({
|
vi.mock('../../hooks/use-credential', () => ({
|
||||||
useSetPluginOAuthCustomClientHook: () => ({
|
useSetPluginOAuthCustomClientHook: () => ({
|
||||||
@ -40,36 +45,19 @@ vi.mock('../../../readme-panel/store', () => ({
|
|||||||
ReadmeShowType: { modal: 'modal' },
|
ReadmeShowType: { modal: 'modal' },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/modal/modal', () => ({
|
vi.mock('@/app/components/base/form/form-scenarios/auth', () => {
|
||||||
default: ({ children, title, onClose: _onClose, onConfirm, onCancel, onExtraButtonClick, footerSlot }: {
|
const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => {
|
||||||
children: React.ReactNode
|
mockAuthFormProps = props
|
||||||
title: string
|
|
||||||
onClose?: () => void
|
|
||||||
onConfirm?: () => void
|
|
||||||
onCancel?: () => void
|
|
||||||
onExtraButtonClick?: () => void
|
|
||||||
footerSlot?: React.ReactNode
|
|
||||||
[key: string]: unknown
|
|
||||||
}) => (
|
|
||||||
<div data-testid="modal">
|
|
||||||
<div data-testid="modal-title">{title}</div>
|
|
||||||
{children}
|
|
||||||
<button data-testid="modal-confirm" onClick={onConfirm}>Save And Auth</button>
|
|
||||||
<button data-testid="modal-cancel" onClick={onCancel}>Save Only</button>
|
|
||||||
<button data-testid="modal-close" onClick={onExtraButtonClick}>Cancel</button>
|
|
||||||
{!!footerSlot && <div data-testid="footer-slot">{footerSlot}</div>}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
|
|
||||||
default: React.forwardRef((_props: Record<string, unknown>, ref: React.Ref<unknown>) => {
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
getFormValues: () => mockFormValues,
|
getFormValues: () => mockFormValues,
|
||||||
}))
|
}))
|
||||||
return <div data-testid="auth-form" />
|
return <div data-testid="auth-form" />
|
||||||
}),
|
}
|
||||||
}))
|
|
||||||
|
return {
|
||||||
|
default: MockAuthForm,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@tanstack/react-form', () => ({
|
vi.mock('@tanstack/react-form', () => ({
|
||||||
useForm: (config: Record<string, unknown>) => ({
|
useForm: (config: Record<string, unknown>) => ({
|
||||||
@ -89,11 +77,72 @@ const defaultSchemas = [
|
|||||||
{ name: 'client_id', label: 'Client ID', type: 'text-input', required: true },
|
{ name: 'client_id', label: 'Client ID', type: 'text-input', required: true },
|
||||||
] as never
|
] as never
|
||||||
|
|
||||||
|
const PopoverSettingsHarness = ({
|
||||||
|
OAuthClientSettings,
|
||||||
|
onClose,
|
||||||
|
onPopoverClose,
|
||||||
|
}: {
|
||||||
|
OAuthClientSettings: React.FC<OAuthClientSettingsProps>
|
||||||
|
onClose: () => void
|
||||||
|
onPopoverClose: () => void
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = React.useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
setOpen(nextOpen)
|
||||||
|
if (!nextOpen)
|
||||||
|
onPopoverClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger render={<button type="button">OAuth</button>} />
|
||||||
|
<PopoverContent>
|
||||||
|
<div data-testid="oauth-popover">
|
||||||
|
<OAuthClientSettings
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
pluginPayload={basePayload}
|
||||||
|
schemas={defaultSchemas}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ControlledSettingsHarness = ({
|
||||||
|
OAuthClientSettings,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
OAuthClientSettings: React.FC<OAuthClientSettingsProps>
|
||||||
|
onClose: () => void
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = React.useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div data-testid="modal-open-state">{String(open)}</div>
|
||||||
|
<OAuthClientSettings
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
pluginPayload={basePayload}
|
||||||
|
schemas={defaultSchemas}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
describe('OAuthClientSettings', () => {
|
describe('OAuthClientSettings', () => {
|
||||||
let OAuthClientSettings: (typeof import('../oauth-client-settings'))['default']
|
let OAuthClientSettings: React.FC<OAuthClientSettingsProps>
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
|
||||||
|
mockAuthFormProps = undefined
|
||||||
const mod = await import('../oauth-client-settings')
|
const mod = await import('../oauth-client-settings')
|
||||||
OAuthClientSettings = mod.default
|
OAuthClientSettings = mod.default
|
||||||
})
|
})
|
||||||
@ -120,6 +169,36 @@ describe('OAuthClientSettings', () => {
|
|||||||
expect(screen.getByTestId('auth-form')).toBeInTheDocument()
|
expect(screen.getByTestId('auth-form')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render backdrop when nested inside another dialog', () => {
|
||||||
|
render(
|
||||||
|
<Dialog open>
|
||||||
|
<DialogContent backdropClassName="bg-transparent">
|
||||||
|
<OAuthClientSettings
|
||||||
|
pluginPayload={basePayload}
|
||||||
|
schemas={defaultSchemas}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(document.querySelector('.bg-background-overlay')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass schema defaults to auth form', () => {
|
||||||
|
render(
|
||||||
|
<OAuthClientSettings
|
||||||
|
pluginPayload={basePayload}
|
||||||
|
schemas={[
|
||||||
|
{ name: 'client_id', label: 'Client ID', type: 'text-input', required: true, default: 'default-client-id' },
|
||||||
|
] as never}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockAuthFormProps?.defaultValues).toMatchObject({
|
||||||
|
client_id: 'default-client-id',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('should call onClose when cancel clicked', () => {
|
it('should call onClose when cancel clicked', () => {
|
||||||
const mockOnClose = vi.fn()
|
const mockOnClose = vi.fn()
|
||||||
render(
|
render(
|
||||||
@ -134,6 +213,33 @@ describe('OAuthClientSettings', () => {
|
|||||||
expect(mockOnClose).toHaveBeenCalled()
|
expect(mockOnClose).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should close through controlled open state when cancel clicked', async () => {
|
||||||
|
const mockOnClose = vi.fn()
|
||||||
|
render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('modal-close'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
|
||||||
|
})
|
||||||
|
expect(mockOnClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should close when backdrop is clicked', async () => {
|
||||||
|
const mockOnClose = vi.fn()
|
||||||
|
render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />)
|
||||||
|
|
||||||
|
const backdrop = document.querySelector('.bg-background-overlay')
|
||||||
|
expect(backdrop).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(backdrop!)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
|
||||||
|
})
|
||||||
|
expect(mockOnClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should save settings on save only button click', async () => {
|
it('should save settings on save only button click', async () => {
|
||||||
const mockOnClose = vi.fn()
|
const mockOnClose = vi.fn()
|
||||||
const mockOnUpdate = vi.fn()
|
const mockOnUpdate = vi.fn()
|
||||||
@ -155,6 +261,38 @@ describe('OAuthClientSettings', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should ignore duplicate save clicks while action is pending', async () => {
|
||||||
|
const mockOnClose = vi.fn()
|
||||||
|
let resolveSave: (value: object) => void = () => {}
|
||||||
|
mockSetPluginOAuthCustomClient.mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
resolveSave = resolve
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OAuthClientSettings
|
||||||
|
pluginPayload={basePayload}
|
||||||
|
schemas={defaultSchemas}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('modal-cancel'))
|
||||||
|
|
||||||
|
expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
resolveSave({})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('should save and authorize on confirm button click', async () => {
|
it('should save and authorize on confirm button click', async () => {
|
||||||
const mockOnAuth = vi.fn().mockResolvedValue(undefined)
|
const mockOnAuth = vi.fn().mockResolvedValue(undefined)
|
||||||
render(
|
render(
|
||||||
@ -172,6 +310,34 @@ describe('OAuthClientSettings', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should remove custom client settings', async () => {
|
||||||
|
const mockOnClose = vi.fn()
|
||||||
|
const mockOnUpdate = vi.fn()
|
||||||
|
render(
|
||||||
|
<OAuthClientSettings
|
||||||
|
pluginPayload={basePayload}
|
||||||
|
schemas={defaultSchemas}
|
||||||
|
editValues={{ client_id: 'test-id' }}
|
||||||
|
hasOriginalClientParams
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onUpdate={mockOnUpdate}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('modal-extra'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDeletePluginOAuthCustomClient).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
expect(mockOnClose).toHaveBeenCalled()
|
||||||
|
expect(mockOnUpdate).toHaveBeenCalled()
|
||||||
|
expect(mockInvalidPluginOAuthClientSchema).toHaveBeenCalled()
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
message: 'common.api.actionSuccess',
|
||||||
|
type: 'success',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
it('should render readme entrance when detail is provided', () => {
|
it('should render readme entrance when detail is provided', () => {
|
||||||
const payload = { ...basePayload, detail: { name: 'Test' } as never }
|
const payload = { ...basePayload, detail: { name: 'Test' } as never }
|
||||||
render(
|
render(
|
||||||
@ -183,4 +349,26 @@ describe('OAuthClientSettings', () => {
|
|||||||
|
|
||||||
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
|
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should stay open when clicking inside the modal from a popover', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const mockOnClose = vi.fn()
|
||||||
|
const mockOnPopoverClose = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PopoverSettingsHarness
|
||||||
|
OAuthClientSettings={OAuthClientSettings}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onPopoverClose={mockOnPopoverClose}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = await screen.findByTestId('auth-form')
|
||||||
|
|
||||||
|
await user.click(form)
|
||||||
|
|
||||||
|
expect(mockOnClose).not.toHaveBeenCalled()
|
||||||
|
expect(mockOnPopoverClose).not.toHaveBeenCalled()
|
||||||
|
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,11 +3,6 @@ import type { PluginPayload } from '../types'
|
|||||||
import type { FormSchema } from '@/app/components/base/form/types'
|
import type { FormSchema } from '@/app/components/base/form/types'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
import {
|
|
||||||
RiClipboardLine,
|
|
||||||
RiEqualizer2Line,
|
|
||||||
RiInformation2Fill,
|
|
||||||
} from '@remixicon/react'
|
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -40,10 +35,12 @@ export type AddOAuthButtonProps = {
|
|||||||
schema?: FormSchema[]
|
schema?: FormSchema[]
|
||||||
is_oauth_custom_client_enabled?: boolean
|
is_oauth_custom_client_enabled?: boolean
|
||||||
is_system_oauth_params_exists?: boolean
|
is_system_oauth_params_exists?: boolean
|
||||||
client_params?: Record<string, any>
|
client_params?: Record<string, unknown>
|
||||||
redirect_uri?: string
|
redirect_uri?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
type OAuthData = NonNullable<AddOAuthButtonProps['oAuthData']>
|
||||||
|
|
||||||
const AddOAuthButton = ({
|
const AddOAuthButton = ({
|
||||||
pluginPayload,
|
pluginPayload,
|
||||||
buttonVariant = 'primary',
|
buttonVariant = 'primary',
|
||||||
@ -59,22 +56,27 @@ const AddOAuthButton = ({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const renderI18nObject = useRenderI18nObject()
|
const renderI18nObject = useRenderI18nObject()
|
||||||
const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false)
|
const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false)
|
||||||
|
const [isOAuthSettingsMounted, setIsOAuthSettingsMounted] = useState(false)
|
||||||
const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload)
|
const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload)
|
||||||
const { data, isLoading } = useGetPluginOAuthClientSchemaHook(pluginPayload)
|
const { data, isLoading } = useGetPluginOAuthClientSchemaHook(pluginPayload)
|
||||||
const mergedOAuthData = useMemo(() => {
|
const mergedOAuthData = useMemo<OAuthData>(() => {
|
||||||
if (oAuthData)
|
if (oAuthData)
|
||||||
return oAuthData
|
return oAuthData
|
||||||
|
|
||||||
return data
|
return data || {}
|
||||||
}, [oAuthData, data])
|
}, [oAuthData, data])
|
||||||
const {
|
const {
|
||||||
schema = [],
|
schema = [],
|
||||||
is_oauth_custom_client_enabled,
|
is_oauth_custom_client_enabled,
|
||||||
is_system_oauth_params_exists,
|
is_system_oauth_params_exists,
|
||||||
client_params,
|
client_params = {},
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
} = mergedOAuthData as any || {}
|
} = mergedOAuthData
|
||||||
const isConfigured = is_system_oauth_params_exists || is_oauth_custom_client_enabled
|
const isConfigured = is_system_oauth_params_exists || is_oauth_custom_client_enabled
|
||||||
|
const openOAuthSettings = useCallback(() => {
|
||||||
|
setIsOAuthSettingsMounted(true)
|
||||||
|
setIsOAuthSettingsOpen(true)
|
||||||
|
}, [])
|
||||||
const handleOAuth = useCallback(async () => {
|
const handleOAuth = useCallback(async () => {
|
||||||
const { authorization_url } = await getPluginOAuthUrl()
|
const { authorization_url } = await getPluginOAuthUrl()
|
||||||
|
|
||||||
@ -91,7 +93,7 @@ const AddOAuthButton = ({
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="mb-4 flex rounded-xl bg-background-section-burn p-4">
|
<div className="mb-4 flex rounded-xl bg-background-section-burn p-4">
|
||||||
<div className="mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg">
|
<div className="mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg">
|
||||||
<RiInformation2Fill className="h-5 w-5 text-text-accent" />
|
<span className="i-ri-information-2-fill h-5 w-5 text-text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-0 grow">
|
<div className="w-0 grow">
|
||||||
<div className="mb-1.5 system-sm-regular">
|
<div className="mb-1.5 system-sm-regular">
|
||||||
@ -107,7 +109,7 @@ const AddOAuthButton = ({
|
|||||||
navigator.clipboard.writeText(redirect_uri || '')
|
navigator.clipboard.writeText(redirect_uri || '')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiClipboardLine className="h-4 w-4" />
|
<span className="i-ri-clipboard-line h-4 w-4" />
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -232,10 +234,10 @@ const AddOAuthButton = ({
|
|||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setIsOAuthSettingsOpen(true)
|
openOAuthSettings()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiEqualizer2Line className="h-4 w-4" />
|
<span className="i-ri-equalizer-2-line h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@ -244,18 +246,20 @@ const AddOAuthButton = ({
|
|||||||
!isConfigured && (
|
!isConfigured && (
|
||||||
<Button
|
<Button
|
||||||
variant={buttonVariant}
|
variant={buttonVariant}
|
||||||
onClick={() => setIsOAuthSettingsOpen(true)}
|
onClick={openOAuthSettings}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<RiEqualizer2Line className="mr-0.5 h-4 w-4" />
|
<span className="mr-0.5 i-ri-equalizer-2-line h-4 w-4" />
|
||||||
{t('auth.setupOAuth', { ns: 'plugin' })}
|
{t('auth.setupOAuth', { ns: 'plugin' })}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
isOAuthSettingsOpen && (
|
isOAuthSettingsMounted && (
|
||||||
<OAuthClientSettings
|
<OAuthClientSettings
|
||||||
|
open={isOAuthSettingsOpen}
|
||||||
|
onOpenChange={setIsOAuthSettingsOpen}
|
||||||
pluginPayload={pluginPayload}
|
pluginPayload={pluginPayload}
|
||||||
onClose={() => setIsOAuthSettingsOpen(false)}
|
onClose={() => setIsOAuthSettingsOpen(false)}
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
|
|||||||
@ -140,7 +140,10 @@ const ApiKeyModal = ({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
>
|
>
|
||||||
<DialogContent className="w-[640px]! max-w-[calc(100vw-2rem)]! p-0!">
|
<DialogContent
|
||||||
|
backdropProps={{ forceRender: true }}
|
||||||
|
className="w-[640px]! max-w-[calc(100vw-2rem)]! p-0!"
|
||||||
|
>
|
||||||
<div data-testid="modal" className="flex max-h-[80dvh] flex-col">
|
<div data-testid="modal" className="flex max-h-[80dvh] flex-col">
|
||||||
<div className="relative shrink-0 p-6 pr-14 pb-3">
|
<div className="relative shrink-0 p-6 pr-14 pb-3">
|
||||||
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
|
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type {
|
|||||||
FormSchema,
|
FormSchema,
|
||||||
} from '@/app/components/base/form/types'
|
} from '@/app/components/base/form/types'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
|
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||||
import { toast } from '@langgenius/dify-ui/toast'
|
import { toast } from '@langgenius/dify-ui/toast'
|
||||||
import {
|
import {
|
||||||
useForm,
|
useForm,
|
||||||
@ -17,7 +18,6 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||||
import Modal from '@/app/components/base/modal/modal'
|
|
||||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||||
import { ReadmeShowType } from '../../readme-panel/store'
|
import { ReadmeShowType } from '../../readme-panel/store'
|
||||||
import {
|
import {
|
||||||
@ -26,10 +26,12 @@ import {
|
|||||||
useSetPluginOAuthCustomClientHook,
|
useSetPluginOAuthCustomClientHook,
|
||||||
} from '../hooks/use-credential'
|
} from '../hooks/use-credential'
|
||||||
|
|
||||||
type OAuthClientSettingsProps = {
|
export type OAuthClientSettingsProps = {
|
||||||
pluginPayload: PluginPayload
|
pluginPayload: PluginPayload
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
editValues?: Record<string, any>
|
editValues?: Record<string, unknown>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
schemas: FormSchema[]
|
schemas: FormSchema[]
|
||||||
onAuth?: () => Promise<void>
|
onAuth?: () => Promise<void>
|
||||||
@ -38,6 +40,8 @@ type OAuthClientSettingsProps = {
|
|||||||
}
|
}
|
||||||
const OAuthClientSettings = ({
|
const OAuthClientSettings = ({
|
||||||
pluginPayload,
|
pluginPayload,
|
||||||
|
open = true,
|
||||||
|
onOpenChange,
|
||||||
onClose,
|
onClose,
|
||||||
editValues,
|
editValues,
|
||||||
disabled,
|
disabled,
|
||||||
@ -53,11 +57,16 @@ const OAuthClientSettings = ({
|
|||||||
doingActionRef.current = value
|
doingActionRef.current = value
|
||||||
setDoingAction(value)
|
setDoingAction(value)
|
||||||
}, [])
|
}, [])
|
||||||
|
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||||
|
onOpenChange?.(nextOpen)
|
||||||
|
if (!nextOpen)
|
||||||
|
onClose?.()
|
||||||
|
}, [onClose, onOpenChange])
|
||||||
const defaultValues = schemas.reduce((acc, schema) => {
|
const defaultValues = schemas.reduce((acc, schema) => {
|
||||||
if (schema.default)
|
if (schema.default)
|
||||||
acc[schema.name] = schema.default
|
acc[schema.name] = schema.default
|
||||||
return acc
|
return acc
|
||||||
}, {} as Record<string, any>)
|
}, {} as Record<string, unknown>)
|
||||||
const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload)
|
const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload)
|
||||||
const invalidPluginOAuthClientSchema = useInvalidPluginOAuthClientSchemaHook(pluginPayload)
|
const invalidPluginOAuthClientSchema = useInvalidPluginOAuthClientSchemaHook(pluginPayload)
|
||||||
const formRef = useRef<FormRefObject>(null)
|
const formRef = useRef<FormRefObject>(null)
|
||||||
@ -87,6 +96,7 @@ const OAuthClientSettings = ({
|
|||||||
})
|
})
|
||||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||||
|
|
||||||
|
onOpenChange?.(false)
|
||||||
onClose?.()
|
onClose?.()
|
||||||
onUpdate?.()
|
onUpdate?.()
|
||||||
invalidPluginOAuthClientSchema()
|
invalidPluginOAuthClientSchema()
|
||||||
@ -94,7 +104,7 @@ const OAuthClientSettings = ({
|
|||||||
finally {
|
finally {
|
||||||
handleSetDoingAction(false)
|
handleSetDoingAction(false)
|
||||||
}
|
}
|
||||||
}, [onClose, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, t, handleSetDoingAction])
|
}, [onClose, onOpenChange, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, t, handleSetDoingAction])
|
||||||
|
|
||||||
const handleConfirmAndAuthorize = useCallback(async () => {
|
const handleConfirmAndAuthorize = useCallback(async () => {
|
||||||
await handleConfirm()
|
await handleConfirm()
|
||||||
@ -110,6 +120,7 @@ const OAuthClientSettings = ({
|
|||||||
handleSetDoingAction(true)
|
handleSetDoingAction(true)
|
||||||
await deletePluginOAuthCustomClient()
|
await deletePluginOAuthCustomClient()
|
||||||
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
toast.success(t('api.actionSuccess', { ns: 'common' }))
|
||||||
|
onOpenChange?.(false)
|
||||||
onClose?.()
|
onClose?.()
|
||||||
onUpdate?.()
|
onUpdate?.()
|
||||||
invalidPluginOAuthClientSchema()
|
invalidPluginOAuthClientSchema()
|
||||||
@ -117,53 +128,89 @@ const OAuthClientSettings = ({
|
|||||||
finally {
|
finally {
|
||||||
handleSetDoingAction(false)
|
handleSetDoingAction(false)
|
||||||
}
|
}
|
||||||
}, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, t, handleSetDoingAction, onClose])
|
}, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, t, handleSetDoingAction, onClose, onOpenChange])
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: editValues || defaultValues,
|
defaultValues: editValues || defaultValues,
|
||||||
})
|
})
|
||||||
const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__)
|
const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__)
|
||||||
|
const isDisabled = disabled || doingAction
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Dialog
|
||||||
title={t('auth.oauthClientSettings', { ns: 'plugin' })}
|
open={open}
|
||||||
confirmButtonText={t('auth.saveAndAuth', { ns: 'plugin' })}
|
onOpenChange={handleOpenChange}
|
||||||
cancelButtonText={t('auth.saveOnly', { ns: 'plugin' })}
|
|
||||||
extraButtonText={t('operation.cancel', { ns: 'common' })}
|
|
||||||
showExtraButton
|
|
||||||
extraButtonVariant="secondary"
|
|
||||||
onExtraButtonClick={onClose}
|
|
||||||
onClose={onClose}
|
|
||||||
onCancel={handleConfirm}
|
|
||||||
onConfirm={handleConfirmAndAuthorize}
|
|
||||||
disabled={disabled || doingAction}
|
|
||||||
footerSlot={
|
|
||||||
__oauth_client__ === 'custom' && hasOriginalClientParams && (
|
|
||||||
<div className="grow">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="text-components-button-destructive-secondary-text"
|
|
||||||
disabled={disabled || doingAction || !editValues}
|
|
||||||
onClick={handleRemove}
|
|
||||||
>
|
|
||||||
{t('operation.remove', { ns: 'common' })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
containerClassName="pt-0"
|
|
||||||
wrapperClassName="z-1002!"
|
|
||||||
clickOutsideNotClose={true}
|
|
||||||
>
|
>
|
||||||
{pluginPayload.detail && (
|
<DialogContent
|
||||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
backdropProps={{ forceRender: true }}
|
||||||
)}
|
className="w-[480px]! max-w-[calc(100vw-2rem)]! p-0!"
|
||||||
<AuthForm
|
>
|
||||||
formFromProps={form}
|
<div data-testid="modal" className="flex max-h-[80dvh] flex-col">
|
||||||
ref={formRef}
|
<div className="relative shrink-0 p-6 pr-14 pb-3">
|
||||||
formSchemas={schemas}
|
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
|
||||||
defaultValues={editValues || defaultValues}
|
{t('auth.oauthClientSettings', { ns: 'plugin' })}
|
||||||
disabled={disabled}
|
</DialogTitle>
|
||||||
/>
|
<DialogCloseButton
|
||||||
</Modal>
|
data-testid="modal-x-close"
|
||||||
|
className="top-5 right-5 h-8 w-8 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0">
|
||||||
|
{pluginPayload.detail && (
|
||||||
|
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||||
|
)}
|
||||||
|
<AuthForm
|
||||||
|
formFromProps={form}
|
||||||
|
ref={formRef}
|
||||||
|
formSchemas={schemas}
|
||||||
|
defaultValues={editValues || defaultValues}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 justify-between p-6 pt-5">
|
||||||
|
<div>
|
||||||
|
{__oauth_client__ === 'custom' && hasOriginalClientParams && (
|
||||||
|
<Button
|
||||||
|
data-testid="modal-extra"
|
||||||
|
variant="secondary"
|
||||||
|
className="text-components-button-destructive-secondary-text"
|
||||||
|
disabled={isDisabled || !editValues}
|
||||||
|
onClick={handleRemove}
|
||||||
|
>
|
||||||
|
{t('operation.remove', { ns: 'common' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
data-testid="modal-close"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{t('operation.cancel', { ns: 'common' })}
|
||||||
|
</Button>
|
||||||
|
<div className="mx-3 h-4 w-px bg-divider-regular"></div>
|
||||||
|
<Button
|
||||||
|
data-testid="modal-cancel"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{t('auth.saveOnly', { ns: 'plugin' })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="modal-confirm"
|
||||||
|
className="ml-2"
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleConfirmAndAuthorize}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{t('auth.saveAndAuth', { ns: 'plugin' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -330,6 +330,27 @@ describe('publisher', () => {
|
|||||||
})
|
})
|
||||||
expect(mockSetShowPricingModal).toHaveBeenCalled()
|
expect(mockSetShowPricingModal).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should keep confirm dialog mounted when first publish opens follow-up overlay', async () => {
|
||||||
|
mockPublishedAt.mockReturnValue(null)
|
||||||
|
renderWithQueryClient(<Publisher />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /workflow.common.publishUpdate/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.mouseDown(document.body)
|
||||||
|
|
||||||
|
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import { RiArrowDownSLine } from '@remixicon/react'
|
import { RiArrowDownSLine } from '@remixicon/react'
|
||||||
|
import { useBoolean } from 'ahooks'
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -13,13 +15,19 @@ import Popup from './popup'
|
|||||||
const Publisher = () => {
|
const Publisher = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false)
|
||||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||||
|
|
||||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||||
|
if (!newOpen && confirmVisible)
|
||||||
|
return
|
||||||
if (newOpen)
|
if (newOpen)
|
||||||
handleSyncWorkflowDraft(true)
|
handleSyncWorkflowDraft(true)
|
||||||
setOpen(newOpen)
|
setOpen(newOpen)
|
||||||
}, [handleSyncWorkflowDraft])
|
}, [confirmVisible, handleSyncWorkflowDraft])
|
||||||
|
const closePopover = useCallback(() => {
|
||||||
|
setOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@ -42,9 +50,14 @@ const Publisher = () => {
|
|||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
alignOffset={40}
|
alignOffset={40}
|
||||||
popupClassName="border-none bg-transparent shadow-none"
|
popupClassName={cn('border-none bg-transparent shadow-none', confirmVisible && 'hidden')}
|
||||||
>
|
>
|
||||||
<Popup onRequestClose={() => handleOpenChange(false)} />
|
<Popup
|
||||||
|
onRequestClose={closePopover}
|
||||||
|
confirmVisible={confirmVisible}
|
||||||
|
onShowConfirm={showConfirm}
|
||||||
|
onHideConfirm={hideConfirm}
|
||||||
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -41,9 +41,17 @@ import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline
|
|||||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||||
type PopupProps = {
|
type PopupProps = {
|
||||||
onRequestClose?: () => void
|
onRequestClose?: () => void
|
||||||
|
confirmVisible?: boolean
|
||||||
|
onShowConfirm?: () => void
|
||||||
|
onHideConfirm?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Popup = ({ onRequestClose }: PopupProps) => {
|
const Popup = ({
|
||||||
|
onRequestClose,
|
||||||
|
confirmVisible: controlledConfirmVisible,
|
||||||
|
onShowConfirm,
|
||||||
|
onHideConfirm,
|
||||||
|
}: PopupProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { datasetId } = useParams()
|
const { datasetId } = useParams()
|
||||||
const { push } = useRouter()
|
const { push } = useRouter()
|
||||||
@ -60,24 +68,32 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
|||||||
const isAllowPublishAsCustomKnowledgePipelineTemplate = useProviderContextSelector(s => s.isAllowPublishAsCustomKnowledgePipelineTemplate)
|
const isAllowPublishAsCustomKnowledgePipelineTemplate = useProviderContextSelector(s => s.isAllowPublishAsCustomKnowledgePipelineTemplate)
|
||||||
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
|
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
|
||||||
const apiReferenceUrl = useDatasetApiAccessUrl()
|
const apiReferenceUrl = useDatasetApiAccessUrl()
|
||||||
const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false)
|
const [localConfirmVisible, { setFalse: hideLocalConfirm, setTrue: showLocalConfirm }] = useBoolean(false)
|
||||||
|
const confirmVisible = controlledConfirmVisible ?? localConfirmVisible
|
||||||
|
const showConfirm = onShowConfirm ?? showLocalConfirm
|
||||||
|
const hideConfirm = onHideConfirm ?? hideLocalConfirm
|
||||||
const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false)
|
const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false)
|
||||||
const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline()
|
const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline()
|
||||||
const [showPublishAsKnowledgePipelineModal, { setFalse: hidePublishAsKnowledgePipelineModal, setTrue: setShowPublishAsKnowledgePipelineModal }] = useBoolean(false)
|
const [showPublishAsKnowledgePipelineModal, { setFalse: hidePublishAsKnowledgePipelineModal, setTrue: setShowPublishAsKnowledgePipelineModal }] = useBoolean(false)
|
||||||
const [isPublishingAsCustomizedPipeline, { setFalse: hidePublishingAsCustomizedPipeline, setTrue: showPublishingAsCustomizedPipeline }] = useBoolean(false)
|
const [isPublishingAsCustomizedPipeline, { setFalse: hidePublishingAsCustomizedPipeline, setTrue: showPublishingAsCustomizedPipeline }] = useBoolean(false)
|
||||||
const invalidPublishedPipelineInfo = useInvalid([...publishedPipelineInfoQueryKeyPrefix, pipelineId])
|
const invalidPublishedPipelineInfo = useInvalid([...publishedPipelineInfoQueryKeyPrefix, pipelineId])
|
||||||
const invalidDatasetList = useInvalidDatasetList()
|
const invalidDatasetList = useInvalidDatasetList()
|
||||||
|
const handleHideConfirm = useCallback(() => {
|
||||||
|
hideConfirm()
|
||||||
|
onRequestClose?.()
|
||||||
|
}, [hideConfirm, onRequestClose])
|
||||||
const handlePublish = useCallback(async (params?: PublishWorkflowParams) => {
|
const handlePublish = useCallback(async (params?: PublishWorkflowParams) => {
|
||||||
if (publishing)
|
if (publishing)
|
||||||
return
|
return
|
||||||
|
let startedPublishing = false
|
||||||
try {
|
try {
|
||||||
const checked = await handleCheckBeforePublish()
|
const checked = await handleCheckBeforePublish()
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!publishedAt && !confirmVisible) {
|
if (!publishedAt && !confirmVisible) {
|
||||||
onRequestClose?.()
|
|
||||||
showConfirm()
|
showConfirm()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
startedPublishing = true
|
||||||
showPublishing()
|
showPublishing()
|
||||||
const res = await publishWorkflow({
|
const res = await publishWorkflow({
|
||||||
url: `/rag/pipelines/${pipelineId}/workflows/publish`,
|
url: `/rag/pipelines/${pipelineId}/workflows/publish`,
|
||||||
@ -114,12 +130,12 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
|||||||
toast.error(t('publishPipeline.error.message', { ns: 'datasetPipeline' }))
|
toast.error(t('publishPipeline.error.message', { ns: 'datasetPipeline' }))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
if (publishing)
|
if (startedPublishing)
|
||||||
hidePublishing()
|
hidePublishing()
|
||||||
if (confirmVisible)
|
if (confirmVisible)
|
||||||
hideConfirm()
|
handleHideConfirm()
|
||||||
}
|
}
|
||||||
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm, onRequestClose])
|
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, handleHideConfirm])
|
||||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (published)
|
if (published)
|
||||||
@ -163,10 +179,12 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
|||||||
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink])
|
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink])
|
||||||
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
|
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
|
||||||
onRequestClose?.()
|
onRequestClose?.()
|
||||||
if (!isAllowPublishAsCustomKnowledgePipelineTemplate)
|
if (!isAllowPublishAsCustomKnowledgePipelineTemplate) {
|
||||||
setShowPricingModal()
|
setShowPricingModal()
|
||||||
else
|
}
|
||||||
|
else {
|
||||||
setShowPublishAsKnowledgePipelineModal()
|
setShowPublishAsKnowledgePipelineModal()
|
||||||
|
}
|
||||||
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
|
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
|
||||||
return (
|
return (
|
||||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
|
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
|
||||||
@ -238,7 +256,7 @@ const Popup = ({ onRequestClose }: PopupProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialog open={confirmVisible} onOpenChange={open => !open && hideConfirm()}>
|
<AlertDialog open={confirmVisible} onOpenChange={open => !open && handleHideConfirm()}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||||
<AlertDialogTitle
|
<AlertDialogTitle
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user