mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
Merge main HEAD (segment 5) into sandboxed-agent-rebase
Resolve 83 conflicts: 10 backend, 62 frontend, 11 config/lock files. Preserve sandbox/agent/collaboration features while adopting main's UI refactorings (Dialog/AlertDialog/Popover), model provider updates, and enterprise features. Made-with: Cursor
This commit is contained in:
@ -1,496 +1,179 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { contactSalesUrl } from '@/app/components/billing/config'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import {
|
||||
initialLangGeniusVersionInfo,
|
||||
initialWorkspaceInfo,
|
||||
useAppContext,
|
||||
userProfilePlaceholder,
|
||||
} from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import CustomPage from '../index'
|
||||
|
||||
// Mock external dependencies only
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock the complex CustomWebAppBrand component to avoid dependency issues
|
||||
// This is acceptable because it has complex dependencies (fetch, APIs)
|
||||
vi.mock('@/app/components/custom/custom-web-app-brand', () => ({
|
||||
default: () => <div data-testid="custom-web-app-brand">CustomWebAppBrand</div>,
|
||||
vi.mock('@/context/app-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/app-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useAppContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseModalContext = vi.mocked(useModalContext)
|
||||
const mockUseAppContext = vi.mocked(useAppContext)
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockUseToastContext = vi.mocked(useToastContext)
|
||||
|
||||
const createProviderContext = ({
|
||||
enableBilling = false,
|
||||
planType = Plan.professional,
|
||||
}: {
|
||||
enableBilling?: boolean
|
||||
planType?: Plan
|
||||
} = {}) => {
|
||||
return createMockProviderContextValue({
|
||||
enableBilling,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const createAppContextValue = (): AppContextValue => ({
|
||||
userProfile: userProfilePlaceholder,
|
||||
mutateUserProfile: vi.fn(),
|
||||
currentWorkspace: {
|
||||
...initialWorkspaceInfo,
|
||||
custom_config: {
|
||||
replace_webapp_logo: 'https://example.com/replace.png',
|
||||
remove_webapp_brand: false,
|
||||
},
|
||||
},
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isCurrentWorkspaceEditor: false,
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
langGeniusVersionInfo: initialLangGeniusVersionInfo,
|
||||
useSelector: vi.fn() as unknown as AppContextValue['useSelector'],
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isValidatingCurrentWorkspace: false,
|
||||
})
|
||||
|
||||
const createSystemFeatures = (): SystemFeatures => ({
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: true,
|
||||
workspace_logo: 'https://example.com/workspace-logo.png',
|
||||
},
|
||||
})
|
||||
|
||||
describe('CustomPage', () => {
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const setShowPricingModal = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default mock setup
|
||||
;(useModalContext as Mock).mockReturnValue({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext())
|
||||
mockUseModalContext.mockReturnValue({
|
||||
setShowPricingModal,
|
||||
} as unknown as ReturnType<typeof useModalContext>)
|
||||
mockUseAppContext.mockReturnValue(createAppContextValue())
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector({
|
||||
systemFeatures: createSystemFeatures(),
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
mockUseToastContext.mockReturnValue({
|
||||
notify: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useToastContext>)
|
||||
})
|
||||
|
||||
// Helper function to render with different provider contexts
|
||||
const renderWithContext = (overrides = {}) => {
|
||||
;(useProviderContext as Mock).mockReturnValue(
|
||||
createMockProviderContextValue(overrides),
|
||||
)
|
||||
return render(<CustomPage />)
|
||||
}
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
// Integration coverage for the page and its child custom brand section.
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext()
|
||||
it('should render the custom brand configuration by default', () => {
|
||||
render(<CustomPage />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should always render CustomWebAppBrand component', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct layout structure', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext()
|
||||
|
||||
// Assert
|
||||
const mainContainer = container.querySelector('.flex.flex-col')
|
||||
expect(mainContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Conditional Rendering - Billing Tip
|
||||
describe('Billing Tip Banner', () => {
|
||||
it('should show billing tip when enableBilling is true and plan is sandbox', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('custom.upgradeTip.des')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show billing tip when enableBilling is false', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('custom.webapp.removeBrand')).toBeInTheDocument()
|
||||
expect(screen.getByText('Chatflow App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show billing tip when plan is professional', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show billing tip when plan is team', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.team },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct gradient styling for billing tip banner', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const banner = container.querySelector('.bg-gradient-to-r')
|
||||
expect(banner).toBeInTheDocument()
|
||||
expect(banner).toHaveClass('from-components-input-border-active-prompt-1')
|
||||
expect(banner).toHaveClass('to-components-input-border-active-prompt-2')
|
||||
expect(banner).toHaveClass('p-4')
|
||||
expect(banner).toHaveClass('pl-6')
|
||||
expect(banner).toHaveClass('shadow-lg')
|
||||
})
|
||||
})
|
||||
|
||||
// Conditional Rendering - Contact Sales
|
||||
describe('Contact Sales Section', () => {
|
||||
it('should show contact section when enableBilling is true and plan is professional', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert - Check that contact section exists with all parts
|
||||
const contactSection = container.querySelector('.absolute.bottom-0')
|
||||
expect(contactSection).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.prefix')
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.suffix')
|
||||
})
|
||||
|
||||
it('should show contact section when enableBilling is true and plan is team', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.team },
|
||||
})
|
||||
|
||||
// Assert - Check that contact section exists with all parts
|
||||
const contactSection = container.querySelector('.absolute.bottom-0')
|
||||
expect(contactSection).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.prefix')
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
expect(contactSection).toHaveTextContent('custom.customize.suffix')
|
||||
})
|
||||
|
||||
it('should not show contact section when enableBilling is false', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show contact section when plan is sandbox', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render contact link with correct URL', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const link = screen.getByText('custom.customize.contactUs').closest('a')
|
||||
expect(link).toHaveAttribute('href', contactSalesUrl)
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should have correct positioning for contact section', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const contactSection = container.querySelector('.absolute.bottom-0')
|
||||
expect(contactSection).toBeInTheDocument()
|
||||
expect(contactSection).toHaveClass('h-[50px]')
|
||||
expect(contactSection).toHaveClass('text-xs')
|
||||
expect(contactSection).toHaveClass('leading-[50px]')
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call setShowPricingModal when upgrade button is clicked', async () => {
|
||||
// Arrange
|
||||
it('should show the upgrade banner and open pricing modal for sandbox billing', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithContext({
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
planType: Plan.sandbox,
|
||||
}))
|
||||
|
||||
// Act
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
await user.click(upgradeButton)
|
||||
render(<CustomPage />)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call setShowPricingModal without arguments', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Act
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
await user.click(upgradeButton)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should handle multiple clicks on upgrade button', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Act
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
await user.click(upgradeButton)
|
||||
await user.click(upgradeButton)
|
||||
await user.click(upgradeButton)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should have correct button styling for upgrade button', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
expect(upgradeButton).toHaveClass('cursor-pointer')
|
||||
expect(upgradeButton).toHaveClass('bg-white')
|
||||
expect(upgradeButton).toHaveClass('text-text-accent')
|
||||
expect(upgradeButton).toHaveClass('rounded-3xl')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined plan type gracefully', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: undefined },
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle plan without type property', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: null },
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show any banners when both conditions are false', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle enableBilling undefined', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: undefined,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show only billing tip for sandbox plan, not contact section', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('billing.upgradeBtn.encourageShort'))
|
||||
|
||||
expect(setShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show only contact section for professional plan, not billing tip', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
it('should show the contact link for professional workspaces', () => {
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
planType: Plan.professional,
|
||||
}))
|
||||
|
||||
// Assert
|
||||
render(<CustomPage />)
|
||||
|
||||
const contactLink = screen.getByText('custom.customize.contactUs').closest('a')
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
expect(contactLink).toHaveAttribute('href', contactSalesUrl)
|
||||
expect(contactLink).toHaveAttribute('target', '_blank')
|
||||
expect(contactLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should show only contact section for team plan, not billing tip', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
it('should show the contact link for team workspaces', () => {
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.team },
|
||||
})
|
||||
planType: Plan.team,
|
||||
}))
|
||||
|
||||
// Assert
|
||||
render(<CustomPage />)
|
||||
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty plan object', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: {},
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Accessibility Tests
|
||||
describe('Accessibility', () => {
|
||||
it('should have clickable upgrade button', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
|
||||
expect(upgradeButton).toBeInTheDocument()
|
||||
expect(upgradeButton).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('should have proper external link attributes on contact link', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const link = screen.getByText('custom.customize.contactUs').closest('a')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('should have proper text hierarchy in billing tip', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
const title = screen.getByText('custom.upgradeTip.title')
|
||||
const description = screen.getByText('custom.upgradeTip.des')
|
||||
|
||||
expect(title).toHaveClass('title-xl-semi-bold')
|
||||
expect(description).toHaveClass('system-sm-regular')
|
||||
})
|
||||
|
||||
it('should use semantic color classes', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert - Check that the billing tip has text content (which implies semantic colors)
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Integration Tests
|
||||
describe('Integration', () => {
|
||||
it('should render both CustomWebAppBrand and billing tip together', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both CustomWebAppBrand and contact section together', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
enableBilling: true,
|
||||
plan: { type: Plan.professional },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only CustomWebAppBrand when no billing conditions met', () => {
|
||||
// Arrange & Act
|
||||
renderWithContext({
|
||||
it('should hide both billing sections when billing is disabled', () => {
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
enableBilling: false,
|
||||
plan: { type: Plan.sandbox },
|
||||
})
|
||||
planType: Plan.sandbox,
|
||||
}))
|
||||
|
||||
render(<CustomPage />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1,147 +1,158 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { updateCurrentWorkspace } from '@/service/common'
|
||||
import useWebAppBrand from '../hooks/use-web-app-brand'
|
||||
import CustomWebAppBrand from '../index'
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/common', () => ({
|
||||
updateCurrentWorkspace: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/app/components/base/image-uploader/utils', () => ({
|
||||
imageUpload: vi.fn(),
|
||||
getImageUploadErrorMessage: vi.fn(),
|
||||
vi.mock('../hooks/use-web-app-brand', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockUseToastContext = vi.mocked(useToastContext)
|
||||
const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace)
|
||||
const mockUseAppContext = vi.mocked(useAppContext)
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockImageUpload = vi.mocked(imageUpload)
|
||||
const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage)
|
||||
const mockUseWebAppBrand = vi.mocked(useWebAppBrand)
|
||||
|
||||
const defaultPlanUsage = {
|
||||
buildApps: 0,
|
||||
teamMembers: 0,
|
||||
annotatedResponse: 0,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
vectorSpace: 0,
|
||||
const createHookState = (overrides: Partial<ReturnType<typeof useWebAppBrand>> = {}): ReturnType<typeof useWebAppBrand> => ({
|
||||
fileId: '',
|
||||
imgKey: 100,
|
||||
uploadProgress: 0,
|
||||
uploading: false,
|
||||
webappLogo: 'https://example.com/replace.png',
|
||||
webappBrandRemoved: false,
|
||||
uploadDisabled: false,
|
||||
workspaceLogo: 'https://example.com/workspace-logo.png',
|
||||
isSandbox: false,
|
||||
isCurrentWorkspaceManager: true,
|
||||
handleApply: vi.fn(),
|
||||
handleCancel: vi.fn(),
|
||||
handleChange: vi.fn(),
|
||||
handleRestore: vi.fn(),
|
||||
handleSwitch: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderComponent = (overrides: Partial<ReturnType<typeof useWebAppBrand>> = {}) => {
|
||||
const hookState = createHookState(overrides)
|
||||
mockUseWebAppBrand.mockReturnValue(hookState)
|
||||
return {
|
||||
hookState,
|
||||
...render(<CustomWebAppBrand />),
|
||||
}
|
||||
}
|
||||
|
||||
const renderComponent = () => render(<CustomWebAppBrand />)
|
||||
|
||||
describe('CustomWebAppBrand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType<typeof useToastContext>)
|
||||
mockUpdateCurrentWorkspace.mockResolvedValue({} as unknown as Awaited<ReturnType<typeof updateCurrentWorkspace>>)
|
||||
mockUseAppContext.mockReturnValue({
|
||||
currentWorkspace: {
|
||||
custom_config: {
|
||||
replace_webapp_logo: 'https://example.com/replace.png',
|
||||
remove_webapp_brand: false,
|
||||
},
|
||||
},
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
isCurrentWorkspaceManager: true,
|
||||
} as unknown as ReturnType<typeof useAppContext>)
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
type: Plan.professional,
|
||||
usage: defaultPlanUsage,
|
||||
total: defaultPlanUsage,
|
||||
reset: {},
|
||||
},
|
||||
enableBilling: false,
|
||||
} as unknown as ReturnType<typeof useProviderContext>)
|
||||
const systemFeaturesState = {
|
||||
branding: {
|
||||
enabled: true,
|
||||
workspace_logo: 'https://example.com/workspace-logo.png',
|
||||
},
|
||||
}
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState, setSystemFeatures: vi.fn() } as unknown as ReturnType<typeof useGlobalPublicStore.getState>) : { systemFeatures: systemFeaturesState })
|
||||
mockGetImageUploadErrorMessage.mockReturnValue('upload error')
|
||||
})
|
||||
|
||||
it('disables upload controls when the user cannot manage the workspace', () => {
|
||||
mockUseAppContext.mockReturnValue({
|
||||
currentWorkspace: {
|
||||
custom_config: {
|
||||
replace_webapp_logo: '',
|
||||
remove_webapp_brand: false,
|
||||
},
|
||||
},
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
isCurrentWorkspaceManager: false,
|
||||
} as unknown as ReturnType<typeof useAppContext>)
|
||||
// Integration coverage for the root component with the hook mocked at the boundary.
|
||||
describe('Rendering', () => {
|
||||
it('should render the upload controls and preview cards with restore action', () => {
|
||||
renderComponent()
|
||||
|
||||
const { container } = renderComponent()
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(fileInput).toBeDisabled()
|
||||
})
|
||||
|
||||
it('toggles remove brand switch and calls the backend + mutate', async () => {
|
||||
const mutateMock = vi.fn()
|
||||
mockUseAppContext.mockReturnValue({
|
||||
currentWorkspace: {
|
||||
custom_config: {
|
||||
replace_webapp_logo: '',
|
||||
remove_webapp_brand: false,
|
||||
},
|
||||
},
|
||||
mutateCurrentWorkspace: mutateMock,
|
||||
isCurrentWorkspaceManager: true,
|
||||
} as unknown as ReturnType<typeof useAppContext>)
|
||||
|
||||
renderComponent()
|
||||
const switchInput = screen.getByRole('switch')
|
||||
fireEvent.click(switchInput)
|
||||
|
||||
await waitFor(() => expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
|
||||
url: '/workspaces/custom-config',
|
||||
body: { remove_webapp_brand: true },
|
||||
}))
|
||||
await waitFor(() => expect(mutateMock).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('shows cancel/apply buttons after successful upload and cancels properly', async () => {
|
||||
mockImageUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }) => {
|
||||
onProgressCallback(50)
|
||||
onSuccessCallback({ id: 'new-logo' })
|
||||
expect(screen.getByText('custom.webapp.removeBrand')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'custom.restore' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'custom.change' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Chatflow App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workflow App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const { container } = renderComponent()
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const testFile = new File(['content'], 'logo.png', { type: 'image/png' })
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } })
|
||||
it('should hide the restore action when uploads are disabled or no logo is configured', () => {
|
||||
renderComponent({
|
||||
uploadDisabled: true,
|
||||
webappLogo: '',
|
||||
})
|
||||
|
||||
await waitFor(() => expect(mockImageUpload).toHaveBeenCalled())
|
||||
await waitFor(() => screen.getByRole('button', { name: 'custom.apply' }))
|
||||
expect(screen.queryByRole('button', { name: 'custom.restore' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'custom.upload' })).toBeDisabled()
|
||||
})
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: 'common.operation.cancel' })
|
||||
fireEvent.click(cancelButton)
|
||||
it('should show the uploading button and failure message when upload state requires it', () => {
|
||||
renderComponent({
|
||||
uploading: true,
|
||||
uploadProgress: -1,
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'custom.apply' })).toBeNull())
|
||||
expect(screen.getByRole('button', { name: 'custom.uploading' })).toBeDisabled()
|
||||
expect(screen.getByText('custom.uploadedFail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show apply and cancel actions when a new file is ready', () => {
|
||||
renderComponent({
|
||||
fileId: 'new-logo',
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'custom.apply' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable the switch when sandbox restrictions are active', () => {
|
||||
renderComponent({
|
||||
isSandbox: true,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('should default the switch to unchecked when brand removal state is missing', () => {
|
||||
const { container } = renderComponent({
|
||||
webappBrandRemoved: undefined,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(container.querySelector('.opacity-30')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should dim the upload row when brand removal is enabled', () => {
|
||||
const { container } = renderComponent({
|
||||
webappBrandRemoved: true,
|
||||
uploadDisabled: true,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(container.querySelector('.opacity-30')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions delegated to the hook callbacks.
|
||||
describe('Interactions', () => {
|
||||
it('should delegate switch changes to the hook handler', () => {
|
||||
const { hookState } = renderComponent()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(hookState.handleSwitch).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should delegate file input changes and reset the native input value on click', () => {
|
||||
const { container, hookState } = renderComponent()
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const file = new File(['logo'], 'logo.png', { type: 'image/png' })
|
||||
|
||||
Object.defineProperty(fileInput, 'value', {
|
||||
configurable: true,
|
||||
value: 'stale-selection',
|
||||
writable: true,
|
||||
})
|
||||
|
||||
fireEvent.click(fileInput)
|
||||
fireEvent.change(fileInput, {
|
||||
target: { files: [file] },
|
||||
})
|
||||
|
||||
expect(fileInput.value).toBe('')
|
||||
expect(hookState.handleChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should delegate restore, cancel, and apply actions to the hook handlers', () => {
|
||||
const { hookState } = renderComponent({
|
||||
fileId: 'new-logo',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'custom.restore' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'custom.apply' }))
|
||||
|
||||
expect(hookState.handleRestore).toHaveBeenCalledTimes(1)
|
||||
expect(hookState.handleCancel).toHaveBeenCalledTimes(1)
|
||||
expect(hookState.handleApply).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ChatPreviewCard from '../chat-preview-card'
|
||||
|
||||
describe('ChatPreviewCard', () => {
|
||||
it('should render the chat preview with the powered-by footer', () => {
|
||||
render(
|
||||
<ChatPreviewCard
|
||||
imgKey={8}
|
||||
webappLogo="https://example.com/custom-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Chatflow App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hello! How can I assist you today?')).toBeInTheDocument()
|
||||
expect(screen.getByText('Talk to Dify')).toBeInTheDocument()
|
||||
expect(screen.getByText('POWERED BY')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide chat branding footer when brand removal is enabled', () => {
|
||||
render(
|
||||
<ChatPreviewCard
|
||||
imgKey={8}
|
||||
webappBrandRemoved
|
||||
webappLogo="https://example.com/custom-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('POWERED BY')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import PoweredByBrand from '../powered-by-brand'
|
||||
|
||||
describe('PoweredByBrand', () => {
|
||||
it('should render the workspace logo when available', () => {
|
||||
render(
|
||||
<PoweredByBrand
|
||||
imgKey={1}
|
||||
workspaceLogo="https://example.com/workspace-logo.png"
|
||||
webappLogo="https://example.com/custom-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('POWERED BY')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png')
|
||||
})
|
||||
|
||||
it('should fall back to the custom web app logo when workspace branding is unavailable', () => {
|
||||
render(
|
||||
<PoweredByBrand
|
||||
imgKey={42}
|
||||
webappLogo="https://example.com/custom-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/custom-logo.png?hash=42')
|
||||
})
|
||||
|
||||
it('should fall back to the Dify logo when no custom branding exists', () => {
|
||||
render(<PoweredByBrand imgKey={7} />)
|
||||
|
||||
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when branding is removed', () => {
|
||||
const { container } = render(<PoweredByBrand imgKey={7} webappBrandRemoved />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,32 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import WorkflowPreviewCard from '../workflow-preview-card'
|
||||
|
||||
describe('WorkflowPreviewCard', () => {
|
||||
it('should render the workflow preview with execute action and branding footer', () => {
|
||||
render(
|
||||
<WorkflowPreviewCard
|
||||
imgKey={9}
|
||||
workspaceLogo="https://example.com/workspace-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Workflow App')).toBeInTheDocument()
|
||||
expect(screen.getByText('RUN ONCE')).toBeInTheDocument()
|
||||
expect(screen.getByText('RUN BATCH')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Execute/i })).toBeDisabled()
|
||||
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png')
|
||||
})
|
||||
|
||||
it('should hide workflow branding footer when brand removal is enabled', () => {
|
||||
render(
|
||||
<WorkflowPreviewCard
|
||||
imgKey={9}
|
||||
webappBrandRemoved
|
||||
workspaceLogo="https://example.com/workspace-logo.png"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('POWERED BY')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,78 @@
|
||||
import Button from '@/app/components/base/button'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import PoweredByBrand from './powered-by-brand'
|
||||
|
||||
type ChatPreviewCardProps = {
|
||||
webappBrandRemoved?: boolean
|
||||
workspaceLogo?: string
|
||||
webappLogo?: string
|
||||
imgKey: number
|
||||
}
|
||||
|
||||
const ChatPreviewCard = ({
|
||||
webappBrandRemoved,
|
||||
workspaceLogo,
|
||||
webappLogo,
|
||||
imgKey,
|
||||
}: ChatPreviewCardProps) => {
|
||||
return (
|
||||
<div className="flex h-[320px] grow basis-1/2 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
|
||||
<div className="flex h-full w-[232px] shrink-0 flex-col p-1 pr-0">
|
||||
<div className="flex items-center gap-3 p-3 pr-2">
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}>
|
||||
<span className="i-custom-vender-solid-communication-bubble-text-mod h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Chatflow App</div>
|
||||
<div className="p-1.5">
|
||||
<span className="i-ri-layout-left-2-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-3">
|
||||
<Button variant="secondary-accent" className="w-full justify-center">
|
||||
<span className="i-ri-edit-box-line mr-1 h-4 w-4" />
|
||||
<div className="p-1 opacity-20">
|
||||
<div className="h-2 w-[94px] rounded-sm bg-text-accent-light-mode-only"></div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grow px-3 pt-5">
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-14 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-[168px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-[128px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between p-3">
|
||||
<div className="p-1.5">
|
||||
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PoweredByBrand
|
||||
webappBrandRemoved={webappBrandRemoved}
|
||||
workspaceLogo={workspaceLogo}
|
||||
webappLogo={webappLogo}
|
||||
imgKey={imgKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-[138px] grow flex-col justify-between p-2 pr-0">
|
||||
<div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pb-4 pl-[22px] pt-16">
|
||||
<div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3">
|
||||
<div className="mb-1 text-text-primary body-md-regular">Hello! How can I assist you today?</div>
|
||||
<Button size="small">
|
||||
<div className="h-2 w-[144px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm body-lg-regular">Talk to Dify</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatPreviewCard
|
||||
@ -0,0 +1,31 @@
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
|
||||
type PoweredByBrandProps = {
|
||||
webappBrandRemoved?: boolean
|
||||
workspaceLogo?: string
|
||||
webappLogo?: string
|
||||
imgKey: number
|
||||
}
|
||||
|
||||
const PoweredByBrand = ({
|
||||
webappBrandRemoved,
|
||||
workspaceLogo,
|
||||
webappLogo,
|
||||
imgKey,
|
||||
}: PoweredByBrandProps) => {
|
||||
if (webappBrandRemoved)
|
||||
return null
|
||||
|
||||
const previewLogo = workspaceLogo || (webappLogo ? `${webappLogo}?hash=${imgKey}` : '')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
|
||||
{previewLogo
|
||||
? <img src={previewLogo} alt="logo" className="block h-5 w-auto" />
|
||||
: <DifyLogo size="small" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PoweredByBrand
|
||||
@ -0,0 +1,64 @@
|
||||
import Button from '@/app/components/base/button'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import PoweredByBrand from './powered-by-brand'
|
||||
|
||||
type WorkflowPreviewCardProps = {
|
||||
webappBrandRemoved?: boolean
|
||||
workspaceLogo?: string
|
||||
webappLogo?: string
|
||||
imgKey: number
|
||||
}
|
||||
|
||||
const WorkflowPreviewCard = ({
|
||||
webappBrandRemoved,
|
||||
workspaceLogo,
|
||||
webappLogo,
|
||||
imgKey,
|
||||
}: WorkflowPreviewCardProps) => {
|
||||
return (
|
||||
<div className="flex h-[320px] grow basis-1/2 flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
|
||||
<div className="w-full border-b-[0.5px] border-divider-subtle p-4 pb-0">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}>
|
||||
<span className="i-ri-exchange-2-fill h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Workflow App</div>
|
||||
<div className="p-1.5">
|
||||
<span className="i-ri-layout-left-2-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary system-md-semibold-uppercase">RUN ONCE</div>
|
||||
<div className="flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary system-md-semibold-uppercase">RUN BATCH</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow bg-components-panel-bg">
|
||||
<div className="p-4 pb-1">
|
||||
<div className="mb-1 py-2">
|
||||
<div className="h-2 w-20 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<Button size="small">
|
||||
<div className="h-2 w-10 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</Button>
|
||||
<Button variant="primary" size="small" disabled>
|
||||
<span className="i-ri-play-large-line mr-1 h-4 w-4" />
|
||||
<span>Execute</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-12 shrink-0 items-center gap-1.5 bg-components-panel-bg p-4 pt-3">
|
||||
<PoweredByBrand
|
||||
webappBrandRemoved={webappBrandRemoved}
|
||||
workspaceLogo={workspaceLogo}
|
||||
webappLogo={webappLogo}
|
||||
imgKey={imgKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowPreviewCard
|
||||
@ -0,0 +1,385 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import {
|
||||
initialLangGeniusVersionInfo,
|
||||
initialWorkspaceInfo,
|
||||
useAppContext,
|
||||
userProfilePlaceholder,
|
||||
} from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { updateCurrentWorkspace } from '@/service/common'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import useWebAppBrand from '../use-web-app-brand'
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/common', () => ({
|
||||
updateCurrentWorkspace: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/app-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/app-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useAppContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/app/components/base/image-uploader/utils', () => ({
|
||||
imageUpload: vi.fn(),
|
||||
getImageUploadErrorMessage: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockUseToastContext = vi.mocked(useToastContext)
|
||||
const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace)
|
||||
const mockUseAppContext = vi.mocked(useAppContext)
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
|
||||
const mockImageUpload = vi.mocked(imageUpload)
|
||||
const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage)
|
||||
|
||||
const createProviderContext = ({
|
||||
enableBilling = false,
|
||||
planType = Plan.professional,
|
||||
}: {
|
||||
enableBilling?: boolean
|
||||
planType?: Plan
|
||||
} = {}) => {
|
||||
return createMockProviderContextValue({
|
||||
enableBilling,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const createSystemFeatures = (brandingOverrides: Partial<SystemFeatures['branding']> = {}): SystemFeatures => ({
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: true,
|
||||
workspace_logo: 'https://example.com/workspace-logo.png',
|
||||
...brandingOverrides,
|
||||
},
|
||||
})
|
||||
|
||||
const createAppContextValue = (overrides: Partial<AppContextValue> = {}): AppContextValue => {
|
||||
const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides
|
||||
const workspaceOverrides: Partial<AppContextValue['currentWorkspace']> = currentWorkspaceOverride ?? {}
|
||||
const currentWorkspace = {
|
||||
...initialWorkspaceInfo,
|
||||
...workspaceOverrides,
|
||||
custom_config: {
|
||||
replace_webapp_logo: 'https://example.com/replace.png',
|
||||
remove_webapp_brand: false,
|
||||
...workspaceOverrides.custom_config,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
userProfile: userProfilePlaceholder,
|
||||
mutateUserProfile: vi.fn(),
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isCurrentWorkspaceEditor: false,
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
langGeniusVersionInfo: initialLangGeniusVersionInfo,
|
||||
useSelector: vi.fn() as unknown as AppContextValue['useSelector'],
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isValidatingCurrentWorkspace: false,
|
||||
...restOverrides,
|
||||
currentWorkspace,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useWebAppBrand', () => {
|
||||
let appContextValue: AppContextValue
|
||||
let systemFeatures: SystemFeatures
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
appContextValue = createAppContextValue()
|
||||
systemFeatures = createSystemFeatures()
|
||||
|
||||
mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType<typeof useToastContext>)
|
||||
mockUpdateCurrentWorkspace.mockResolvedValue(appContextValue.currentWorkspace)
|
||||
mockUseAppContext.mockImplementation(() => appContextValue)
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext())
|
||||
mockUseGlobalPublicStore.mockImplementation(selector => selector({
|
||||
systemFeatures,
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
mockGetImageUploadErrorMessage.mockReturnValue('upload error')
|
||||
})
|
||||
|
||||
// Derived state from context and store inputs.
|
||||
describe('derived state', () => {
|
||||
it('should expose workspace branding and upload availability by default', () => {
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
expect(result.current.webappLogo).toBe('https://example.com/replace.png')
|
||||
expect(result.current.workspaceLogo).toBe('https://example.com/workspace-logo.png')
|
||||
expect(result.current.uploadDisabled).toBe(false)
|
||||
expect(result.current.uploading).toBe(false)
|
||||
})
|
||||
|
||||
it('should disable uploads in sandbox workspaces and when branding is removed', () => {
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({
|
||||
enableBilling: true,
|
||||
planType: Plan.sandbox,
|
||||
}))
|
||||
appContextValue = createAppContextValue({
|
||||
currentWorkspace: {
|
||||
...initialWorkspaceInfo,
|
||||
custom_config: {
|
||||
replace_webapp_logo: 'https://example.com/replace.png',
|
||||
remove_webapp_brand: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
expect(result.current.isSandbox).toBe(true)
|
||||
expect(result.current.webappBrandRemoved).toBe(true)
|
||||
expect(result.current.uploadDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should fall back to an empty workspace logo when branding is disabled', () => {
|
||||
systemFeatures = createSystemFeatures({
|
||||
enabled: false,
|
||||
workspace_logo: '',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
expect(result.current.workspaceLogo).toBe('')
|
||||
})
|
||||
|
||||
it('should fall back to an empty custom logo when custom config is missing', () => {
|
||||
appContextValue = {
|
||||
...createAppContextValue(),
|
||||
currentWorkspace: {
|
||||
...initialWorkspaceInfo,
|
||||
},
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
expect(result.current.webappLogo).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// State transitions driven by user actions.
|
||||
describe('actions', () => {
|
||||
it('should ignore empty file selections', () => {
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(mockImageUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject oversized files before upload starts', () => {
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
const oversizedFile = new File(['logo'], 'logo.png', { type: 'image/png' })
|
||||
|
||||
Object.defineProperty(oversizedFile, 'size', {
|
||||
configurable: true,
|
||||
value: 5 * 1024 * 1024 + 1,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [oversizedFile] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(mockImageUpload).not.toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.imageUploader.uploadFromComputerLimit:{"size":5}',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update upload state after a successful file upload', () => {
|
||||
mockImageUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }) => {
|
||||
onProgressCallback(100)
|
||||
onSuccessCallback({ id: 'new-logo' })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(result.current.fileId).toBe('new-logo')
|
||||
expect(result.current.uploadProgress).toBe(100)
|
||||
expect(result.current.uploading).toBe(false)
|
||||
})
|
||||
|
||||
it('should expose the uploading state while progress is incomplete', () => {
|
||||
mockImageUpload.mockImplementation(({ onProgressCallback }) => {
|
||||
onProgressCallback(50)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(result.current.uploadProgress).toBe(50)
|
||||
expect(result.current.uploading).toBe(true)
|
||||
})
|
||||
|
||||
it('should surface upload errors and set the failure state', () => {
|
||||
mockImageUpload.mockImplementation(({ onErrorCallback }) => {
|
||||
onErrorCallback({ response: { code: 'forbidden' } })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
expect(mockGetImageUploadErrorMessage).toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'upload error',
|
||||
})
|
||||
expect(result.current.uploadProgress).toBe(-1)
|
||||
})
|
||||
|
||||
it('should persist the selected logo and reset transient state on apply', async () => {
|
||||
const mutateCurrentWorkspace = vi.fn()
|
||||
appContextValue = createAppContextValue({
|
||||
mutateCurrentWorkspace,
|
||||
})
|
||||
mockImageUpload.mockImplementation(({ onSuccessCallback }) => {
|
||||
onSuccessCallback({ id: 'new-logo' })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
const previousImgKey = result.current.imgKey
|
||||
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(previousImgKey + 1)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleApply()
|
||||
})
|
||||
|
||||
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: 'new-logo',
|
||||
},
|
||||
})
|
||||
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.fileId).toBe('')
|
||||
expect(result.current.imgKey).toBe(previousImgKey + 1)
|
||||
dateNowSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should restore the default branding configuration', async () => {
|
||||
const mutateCurrentWorkspace = vi.fn()
|
||||
appContextValue = createAppContextValue({
|
||||
mutateCurrentWorkspace,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRestore()
|
||||
})
|
||||
|
||||
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
})
|
||||
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should persist brand removal changes', async () => {
|
||||
const mutateCurrentWorkspace = vi.fn()
|
||||
appContextValue = createAppContextValue({
|
||||
mutateCurrentWorkspace,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSwitch(true)
|
||||
})
|
||||
|
||||
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: true,
|
||||
},
|
||||
})
|
||||
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should clear temporary upload state on cancel', () => {
|
||||
mockImageUpload.mockImplementation(({ onSuccessCallback }) => {
|
||||
onSuccessCallback({ id: 'new-logo' })
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChange({
|
||||
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
|
||||
} as unknown as ChangeEvent<HTMLInputElement>)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancel()
|
||||
})
|
||||
|
||||
expect(result.current.fileId).toBe('')
|
||||
expect(result.current.uploadProgress).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,121 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { updateCurrentWorkspace } from '@/service/common'
|
||||
|
||||
const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024
|
||||
const CUSTOM_CONFIG_URL = '/workspaces/custom-config'
|
||||
const WEB_APP_LOGO_UPLOAD_URL = '/workspaces/custom-config/webapp-logo/upload'
|
||||
|
||||
const useWebAppBrand = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const {
|
||||
currentWorkspace,
|
||||
mutateCurrentWorkspace,
|
||||
isCurrentWorkspaceManager,
|
||||
} = useAppContext()
|
||||
const [fileId, setFileId] = useState('')
|
||||
const [imgKey, setImgKey] = useState(() => Date.now())
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const isSandbox = enableBilling && plan.type === Plan.sandbox
|
||||
const uploading = uploadProgress > 0 && uploadProgress < 100
|
||||
const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''
|
||||
const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand
|
||||
const uploadDisabled = isSandbox || webappBrandRemoved || !isCurrentWorkspaceManager
|
||||
const workspaceLogo = systemFeatures.branding.enabled ? systemFeatures.branding.workspace_logo : ''
|
||||
|
||||
const persistWorkspaceBrand = async (body: Record<string, unknown>) => {
|
||||
await updateCurrentWorkspace({
|
||||
url: CUSTOM_CONFIG_URL,
|
||||
body,
|
||||
})
|
||||
mutateCurrentWorkspace()
|
||||
}
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
if (!file)
|
||||
return
|
||||
|
||||
if (file.size > MAX_LOGO_FILE_SIZE) {
|
||||
notify({ type: 'error', message: t('imageUploader.uploadFromComputerLimit', { ns: 'common', size: 5 }) })
|
||||
return
|
||||
}
|
||||
|
||||
imageUpload({
|
||||
file,
|
||||
onProgressCallback: setUploadProgress,
|
||||
onSuccessCallback: (res) => {
|
||||
setUploadProgress(100)
|
||||
setFileId(res.id)
|
||||
},
|
||||
onErrorCallback: (error) => {
|
||||
const errorMessage = getImageUploadErrorMessage(
|
||||
error,
|
||||
t('imageUploader.uploadFromComputerUploadError', { ns: 'common' }),
|
||||
t,
|
||||
)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
setUploadProgress(-1)
|
||||
},
|
||||
}, false, WEB_APP_LOGO_UPLOAD_URL)
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
await persistWorkspaceBrand({
|
||||
remove_webapp_brand: webappBrandRemoved,
|
||||
replace_webapp_logo: fileId,
|
||||
})
|
||||
setFileId('')
|
||||
setImgKey(Date.now())
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
await persistWorkspaceBrand({
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSwitch = async (checked: boolean) => {
|
||||
await persistWorkspaceBrand({
|
||||
remove_webapp_brand: checked,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setFileId('')
|
||||
setUploadProgress(0)
|
||||
}
|
||||
|
||||
return {
|
||||
fileId,
|
||||
imgKey,
|
||||
uploadProgress,
|
||||
uploading,
|
||||
webappLogo,
|
||||
webappBrandRemoved,
|
||||
uploadDisabled,
|
||||
workspaceLogo,
|
||||
isSandbox,
|
||||
isCurrentWorkspaceManager,
|
||||
handleApply,
|
||||
handleCancel,
|
||||
handleChange,
|
||||
handleRestore,
|
||||
handleSwitch,
|
||||
}
|
||||
}
|
||||
|
||||
export default useWebAppBrand
|
||||
@ -1,118 +1,33 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import {
|
||||
RiEditBoxLine,
|
||||
RiEqualizer2Line,
|
||||
RiExchange2Fill,
|
||||
RiImageAddLine,
|
||||
RiLayoutLeft2Line,
|
||||
RiLoader2Line,
|
||||
RiPlayLargeLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { BubbleTextMod } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
updateCurrentWorkspace,
|
||||
} from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ChatPreviewCard from './components/chat-preview-card'
|
||||
import WorkflowPreviewCard from './components/workflow-preview-card'
|
||||
import useWebAppBrand from './hooks/use-web-app-brand'
|
||||
|
||||
const ALLOW_FILE_EXTENSIONS = ['svg', 'png']
|
||||
|
||||
const CustomWebAppBrand = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const {
|
||||
currentWorkspace,
|
||||
mutateCurrentWorkspace,
|
||||
fileId,
|
||||
imgKey,
|
||||
uploadProgress,
|
||||
uploading,
|
||||
webappLogo,
|
||||
webappBrandRemoved,
|
||||
uploadDisabled,
|
||||
workspaceLogo,
|
||||
isCurrentWorkspaceManager,
|
||||
} = useAppContext()
|
||||
const [fileId, setFileId] = useState('')
|
||||
const [imgKey, setImgKey] = useState(() => Date.now())
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const isSandbox = enableBilling && plan.type === Plan.sandbox
|
||||
const uploading = uploadProgress > 0 && uploadProgress < 100
|
||||
const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''
|
||||
const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand
|
||||
const uploadDisabled = isSandbox || webappBrandRemoved || !isCurrentWorkspaceManager
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
if (!file)
|
||||
return
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
notify({ type: 'error', message: t('imageUploader.uploadFromComputerLimit', { ns: 'common', size: 5 }) })
|
||||
return
|
||||
}
|
||||
|
||||
imageUpload({
|
||||
file,
|
||||
onProgressCallback: (progress) => {
|
||||
setUploadProgress(progress)
|
||||
},
|
||||
onSuccessCallback: (res) => {
|
||||
setUploadProgress(100)
|
||||
setFileId(res.id)
|
||||
},
|
||||
onErrorCallback: (error?: any) => {
|
||||
const errorMessage = getImageUploadErrorMessage(error, t('imageUploader.uploadFromComputerUploadError', { ns: 'common' }), t as any)
|
||||
notify({ type: 'error', message: errorMessage })
|
||||
setUploadProgress(-1)
|
||||
},
|
||||
}, false, '/workspaces/custom-config/webapp-logo/upload')
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
await updateCurrentWorkspace({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: webappBrandRemoved,
|
||||
replace_webapp_logo: fileId,
|
||||
},
|
||||
})
|
||||
mutateCurrentWorkspace()
|
||||
setFileId('')
|
||||
setImgKey(Date.now())
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
await updateCurrentWorkspace({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
})
|
||||
mutateCurrentWorkspace()
|
||||
}
|
||||
|
||||
const handleSwitch = async (checked: boolean) => {
|
||||
await updateCurrentWorkspace({
|
||||
url: '/workspaces/custom-config',
|
||||
body: {
|
||||
remove_webapp_brand: checked,
|
||||
},
|
||||
})
|
||||
mutateCurrentWorkspace()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setFileId('')
|
||||
setUploadProgress(0)
|
||||
}
|
||||
isSandbox,
|
||||
handleApply,
|
||||
handleCancel,
|
||||
handleChange,
|
||||
handleRestore,
|
||||
handleSwitch,
|
||||
} = useWebAppBrand()
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
@ -149,7 +64,7 @@ const CustomWebAppBrand = () => {
|
||||
className="relative mr-2"
|
||||
disabled={uploadDisabled}
|
||||
>
|
||||
<RiImageAddLine className="mr-1 h-4 w-4" />
|
||||
<span className="i-ri-image-add-line mr-1 h-4 w-4" />
|
||||
{
|
||||
(webappLogo || fileId)
|
||||
? t('change', { ns: 'custom' })
|
||||
@ -172,7 +87,7 @@ const CustomWebAppBrand = () => {
|
||||
className="relative mr-2"
|
||||
disabled={true}
|
||||
>
|
||||
<RiLoader2Line className="mr-1 h-4 w-4 animate-spin" />
|
||||
<span className="i-ri-loader-2-line mr-1 h-4 w-4 animate-spin" />
|
||||
{t('uploading', { ns: 'custom' })}
|
||||
</Button>
|
||||
)
|
||||
@ -208,118 +123,18 @@ const CustomWebAppBrand = () => {
|
||||
<Divider bgStyle="gradient" className="grow" />
|
||||
</div>
|
||||
<div className="relative mb-2 flex items-center gap-3">
|
||||
{/* chat card */}
|
||||
<div className="flex h-[320px] grow basis-1/2 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
|
||||
<div className="flex h-full w-[232px] shrink-0 flex-col p-1 pr-0">
|
||||
<div className="flex items-center gap-3 p-3 pr-2">
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}>
|
||||
<BubbleTextMod className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Chatflow App</div>
|
||||
<div className="p-1.5">
|
||||
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-3">
|
||||
<Button variant="secondary-accent" className="w-full justify-center">
|
||||
<RiEditBoxLine className="mr-1 h-4 w-4" />
|
||||
<div className="p-1 opacity-20">
|
||||
<div className="h-2 w-[94px] rounded-sm bg-text-accent-light-mode-only"></div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grow px-3 pt-5">
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-14 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-[168px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="flex h-8 items-center px-3 py-1">
|
||||
<div className="h-2 w-[128px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between p-3">
|
||||
<div className="p-1.5">
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!webappBrandRemoved && (
|
||||
<>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
|
||||
: webappLogo
|
||||
? <img src={`${webappLogo}?hash=${imgKey}`} alt="logo" className="block h-5 w-auto" />
|
||||
: <DifyLogo size="small" />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-[138px] grow flex-col justify-between p-2 pr-0">
|
||||
<div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pb-4 pl-[22px] pt-16">
|
||||
<div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3">
|
||||
<div className="mb-1 text-text-primary body-md-regular">Hello! How can I assist you today?</div>
|
||||
<Button size="small">
|
||||
<div className="h-2 w-[144px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm body-lg-regular">Talk to Dify</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* workflow card */}
|
||||
<div className="flex h-[320px] grow basis-1/2 flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
|
||||
<div className="w-full border-b-[0.5px] border-divider-subtle p-4 pb-0">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}>
|
||||
<RiExchange2Fill className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Workflow App</div>
|
||||
<div className="p-1.5">
|
||||
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary system-md-semibold-uppercase">RUN ONCE</div>
|
||||
<div className="flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary system-md-semibold-uppercase">RUN BATCH</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow bg-components-panel-bg">
|
||||
<div className="p-4 pb-1">
|
||||
<div className="mb-1 py-2">
|
||||
<div className="h-2 w-20 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<Button size="small">
|
||||
<div className="h-2 w-10 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</Button>
|
||||
<Button variant="primary" size="small" disabled>
|
||||
<RiPlayLargeLine className="mr-1 h-4 w-4" />
|
||||
<span>Execute</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-12 shrink-0 items-center gap-1.5 bg-components-panel-bg p-4 pt-3">
|
||||
{!webappBrandRemoved && (
|
||||
<>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
|
||||
: webappLogo
|
||||
? <img src={`${webappLogo}?hash=${imgKey}`} alt="logo" className="block h-5 w-auto" />
|
||||
: <DifyLogo size="small" />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChatPreviewCard
|
||||
webappBrandRemoved={webappBrandRemoved}
|
||||
workspaceLogo={workspaceLogo}
|
||||
webappLogo={webappLogo}
|
||||
imgKey={imgKey}
|
||||
/>
|
||||
<WorkflowPreviewCard
|
||||
webappBrandRemoved={webappBrandRemoved}
|
||||
workspaceLogo={workspaceLogo}
|
||||
webappLogo={webappLogo}
|
||||
imgKey={imgKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user