mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
refactor(web): remove legacy enableBilling consumers (#41913)
This commit is contained in:
parent
c7423ffb2d
commit
fc0136647f
@ -16,7 +16,7 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: true,
|
||||
plan: defaultPlan,
|
||||
enableBilling: false,
|
||||
|
||||
enableSkill: false,
|
||||
enableReplaceWebAppLogo: false,
|
||||
modelLoadBalancingEnabled: false,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { RenderOptions } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
@ -29,10 +30,7 @@ let mockEducationStatus = { is_student: false, allow_refresh: false, expire_at:
|
||||
|
||||
const render = (ui: ReactElement, options: RenderOptions = {}, vectorSpaceUsageUnknown = false) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
const plan = mockProviderCtx.plan as {
|
||||
usage: { vectorSpace: number }
|
||||
total: { vectorSpace: number }
|
||||
}
|
||||
const plan = mockProviderCtx.plan as ReturnType<typeof createPlanData>
|
||||
queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryOptions().queryKey, {
|
||||
size: plan.usage.vectorSpace,
|
||||
limit: plan.total.vectorSpace,
|
||||
@ -46,6 +44,10 @@ const render = (ui: ReactElement, options: RenderOptions = {}, vectorSpaceUsageU
|
||||
accountProfile: mockConsoleState.userProfile as { email?: string },
|
||||
accountProfileMeta: { currentVersion: '1.0.0' },
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: {
|
||||
billing: { subscription: { plan: plan.type } },
|
||||
apps: { size: plan.usage.buildApps, limit: plan.total.buildApps },
|
||||
},
|
||||
queryClient,
|
||||
})
|
||||
return renderWithConsoleState(ui, { ...options, wrapper })
|
||||
@ -86,7 +88,7 @@ vi.mock('@/app/components/header/utils/util', () => ({
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: string
|
||||
type?: CloudPlan
|
||||
usage?: Partial<UsagePlanInfo>
|
||||
total?: Partial<UsagePlanInfo>
|
||||
reset?: Partial<UsageResetInfo>
|
||||
@ -114,7 +116,6 @@ const setupProviderContext = (
|
||||
}
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
enableEducationPlan: false,
|
||||
...extra,
|
||||
}
|
||||
@ -277,14 +278,6 @@ describe('Billing Page + Plan Integration', () => {
|
||||
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when billing is disabled', () => {
|
||||
setupProviderContext({ type: 'sandbox' }, { enableBilling: false })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -93,7 +93,6 @@ const setupContexts = (
|
||||
}
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
enableEducationPlan: false,
|
||||
...providerOverrides,
|
||||
}
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Mock } from 'vite-plus/test'
|
||||
import type { AnnotationItem } from '../type'
|
||||
import type { App } from '@/types/app'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
addAnnotation,
|
||||
delAnnotation,
|
||||
@ -17,10 +16,14 @@ import {
|
||||
updateAnnotationScore,
|
||||
updateAnnotationStatus,
|
||||
} from '@/service/annotation'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Annotation from '../index'
|
||||
import { AnnotationEnableStatus, JobStatus } from '../type'
|
||||
|
||||
let annotationQuota = { size: 0, limit: 10 }
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
}))
|
||||
@ -41,10 +44,6 @@ vi.mock('@/service/annotation', () => ({
|
||||
updateAnnotationStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../filter', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="filter">{children}</div>
|
||||
@ -180,7 +179,6 @@ const fetchAnnotationListMock = fetchAnnotationList as Mock
|
||||
const queryAnnotationJobStatusMock = queryAnnotationJobStatus as Mock
|
||||
const updateAnnotationScoreMock = updateAnnotationScore as Mock
|
||||
const updateAnnotationStatusMock = updateAnnotationStatus as Mock
|
||||
const useProviderContextMock = useProviderContext as Mock
|
||||
|
||||
const appDetail = {
|
||||
id: 'app-id',
|
||||
@ -197,6 +195,13 @@ const createAnnotation = (overrides: Partial<AnnotationItem> = {}): AnnotationIt
|
||||
|
||||
const renderComponent = () => render(<Annotation appDetail={appDetail} />)
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: { annotation_quota_limit: annotationQuota },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Annotation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -215,13 +220,7 @@ describe('Annotation', () => {
|
||||
updateAnnotationStatusMock.mockResolvedValue({ job_id: 'job-1' })
|
||||
updateAnnotationScoreMock.mockResolvedValue(undefined)
|
||||
editAnnotationMock.mockResolvedValue(undefined)
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: false,
|
||||
})
|
||||
annotationQuota = { size: 0, limit: 10 }
|
||||
})
|
||||
|
||||
it('should render empty element when no annotations are returned', async () => {
|
||||
@ -338,13 +337,7 @@ describe('Annotation', () => {
|
||||
})
|
||||
|
||||
it('should show the annotation-full modal when enabling annotations exceeds the plan quota', async () => {
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: true,
|
||||
})
|
||||
annotationQuota = { size: 10, limit: 10 }
|
||||
|
||||
renderComponent()
|
||||
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
import type { Mock } from 'vite-plus/test'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import AddAnnotationModal from '../index'
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
default: {
|
||||
@ -25,15 +21,14 @@ vi.mock('@/app/components/billing/annotation-full', () => ({
|
||||
default: () => <div data-testid="annotation-full" />,
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = useProviderContext as Mock
|
||||
let annotationQuota = { size: 0, limit: 10 }
|
||||
|
||||
const getProviderContext = ({ usage = 0, total = 10, enableBilling = false } = {}) => ({
|
||||
plan: {
|
||||
usage: { annotatedResponse: usage },
|
||||
total: { annotatedResponse: total },
|
||||
},
|
||||
enableBilling,
|
||||
})
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: { annotation_quota_limit: annotationQuota },
|
||||
})
|
||||
}
|
||||
|
||||
describe('AddAnnotationModal', () => {
|
||||
const baseProps = {
|
||||
@ -44,7 +39,7 @@ describe('AddAnnotationModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseProviderContext.mockReturnValue(getProviderContext())
|
||||
annotationQuota = { size: 0, limit: 10 }
|
||||
})
|
||||
|
||||
const typeQuestion = (value: string) => {
|
||||
@ -82,9 +77,7 @@ describe('AddAnnotationModal', () => {
|
||||
})
|
||||
|
||||
it('should show annotation full notice and disable submit when quota exceeded', () => {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
getProviderContext({ usage: 10, total: 10, enableBilling: true }),
|
||||
)
|
||||
annotationQuota = { size: 10, limit: 10 }
|
||||
render(<AddAnnotationModal {...baseProps} />)
|
||||
|
||||
expect(screen.getByTestId('annotation-full')).toBeInTheDocument()
|
||||
|
||||
@ -14,11 +14,14 @@ import {
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AnnotationFull from '@/app/components/billing/annotation-full'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import EditItem, { EditItemType } from './edit-item'
|
||||
|
||||
type Props = Readonly<{
|
||||
@ -29,9 +32,21 @@ type Props = Readonly<{
|
||||
|
||||
const AddAnnotationModal: FC<Props> = ({ isShow, onHide, onAdd }) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: annotationQuota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.annotation_quota_limit,
|
||||
}),
|
||||
)
|
||||
const isAnnotationQuotaUnavailable =
|
||||
deploymentEdition === 'CLOUD' && annotationQuota === undefined
|
||||
// A limit of 0 means unlimited.
|
||||
const isAnnotationFull =
|
||||
enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
annotationQuota !== undefined &&
|
||||
annotationQuota.limit > 0 &&
|
||||
annotationQuota.size >= annotationQuota.limit
|
||||
const [question, setQuestion] = useState('')
|
||||
const [answer, setAnswer] = useState('')
|
||||
const [isCreateNext, setIsCreateNext] = useState(false)
|
||||
@ -46,6 +61,7 @@ const AddAnnotationModal: FC<Props> = ({ isShow, onHide, onAdd }) => {
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isAnnotationQuotaUnavailable || isAnnotationFull) return
|
||||
const payload = {
|
||||
question,
|
||||
answer,
|
||||
@ -123,7 +139,7 @@ const AddAnnotationModal: FC<Props> = ({ isShow, onHide, onAdd }) => {
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
disabled={isAnnotationFull}
|
||||
disabled={isAnnotationQuotaUnavailable || isAnnotationFull}
|
||||
>
|
||||
{t(($) => $['operation.add'], { ns: 'common' })}
|
||||
</Button>
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Mock } from 'vite-plus/test'
|
||||
import type { IBatchModalProps } from '../index'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import BatchModal, { ProcessStatus } from '../index'
|
||||
|
||||
let annotationQuota = { size: 0, limit: 10 }
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
annotationBatchImport: vi.fn(),
|
||||
checkAnnotationBatchImportProgress: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../csv-downloader', () => ({
|
||||
default: () => <div data-testid="csv-downloader-stub" />,
|
||||
}))
|
||||
@ -54,7 +53,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
info: (message: string) => mockNotify({ type: 'info', message }),
|
||||
},
|
||||
}))
|
||||
const useProviderContextMock = useProviderContext as Mock
|
||||
const annotationBatchImportMock = annotationBatchImport as Mock
|
||||
const checkAnnotationBatchImportProgressMock = checkAnnotationBatchImportProgress as Mock
|
||||
|
||||
@ -72,27 +70,22 @@ const renderComponent = (props: Partial<IBatchModalProps> = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: { annotation_quota_limit: annotationQuota },
|
||||
})
|
||||
}
|
||||
|
||||
describe('BatchModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
lastUploadedFile = undefined
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: false,
|
||||
})
|
||||
annotationQuota = { size: 0, limit: 10 }
|
||||
})
|
||||
|
||||
it('should disable run action and show billing hint when annotation quota is full', () => {
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: true,
|
||||
})
|
||||
annotationQuota = { size: 10, limit: 10 }
|
||||
|
||||
renderComponent()
|
||||
|
||||
|
||||
@ -4,12 +4,15 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AnnotationFull from '@/app/components/billing/annotation-full'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import CSVDownloader from './csv-downloader'
|
||||
import CSVUploader from './csv-uploader'
|
||||
|
||||
@ -29,9 +32,21 @@ export type IBatchModalProps = {
|
||||
|
||||
const BatchModal: FC<IBatchModalProps> = ({ appId, isShow, onCancel, onAdded }) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: annotationQuota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.annotation_quota_limit,
|
||||
}),
|
||||
)
|
||||
const isAnnotationQuotaUnavailable =
|
||||
deploymentEdition === 'CLOUD' && annotationQuota === undefined
|
||||
// A limit of 0 means unlimited.
|
||||
const isAnnotationFull =
|
||||
enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
annotationQuota !== undefined &&
|
||||
annotationQuota.limit > 0 &&
|
||||
annotationQuota.size >= annotationQuota.limit
|
||||
const [currentCSV, setCurrentCSV] = useState<File>()
|
||||
const handleFile = (file?: File) => setCurrentCSV(file)
|
||||
|
||||
@ -112,7 +127,7 @@ const BatchModal: FC<IBatchModalProps> = ({ appId, isShow, onCancel, onAdded })
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSend}
|
||||
disabled={isAnnotationFull || !currentCSV}
|
||||
disabled={isAnnotationQuotaUnavailable || isAnnotationFull || !currentCSV}
|
||||
loading={
|
||||
importStatus === ProcessStatus.PROCESSING || importStatus === ProcessStatus.WAITING
|
||||
}
|
||||
|
||||
@ -3,27 +3,15 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import EditAnnotationModal from '../index'
|
||||
|
||||
const { mockAddAnnotation, mockEditAnnotation } = vi.hoisted(() => ({
|
||||
mockAddAnnotation: vi.fn(),
|
||||
const { mockEditAnnotation } = vi.hoisted(() => ({
|
||||
mockEditAnnotation: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock only external dependencies
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
addAnnotation: mockAddAnnotation,
|
||||
editAnnotation: mockEditAnnotation,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 5 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: () => '2023-12-01 10:30:00',
|
||||
@ -44,16 +32,17 @@ describe('EditAnnotationModal', () => {
|
||||
isShow: true,
|
||||
onHide: vi.fn(),
|
||||
appId: 'test-app-id',
|
||||
annotationId: 'test-annotation-id',
|
||||
query: 'Test query',
|
||||
answer: 'Test answer',
|
||||
onEdited: vi.fn(),
|
||||
onAdded: vi.fn(),
|
||||
|
||||
onRemove: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAddAnnotation.mockResolvedValue({
|
||||
mockEditAnnotation.mockResolvedValue({
|
||||
id: 'test-id',
|
||||
account: { name: 'Test User' },
|
||||
})
|
||||
@ -198,86 +187,10 @@ describe('EditAnnotationModal', () => {
|
||||
// Assert
|
||||
expect(screen.getByText('appAnnotation.editModal.removeThisCache'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save content when edited', async () => {
|
||||
// Arrange
|
||||
const mockOnAdded = vi.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAdded: mockOnAdded,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mock API response
|
||||
mockAddAnnotation.mockResolvedValueOnce({
|
||||
id: 'test-annotation-id',
|
||||
account: { name: 'Test User' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Find and click edit link for query
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0]!)
|
||||
|
||||
// Find textarea and enter new content
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'New query content')
|
||||
|
||||
// Click save button
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', {
|
||||
question: 'New query content',
|
||||
answer: 'Test answer',
|
||||
message_id: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// API Calls
|
||||
describe('API Calls', () => {
|
||||
it('should call addAnnotation when saving new annotation', async () => {
|
||||
// Arrange
|
||||
const mockOnAdded = vi.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAdded: mockOnAdded,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mock the API response
|
||||
mockAddAnnotation.mockResolvedValueOnce({
|
||||
id: 'test-annotation-id',
|
||||
account: { name: 'Test User' },
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Edit query content
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0]!)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Updated query')
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app-id', {
|
||||
question: 'Updated query',
|
||||
answer: 'Test answer',
|
||||
message_id: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call editAnnotation when updating existing annotation', async () => {
|
||||
// Arrange
|
||||
const mockOnEdited = vi.fn()
|
||||
@ -494,82 +407,6 @@ describe('EditAnnotationModal', () => {
|
||||
|
||||
// Error Handling (CRITICAL for coverage)
|
||||
describe('Error Handling', () => {
|
||||
it('should show error toast and skip callbacks when addAnnotation fails', async () => {
|
||||
// Arrange
|
||||
const mockOnAdded = vi.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAdded: mockOnAdded,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mock API failure
|
||||
mockAddAnnotation.mockRejectedValueOnce(new Error('API Error'))
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Find and click edit link for query
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0]!)
|
||||
|
||||
// Find textarea and enter new content
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'New query content')
|
||||
|
||||
// Click save button
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith('API Error')
|
||||
})
|
||||
expect(mockOnAdded).not.toHaveBeenCalled()
|
||||
|
||||
// Verify edit mode remains open (textarea should still be visible)
|
||||
// Verify edit mode remains open (textarea should still be visible)
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show fallback error message when addAnnotation error has no message', async () => {
|
||||
// Arrange
|
||||
const mockOnAdded = vi.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAdded: mockOnAdded,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
mockAddAnnotation.mockRejectedValueOnce({})
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0]!)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'New query content')
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
})
|
||||
expect(mockOnAdded).not.toHaveBeenCalled()
|
||||
|
||||
// Verify edit mode remains open (textarea should still be visible)
|
||||
// Verify edit mode remains open (textarea should still be visible)
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error toast and skip callbacks when editAnnotation fails', async () => {
|
||||
// Arrange
|
||||
const mockOnEdited = vi.fn()
|
||||
@ -728,19 +565,6 @@ describe('EditAnnotationModal', () => {
|
||||
|
||||
// React.memo Performance Testing
|
||||
describe('React.memo Performance', () => {
|
||||
it('should not re-render when props are the same', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const { rerender } = render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Act - Re-render with same props
|
||||
rerender(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Component should still be visible (no errors thrown)
|
||||
// Assert - Component should still be visible (no errors thrown)
|
||||
expect(screen.getByText('appAnnotation.editModal.title'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-render when props change', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
|
||||
@ -23,10 +23,8 @@ import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
|
||||
import AnnotationFull from '@/app/components/billing/annotation-full'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { addAnnotation, editAnnotation } from '@/service/annotation'
|
||||
import { editAnnotation } from '@/service/annotation'
|
||||
import EditItem, { EditItemType } from './edit-item'
|
||||
|
||||
type Props = Readonly<{
|
||||
@ -34,16 +32,10 @@ type Props = Readonly<{
|
||||
onHide: () => void
|
||||
appId: string
|
||||
messageId?: string
|
||||
annotationId?: string
|
||||
annotationId: string
|
||||
query: string
|
||||
answer: string
|
||||
onEdited: (editedQuery: string, editedAnswer: string) => void
|
||||
onAdded: (
|
||||
annotationId: string,
|
||||
authorName: string,
|
||||
editedQuery: string,
|
||||
editedAnswer: string,
|
||||
) => void
|
||||
createdAt?: number
|
||||
onRemove: () => void
|
||||
onlyEditResponse?: boolean
|
||||
@ -55,7 +47,6 @@ const EditAnnotationModal: FC<Props> = ({
|
||||
query,
|
||||
answer,
|
||||
onEdited,
|
||||
onAdded,
|
||||
appId,
|
||||
messageId,
|
||||
annotationId,
|
||||
@ -65,31 +56,18 @@ const EditAnnotationModal: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAdd = !annotationId
|
||||
const isAnnotationFull =
|
||||
enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse
|
||||
const handleSave = async (type: EditItemType, editedContent: string) => {
|
||||
let postQuery = query
|
||||
let postAnswer = answer
|
||||
if (type === EditItemType.Query) postQuery = editedContent
|
||||
else postAnswer = editedContent
|
||||
try {
|
||||
if (!isAdd) {
|
||||
await editAnnotation(appId, annotationId, {
|
||||
message_id: messageId,
|
||||
question: postQuery,
|
||||
answer: postAnswer,
|
||||
})
|
||||
onEdited(postQuery, postAnswer)
|
||||
} else {
|
||||
const res = await addAnnotation(appId, {
|
||||
question: postQuery,
|
||||
answer: postAnswer,
|
||||
message_id: messageId,
|
||||
})
|
||||
onAdded(res.id, res.account?.name ?? '', postQuery, postAnswer)
|
||||
}
|
||||
await editAnnotation(appId, annotationId, {
|
||||
message_id: messageId,
|
||||
question: postQuery,
|
||||
answer: postAnswer,
|
||||
})
|
||||
onEdited(postQuery, postAnswer)
|
||||
|
||||
toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }) as string)
|
||||
} catch (error) {
|
||||
@ -135,13 +113,12 @@ const EditAnnotationModal: FC<Props> = ({
|
||||
<EditItem
|
||||
type={EditItemType.Query}
|
||||
content={query}
|
||||
readonly={(isAdd && isAnnotationFull) || onlyEditResponse}
|
||||
readonly={onlyEditResponse}
|
||||
onSave={(editedContent) => handleSave(EditItemType.Query, editedContent)}
|
||||
/>
|
||||
<EditItem
|
||||
type={EditItemType.Answer}
|
||||
content={answer}
|
||||
readonly={isAdd && isAnnotationFull}
|
||||
onSave={(editedContent) => handleSave(EditItemType.Answer, editedContent)}
|
||||
/>
|
||||
<AlertDialog
|
||||
@ -179,35 +156,25 @@ const EditAnnotationModal: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{isAnnotationFull && (
|
||||
<div className="mt-6 mb-4 px-6">
|
||||
<AnnotationFull />
|
||||
<div className="flex h-16 items-center justify-between rounded-b-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary">
|
||||
<div
|
||||
className="flex cursor-pointer items-center space-x-2 pl-3"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<MessageCheckRemove />
|
||||
<div>{t(($) => $['editModal.removeThisCache'], { ns: 'appAnnotation' })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{annotationId ? (
|
||||
<div className="flex h-16 items-center justify-between rounded-b-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary">
|
||||
<div
|
||||
className="flex cursor-pointer items-center space-x-2 pl-3"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<MessageCheckRemove />
|
||||
<div>
|
||||
{t(($) => $['editModal.removeThisCache'], { ns: 'appAnnotation' })}
|
||||
</div>
|
||||
{!!createdAt && (
|
||||
<div>
|
||||
{t(($) => $['editModal.createdAt'], { ns: 'appAnnotation' })}
|
||||
|
||||
{formatTime(
|
||||
createdAt,
|
||||
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
|
||||
)}
|
||||
</div>
|
||||
{!!createdAt && (
|
||||
<div>
|
||||
{t(($) => $['editModal.createdAt'], { ns: 'appAnnotation' })}
|
||||
|
||||
{formatTime(
|
||||
createdAt,
|
||||
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : undefined}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { Mock } from 'vite-plus/test'
|
||||
import type { AnnotationItemBasic } from '../../type'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import HeaderOptions from '../index'
|
||||
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
|
||||
const mockJsonToCSV = vi.fn((_: unknown) => 'csv-content')
|
||||
const mockCSVDownloader = vi.fn(({ children }) => <>{children}</>)
|
||||
|
||||
@ -32,7 +34,6 @@ vi.mock('@/context/provider-context', () => ({
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@ -10,7 +10,9 @@ import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -20,7 +22,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import {
|
||||
addAnnotation,
|
||||
delAnnotation,
|
||||
@ -32,6 +34,7 @@ import {
|
||||
updateAnnotationScore,
|
||||
updateAnnotationStatus,
|
||||
} from '@/service/annotation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { sleep } from '@/utils'
|
||||
import PageTitle from '../log-annotation/page-title'
|
||||
@ -54,9 +57,21 @@ const Annotation: FC<Props> = (props) => {
|
||||
const [annotationConfig, setAnnotationConfig] = useState<AnnotationReplyConfig | null>(null)
|
||||
const [isChatApp] = useState(appDetail.mode !== AppModeEnum.COMPLETION)
|
||||
const [controlRefreshSwitch, setControlRefreshSwitch] = useState(() => Date.now())
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: annotationQuota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.annotation_quota_limit,
|
||||
}),
|
||||
)
|
||||
const isAnnotationQuotaUnavailable =
|
||||
deploymentEdition === 'CLOUD' && annotationQuota === undefined
|
||||
// A limit of 0 means unlimited.
|
||||
const isAnnotationFull =
|
||||
enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
annotationQuota !== undefined &&
|
||||
annotationQuota.limit > 0 &&
|
||||
annotationQuota.size >= annotationQuota.limit
|
||||
const [isShowAnnotationFullModal, setIsShowAnnotationFullModal] = useState(false)
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>({})
|
||||
const [currPage, setCurrPage] = useState(0)
|
||||
@ -177,9 +192,11 @@ const Annotation: FC<Props> = (props) => {
|
||||
<Switch
|
||||
key={controlRefreshSwitch}
|
||||
checked={annotationConfig?.enabled ?? false}
|
||||
disabled={!annotationConfig?.enabled && isAnnotationQuotaUnavailable}
|
||||
size="md"
|
||||
onCheckedChange={async (value) => {
|
||||
if (value) {
|
||||
if (isAnnotationQuotaUnavailable) return
|
||||
if (isAnnotationFull) {
|
||||
setIsShowAnnotationFullModal(true)
|
||||
setControlRefreshSwitch(Date.now())
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
@ -48,6 +48,7 @@ vi.mock('@/service/client', async (importOriginal) => {
|
||||
...actual,
|
||||
consoleQuery: {
|
||||
...actual.consoleQuery,
|
||||
features: actual.consoleQuery.features,
|
||||
account: {
|
||||
profile: {
|
||||
get: {
|
||||
@ -90,9 +91,6 @@ vi.mock('@/app/components/base/app-icon', () => ({
|
||||
vi.mock('@/utils/app-redirection', () => ({
|
||||
getRedirection: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
@ -110,18 +108,9 @@ const mockUseRouter = vi.mocked(useRouter)
|
||||
const mockPush = vi.fn()
|
||||
const mockTrackCreateApp = vi.mocked(trackCreateApp)
|
||||
const mockGetRedirection = vi.mocked(getRedirection)
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const { mockToastSuccess, mockToastError } = toastMocks
|
||||
|
||||
const defaultPlanUsage = {
|
||||
buildApps: 0,
|
||||
teamMembers: 0,
|
||||
annotatedResponse: 0,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
vectorSpace: 0,
|
||||
}
|
||||
let appQuota = { size: 0, limit: 1 }
|
||||
|
||||
const renderModal = () => {
|
||||
const onClose = vi.fn()
|
||||
@ -137,20 +126,19 @@ const renderModal = () => {
|
||||
return { onClose, onCreateFromTemplate }
|
||||
}
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: { apps: appQuota },
|
||||
})
|
||||
}
|
||||
|
||||
describe('CreateAppModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ahooksMocks.keyPressHandlers.length = 0
|
||||
mockUseRouter.mockReturnValue({ push: mockPush } as unknown as ReturnType<typeof useRouter>)
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
type: AppModeEnum.ADVANCED_CHAT,
|
||||
usage: defaultPlanUsage,
|
||||
total: { ...defaultPlanUsage, buildApps: 1 },
|
||||
reset: {},
|
||||
},
|
||||
enableBilling: true,
|
||||
} as unknown as ReturnType<typeof useProviderContext>)
|
||||
appQuota = { size: 0, limit: 1 }
|
||||
mockConsoleStateReader.mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: ['app.create_and_management'],
|
||||
@ -253,15 +241,7 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
|
||||
it('shows the apps-full notice and disables creation when the workspace quota is exhausted', () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
type: AppModeEnum.ADVANCED_CHAT,
|
||||
usage: { ...defaultPlanUsage, buildApps: 1 },
|
||||
total: { ...defaultPlanUsage, buildApps: 1 },
|
||||
reset: {},
|
||||
},
|
||||
enableBilling: true,
|
||||
} as unknown as ReturnType<typeof useProviderContext>)
|
||||
appQuota = { size: 1, limit: 1 }
|
||||
|
||||
renderModal()
|
||||
|
||||
@ -323,15 +303,7 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
|
||||
it('ignores the keyboard shortcut when the app quota is exhausted and closes the icon picker', async () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
type: AppModeEnum.ADVANCED_CHAT,
|
||||
usage: { ...defaultPlanUsage, buildApps: 1 },
|
||||
total: { ...defaultPlanUsage, buildApps: 1 },
|
||||
reset: {},
|
||||
},
|
||||
enableBilling: true,
|
||||
} as unknown as ReturnType<typeof useProviderContext>)
|
||||
appQuota = { size: 1, limit: 1 }
|
||||
|
||||
renderModal()
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useId, useRef, useState } from 'react'
|
||||
@ -19,7 +19,6 @@ import AppIcon from '@/app/components/base/app-icon'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
@ -67,9 +66,21 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
|
||||
shouldExpandBeginnerAppTypes(defaultAppMode),
|
||||
)
|
||||
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const deploymentEdition = systemFeatures.deployment_edition
|
||||
const { data: appQuota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.apps,
|
||||
}),
|
||||
)
|
||||
const isAppQuotaUnavailable = deploymentEdition === 'CLOUD' && appQuota === undefined
|
||||
// A limit of 0 means unlimited.
|
||||
const isAppsFull =
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
appQuota !== undefined &&
|
||||
appQuota.limit > 0 &&
|
||||
appQuota.size >= appQuota.limit
|
||||
const { data: currentUserId } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: (data) => data.profile.id,
|
||||
@ -82,7 +93,7 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const onCreate = useCallback(async () => {
|
||||
if (!canCreateApp) return
|
||||
if (isAppQuotaUnavailable || isAppsFull || !canCreateApp) return
|
||||
|
||||
if (!appMode) {
|
||||
toast.error(t(($) => $['newApp.appTypeRequired'], { ns: 'app' }))
|
||||
@ -137,6 +148,8 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
|
||||
setIsCreating(false)
|
||||
}
|
||||
}, [
|
||||
isAppQuotaUnavailable,
|
||||
isAppsFull,
|
||||
canCreateApp,
|
||||
currentUserId,
|
||||
name,
|
||||
@ -155,7 +168,7 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
|
||||
useHotkey(
|
||||
CREATE_APP_HOTKEY,
|
||||
() => {
|
||||
if (isAppsFull || !canCreateApp) return
|
||||
if (isAppQuotaUnavailable || isAppsFull || !canCreateApp) return
|
||||
handleCreateApp()
|
||||
},
|
||||
{
|
||||
@ -358,7 +371,7 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onClose}>{t(($) => $['newApp.Cancel'], { ns: 'app' })}</Button>
|
||||
<Button
|
||||
disabled={!canCreateApp || isAppsFull || !name}
|
||||
disabled={isAppQuotaUnavailable || !canCreateApp || isAppsFull || !name}
|
||||
loading={isCreating}
|
||||
variant="primary"
|
||||
onClick={handleCreateApp}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import type { ReactElement } from 'react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DSLImportMode, DSLImportStatus } from '@/models/app'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import CreateFromDSLModal from '../index'
|
||||
import { CreateFromDSLModalTab } from '../types'
|
||||
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockImportDSL = vi.fn()
|
||||
const mockImportDSLConfirm = vi.fn()
|
||||
@ -25,8 +27,8 @@ const toastMocks = vi.hoisted(() => ({
|
||||
const hotkeyMocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, { handler: () => void; options?: { enabled?: boolean } }>(),
|
||||
}))
|
||||
let mockPlanUsage = 0
|
||||
let mockPlanTotal = 10
|
||||
let appCount = 0
|
||||
let appLimit = 10
|
||||
let mockWorkspacePermissionKeys: string[] = ['app.create_and_management']
|
||||
const mockUserProfile = { id: 'user-1' }
|
||||
vi.mock('ahooks', () => ({
|
||||
@ -76,6 +78,7 @@ vi.mock('@/service/client', async (importOriginal) => {
|
||||
},
|
||||
consoleQuery: {
|
||||
...actual.consoleQuery,
|
||||
features: actual.consoleQuery.features,
|
||||
account: {
|
||||
profile: {
|
||||
get: {
|
||||
@ -122,20 +125,6 @@ vi.mock('@/context/permission-state', async () => {
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: {
|
||||
buildApps: mockPlanUsage,
|
||||
},
|
||||
total: {
|
||||
buildApps: mockPlanTotal,
|
||||
},
|
||||
},
|
||||
enableBilling: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/app-redirection', () => ({
|
||||
getRedirection: (...args: unknown[]) => mockGetRedirection(...args),
|
||||
}))
|
||||
@ -157,12 +146,19 @@ vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({
|
||||
default: () => <div>apps-full</div>,
|
||||
}))
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: { apps: { size: appCount, limit: appLimit } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('CreateFromDSLModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hotkeyMocks.handlers.clear()
|
||||
mockPlanUsage = 0
|
||||
mockPlanTotal = 10
|
||||
appCount = 0
|
||||
appLimit = 10
|
||||
mockWorkspacePermissionKeys = ['app.create_and_management']
|
||||
Object.defineProperty(File.prototype, 'text', {
|
||||
configurable: true,
|
||||
@ -803,8 +799,8 @@ describe('CreateFromDSLModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
mockPlanUsage = 1
|
||||
mockPlanTotal = 1
|
||||
appCount = 1
|
||||
appLimit = 1
|
||||
render(
|
||||
<CreateFromDSLModal
|
||||
show
|
||||
|
||||
@ -19,14 +19,13 @@ import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
@ -128,8 +127,20 @@ function CreateFromDSLModal({
|
||||
select: (data) => data.profile.id,
|
||||
})
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps
|
||||
const deploymentEdition = systemFeatures.deployment_edition
|
||||
const { data: appQuota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.apps,
|
||||
}),
|
||||
)
|
||||
const isAppQuotaUnavailable = deploymentEdition === 'CLOUD' && appQuota === undefined
|
||||
// A limit of 0 means unlimited.
|
||||
const isAppsFull =
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
appQuota !== undefined &&
|
||||
appQuota.limit > 0 &&
|
||||
appQuota.size >= appQuota.limit
|
||||
const isImporting = importMutation.isPending
|
||||
const isConfirming = confirmImportMutation.isPending
|
||||
|
||||
@ -190,7 +201,7 @@ function CreateFromDSLModal({
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: ImportFormValues) => {
|
||||
if (isAppsFull || isImporting) return
|
||||
if (isAppQuotaUnavailable || isAppsFull || isImporting) return
|
||||
|
||||
try {
|
||||
let source: ImportSource
|
||||
@ -236,7 +247,9 @@ function CreateFromDSLModal({
|
||||
}
|
||||
|
||||
const createDisabled =
|
||||
isAppsFull || (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
|
||||
isAppQuotaUnavailable ||
|
||||
isAppsFull ||
|
||||
(currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
|
||||
|
||||
useHotkey(CREATE_FROM_DSL_HOTKEY, () => formRef.current?.requestSubmit(), {
|
||||
enabled: show && !createDisabled && !isImporting && !pendingImport,
|
||||
|
||||
@ -1,22 +1,19 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
createConsoleQueryClient,
|
||||
renderWithConsoleQuery,
|
||||
seedFeatures,
|
||||
} from '@/test/console/query-data'
|
||||
import DuplicateAppModal from '../index'
|
||||
|
||||
const { mockProviderContext, toastErrorMock } = vi.hoisted(() => ({
|
||||
mockProviderContext: {
|
||||
plan: {
|
||||
usage: { buildApps: 0 },
|
||||
total: { buildApps: 1 },
|
||||
},
|
||||
enableBilling: true,
|
||||
},
|
||||
const { mockAppQuota, toastErrorMock } = vi.hoisted(() => ({
|
||||
mockAppQuota: { size: 0, limit: 1 },
|
||||
toastErrorMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderContext,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => toastErrorMock(...args),
|
||||
@ -70,6 +67,13 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: { apps: mockAppQuota },
|
||||
})
|
||||
}
|
||||
|
||||
describe('DuplicateAppModal', () => {
|
||||
const getIconButton = () =>
|
||||
screen.getByRole('button', {
|
||||
@ -78,8 +82,8 @@ describe('DuplicateAppModal', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProviderContext.plan.usage.buildApps = 0
|
||||
mockProviderContext.plan.total.buildApps = 1
|
||||
mockAppQuota.size = 0
|
||||
mockAppQuota.limit = 1
|
||||
})
|
||||
|
||||
it('should render a named dialog', () => {
|
||||
@ -212,7 +216,7 @@ describe('DuplicateAppModal', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onHide = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
mockProviderContext.plan.usage.buildApps = 1
|
||||
mockAppQuota.size = 1
|
||||
|
||||
render(
|
||||
<DuplicateAppModal
|
||||
@ -280,3 +284,32 @@ describe('DuplicateAppModal', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for the real Cloud quota and allows an unlimited quota without an upgrade notice', async () => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
void queryClient.query({
|
||||
...consoleQuery.features.get.queryOptions(),
|
||||
queryFn: () => new Promise(() => {}),
|
||||
})
|
||||
const onConfirm = vi.fn()
|
||||
renderWithConsoleQuery(
|
||||
<DuplicateAppModal
|
||||
appName="Existing"
|
||||
icon_type="emoji"
|
||||
icon="🤖"
|
||||
show
|
||||
onConfirm={onConfirm}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
{ queryClient, systemFeatures: { deployment_edition: 'CLOUD' } },
|
||||
)
|
||||
const button = screen.getByRole('button', { name: /(?:^|\.)duplicate(?=$|:)/ })
|
||||
expect(button).toBeDisabled()
|
||||
expect(screen.queryByText('apps-full')).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
seedFeatures(queryClient, { apps: { size: 100, limit: 0 } })
|
||||
})
|
||||
await waitFor(() => expect(button).toBeEnabled())
|
||||
await userEvent.setup().click(button)
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@ -6,12 +6,15 @@ import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import AppIconPicker from '../../base/app-icon-picker'
|
||||
|
||||
export type DuplicateAppModalProps = {
|
||||
@ -51,11 +54,23 @@ const DuplicateAppModal = ({
|
||||
: { type: 'emoji' as const, icon, background: icon_background },
|
||||
)
|
||||
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: appQuota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.apps,
|
||||
}),
|
||||
)
|
||||
const isAppQuotaUnavailable = deploymentEdition === 'CLOUD' && appQuota === undefined
|
||||
// A limit of 0 means unlimited.
|
||||
const isAppsFull =
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
appQuota !== undefined &&
|
||||
appQuota.limit > 0 &&
|
||||
appQuota.size >= appQuota.limit
|
||||
|
||||
const submit = () => {
|
||||
if (isAppsFull) return
|
||||
if (isAppQuotaUnavailable || isAppsFull) return
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error(t(($) => $['appCustomize.nameRequired'], { ns: 'explore' }))
|
||||
@ -130,7 +145,12 @@ const DuplicateAppModal = ({
|
||||
{isAppsFull && <AppsFull className="mt-4" loc="app-duplicate-create" />}
|
||||
</div>
|
||||
<div className="flex flex-row-reverse">
|
||||
<Button type="submit" disabled={isAppsFull} className="ml-2 w-24" variant="primary">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isAppQuotaUnavailable || isAppsFull}
|
||||
className="ml-2 w-24"
|
||||
variant="primary"
|
||||
>
|
||||
{t(($) => $.duplicate, { ns: 'app' })}
|
||||
</Button>
|
||||
<Button type="button" className="w-24" onClick={onHide}>
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { ArchivedLogsNotice } from '../archived-logs-notice'
|
||||
@ -16,45 +13,26 @@ vi.mock('@/context/workspace-state', async () => {
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const setSettingsDestination = vi.fn()
|
||||
vi.mock('nuqs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('nuqs')>()
|
||||
return { ...actual, useQueryState: () => [null, setSettingsDestination] }
|
||||
})
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
|
||||
function mockProviderPlan(planType: CloudPlan) {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
let plan: CloudPlan = 'professional'
|
||||
|
||||
describe('ArchivedLogsNotice', () => {
|
||||
const renderNotice = () => {
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: { billing: { subscription: { plan } } },
|
||||
})
|
||||
return render(<ArchivedLogsNotice />, { wrapper })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProviderPlan('professional')
|
||||
plan = 'professional'
|
||||
})
|
||||
|
||||
it('should show an accessible notice for paid workspace managers', async () => {
|
||||
@ -71,7 +49,7 @@ describe('ArchivedLogsNotice', () => {
|
||||
})
|
||||
|
||||
it('should not show notice for sandbox workspaces', () => {
|
||||
mockProviderPlan('sandbox')
|
||||
plan = 'sandbox'
|
||||
|
||||
renderNotice()
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -9,9 +9,9 @@ import {
|
||||
settingsQueryParamName,
|
||||
settingsQueryParser,
|
||||
} from '@/app/components/header/account-setting/query-params'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
export function ArchivedLogsNotice() {
|
||||
const { t } = useTranslation()
|
||||
@ -20,14 +20,18 @@ export function ArchivedLogsNotice() {
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const { enableBilling, plan } = useProviderContext()
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
|
||||
|
||||
if (
|
||||
deploymentEdition !== 'CLOUD' ||
|
||||
!isCurrentWorkspaceManager ||
|
||||
!enableBilling ||
|
||||
plan.type === 'sandbox'
|
||||
(plan !== 'professional' && plan !== 'team')
|
||||
)
|
||||
return null
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@ const defaultProviderContext = {
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: false,
|
||||
plan: defaultPlan,
|
||||
enableBilling: false,
|
||||
|
||||
enableSkill: false,
|
||||
enableReplaceWebAppLogo: false,
|
||||
modelLoadBalancingEnabled: false,
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { baseProviderContextValue } from '@/context/provider-context'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import SettingsModal from '../index'
|
||||
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD'
|
||||
let copyrightEnabled = true
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { withSelectorKey, withSelectorKeyProps } = await import('@/test/i18n-mock')
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
@ -58,7 +61,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnSave = vi.fn()
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockUseProviderContext = vi.fn<() => ProviderContextState>()
|
||||
|
||||
const buildModalContext = (): ModalContextState => ({
|
||||
hasBlockingModalOpen: false,
|
||||
@ -84,16 +86,6 @@ vi.mock('@/context/i18n', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/context/provider-context')>(
|
||||
'@/context/provider-context',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockAppInfo = {
|
||||
site: {
|
||||
title: 'Test App',
|
||||
@ -136,15 +128,8 @@ describe('SettingsModal', () => {
|
||||
mockOnClose.mockClear()
|
||||
mockOnSave.mockClear()
|
||||
mockSetShowPricingModal.mockClear()
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'professional',
|
||||
},
|
||||
webappCopyrightEnabled: true,
|
||||
})
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -358,36 +343,29 @@ describe('SettingsModal', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should display paid webapp settings as defaults for Cloud sandbox plans', async () => {
|
||||
it('should preserve restricted settings when saving other Cloud settings', async () => {
|
||||
mockOnSave.mockResolvedValueOnce(undefined)
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'sandbox',
|
||||
},
|
||||
webappCopyrightEnabled: true,
|
||||
})
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = false
|
||||
|
||||
renderSettingsModal()
|
||||
|
||||
const inputPlaceholder = screen.getByRole('textbox', { name: inputPlaceholderName })
|
||||
expect(inputPlaceholder).toBeDisabled()
|
||||
expect(inputPlaceholder).toHaveValue('')
|
||||
expect(inputPlaceholder).toHaveValue(mockAppInfo.site.input_placeholder)
|
||||
expect(
|
||||
screen.queryByPlaceholderText(
|
||||
'appOverview.overview.appInfo.settings.more.copyRightPlaceholder',
|
||||
),
|
||||
).not.toBeInTheDocument()
|
||||
).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
copyright: '',
|
||||
input_placeholder: '',
|
||||
copyright: undefined,
|
||||
input_placeholder: undefined,
|
||||
}),
|
||||
)
|
||||
})
|
||||
@ -395,15 +373,8 @@ describe('SettingsModal', () => {
|
||||
|
||||
it('should keep the input placeholder editable when billing is disabled', async () => {
|
||||
mockOnSave.mockResolvedValueOnce(undefined)
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: false,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'sandbox',
|
||||
},
|
||||
webappCopyrightEnabled: false,
|
||||
})
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
copyrightEnabled = false
|
||||
|
||||
renderSettingsModal()
|
||||
const inputPlaceholder = screen.getByRole('textbox', { name: inputPlaceholderName })
|
||||
@ -415,7 +386,7 @@ describe('SettingsModal', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
copyright: '',
|
||||
copyright: undefined,
|
||||
input_placeholder: 'Self-hosted prompt',
|
||||
}),
|
||||
)
|
||||
@ -423,15 +394,8 @@ describe('SettingsModal', () => {
|
||||
})
|
||||
|
||||
it('should open the pricing modal from the copyright upgrade badge for sandbox plans', async () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'sandbox',
|
||||
},
|
||||
webappCopyrightEnabled: false,
|
||||
})
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = false
|
||||
|
||||
renderSettingsModal()
|
||||
fireEvent.click((await screen.findAllByText('billing.upgradeBtn.encourageShort'))[0]!)
|
||||
@ -440,15 +404,8 @@ describe('SettingsModal', () => {
|
||||
})
|
||||
|
||||
it('should hide the upgrade badge for non-sandbox plans', async () => {
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'professional',
|
||||
},
|
||||
webappCopyrightEnabled: true,
|
||||
})
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = true
|
||||
|
||||
renderSettingsModal()
|
||||
await waitFor(() => {
|
||||
@ -502,3 +459,35 @@ describe('SettingsModal', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
features: { webapp_copyright_enabled: copyrightEnabled },
|
||||
})
|
||||
}
|
||||
|
||||
it('saves unrelated settings while entitlements are pending without clearing protected fields', async () => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
void queryClient.query({
|
||||
...consoleQuery.features.get.queryOptions(),
|
||||
queryFn: () => new Promise(() => {}),
|
||||
})
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
renderWithConsoleQuery(
|
||||
<SettingsModal isChat isShow appInfo={mockAppInfo} onClose={vi.fn()} onSave={onSave} />,
|
||||
{ queryClient, systemFeatures: { deployment_edition: 'CLOUD' } },
|
||||
)
|
||||
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toBeDisabled()
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await waitFor(() =>
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
copyright: undefined,
|
||||
input_placeholder: undefined,
|
||||
title: mockAppInfo.site.title,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@ -28,6 +28,8 @@ import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
@ -36,9 +38,10 @@ import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { PremiumBadgeButton } from '@/app/components/base/premium-badge'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
type ISettingsModalProps = {
|
||||
@ -92,10 +95,10 @@ export type ConfigParams = {
|
||||
chat_color_theme: string
|
||||
chat_color_theme_inverted: boolean
|
||||
prompt_public: boolean
|
||||
copyright: string
|
||||
copyright?: string
|
||||
privacy_policy: string
|
||||
custom_disclaimer: string
|
||||
input_placeholder: string
|
||||
input_placeholder?: string
|
||||
icon_type: AppIconType
|
||||
icon: string
|
||||
icon_background?: string
|
||||
@ -203,24 +206,29 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
const [previousIsShow, setPreviousIsShow] = useState(isShow)
|
||||
const [previousSettingsResetKey, setPreviousSettingsResetKey] = useState(settingsResetKey)
|
||||
|
||||
const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: webappCopyrightEnabled } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
select: (data) => data.webapp_copyright_enabled,
|
||||
}),
|
||||
)
|
||||
const { setShowPricingModal } = useModalContext()
|
||||
const isCloudSandboxPlan = enableBilling && plan.type === 'sandbox'
|
||||
const canCustomizePlaceholder = deploymentEdition !== 'CLOUD' || webappCopyrightEnabled === true
|
||||
const selectedLanguage = LANGUAGE_OPTIONS.find((item) => item.value === language)
|
||||
const inputPlaceholderLabelId = React.useId()
|
||||
const inputPlaceholderDescriptionId = React.useId()
|
||||
const inputPlaceholderValue = isCloudSandboxPlan ? '' : (inputInfo.inputPlaceholder ?? '')
|
||||
const copyrightSwitchValue = isCloudSandboxPlan ? false : inputInfo.copyrightSwitchValue
|
||||
const inputPlaceholderValue = inputInfo.inputPlaceholder ?? ''
|
||||
const copyrightSwitchValue = inputInfo.copyrightSwitchValue
|
||||
const showInputPlaceholderPreview =
|
||||
!isCloudSandboxPlan && inputPlaceholderValue.trim().length > 0 && !inputPlaceholderFocused
|
||||
canCustomizePlaceholder && inputPlaceholderValue.trim().length > 0 && !inputPlaceholderFocused
|
||||
const inputPlaceholderField = (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 flex h-10 items-center gap-2 rounded-lg border border-components-input-border-hover bg-components-input-bg-normal pr-1 pl-3 transition-colors',
|
||||
!isCloudSandboxPlan &&
|
||||
canCustomizePlaceholder &&
|
||||
inputPlaceholderFocused &&
|
||||
'border-components-input-border-active bg-components-input-bg-active',
|
||||
isCloudSandboxPlan && 'cursor-not-allowed opacity-60',
|
||||
!canCustomizePlaceholder && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
@ -230,7 +238,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
onChange={(e) => setInputInfo((item) => ({ ...item, inputPlaceholder: e.target.value }))}
|
||||
onFocus={() => setInputPlaceholderFocused(true)}
|
||||
onBlur={() => setInputPlaceholderFocused(false)}
|
||||
disabled={isCloudSandboxPlan}
|
||||
disabled={!canCustomizePlaceholder}
|
||||
maxLength={INPUT_PLACEHOLDER_MAX_LENGTH}
|
||||
autoComplete="off"
|
||||
aria-labelledby={inputPlaceholderLabelId}
|
||||
@ -243,7 +251,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
className={cn(
|
||||
'flex-1 bg-transparent body-md-regular outline-hidden',
|
||||
showInputPlaceholderPreview ? 'text-text-placeholder' : 'text-text-primary',
|
||||
isCloudSandboxPlan && 'cursor-not-allowed',
|
||||
!canCustomizePlaceholder && 'cursor-not-allowed',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
@ -318,16 +326,16 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
chat_color_theme: inputInfo.chatColorTheme,
|
||||
chat_color_theme_inverted: inputInfo.chatColorThemeInverted,
|
||||
prompt_public: false,
|
||||
copyright:
|
||||
!webappCopyrightEnabled || isCloudSandboxPlan
|
||||
? ''
|
||||
: copyrightSwitchValue
|
||||
? inputInfo.copyright
|
||||
: '',
|
||||
copyright: !webappCopyrightEnabled
|
||||
? undefined
|
||||
: copyrightSwitchValue
|
||||
? inputInfo.copyright
|
||||
: '',
|
||||
privacy_policy: inputInfo.privacyPolicy,
|
||||
custom_disclaimer: inputInfo.customDisclaimer,
|
||||
input_placeholder:
|
||||
isCloudSandboxPlan || !INPUT_PLACEHOLDER_SUPPORTED_MODES.includes(appInfo.mode)
|
||||
input_placeholder: !canCustomizePlaceholder
|
||||
? undefined
|
||||
: !INPUT_PLACEHOLDER_SUPPORTED_MODES.includes(appInfo.mode)
|
||||
? ''
|
||||
: (inputInfo.inputPlaceholder ?? '').slice(0, INPUT_PLACEHOLDER_MAX_LENGTH),
|
||||
icon_type: appIcon.type,
|
||||
@ -588,7 +596,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
ns: 'appOverview',
|
||||
})}
|
||||
</div>
|
||||
{isCloudSandboxPlan && (
|
||||
{deploymentEdition === 'CLOUD' && webappCopyrightEnabled === false && (
|
||||
<div className="h-4.5 select-none">
|
||||
<PremiumBadgeButton size="s" color="blue" onClick={handlePlanClick}>
|
||||
<span
|
||||
@ -613,7 +621,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
ns: 'appOverview',
|
||||
})}
|
||||
</p>
|
||||
{isCloudSandboxPlan ? (
|
||||
{deploymentEdition === 'CLOUD' && webappCopyrightEnabled === false ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={inputPlaceholderField} />
|
||||
<TooltipContent className="w-45">
|
||||
@ -625,7 +633,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
) : (
|
||||
inputPlaceholderField
|
||||
)}
|
||||
{!isCloudSandboxPlan && (
|
||||
{canCustomizePlaceholder && (
|
||||
<div className="mt-1 text-right body-xs-regular text-text-tertiary">
|
||||
{`${inputInfo.inputPlaceholder?.length ?? 0} / ${INPUT_PLACEHOLDER_MAX_LENGTH}`}
|
||||
</div>
|
||||
@ -640,7 +648,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
{t(($) => $[`${prefixSettings}.more.copyright`], { ns: 'appOverview' })}
|
||||
</div>
|
||||
{/* upgrade button */}
|
||||
{isCloudSandboxPlan && (
|
||||
{deploymentEdition === 'CLOUD' && webappCopyrightEnabled === false && (
|
||||
<div className="h-4.5 select-none">
|
||||
<PremiumBadgeButton size="s" color="blue" onClick={handlePlanClick}>
|
||||
<span
|
||||
@ -656,8 +664,9 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{webappCopyrightEnabled ? (
|
||||
{webappCopyrightEnabled !== false ? (
|
||||
<Switch
|
||||
disabled={webappCopyrightEnabled !== true}
|
||||
aria-label={t(($) => $[`${prefixSettings}.more.copyright`], {
|
||||
ns: 'appOverview',
|
||||
})}
|
||||
@ -701,6 +710,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
ns: 'appOverview',
|
||||
})}
|
||||
className="mt-2 h-10"
|
||||
disabled={webappCopyrightEnabled !== true}
|
||||
value={inputInfo.copyright}
|
||||
onChange={onChange('copyright')}
|
||||
placeholder={
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import SwitchAppModal from '../index'
|
||||
|
||||
@ -37,7 +38,7 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
let mockEnableBilling = false
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY'
|
||||
let mockPlan = {
|
||||
type: 'sandbox',
|
||||
usage: {
|
||||
@ -59,12 +60,6 @@ let mockPlan = {
|
||||
vectorSpace: 0,
|
||||
},
|
||||
}
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: mockPlan,
|
||||
enableBilling: mockEnableBilling,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({
|
||||
default: ({ loc }: { loc: string }) => (
|
||||
@ -140,6 +135,13 @@ const renderComponent = (overrides: Partial<React.ComponentProps<typeof SwitchAp
|
||||
|
||||
const setAppDetailSpy = vi.fn()
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
features: { apps: { size: mockPlan.usage.buildApps, limit: mockPlan.total.buildApps } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('SwitchAppModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -152,7 +154,7 @@ describe('SwitchAppModal', () => {
|
||||
originalSetAppDetail(...args)
|
||||
})
|
||||
useAppStore.setState({ setAppDetail: setAppDetailSpy as typeof originalSetAppDetail })
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
mockPlan = {
|
||||
type: 'sandbox',
|
||||
usage: {
|
||||
@ -216,7 +218,7 @@ describe('SwitchAppModal', () => {
|
||||
|
||||
it('should render the apps full warning when plan limits are reached', () => {
|
||||
// Arrange
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlan = {
|
||||
...mockPlan,
|
||||
usage: { ...mockPlan.usage, buildApps: 10 },
|
||||
|
||||
@ -17,13 +17,12 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -49,8 +48,20 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onClose }: Switc
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps
|
||||
const deploymentEdition = systemFeatures.deployment_edition
|
||||
const { data: appQuota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.apps,
|
||||
}),
|
||||
)
|
||||
const isAppQuotaUnavailable = deploymentEdition === 'CLOUD' && appQuota === undefined
|
||||
// A limit of 0 means unlimited.
|
||||
const isAppsFull =
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
appQuota !== undefined &&
|
||||
appQuota.limit > 0 &&
|
||||
appQuota.size >= appQuota.limit
|
||||
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
const appIconType = zIconType.safeParse(appDetail.icon_type).data
|
||||
@ -75,6 +86,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onClose }: Switc
|
||||
)
|
||||
|
||||
const goStart = async () => {
|
||||
if (isAppQuotaUnavailable || isAppsFull) return
|
||||
try {
|
||||
const { new_app_id: newAppID, permission_keys } = await convertToWorkflow({
|
||||
params: { app_id: appDetail.id },
|
||||
@ -214,7 +226,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onClose }: Switc
|
||||
</Button>
|
||||
<Button
|
||||
className="inset-ring-red-700"
|
||||
disabled={isAppsFull || !name}
|
||||
disabled={isAppQuotaUnavailable || isAppsFull || !name}
|
||||
variant="primary"
|
||||
tone="destructive"
|
||||
onClick={goStart}
|
||||
|
||||
@ -3,25 +3,15 @@ import type { ChatContextValue } from '../../context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import Operation from '../operation'
|
||||
|
||||
const { mockSetShowAnnotationFullModal, mockProviderContext, mockT, mockAddAnnotation } =
|
||||
vi.hoisted(() => {
|
||||
return {
|
||||
mockAddAnnotation: vi.fn(),
|
||||
mockSetShowAnnotationFullModal: vi.fn(),
|
||||
mockT: vi.fn((key: string): string => key),
|
||||
mockProviderContext: {
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 100 },
|
||||
},
|
||||
enableBilling: false,
|
||||
},
|
||||
}
|
||||
})
|
||||
const { mockSetShowAnnotationFullModal, mockT, mockAddAnnotation } = vi.hoisted(() => {
|
||||
return {
|
||||
mockAddAnnotation: vi.fn(),
|
||||
mockSetShowAnnotationFullModal: vi.fn(),
|
||||
mockT: vi.fn((key: string): string => key),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
|
||||
|
||||
@ -35,10 +25,6 @@ vi.mock('@/context/modal-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderContext,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
addAnnotation: mockAddAnnotation,
|
||||
}))
|
||||
@ -59,13 +45,11 @@ vi.mock('@/app/components/app/annotation/edit-annotation-modal', () => ({
|
||||
isShow,
|
||||
onHide,
|
||||
onEdited,
|
||||
onAdded,
|
||||
onRemove,
|
||||
}: {
|
||||
isShow: boolean
|
||||
onHide: () => void
|
||||
onEdited: (q: string, a: string) => void
|
||||
onAdded: (id: string, name: string, q: string, a: string) => void
|
||||
onRemove: () => void
|
||||
}) =>
|
||||
isShow ? (
|
||||
@ -76,9 +60,6 @@ vi.mock('@/app/components/app/annotation/edit-annotation-modal', () => ({
|
||||
<button data-testid="modal-edit" onClick={() => onEdited('eq', 'ea')}>
|
||||
Edit
|
||||
</button>
|
||||
<button data-testid="modal-add" onClick={() => onAdded('a1', 'author', 'eq', 'ea')}>
|
||||
Add
|
||||
</button>
|
||||
<button data-testid="modal-remove" onClick={onRemove}>
|
||||
Remove
|
||||
</button>
|
||||
@ -98,13 +79,7 @@ vi.mock(
|
||||
onEdit: () => void
|
||||
cached: boolean
|
||||
}) {
|
||||
const { setShowAnnotationFullModal } = useModalContext()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const handleAdd = () => {
|
||||
if (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) {
|
||||
setShowAnnotationFullModal()
|
||||
return
|
||||
}
|
||||
onAdded('ann-new', 'Test User')
|
||||
}
|
||||
return (
|
||||
@ -266,8 +241,7 @@ describe('Operation', () => {
|
||||
mockContextValue.onAnnotationRemoved = vi.fn()
|
||||
mockContextValue.readonly = false
|
||||
mockContextValue.showRegenerate = false
|
||||
mockProviderContext.plan.usage.annotatedResponse = 0
|
||||
mockProviderContext.enableBilling = false
|
||||
|
||||
mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } })
|
||||
})
|
||||
|
||||
@ -1060,17 +1034,6 @@ describe('Operation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should show annotation full modal when limit reached', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockProviderContext.enableBilling = true
|
||||
mockProviderContext.plan.usage.annotatedResponse = 100
|
||||
renderOperation()
|
||||
const addBtn = screen.getByTestId('annotation-add-btn')
|
||||
await user.click(addBtn)
|
||||
expect(mockSetShowAnnotationFullModal).toHaveBeenCalled()
|
||||
expect(mockAddAnnotation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open edit reply modal when cached annotation exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = {
|
||||
@ -1096,19 +1059,6 @@ describe('Operation', () => {
|
||||
expect(mockContextValue.onAnnotationEdited).toHaveBeenCalledWith('eq', 'ea', 0)
|
||||
})
|
||||
|
||||
it('should call onAnnotationAdded from edit reply modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = {
|
||||
...baseItem,
|
||||
annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' },
|
||||
}
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
await user.click(screen.getByTestId('modal-add'))
|
||||
expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith('a1', 'author', 'eq', 'ea', 0)
|
||||
})
|
||||
|
||||
it('should call onAnnotationRemoved from edit reply modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = {
|
||||
|
||||
@ -443,21 +443,18 @@ function Operation({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canManageAnnotation && (
|
||||
{canManageAnnotation && annotation?.id && isShowReplyModal && (
|
||||
<EditReplyModal
|
||||
isShow={isShowReplyModal}
|
||||
isShow
|
||||
onHide={() => setIsShowReplyModal(false)}
|
||||
query={question}
|
||||
answer={content}
|
||||
onEdited={(editedQuery, editedAnswer) =>
|
||||
onAnnotationEdited?.(editedQuery, editedAnswer, index)
|
||||
}
|
||||
onAdded={(annotationId, authorName, editedQuery, editedAnswer) =>
|
||||
onAnnotationAdded?.(annotationId, authorName, editedQuery, editedAnswer, index)
|
||||
}
|
||||
appId={config?.appId || ''}
|
||||
messageId={id}
|
||||
annotationId={annotation?.id || ''}
|
||||
annotationId={annotation.id}
|
||||
createdAt={annotation?.created_at}
|
||||
onRemove={() => onAnnotationRemoved?.(index)}
|
||||
/>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Features } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import NewFeaturePanel from '../index'
|
||||
|
||||
@ -88,7 +89,7 @@ const renderPanel = (
|
||||
showAnnotationReply: boolean
|
||||
}> = {},
|
||||
) => {
|
||||
return render(
|
||||
return renderWithConsoleQuery(
|
||||
<FeaturesProvider features={defaultFeatures}>
|
||||
<NewFeaturePanel
|
||||
show={props.show ?? true}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import AnnotationCtrlButton from '../annotation-ctrl-button'
|
||||
|
||||
const mockSetShowAnnotationFullModal = vi.fn()
|
||||
@ -9,19 +11,6 @@ vi.mock('@/context/modal-context', () => ({
|
||||
}))
|
||||
|
||||
let mockAnnotatedResponseUsage = 5
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: {
|
||||
get annotatedResponse() {
|
||||
return mockAnnotatedResponseUsage
|
||||
},
|
||||
},
|
||||
total: { annotatedResponse: 100 },
|
||||
},
|
||||
enableBilling: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockAddAnnotation = vi.fn().mockResolvedValue({
|
||||
id: 'annotation-1',
|
||||
@ -42,6 +31,13 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: { annotation_quota_limit: { size: mockAnnotatedResponseUsage, limit: 100 } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('AnnotationCtrlButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@ -1,19 +1,11 @@
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { act } from '@testing-library/react'
|
||||
import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation'
|
||||
import { renderHookWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { sleep } from '@/utils'
|
||||
import useAnnotationConfig from '../use-annotation-config'
|
||||
|
||||
let mockIsAnnotationFull = false
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: { annotatedResponse: mockIsAnnotationFull ? 100 : 5 },
|
||||
total: { annotatedResponse: 100 },
|
||||
},
|
||||
enableBilling: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
updateAnnotationStatus: vi.fn().mockResolvedValue({ job_id: 'test-job-id' }),
|
||||
@ -24,6 +16,13 @@ vi.mock('@/utils', () => ({
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
function renderHook<Result>(callback: () => Result) {
|
||||
return renderHookWithConsoleQuery(callback, {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: { annotation_quota_limit: { size: mockIsAnnotationFull ? 100 : 5, limit: 100 } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('useAnnotationConfig', () => {
|
||||
const defaultConfig: AnnotationReplyConfig = {
|
||||
id: 'test-id',
|
||||
@ -40,6 +39,27 @@ describe('useAnnotationConfig', () => {
|
||||
mockIsAnnotationFull = false
|
||||
})
|
||||
|
||||
it('edits enabled annotation parameters without depending on unused creation quota', async () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHookWithConsoleQuery(
|
||||
() =>
|
||||
useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: { ...defaultConfig, enabled: true },
|
||||
setAnnotationConfig,
|
||||
}),
|
||||
{ systemFeatures: { deployment_edition: 'CLOUD' } },
|
||||
)
|
||||
|
||||
act(() => result.current.setIsShowAnnotationConfigInit(true))
|
||||
expect(result.current.isShowAnnotationConfigInit).toBe(true)
|
||||
await act(async () => {
|
||||
await result.current.handleEnableAnnotation(defaultConfig.embedding_model!)
|
||||
})
|
||||
expect(updateAnnotationStatus).toHaveBeenCalled()
|
||||
expect(setAnnotationConfig).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should initialize with annotation config init hidden', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@ -4,11 +4,14 @@ import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiEditLine, RiFileEditLine } from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { addAnnotation } from '@/service/annotation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type Props = Readonly<{
|
||||
appId: string
|
||||
@ -29,11 +32,24 @@ const AnnotationCtrlButton: FC<Props> = ({
|
||||
onEdit,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: annotationQuota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.annotation_quota_limit,
|
||||
}),
|
||||
)
|
||||
const isAnnotationQuotaUnavailable =
|
||||
deploymentEdition === 'CLOUD' && annotationQuota === undefined
|
||||
// A limit of 0 means unlimited.
|
||||
const isAnnotationFull =
|
||||
enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
annotationQuota !== undefined &&
|
||||
annotationQuota.limit > 0 &&
|
||||
annotationQuota.size >= annotationQuota.limit
|
||||
const { setShowAnnotationFullModal } = useModalContext()
|
||||
const handleAdd = async () => {
|
||||
if (isAnnotationQuotaUnavailable) return
|
||||
if (isAnnotationFull) {
|
||||
setShowAnnotationFullModal()
|
||||
return
|
||||
@ -71,6 +87,7 @@ const AnnotationCtrlButton: FC<Props> = ({
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={t(($) => $['feature.annotation.add'], { ns: 'appDebug' })}
|
||||
disabled={isAnnotationQuotaUnavailable}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<RiFileEditLine aria-hidden className="size-4" />
|
||||
|
||||
@ -43,6 +43,7 @@ const AnnotationReply = ({ disabled, onChange }: Props) => {
|
||||
)
|
||||
|
||||
const {
|
||||
isAnnotationQuotaUnavailable,
|
||||
handleEnableAnnotation,
|
||||
handleDisableAnnotation,
|
||||
isShowAnnotationConfigInit,
|
||||
@ -86,7 +87,7 @@ const AnnotationReply = ({ disabled, onChange }: Props) => {
|
||||
onChange={(state) => handleSwitch(state)}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
disabled={disabled}
|
||||
disabled={disabled || (!annotationReply?.enabled && isAnnotationQuotaUnavailable)}
|
||||
>
|
||||
<>
|
||||
{!annotationReply?.enabled && (
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import type { EmbeddingModelConfig } from '@/app/components/app/annotation/type'
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { produce } from 'immer'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { AnnotationEnableStatus, JobStatus } from '@/app/components/app/annotation/type'
|
||||
import { ANNOTATION_DEFAULT } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { sleep } from '@/utils'
|
||||
|
||||
type Params = {
|
||||
@ -15,13 +18,26 @@ type Params = {
|
||||
setAnnotationConfig: (annotationConfig: AnnotationReplyConfig) => void
|
||||
}
|
||||
const useAnnotationConfig = ({ appId, annotationConfig, setAnnotationConfig }: Params) => {
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: annotationQuota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD' && !annotationConfig.enabled,
|
||||
select: (data) => data.annotation_quota_limit,
|
||||
}),
|
||||
)
|
||||
const isAnnotationQuotaUnavailable =
|
||||
deploymentEdition === 'CLOUD' && annotationQuota === undefined
|
||||
// A limit of 0 means unlimited.
|
||||
const isAnnotationFull =
|
||||
enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
annotationQuota !== undefined &&
|
||||
annotationQuota.limit > 0 &&
|
||||
annotationQuota.size >= annotationQuota.limit
|
||||
const [isShowAnnotationFullModal, setIsShowAnnotationFullModal] = useState(false)
|
||||
const [isShowAnnotationConfigInit, doSetIsShowAnnotationConfigInit] = React.useState(false)
|
||||
const setIsShowAnnotationConfigInit = (isShow: boolean) => {
|
||||
if (isShow) {
|
||||
if (isShow && !annotationConfig.enabled) {
|
||||
if (isAnnotationQuotaUnavailable) return
|
||||
if (isAnnotationFull) {
|
||||
setIsShowAnnotationFullModal(true)
|
||||
return
|
||||
@ -41,7 +57,7 @@ const useAnnotationConfig = ({ appId, annotationConfig, setAnnotationConfig }: P
|
||||
}
|
||||
|
||||
const handleEnableAnnotation = async (embeddingModel: EmbeddingModelConfig, score?: number) => {
|
||||
if (isAnnotationFull) return
|
||||
if (!annotationConfig.enabled && (isAnnotationQuotaUnavailable || isAnnotationFull)) return
|
||||
|
||||
const { job_id: jobId }: any = await updateAnnotationStatus(
|
||||
appId,
|
||||
@ -81,6 +97,7 @@ const useAnnotationConfig = ({ appId, annotationConfig, setAnnotationConfig }: P
|
||||
}
|
||||
|
||||
return {
|
||||
isAnnotationQuotaUnavailable,
|
||||
handleEnableAnnotation,
|
||||
handleDisableAnnotation,
|
||||
isShowAnnotationConfigInit,
|
||||
|
||||
@ -4,18 +4,22 @@ import type { FC } from 'react'
|
||||
import { buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Meter, MeterIndicator, MeterTrack } from '@langgenius/dify-ui/meter'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mailToSupport } from '@/app/components/header/utils/util'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import UpgradeBtn from '../upgrade-btn'
|
||||
import s from './style.module.css'
|
||||
|
||||
const AppsFull: FC<{ loc: string; className?: string }> = ({ loc, className }) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan } = useProviderContext()
|
||||
const { data: billing } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
select: (data) => ({ plan: data.billing.subscription.plan, apps: data.apps }),
|
||||
}),
|
||||
)
|
||||
const { data: accountProfile } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: (data) => ({
|
||||
@ -23,9 +27,10 @@ const AppsFull: FC<{ loc: string; className?: string }> = ({ loc, className }) =
|
||||
currentVersion: data.meta.currentVersion,
|
||||
}),
|
||||
})
|
||||
const isTeam = plan.type === 'team'
|
||||
const usage = plan.usage.buildApps
|
||||
const total = plan.total.buildApps
|
||||
if (!billing) return null
|
||||
const isTeam = billing.plan === 'team'
|
||||
const usage = billing.apps.size
|
||||
const total = billing.apps.limit
|
||||
const percent = total > 0 ? (usage / total) * 100 : 0
|
||||
const tone: MeterTone = percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'neutral'
|
||||
const buildAppsLabel = t(($) => $['usagePage.buildApps'], { ns: 'billing' })
|
||||
@ -57,16 +62,16 @@ const AppsFull: FC<{ loc: string; className?: string }> = ({ loc, className }) =
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(plan.type === 'sandbox' || plan.type === 'professional') && (
|
||||
{(billing.plan === 'sandbox' || billing.plan === 'professional') && (
|
||||
<UpgradeBtn isShort loc={loc} />
|
||||
)}
|
||||
{plan.type !== 'sandbox' && plan.type !== 'professional' && (
|
||||
{billing.plan !== 'sandbox' && billing.plan !== 'professional' && (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={mailToSupport(
|
||||
accountProfile.email,
|
||||
plan.type,
|
||||
billing.plan,
|
||||
accountProfile.currentVersion ?? '',
|
||||
)}
|
||||
className={buttonVariants({ variant: 'secondary-accent' })}
|
||||
|
||||
@ -7,7 +7,7 @@ import Billing from '../index'
|
||||
|
||||
let currentBillingUrl: string | undefined = 'https://billing.example.com'
|
||||
let isManager = true
|
||||
let enableBilling = true
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
request: vi.fn(() => new Promise(() => {})),
|
||||
@ -25,12 +25,6 @@ vi.mock('@/context/workspace-state', async () => {
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
enableBilling,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../plan', () => ({
|
||||
default: ({ loc }: { loc: string }) => <div data-testid="plan-component" data-loc={loc} />,
|
||||
}))
|
||||
@ -42,7 +36,10 @@ const renderBilling = () => {
|
||||
url: currentBillingUrl,
|
||||
})
|
||||
}
|
||||
const { wrapper } = createConsoleQueryWrapper({ queryClient })
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
queryClient,
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
})
|
||||
|
||||
return render(<Billing />, { wrapper })
|
||||
}
|
||||
@ -52,7 +49,7 @@ describe('Billing', () => {
|
||||
vi.clearAllMocks()
|
||||
currentBillingUrl = 'https://billing.example.com'
|
||||
isManager = true
|
||||
enableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
})
|
||||
|
||||
it('renders the billing portal as a keyboard-accessible external link for workspace managers', async () => {
|
||||
@ -77,7 +74,7 @@ describe('Billing', () => {
|
||||
})
|
||||
|
||||
it('hides the billing action when billing is disabled', () => {
|
||||
enableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
|
||||
renderBilling()
|
||||
|
||||
|
||||
@ -5,25 +5,26 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import PlanComp from '../plan'
|
||||
|
||||
const Billing: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const { enableBilling } = useProviderContext()
|
||||
const canManageBilling = enableBilling && isCurrentWorkspaceManager
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: billing } = useQuery(
|
||||
consoleQuery.billing.invoices.get.queryOptions({ enabled: canManageBilling }),
|
||||
consoleQuery.billing.invoices.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD' && isCurrentWorkspaceManager,
|
||||
}),
|
||||
)
|
||||
const billingUrl = billing?.url
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PlanComp loc="billing-page" />
|
||||
{canManageBilling && (
|
||||
{deploymentEdition === 'CLOUD' && isCurrentWorkspaceManager && (
|
||||
<a
|
||||
className={cn(
|
||||
'mt-3 flex w-full items-center justify-between rounded-xl bg-background-section-burn px-4 py-3 outline-hidden',
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiAedFill } from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type PriorityLabelProps = {
|
||||
className?: string
|
||||
@ -11,17 +13,17 @@ type PriorityLabelProps = {
|
||||
|
||||
const PriorityLabel = ({ className }: PriorityLabelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
|
||||
const priority = useMemo(() => {
|
||||
if (plan.type === 'sandbox') return 'standard'
|
||||
|
||||
if (plan.type === 'professional') return 'priority'
|
||||
|
||||
if (plan.type === 'team') return 'top-priority'
|
||||
|
||||
return 'standard'
|
||||
}, [plan])
|
||||
if (deploymentEdition !== 'CLOUD' || plan === undefined) return null
|
||||
const priority = { sandbox: 'standard', professional: 'priority', team: 'top-priority' } as const
|
||||
const label = priority[plan]
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
@ -35,17 +37,15 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(plan.type === 'professional' || plan.type === 'team') && (
|
||||
<RiAedFill className="mr-0.5 size-3" />
|
||||
)}
|
||||
<span>{t(($) => $[`plansCommon.priority.${priority}`], { ns: 'billing' })}</span>
|
||||
{(plan === 'professional' || plan === 'team') && <RiAedFill className="mr-0.5 size-3" />}
|
||||
<span>{t(($) => $[`plansCommon.priority.${label}`], { ns: 'billing' })}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="mb-1 text-xs font-semibold text-text-primary">
|
||||
{t(($) => $['plansCommon.documentProcessingPriority'], { ns: 'billing' })}:{' '}
|
||||
{t(($) => $[`plansCommon.priority.${priority}`], { ns: 'billing' })}
|
||||
{t(($) => $[`plansCommon.priority.${label}`], { ns: 'billing' })}
|
||||
</div>
|
||||
{priority !== 'top-priority' && (
|
||||
{label !== 'top-priority' && (
|
||||
<div className="text-xs text-text-secondary">
|
||||
{t(($) => $['plansCommon.documentProcessingPriorityTip'], { ns: 'billing' })}
|
||||
</div>
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config'
|
||||
import { contactSalesUrl } from '@/app/components/billing/config'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import CustomPage from '../index'
|
||||
|
||||
let deploymentEdition: GetSystemFeaturesResponse['deployment_edition'] = 'COMMUNITY'
|
||||
let canReplaceLogo = true
|
||||
let plan: CloudPlan = 'professional'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
@ -18,7 +21,7 @@ vi.mock('@/config', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const render = (ui: ReactElement) => {
|
||||
function render(ui: ReactElement) {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
queryClient.setQueryData(consoleQuery.workspaces.customConfig.get.queryKey(), {
|
||||
remove_webapp_brand: false,
|
||||
@ -27,8 +30,12 @@ const render = (ui: ReactElement) => {
|
||||
|
||||
return renderWithConsoleQuery(ui, {
|
||||
queryClient,
|
||||
features: {
|
||||
can_replace_logo: canReplaceLogo,
|
||||
billing: { subscription: { plan } },
|
||||
},
|
||||
systemFeatures: {
|
||||
deployment_edition: 'CLOUD',
|
||||
deployment_edition: deploymentEdition,
|
||||
branding: {
|
||||
enabled: true,
|
||||
workspace_logo: 'https://example.com/workspace-logo.png',
|
||||
@ -49,9 +56,7 @@ const { mockToast } = vi.hoisted(() => {
|
||||
})
|
||||
return { mockToast }
|
||||
})
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
}))
|
||||
@ -59,32 +64,17 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: mockToast,
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseModalContext = vi.mocked(useModalContext)
|
||||
|
||||
const createProviderContext = ({
|
||||
enableBilling = false,
|
||||
planType = 'professional',
|
||||
}: {
|
||||
enableBilling?: boolean
|
||||
planType?: CloudPlan
|
||||
} = {}) => {
|
||||
return createMockProviderContextValue({
|
||||
enableBilling,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('CustomPage', () => {
|
||||
const setShowPricingModal = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext())
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
canReplaceLogo = true
|
||||
plan = 'professional'
|
||||
mockUseModalContext.mockReturnValue({
|
||||
setShowPricingModal,
|
||||
} as unknown as ReturnType<typeof useModalContext>)
|
||||
@ -102,13 +92,10 @@ describe('CustomPage', () => {
|
||||
})
|
||||
|
||||
it('should show the upgrade banner and open pricing modal for sandbox billing', async () => {
|
||||
deploymentEdition = 'CLOUD'
|
||||
const user = userEvent.setup()
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createProviderContext({
|
||||
enableBilling: true,
|
||||
planType: 'sandbox',
|
||||
}),
|
||||
)
|
||||
plan = 'sandbox'
|
||||
canReplaceLogo = false
|
||||
|
||||
render(<CustomPage />)
|
||||
|
||||
@ -121,12 +108,8 @@ describe('CustomPage', () => {
|
||||
})
|
||||
|
||||
it('should show the contact link for professional workspaces', () => {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createProviderContext({
|
||||
enableBilling: true,
|
||||
planType: 'professional',
|
||||
}),
|
||||
)
|
||||
deploymentEdition = 'CLOUD'
|
||||
canReplaceLogo = true
|
||||
|
||||
render(<CustomPage />)
|
||||
|
||||
@ -138,12 +121,9 @@ describe('CustomPage', () => {
|
||||
})
|
||||
|
||||
it('should show the contact link for team workspaces', () => {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createProviderContext({
|
||||
enableBilling: true,
|
||||
planType: 'team',
|
||||
}),
|
||||
)
|
||||
plan = 'team'
|
||||
deploymentEdition = 'CLOUD'
|
||||
canReplaceLogo = true
|
||||
|
||||
render(<CustomPage />)
|
||||
|
||||
@ -151,13 +131,8 @@ describe('CustomPage', () => {
|
||||
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide both billing sections when billing is disabled', () => {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createProviderContext({
|
||||
enableBilling: false,
|
||||
planType: 'sandbox',
|
||||
}),
|
||||
)
|
||||
it('should hide both billing sections for Community deployments', () => {
|
||||
canReplaceLogo = false
|
||||
|
||||
render(<CustomPage />)
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { contactSalesUrl } from '@/app/components/billing/config'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import CustomWebAppBrand from '../custom-web-app-brand'
|
||||
|
||||
const CustomPage = () => {
|
||||
@ -12,10 +12,19 @@ const CustomPage = () => {
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const { data: billing } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => ({
|
||||
plan: data.billing.subscription.plan,
|
||||
canReplaceLogo: data.can_replace_logo,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const { setShowPricingModal } = useModalContext()
|
||||
const showBillingTip = deploymentEdition === 'CLOUD' && enableBilling && plan.type === 'sandbox'
|
||||
const showContact = enableBilling && (plan.type === 'professional' || plan.type === 'team')
|
||||
const showBillingTip = deploymentEdition === 'CLOUD' && billing?.canReplaceLogo === false
|
||||
const showContact =
|
||||
deploymentEdition === 'CLOUD' && (billing?.plan === 'professional' || billing?.plan === 'team')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-x-hidden">
|
||||
|
||||
@ -21,7 +21,7 @@ const createHookState = (
|
||||
isCustomConfigUnavailable: false,
|
||||
uploadDisabled: false,
|
||||
workspaceLogo: 'https://example.com/workspace-logo.png',
|
||||
isSandbox: false,
|
||||
canReplaceLogo: true,
|
||||
canManageCustomBrand: true,
|
||||
handleApply: vi.fn(),
|
||||
handleCancel: vi.fn(),
|
||||
@ -88,7 +88,7 @@ describe('CustomWebAppBrand', () => {
|
||||
|
||||
it('should disable the switch when sandbox restrictions are active', () => {
|
||||
renderComponent({
|
||||
isSandbox: true,
|
||||
canReplaceLogo: false,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { createConsoleQueryClient, renderHookWithConsoleQuery } from '@/test/console/query-data'
|
||||
import useWebAppBrand from '../use-web-app-brand'
|
||||
|
||||
let canReplaceLogo = true
|
||||
|
||||
let currentBrandingOverrides: Partial<GetSystemFeaturesResponse['branding']> = {}
|
||||
let customConfig = {
|
||||
replace_webapp_logo: 'https://example.com/replace.png',
|
||||
@ -32,6 +30,9 @@ const renderHook = <Result, Props = void>(callback: (props: Props) => Result) =>
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
features: {
|
||||
can_replace_logo: canReplaceLogo,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -113,15 +114,12 @@ vi.mock('@/context/permission-state', async () => {
|
||||
refreshCurrentWorkspace: consoleStateRef.value?.refreshCurrentWorkspace,
|
||||
}))
|
||||
})
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/image-uploader/utils', () => ({
|
||||
imageUpload: vi.fn(),
|
||||
getImageUploadErrorMessage: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockImageUpload = vi.mocked(imageUpload)
|
||||
const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage)
|
||||
|
||||
@ -134,22 +132,6 @@ const testUserProfile = {
|
||||
is_password_set: false,
|
||||
}
|
||||
|
||||
const createProviderContext = ({
|
||||
enableBilling = false,
|
||||
planType = 'professional',
|
||||
}: {
|
||||
enableBilling?: boolean
|
||||
planType?: CloudPlan
|
||||
} = {}) => {
|
||||
return createMockProviderContextValue({
|
||||
enableBilling,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const createConsoleState = (overrides: Partial<ConsoleStateFixture> = {}): ConsoleStateFixture => {
|
||||
return {
|
||||
userProfile: testUserProfile,
|
||||
@ -183,7 +165,7 @@ describe('useWebAppBrand', () => {
|
||||
customConfigQueryPending = false
|
||||
customConfigQueryError = undefined
|
||||
mockUpdateCustomConfig.mockResolvedValue(customConfig)
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext())
|
||||
canReplaceLogo = true
|
||||
mockGetImageUploadErrorMessage.mockReturnValue('upload error')
|
||||
})
|
||||
|
||||
@ -228,17 +210,12 @@ describe('useWebAppBrand', () => {
|
||||
})
|
||||
|
||||
it('should disable uploads in sandbox workspaces and when branding is removed', () => {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createProviderContext({
|
||||
enableBilling: true,
|
||||
planType: 'sandbox',
|
||||
}),
|
||||
)
|
||||
canReplaceLogo = false
|
||||
customConfig = { ...customConfig, remove_webapp_brand: true }
|
||||
|
||||
const { result } = renderHook(() => useWebAppBrand())
|
||||
|
||||
expect(result.current.isSandbox).toBe(true)
|
||||
expect(!result.current.canReplaceLogo).toBe(true)
|
||||
expect(result.current.webappBrandRemoved).toBe(true)
|
||||
expect(result.current.uploadDisabled).toBe(true)
|
||||
})
|
||||
|
||||
@ -7,7 +7,6 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
@ -16,7 +15,11 @@ const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024
|
||||
const WEB_APP_LOGO_UPLOAD_URL = '/workspaces/custom-config/webapp-logo/upload'
|
||||
const useWebAppBrand = () => {
|
||||
const { t } = useTranslation()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const { data: canReplaceLogo } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
select: (data) => data.can_replace_logo,
|
||||
}),
|
||||
)
|
||||
const queryClient = useQueryClient()
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const [fileId, setFileId] = useState('')
|
||||
@ -28,24 +31,25 @@ const useWebAppBrand = () => {
|
||||
const updateCustomConfigMutation = useMutation(
|
||||
consoleQuery.workspaces.customConfig.post.mutationOptions(),
|
||||
)
|
||||
const isSandbox = enableBilling && plan.type === 'sandbox'
|
||||
const uploading = uploadProgress > 0 && uploadProgress < 100
|
||||
const webappLogo = customConfig?.replace_webapp_logo || ''
|
||||
const webappBrandRemoved = customConfig?.remove_webapp_brand ?? undefined
|
||||
const canManageCustomBrand = hasPermission(workspacePermissionKeys, 'customization.manage')
|
||||
const isCustomConfigUnavailable = customConfigQuery.isPending || customConfigQuery.isError
|
||||
const isCustomConfigUnavailable = customConfig === undefined || canReplaceLogo === undefined
|
||||
const uploadDisabled =
|
||||
isCustomConfigUnavailable || isSandbox || webappBrandRemoved || !canManageCustomBrand
|
||||
isCustomConfigUnavailable || !canReplaceLogo || webappBrandRemoved || !canManageCustomBrand
|
||||
const workspaceLogo = systemFeatures.branding.enabled
|
||||
? systemFeatures.branding.workspace_logo
|
||||
: ''
|
||||
const persistWorkspaceBrand = async (body: WorkspaceCustomConfigPayload) => {
|
||||
if (isCustomConfigUnavailable || !canReplaceLogo || !canManageCustomBrand) return
|
||||
await updateCustomConfigMutation.mutateAsync({ body })
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.workspaces.customConfig.get.key(),
|
||||
})
|
||||
}
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (uploadDisabled) return
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (file.size > MAX_LOGO_FILE_SIZE) {
|
||||
@ -107,7 +111,7 @@ const useWebAppBrand = () => {
|
||||
isCustomConfigUnavailable,
|
||||
uploadDisabled,
|
||||
workspaceLogo,
|
||||
isSandbox,
|
||||
canReplaceLogo,
|
||||
canManageCustomBrand,
|
||||
handleApply,
|
||||
handleCancel,
|
||||
|
||||
@ -22,7 +22,7 @@ const CustomWebAppBrand = () => {
|
||||
uploadDisabled,
|
||||
workspaceLogo,
|
||||
canManageCustomBrand,
|
||||
isSandbox,
|
||||
canReplaceLogo,
|
||||
handleApply,
|
||||
handleCancel,
|
||||
handleChange,
|
||||
@ -37,7 +37,7 @@ const CustomWebAppBrand = () => {
|
||||
<Switch
|
||||
size="lg"
|
||||
checked={webappBrandRemoved ?? false}
|
||||
disabled={isCustomConfigUnavailable || isSandbox || !canManageCustomBrand}
|
||||
disabled={isCustomConfigUnavailable || !canReplaceLogo || !canManageCustomBrand}
|
||||
onCheckedChange={handleSwitch}
|
||||
/>
|
||||
</div>
|
||||
@ -106,7 +106,12 @@ const CustomWebAppBrand = () => {
|
||||
variant="primary"
|
||||
className="mr-2"
|
||||
onClick={handleApply}
|
||||
disabled={isCustomConfigUnavailable || webappBrandRemoved || !canManageCustomBrand}
|
||||
disabled={
|
||||
isCustomConfigUnavailable ||
|
||||
!canReplaceLogo ||
|
||||
webappBrandRemoved ||
|
||||
!canManageCustomBrand
|
||||
}
|
||||
>
|
||||
{t(($) => $.apply, { ns: 'custom' })}
|
||||
</Button>
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { IndexingStatusResponse } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import EmbeddingProcess from '../index'
|
||||
|
||||
const mockInvalidDocumentList = vi.fn()
|
||||
let mockEnableBilling = false
|
||||
let mockPlanType = 'sandbox'
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY'
|
||||
let mockPlanType: 'sandbox' | 'professional' | 'team' = 'sandbox'
|
||||
let mockPollingState: {
|
||||
statusList: IndexingStatusResponse[]
|
||||
isEmbedding: boolean
|
||||
@ -45,13 +47,6 @@ vi.mock('@/hooks/use-api-access-url', () => ({
|
||||
useDatasetApiAccessUrl: () => 'https://api.example.com/docs',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
enableBilling: mockEnableBilling,
|
||||
plan: { type: mockPlanType },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-indexing-status-polling', () => ({
|
||||
useIndexingStatusPolling: () => mockPollingState,
|
||||
}))
|
||||
@ -84,10 +79,17 @@ vi.mock('@/app/components/datasets/common/vector-space-admission-alert', () => (
|
||||
),
|
||||
}))
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
features: { billing: { subscription: { plan: mockPlanType } } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('EmbeddingProcess', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
mockPlanType = 'sandbox'
|
||||
mockPollingState = {
|
||||
statusList: [],
|
||||
@ -156,7 +158,7 @@ describe('EmbeddingProcess', () => {
|
||||
})
|
||||
|
||||
it('does not suggest an upgrade to team users', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'team'
|
||||
mockPollingState = {
|
||||
statusList: [
|
||||
@ -200,7 +202,7 @@ describe('EmbeddingProcess', () => {
|
||||
})
|
||||
|
||||
it('offers a processing-priority upgrade outside the team plan', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
|
||||
render(<EmbeddingProcess datasetId="dataset-1" batchId="batch-1" />)
|
||||
|
||||
|
||||
@ -106,16 +106,4 @@ describe('IndexingProgressItem', () => {
|
||||
|
||||
expect(screen.getByText('common.error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show priority label when billing is enabled', () => {
|
||||
render(<IndexingProgressItem detail={makeDetail()} name="test.pdf" enableBilling={true} />)
|
||||
|
||||
expect(screen.getByTestId('priority-label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show priority label when billing is disabled', () => {
|
||||
render(<IndexingProgressItem detail={makeDetail()} name="test.pdf" enableBilling={false} />)
|
||||
|
||||
expect(screen.queryByTestId('priority-label')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,13 +4,16 @@ import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowRightLine, RiLoader2Fill, RiTerminalBoxLine } from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import VectorSpaceAdmissionAlert from '@/app/components/datasets/common/vector-space-admission-alert'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useProcessRule } from '@/service/knowledge/use-dataset'
|
||||
import { useInvalidDocumentList } from '@/service/knowledge/use-document'
|
||||
import IndexingProgressItem from './indexing-progress-item'
|
||||
@ -85,7 +88,13 @@ const EmbeddingProcess: FC<EmbeddingProcessProps> = ({
|
||||
indexingType,
|
||||
retrievalMethod,
|
||||
}) => {
|
||||
const { enableBilling, plan } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const invalidDocumentList = useInvalidDocumentList()
|
||||
const apiReferenceUrl = useDatasetApiAccessUrl()
|
||||
|
||||
@ -104,9 +113,10 @@ const EmbeddingProcess: FC<EmbeddingProcessProps> = ({
|
||||
|
||||
const documentsHref = `/datasets/${datasetId}/documents`
|
||||
|
||||
const showUpgradeBanner = enableBilling && plan.type !== 'team'
|
||||
const showUpgradeBanner =
|
||||
deploymentEdition === 'CLOUD' && (plan === 'sandbox' || plan === 'professional')
|
||||
const showVectorSpaceUpgrade =
|
||||
enableBilling && (plan.type === 'sandbox' || plan.type === 'professional')
|
||||
deploymentEdition === 'CLOUD' && (plan === 'sandbox' || plan === 'professional')
|
||||
const vectorSpaceAdmissionError = statusList.find(
|
||||
(detail) => detail.error_code === 'vector_space_estimate_exceeded',
|
||||
)
|
||||
@ -135,7 +145,6 @@ const EmbeddingProcess: FC<EmbeddingProcessProps> = ({
|
||||
name={documentLookup.getName(detail.id)}
|
||||
sourceType={documentLookup.getSourceType(detail.id)}
|
||||
notionIcon={documentLookup.getNotionIcon(detail.id)}
|
||||
enableBilling={enableBilling}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -15,7 +15,6 @@ type IndexingProgressItemProps = {
|
||||
name?: string
|
||||
sourceType?: DataSourceType
|
||||
notionIcon?: string
|
||||
enableBilling?: boolean
|
||||
}
|
||||
|
||||
// Status icon component for completed/error states
|
||||
@ -71,7 +70,6 @@ const IndexingProgressItem: FC<IndexingProgressItemProps> = ({
|
||||
name,
|
||||
sourceType,
|
||||
notionIcon,
|
||||
enableBilling,
|
||||
}) => {
|
||||
const isEmbedding = isSourceEmbedding(detail)
|
||||
const percent = getSourcePercent(detail)
|
||||
@ -94,7 +92,7 @@ const IndexingProgressItem: FC<IndexingProgressItemProps> = ({
|
||||
<SourceTypeIcon sourceType={sourceType} name={name} notionIcon={notionIcon} />
|
||||
<div className="flex w-0 grow items-center gap-1" title={name}>
|
||||
<div className="truncate system-xs-medium text-text-secondary">{name}</div>
|
||||
{enableBilling && <PriorityLabel className="ml-0" />}
|
||||
<PriorityLabel className="ml-0" />
|
||||
</div>
|
||||
{isEmbedding && <div className="shrink-0 text-xs text-text-secondary">{`${percent}%`}</div>}
|
||||
<StatusIcon status={detail.indexing_status} error={detail.error} />
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { ReactElement, RefObject } from 'react'
|
||||
import type { UploadDropzoneProps } from '../upload-dropzone'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import UploadDropzone from '../upload-dropzone'
|
||||
|
||||
let mockEnableBilling = false
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: <T,>(
|
||||
selector: (state: Pick<ProviderContextState, 'enableBilling'>) => T,
|
||||
): T => selector({ enableBilling: mockEnableBilling }),
|
||||
}))
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY'
|
||||
|
||||
// Helper to create mock ref objects for testing
|
||||
const createMockRef = <T,>(value: T | null = null): RefObject<T | null> => ({ current: value })
|
||||
@ -36,7 +30,7 @@ describe('UploadDropzone', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
@ -83,7 +77,7 @@ describe('UploadDropzone', () => {
|
||||
|
||||
describe('tip rendering by billing state', () => {
|
||||
it('should render tip without total count limit when billing is disabled', () => {
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
|
||||
@ -97,7 +91,7 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
|
||||
it('should render tip with total count limit when billing is enabled', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
|
||||
@ -110,7 +104,7 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
|
||||
it('should pass file size, batch count and supported types to tip when billing is disabled', () => {
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
|
||||
@ -122,7 +116,7 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
|
||||
it('should additionally pass total count to tip when billing is enabled', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
|
||||
@ -275,3 +269,10 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
features: {},
|
||||
})
|
||||
}
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { FileUploadConfig } from '../hooks/use-file-upload'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
|
||||
export type UploadDropzoneProps = {
|
||||
dropRef: RefObject<HTMLDivElement | null>
|
||||
@ -31,7 +32,7 @@ const UploadDropzone = ({
|
||||
onFileChange,
|
||||
}: UploadDropzoneProps) => {
|
||||
const { t } = useTranslation()
|
||||
const enableBilling = useProviderContextSelector((state) => state.enableBilling)
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -69,7 +70,7 @@ const UploadDropzone = ({
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{enableBilling
|
||||
{deploymentEdition === 'CLOUD'
|
||||
? t(($) => $['stepOne.uploader.tipWithTotalLimit'], {
|
||||
ns: 'datasetCreation',
|
||||
size: fileUploadConfig.file_size_limit,
|
||||
|
||||
@ -28,6 +28,8 @@ let mockPlan: {
|
||||
total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 },
|
||||
}
|
||||
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY'
|
||||
|
||||
const render = (ui: React.ReactElement, vectorSpaceUsageUnknown = false) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryOptions().queryKey, {
|
||||
@ -36,8 +38,9 @@ const render = (ui: React.ReactElement, vectorSpaceUsageUnknown = false) => {
|
||||
usage_unknown: vectorSpaceUsageUnknown,
|
||||
})
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
queryClient,
|
||||
features: { billing: { subscription: { plan: mockPlan.type } } },
|
||||
})
|
||||
}
|
||||
|
||||
@ -60,14 +63,6 @@ vi.mock('@/context/dataset-detail', () => ({
|
||||
}))
|
||||
|
||||
// Mock provider context
|
||||
let mockEnableBilling = false
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: mockPlan,
|
||||
enableBilling: mockEnableBilling,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../file-uploader', () => ({
|
||||
default: ({ onPreview, fileList }: { onPreview: (file: File) => void; fileList: FileItem[] }) => (
|
||||
@ -250,7 +245,7 @@ describe('StepOne', () => {
|
||||
usage: { vectorSpace: 50, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 },
|
||||
total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 },
|
||||
}
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -430,7 +425,7 @@ describe('StepOne', () => {
|
||||
})
|
||||
|
||||
it('should show plan upgrade modal when batch upload not supported and multiple files', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlan.type = 'sandbox'
|
||||
const files = [createMockFileItem(), createMockFileItem()]
|
||||
render(<StepOne {...defaultProps} files={files} />)
|
||||
@ -441,7 +436,7 @@ describe('StepOne', () => {
|
||||
})
|
||||
|
||||
it('should show upgrade card immediately when in sandbox plan', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlan.type = 'sandbox'
|
||||
|
||||
render(<StepOne {...defaultProps} files={[]} />)
|
||||
@ -453,7 +448,7 @@ describe('StepOne', () => {
|
||||
// Vector Space Full Tests
|
||||
describe('Vector Space Full', () => {
|
||||
it('should show VectorSpaceFull when vector space is full and billing is enabled', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlan.usage.vectorSpace = 100
|
||||
mockPlan.total.vectorSpace = 100
|
||||
const files = [createMockFileItem()]
|
||||
@ -464,7 +459,7 @@ describe('StepOne', () => {
|
||||
})
|
||||
|
||||
it('should disable next button when vector space is full', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlan.usage.vectorSpace = 100
|
||||
mockPlan.total.vectorSpace = 100
|
||||
const files = [createMockFileItem()]
|
||||
@ -475,7 +470,7 @@ describe('StepOne', () => {
|
||||
})
|
||||
|
||||
it('should require sandbox users to retry when vector space usage is unknown', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlan.type = 'sandbox'
|
||||
mockPlan.usage.vectorSpace = 100
|
||||
mockPlan.total.vectorSpace = 100
|
||||
@ -490,7 +485,7 @@ describe('StepOne', () => {
|
||||
})
|
||||
|
||||
it('should allow paid users to continue when vector space usage is unknown', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlan.type = 'professional'
|
||||
const files = [createMockFileItem()]
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiFolder6Line } from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NotionConnector from '@/app/components/base/notion-connector'
|
||||
@ -14,7 +15,7 @@ import { NotionPageSelector } from '@/app/components/base/notion-page-selector'
|
||||
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
|
||||
import VectorSpaceUnavailable from '@/app/components/billing/vector-space-unavailable'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import EmptyDatasetCreationModal from '../empty-dataset-creation-modal'
|
||||
@ -100,7 +101,13 @@ const StepOne = ({
|
||||
}: IStepOneProps) => {
|
||||
const { t } = useTranslation()
|
||||
const dataset = useDatasetDetailContextWithSelector((state) => state.dataset)
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
|
||||
// Preview state management
|
||||
const {
|
||||
@ -134,7 +141,7 @@ const StepOne = ({
|
||||
|
||||
const allFileLoaded = files.length > 0 && files.every((file) => file.file.id)
|
||||
const hasNotion = notionPages.length > 0
|
||||
const shouldCheckVectorSpace = enableBilling && (allFileLoaded || hasNotion)
|
||||
const shouldCheckVectorSpace = deploymentEdition === 'CLOUD' && (allFileLoaded || hasNotion)
|
||||
const {
|
||||
data: vectorSpace,
|
||||
isFetching: isFetchingVectorSpacePlan,
|
||||
@ -144,14 +151,17 @@ const StepOne = ({
|
||||
)
|
||||
const isCheckingVectorSpace = shouldCheckVectorSpace && !vectorSpace && isFetchingVectorSpacePlan
|
||||
const isVectorSpaceUnavailable =
|
||||
shouldCheckVectorSpace && plan.type === 'sandbox' && !!vectorSpace?.usage_unknown
|
||||
shouldCheckVectorSpace && plan === 'sandbox' && !!vectorSpace?.usage_unknown
|
||||
const isVectorSpaceFull =
|
||||
!!vectorSpace &&
|
||||
!vectorSpace.usage_unknown &&
|
||||
vectorSpace.limit > 0 &&
|
||||
vectorSpace.size >= vectorSpace.limit
|
||||
const isShowVectorSpaceFull = (allFileLoaded || hasNotion) && isVectorSpaceFull && enableBilling
|
||||
const supportBatchUpload = !enableBilling || plan.type !== 'sandbox'
|
||||
const isShowVectorSpaceFull =
|
||||
(allFileLoaded || hasNotion) && isVectorSpaceFull && deploymentEdition === 'CLOUD'
|
||||
const isPlanUnavailable = deploymentEdition === 'CLOUD' && plan === undefined
|
||||
const supportBatchUpload =
|
||||
deploymentEdition !== 'CLOUD' || plan === 'professional' || plan === 'team'
|
||||
|
||||
const isNotionAuthed = useMemo(
|
||||
() => checkNotionAuth(authedDataSourceList),
|
||||
@ -181,6 +191,7 @@ const StepOne = ({
|
||||
|
||||
// Handle step change with batch upload check
|
||||
const onStepChange = useCallback(() => {
|
||||
if (isPlanUnavailable) return
|
||||
if (!supportBatchUpload && dataSourceType) {
|
||||
const checkFn = MULTIPLE_ITEMS_CHECK[dataSourceType]
|
||||
if (checkFn?.({ files, notionPages, websitePages })) {
|
||||
@ -193,6 +204,7 @@ const StepOne = ({
|
||||
dataSourceType,
|
||||
doOnStepChange,
|
||||
files,
|
||||
isPlanUnavailable,
|
||||
supportBatchUpload,
|
||||
notionPages,
|
||||
showPlanUpgradeModal,
|
||||
@ -247,8 +259,11 @@ const StepOne = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<NextStepButton disabled={fileNextDisabled} onClick={onStepChange} />
|
||||
{enableBilling && plan.type === 'sandbox' && (
|
||||
<NextStepButton
|
||||
disabled={isPlanUnavailable || fileNextDisabled}
|
||||
onClick={onStepChange}
|
||||
/>
|
||||
{deploymentEdition === 'CLOUD' && plan === 'sandbox' && (
|
||||
<div className="mt-5">
|
||||
<div className="mb-4 h-px bg-divider-subtle" />
|
||||
<UpgradeCard />
|
||||
@ -292,7 +307,10 @@ const StepOne = ({
|
||||
)}
|
||||
<NextStepButton
|
||||
disabled={
|
||||
isShowVectorSpaceFull || isVectorSpaceUnavailable || !notionPages.length
|
||||
isPlanUnavailable ||
|
||||
isShowVectorSpaceFull ||
|
||||
isVectorSpaceUnavailable ||
|
||||
!notionPages.length
|
||||
}
|
||||
onClick={onStepChange}
|
||||
/>
|
||||
@ -322,7 +340,7 @@ const StepOne = ({
|
||||
</div>
|
||||
)}
|
||||
<NextStepButton
|
||||
disabled={isShowVectorSpaceFull || !websitePages.length}
|
||||
disabled={isPlanUnavailable || isShowVectorSpaceFull || !websitePages.length}
|
||||
onClick={onStepChange}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -6,7 +6,7 @@ import CreateFromPipeline from '../index'
|
||||
const mockPlan = {
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 100 },
|
||||
type: 'professional',
|
||||
type: 'professional' as 'professional' | 'sandbox',
|
||||
}
|
||||
|
||||
const render = (ui: React.ReactElement, vectorSpaceUsageUnknown = false) => {
|
||||
@ -16,7 +16,11 @@ const render = (ui: React.ReactElement, vectorSpaceUsageUnknown = false) => {
|
||||
limit: mockPlan.total.vectorSpace,
|
||||
usage_unknown: vectorSpaceUsageUnknown,
|
||||
})
|
||||
return renderWithConsoleQuery(ui, { queryClient })
|
||||
return renderWithConsoleQuery(ui, {
|
||||
queryClient,
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: { billing: { subscription: { plan: mockPlan.type } } },
|
||||
})
|
||||
}
|
||||
|
||||
let mockDatasetPermissionKeys = ['dataset.acl.use']
|
||||
@ -24,12 +28,6 @@ let mockAllFileLoaded = false
|
||||
const mockRouterReplace = vi.fn()
|
||||
const mockStepOneContent = vi.fn()
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: (
|
||||
selector: (state: { plan: typeof mockPlan; enableBilling: boolean }) => unknown,
|
||||
) => selector({ plan: mockPlan, enableBilling: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
@ -37,6 +35,7 @@ vi.mock('@/context/workspace-state', async () => {
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
isLoadingWorkspacePermissionKeys: false,
|
||||
deploymentEdition: 'CLOUD',
|
||||
}))
|
||||
})
|
||||
|
||||
@ -47,6 +46,7 @@ vi.mock('@/context/permission-state', async () => {
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
isLoadingWorkspacePermissionKeys: false,
|
||||
deploymentEdition: 'CLOUD',
|
||||
}))
|
||||
})
|
||||
|
||||
@ -57,6 +57,7 @@ vi.mock('@/features/system-features/state', async () => {
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
isLoadingWorkspacePermissionKeys: false,
|
||||
deploymentEdition: 'CLOUD',
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FileItem } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import LocalFile from '../index'
|
||||
|
||||
// Mock the hook
|
||||
@ -16,9 +17,6 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
}))
|
||||
|
||||
// Mock theme types
|
||||
vi.mock('@/types/app', () => ({
|
||||
Theme: { dark: 'dark', light: 'light' },
|
||||
}))
|
||||
|
||||
// Mock DocumentFileIcon
|
||||
vi.mock('@/app/components/datasets/common/document-file-icon', () => ({
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { ReactElement, RefObject } from 'react'
|
||||
import type { UploadDropzoneProps } from '../upload-dropzone'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import UploadDropzone from '../upload-dropzone'
|
||||
|
||||
let mockEnableBilling = false
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: <T,>(
|
||||
selector: (state: Pick<ProviderContextState, 'enableBilling'>) => T,
|
||||
): T => selector({ enableBilling: mockEnableBilling }),
|
||||
}))
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY'
|
||||
|
||||
// Helper to create mock ref objects for testing
|
||||
const createMockRef = <T,>(value: T | null = null): RefObject<T | null> => ({ current: value })
|
||||
@ -37,7 +31,7 @@ describe('UploadDropzone', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
@ -89,7 +83,7 @@ describe('UploadDropzone', () => {
|
||||
|
||||
describe('tip rendering by billing state', () => {
|
||||
it('should render tip without total count limit when billing is disabled', () => {
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
|
||||
@ -103,7 +97,7 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
|
||||
it('should render tip with total count limit when billing is enabled', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
|
||||
@ -116,7 +110,7 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
|
||||
it('should pass file size, batch count and supported types to tip when billing is disabled', () => {
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
|
||||
@ -128,7 +122,7 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
|
||||
it('should additionally pass total count to tip when billing is enabled', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
|
||||
render(<UploadDropzone {...defaultProps} />)
|
||||
|
||||
@ -294,3 +288,10 @@ describe('UploadDropzone', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
features: {},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { ChangeEvent, RefObject } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
|
||||
type FileUploadConfig = {
|
||||
file_size_limit: number
|
||||
@ -37,7 +38,7 @@ const UploadDropzone = ({
|
||||
allowedExtensions,
|
||||
}: UploadDropzoneProps) => {
|
||||
const { t } = useTranslation()
|
||||
const enableBilling = useProviderContextSelector((state) => state.enableBilling)
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -75,7 +76,7 @@ const UploadDropzone = ({
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{enableBilling
|
||||
{deploymentEdition === 'CLOUD'
|
||||
? t(($) => $['stepOne.uploader.tipWithTotalLimit'], {
|
||||
ns: 'datasetCreation',
|
||||
size: fileUploadConfig.file_size_limit,
|
||||
|
||||
@ -15,7 +15,7 @@ describe('useDatasourceUIState', () => {
|
||||
selectedFileIdsLength: 0,
|
||||
onlineDriveFileList: [] as OnlineDriveFile[],
|
||||
isVectorSpaceFull: false,
|
||||
enableBilling: false,
|
||||
|
||||
currentWorkspacePagesLength: 0,
|
||||
fileUploadConfig: { file_size_limit: 50, batch_count_limit: 20 },
|
||||
}
|
||||
@ -39,19 +39,12 @@ describe('useDatasourceUIState', () => {
|
||||
})
|
||||
|
||||
describe('isShowVectorSpaceFull', () => {
|
||||
it('should be false when billing disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDatasourceUIState({ ...defaultParams, isVectorSpaceFull: true }),
|
||||
)
|
||||
expect(result.current.isShowVectorSpaceFull).toBe(false)
|
||||
})
|
||||
|
||||
it('should be true when billing enabled and space is full for local file', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDatasourceUIState({
|
||||
...defaultParams,
|
||||
isVectorSpaceFull: true,
|
||||
enableBilling: true,
|
||||
|
||||
allFileLoaded: true,
|
||||
}),
|
||||
)
|
||||
@ -64,7 +57,6 @@ describe('useDatasourceUIState', () => {
|
||||
...defaultParams,
|
||||
datasource: undefined,
|
||||
isVectorSpaceFull: true,
|
||||
enableBilling: true,
|
||||
}),
|
||||
)
|
||||
expect(result.current.isShowVectorSpaceFull).toBe(false)
|
||||
|
||||
@ -14,7 +14,6 @@ type DatasourceUIStateParams = {
|
||||
onlineDriveFileList: OnlineDriveFile[]
|
||||
isVectorSpaceFull: boolean
|
||||
isCheckingVectorSpace?: boolean
|
||||
enableBilling: boolean
|
||||
currentWorkspacePagesLength: number
|
||||
fileUploadConfig: { file_size_limit: number; batch_count_limit: number }
|
||||
}
|
||||
@ -32,7 +31,6 @@ export const useDatasourceUIState = ({
|
||||
onlineDriveFileList,
|
||||
isVectorSpaceFull,
|
||||
isCheckingVectorSpace = false,
|
||||
enableBilling,
|
||||
currentWorkspacePagesLength,
|
||||
fileUploadConfig,
|
||||
}: DatasourceUIStateParams) => {
|
||||
@ -51,7 +49,7 @@ export const useDatasourceUIState = ({
|
||||
}
|
||||
|
||||
const condition = vectorSpaceFullConditions[datasourceType]
|
||||
return condition && isVectorSpaceFull && enableBilling
|
||||
return condition && isVectorSpaceFull
|
||||
}, [
|
||||
datasource,
|
||||
datasourceType,
|
||||
@ -60,7 +58,6 @@ export const useDatasourceUIState = ({
|
||||
websitePagesLength,
|
||||
onlineDriveFileList.length,
|
||||
isVectorSpaceFull,
|
||||
enableBilling,
|
||||
])
|
||||
|
||||
// Lookup table for next button disabled conditions
|
||||
|
||||
@ -16,8 +16,8 @@ import {
|
||||
workspacePermissionKeysAtom,
|
||||
workspacePermissionKeysLoadingAtom,
|
||||
} from '@/context/permission-state'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -42,8 +42,13 @@ import { StepOnePreview, StepTwoPreview } from './steps/preview-panel'
|
||||
const CreateFormPipeline = () => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const plan = useProviderContextSelector((state) => state.plan)
|
||||
const enableBilling = useProviderContextSelector((state) => state.enableBilling)
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const dataset = useDatasetDetailContextWithSelector((s) => s.dataset)
|
||||
const pipelineId = dataset?.pipeline_id
|
||||
const { data: currentUserId } = useSuspenseQuery({
|
||||
@ -119,7 +124,7 @@ const CreateFormPipeline = () => {
|
||||
|
||||
// Computed values
|
||||
const shouldCheckVectorSpace =
|
||||
enableBilling &&
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
(allFileLoaded ||
|
||||
onlineDocuments.length > 0 ||
|
||||
websitePages.length > 0 ||
|
||||
@ -133,13 +138,16 @@ const CreateFormPipeline = () => {
|
||||
)
|
||||
const isCheckingVectorSpace = shouldCheckVectorSpace && !vectorSpace && isFetchingVectorSpacePlan
|
||||
const isVectorSpaceUnavailable =
|
||||
shouldCheckVectorSpace && plan.type === 'sandbox' && !!vectorSpace?.usage_unknown
|
||||
shouldCheckVectorSpace && plan === 'sandbox' && !!vectorSpace?.usage_unknown
|
||||
const isVectorSpaceFull =
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
!!vectorSpace &&
|
||||
!vectorSpace.usage_unknown &&
|
||||
vectorSpace.limit > 0 &&
|
||||
vectorSpace.size >= vectorSpace.limit
|
||||
const supportBatchUpload = !enableBilling || plan.type !== 'sandbox'
|
||||
const isPlanUnavailable = deploymentEdition === 'CLOUD' && plan === undefined
|
||||
const supportBatchUpload =
|
||||
deploymentEdition !== 'CLOUD' || plan === 'professional' || plan === 'team'
|
||||
|
||||
// UI state
|
||||
const {
|
||||
@ -160,7 +168,6 @@ const CreateFormPipeline = () => {
|
||||
onlineDriveFileList,
|
||||
isVectorSpaceFull,
|
||||
isCheckingVectorSpace: isCheckingVectorSpace || isVectorSpaceUnavailable,
|
||||
enableBilling,
|
||||
currentWorkspacePagesLength: currentWorkspace?.pages.length ?? 0,
|
||||
fileUploadConfig,
|
||||
})
|
||||
@ -173,6 +180,7 @@ const CreateFormPipeline = () => {
|
||||
|
||||
// Next step with batch upload check
|
||||
const handleNextStep = useCallback(() => {
|
||||
if (isPlanUnavailable) return
|
||||
if (!supportBatchUpload) {
|
||||
const multipleCheckMap: Record<string, number> = {
|
||||
[DatasourceType.localFile]: localFileList.length,
|
||||
@ -194,6 +202,7 @@ const CreateFormPipeline = () => {
|
||||
onlineDocuments.length,
|
||||
selectedFileIds.length,
|
||||
showPlanUpgradeModal,
|
||||
isPlanUnavailable,
|
||||
supportBatchUpload,
|
||||
websitePages.length,
|
||||
])
|
||||
@ -222,7 +231,7 @@ const CreateFormPipeline = () => {
|
||||
setEstimateData,
|
||||
setBatchId,
|
||||
setDocuments,
|
||||
handleNextStep,
|
||||
handleNextStep: doHandleNextStep,
|
||||
PagesMapAndSelectedPagesId,
|
||||
currentWorkspacePages: currentWorkspace?.pages,
|
||||
clearOnlineDocumentData,
|
||||
@ -257,6 +266,7 @@ const CreateFormPipeline = () => {
|
||||
datasourceType={datasourceType}
|
||||
pipelineNodes={(pipelineInfo?.graph.nodes || []) as Node<DataSourceNodeType>[]}
|
||||
supportBatchUpload={supportBatchUpload}
|
||||
showBatchUploadUpgrade={deploymentEdition === 'CLOUD' && plan === 'sandbox'}
|
||||
isShowVectorSpaceFull={isShowVectorSpaceFull}
|
||||
isShowVectorSpaceUnavailable={isVectorSpaceUnavailable}
|
||||
isRetryingVectorSpace={isFetchingVectorSpacePlan}
|
||||
@ -264,7 +274,7 @@ const CreateFormPipeline = () => {
|
||||
totalOptions={totalOptions}
|
||||
selectedOptions={selectedOptions}
|
||||
tip={tip}
|
||||
nextBtnDisabled={nextBtnDisabled}
|
||||
nextBtnDisabled={isPlanUnavailable || nextBtnDisabled}
|
||||
onSelectDataSource={handleSwitchDataSource}
|
||||
onCredentialChange={handleCredentialChange}
|
||||
onSelectAll={handleSelectAll}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Mock } from 'vite-plus/test'
|
||||
import type { DocumentIndexingStatus, IndexingStatusResponse } from '@/models/datasets'
|
||||
import type { InitialDocumentDetail } from '@/models/pipeline'
|
||||
@ -6,7 +7,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import EmbeddingProcess from '../index'
|
||||
|
||||
@ -29,14 +30,8 @@ vi.mock('@/next/link', () => ({
|
||||
}))
|
||||
|
||||
// Mock provider context
|
||||
let mockEnableBilling = false
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY'
|
||||
let mockPlanType: CloudPlan = 'sandbox'
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
enableBilling: mockEnableBilling,
|
||||
plan: { type: mockPlanType },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/common/vector-space-admission-alert', () => ({
|
||||
default: ({
|
||||
@ -142,6 +137,13 @@ const createDefaultProps = (
|
||||
...overrides,
|
||||
})
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
features: { billing: { subscription: { plan: mockPlanType } } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('EmbeddingProcess', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -151,7 +153,7 @@ describe('EmbeddingProcess', () => {
|
||||
documentIdCounter = 0
|
||||
|
||||
// Reset mock states
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
mockPlanType = 'sandbox'
|
||||
mockIndexingStatusData = []
|
||||
|
||||
@ -192,7 +194,7 @@ describe('EmbeddingProcess', () => {
|
||||
describe('Billing and Upgrade Banner', () => {
|
||||
// Tests for billing-related UI
|
||||
it('should not show upgrade banner when billing is disabled', () => {
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
const props = createDefaultProps()
|
||||
|
||||
render(<EmbeddingProcess {...props} />)
|
||||
@ -203,7 +205,7 @@ describe('EmbeddingProcess', () => {
|
||||
})
|
||||
|
||||
it('should show upgrade banner when billing is enabled and plan is not team', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'sandbox'
|
||||
const props = createDefaultProps()
|
||||
|
||||
@ -215,7 +217,7 @@ describe('EmbeddingProcess', () => {
|
||||
})
|
||||
|
||||
it('should not show upgrade banner when plan is team', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'team'
|
||||
const props = createDefaultProps()
|
||||
|
||||
@ -227,7 +229,7 @@ describe('EmbeddingProcess', () => {
|
||||
})
|
||||
|
||||
it('should show upgrade banner for professional plan', () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'professional'
|
||||
const props = createDefaultProps()
|
||||
|
||||
@ -379,7 +381,7 @@ describe('EmbeddingProcess', () => {
|
||||
})
|
||||
|
||||
it('should not suggest an upgrade to team users', async () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'team'
|
||||
const doc1 = createMockDocument({ id: 'doc-1' })
|
||||
mockIndexingStatusData = [
|
||||
@ -1052,7 +1054,7 @@ describe('EmbeddingProcess', () => {
|
||||
describe('Priority Label', () => {
|
||||
// Tests for priority label display
|
||||
it('should show priority label when billing is enabled', async () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'sandbox'
|
||||
const doc1 = createMockDocument({ id: 'doc-1' })
|
||||
mockIndexingStatusData = [
|
||||
@ -1071,7 +1073,7 @@ describe('EmbeddingProcess', () => {
|
||||
})
|
||||
|
||||
it('should not show priority label when billing is disabled', async () => {
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
const doc1 = createMockDocument({ id: 'doc-1' })
|
||||
mockIndexingStatusData = [
|
||||
createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }),
|
||||
|
||||
@ -13,6 +13,8 @@ import {
|
||||
RiLoader2Fill,
|
||||
RiTerminalBoxLine,
|
||||
} from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -22,10 +24,11 @@ import PriorityLabel from '@/app/components/billing/priority-label'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
|
||||
import VectorSpaceAdmissionAlert from '@/app/components/datasets/common/vector-space-admission-alert'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useIndexingStatusBatch, useProcessRule } from '@/service/knowledge/use-dataset'
|
||||
import { useInvalidDocumentList } from '@/service/knowledge/use-document'
|
||||
import RuleDetail from './rule-detail'
|
||||
@ -46,7 +49,13 @@ const EmbeddingProcess = ({
|
||||
retrievalMethod,
|
||||
}: EmbeddingProcessProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { enableBilling, plan } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const [indexingStatusBatchDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse[]>(
|
||||
[],
|
||||
)
|
||||
@ -114,7 +123,8 @@ const EmbeddingProcess = ({
|
||||
),
|
||||
[indexingStatusBatchDetail],
|
||||
)
|
||||
const showUpgrade = enableBilling && (plan.type === 'sandbox' || plan.type === 'professional')
|
||||
const showUpgrade =
|
||||
deploymentEdition === 'CLOUD' && (plan === 'sandbox' || plan === 'professional')
|
||||
|
||||
const getSourceName = (id: string) => {
|
||||
const doc = documents.find((document) => document.id === id)
|
||||
@ -166,7 +176,7 @@ const EmbeddingProcess = ({
|
||||
planLimitMb={vectorSpaceAdmissionError.vector_space_limit_mb}
|
||||
/>
|
||||
)}
|
||||
{enableBilling && plan.type !== 'team' && (
|
||||
{deploymentEdition === 'CLOUD' && (plan === 'sandbox' || plan === 'professional') && (
|
||||
<div className="flex h-13 items-center gap-x-2 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-2.5 pl-3 shadow-xs shadow-shadow-shadow-3">
|
||||
<div className="flex shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 shadow-md shadow-shadow-shadow-5">
|
||||
<RiAedFill className="size-4 text-text-primary-on-surface" />
|
||||
@ -216,7 +226,7 @@ const EmbeddingProcess = ({
|
||||
<div className="truncate system-xs-medium text-text-secondary">
|
||||
{getSourceName(indexingStatusDetail.id)}
|
||||
</div>
|
||||
{enableBilling && <PriorityLabel className="ml-0" />}
|
||||
<PriorityLabel className="ml-0" />
|
||||
</div>
|
||||
{isSourceEmbedding(indexingStatusDetail) && (
|
||||
<div className="shrink-0 text-xs text-text-secondary">{`${getSourcePercent(indexingStatusDetail)}%`}</div>
|
||||
|
||||
@ -254,6 +254,7 @@ describe('StepOneContent', () => {
|
||||
datasourceType: DatasourceType.localFile,
|
||||
pipelineNodes: mockPipelineNodes,
|
||||
supportBatchUpload: true,
|
||||
showBatchUploadUpgrade: false,
|
||||
isShowVectorSpaceFull: false,
|
||||
isShowVectorSpaceUnavailable: false,
|
||||
isRetryingVectorSpace: false,
|
||||
@ -346,7 +347,20 @@ describe('StepOneContent', () => {
|
||||
})
|
||||
|
||||
describe('Conditional Rendering - UpgradeCard', () => {
|
||||
it('should render UpgradeCard immediately when batch upload is not supported', () => {
|
||||
it('should render UpgradeCard for a Sandbox local file source', () => {
|
||||
render(
|
||||
<StepOneContent
|
||||
{...defaultProps}
|
||||
supportBatchUpload={false}
|
||||
showBatchUploadUpgrade
|
||||
datasourceType={DatasourceType.localFile}
|
||||
/>,
|
||||
)
|
||||
// UpgradeCard contains an upgrade button
|
||||
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not infer an upgrade requirement from unavailable batch upload', () => {
|
||||
render(
|
||||
<StepOneContent
|
||||
{...defaultProps}
|
||||
@ -354,8 +368,7 @@ describe('StepOneContent', () => {
|
||||
datasourceType={DatasourceType.localFile}
|
||||
/>,
|
||||
)
|
||||
// UpgradeCard contains an upgrade button
|
||||
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('upgrade-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render UpgradeCard when batch upload is supported', () => {
|
||||
|
||||
@ -20,6 +20,7 @@ type StepOneContentProps = {
|
||||
datasourceType: string | undefined
|
||||
pipelineNodes: Node<DataSourceNodeType>[]
|
||||
supportBatchUpload: boolean
|
||||
showBatchUploadUpgrade: boolean
|
||||
isShowVectorSpaceFull: boolean
|
||||
isShowVectorSpaceUnavailable: boolean
|
||||
isRetryingVectorSpace: boolean
|
||||
@ -40,6 +41,7 @@ const StepOneContent = ({
|
||||
datasourceType,
|
||||
pipelineNodes,
|
||||
supportBatchUpload,
|
||||
showBatchUploadUpgrade,
|
||||
isShowVectorSpaceFull,
|
||||
isShowVectorSpaceUnavailable,
|
||||
isRetryingVectorSpace,
|
||||
@ -54,7 +56,7 @@ const StepOneContent = ({
|
||||
onRetryVectorSpace,
|
||||
onNextStep,
|
||||
}: StepOneContentProps) => {
|
||||
const showUpgradeCard = !supportBatchUpload && datasourceType === DatasourceType.localFile
|
||||
const showUpgradeCard = showBatchUploadUpgrade && datasourceType === DatasourceType.localFile
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-5 pt-4">
|
||||
|
||||
@ -1,26 +1,28 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
import { SegmentAdd } from '../index'
|
||||
|
||||
// Mock provider context
|
||||
let mockPlan: { type: CloudPlan } = { type: 'professional' }
|
||||
let mockEnableBilling = true
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: mockPlan,
|
||||
enableBilling: mockEnableBilling,
|
||||
}),
|
||||
}))
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD'
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
features: { billing: { subscription: { plan: mockPlan.type } } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('SegmentAdd', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPlan = { type: 'professional' }
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
@ -192,7 +194,7 @@ describe('SegmentAdd', () => {
|
||||
|
||||
it('should allow add when billing is disabled regardless of plan', () => {
|
||||
mockPlan = { type: 'sandbox' }
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
const mockShowNewSegmentModal = vi.fn()
|
||||
render(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal} />)
|
||||
|
||||
|
||||
@ -7,10 +7,13 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
|
||||
type SegmentAddProps = {
|
||||
@ -30,15 +33,22 @@ export function SegmentAdd({
|
||||
}: SegmentAddProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false)
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const canAddChunks = !enableBilling || plan.type !== 'sandbox'
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const isPlanUnavailable = deploymentEdition === 'CLOUD' && plan === undefined
|
||||
|
||||
const textColor = embedding
|
||||
? 'text-components-button-secondary-accent-text-disabled'
|
||||
: 'text-components-button-secondary-accent-text'
|
||||
|
||||
const openSegmentDialog = (openDialog: () => void) => {
|
||||
if (!canAddChunks) {
|
||||
if (isPlanUnavailable) return
|
||||
if (deploymentEdition === 'CLOUD' && plan === 'sandbox') {
|
||||
setIsPlanUpgradeModalOpen(true)
|
||||
return
|
||||
}
|
||||
@ -123,7 +133,7 @@ export function SegmentAdd({
|
||||
type="button"
|
||||
className={`inline-flex items-center rounded-l-lg border-0 border-r border-r-divider-subtle bg-transparent px-2.5 py-2 text-left hover:bg-state-base-hover disabled:cursor-not-allowed disabled:hover:bg-transparent`}
|
||||
onClick={() => openSegmentDialog(showNewSegmentModal)}
|
||||
disabled={embedding}
|
||||
disabled={embedding || isPlanUnavailable}
|
||||
>
|
||||
<span aria-hidden className={cn('i-ri-add-line size-4', textColor)} />
|
||||
<span
|
||||
@ -135,7 +145,7 @@ export function SegmentAdd({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(($) => $['list.action.batchAdd'], { ns: 'datasetDocuments' })}
|
||||
disabled={embedding}
|
||||
disabled={embedding || isPlanUnavailable}
|
||||
className={cn(
|
||||
`rounded-l-none rounded-r-lg border-0 bg-transparent p-2 backdrop-blur-[5px] hover:bg-state-base-hover disabled:cursor-not-allowed disabled:bg-transparent disabled:hover:bg-transparent data-popup-open:bg-state-base-hover`,
|
||||
)}
|
||||
|
||||
@ -45,7 +45,7 @@ vi.mock('@/context/permission-state', async () => {
|
||||
}))
|
||||
})
|
||||
|
||||
const render = (ui: ReactElement) => {
|
||||
function render(ui: ReactElement) {
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { rbac_enabled: false },
|
||||
})
|
||||
@ -217,7 +217,6 @@ vi.mock('@/context/provider-context', () => ({
|
||||
moderationModelList: [],
|
||||
hasSettedApiKey: true,
|
||||
plan: { type: 'free' },
|
||||
enableBilling: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { CreateAppModalProps } from '../index'
|
||||
import type { UsagePlanInfo } from '@/app/components/billing/type'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
createMockPlan,
|
||||
createMockPlanTotal,
|
||||
createMockPlanUsage,
|
||||
} from '@/__mocks__/provider-context'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import CreateAppModal from '../index'
|
||||
|
||||
@ -56,20 +53,11 @@ const createPlanInfo = (buildApps: number): UsagePlanInfo => ({
|
||||
triggerEvents: 0,
|
||||
})
|
||||
|
||||
let mockEnableBilling = false
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY'
|
||||
let mockPlanType: CloudPlan = 'team'
|
||||
let mockUsagePlanInfo: UsagePlanInfo = createPlanInfo(1)
|
||||
let mockTotalPlanInfo: UsagePlanInfo = createPlanInfo(10)
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => {
|
||||
const withPlan = createMockPlan(mockPlanType)
|
||||
const withUsage = createMockPlanUsage(mockUsagePlanInfo, withPlan)
|
||||
const withTotal = createMockPlanTotal(mockTotalPlanInfo, withUsage)
|
||||
return { ...withTotal, enableBilling: mockEnableBilling }
|
||||
},
|
||||
}))
|
||||
|
||||
type ConfirmPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
|
||||
|
||||
const setup = async (overrides: Partial<CreateAppModalProps> = {}) => {
|
||||
@ -114,10 +102,20 @@ const openAppIconPicker = () => {
|
||||
return screen.getByRole('dialog', { name: 'app.iconPicker.emoji' })
|
||||
}
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
features: {
|
||||
billing: { subscription: { plan: mockPlanType } },
|
||||
apps: { size: mockUsagePlanInfo.buildApps, limit: mockTotalPlanInfo.buildApps },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('CreateAppModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnableBilling = false
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
mockPlanType = 'team'
|
||||
mockUsagePlanInfo = createPlanInfo(1)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
@ -222,7 +220,7 @@ describe('CreateAppModal', () => {
|
||||
|
||||
describe('Quota Gating', () => {
|
||||
it('should show AppsFull and disable create when apps quota is reached', async () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'team'
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
@ -234,7 +232,7 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
|
||||
it('should allow saving when apps quota is reached in edit mode', async () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'team'
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
@ -280,7 +278,7 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
|
||||
it('should not submit when apps quota is reached in create mode', async () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'team'
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
@ -297,7 +295,7 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
|
||||
it('should submit when apps quota is reached in edit mode', async () => {
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'team'
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
@ -555,3 +553,24 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('edits an existing app without waiting for application quota data', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
renderWithConsoleQuery(
|
||||
<CreateAppModal
|
||||
isEditModal
|
||||
show
|
||||
appName="Existing"
|
||||
appDescription=""
|
||||
appIconType="emoji"
|
||||
appIcon="🤖"
|
||||
onConfirm={onConfirm}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
{ systemFeatures: { deployment_edition: 'CLOUD' } },
|
||||
)
|
||||
const save = screen.getByRole('button', { name: /operation.save/ })
|
||||
expect(save).toBeEnabled()
|
||||
await userEvent.setup().click(save)
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
@ -10,13 +10,16 @@ import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppIconPicker from '../../base/app-icon-picker'
|
||||
|
||||
@ -86,10 +89,24 @@ const CreateAppModal = ({
|
||||
: '',
|
||||
)
|
||||
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: appQuota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD' && !isEditModal,
|
||||
select: (data) => data.apps,
|
||||
}),
|
||||
)
|
||||
const isAppQuotaUnavailable =
|
||||
deploymentEdition === 'CLOUD' && !isEditModal && appQuota === undefined
|
||||
// A limit of 0 means unlimited.
|
||||
const isAppsFull =
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
appQuota !== undefined &&
|
||||
appQuota.limit > 0 &&
|
||||
appQuota.size >= appQuota.limit
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (!isEditModal && (isAppQuotaUnavailable || isAppsFull)) return
|
||||
if (!name.trim()) {
|
||||
toast(
|
||||
t(($) => $['appCustomize.nameRequired'], { ns: 'explore' }),
|
||||
@ -112,6 +129,9 @@ const CreateAppModal = ({
|
||||
onConfirm(payload)
|
||||
onHide()
|
||||
}, [
|
||||
isEditModal,
|
||||
isAppQuotaUnavailable,
|
||||
isAppsFull,
|
||||
name,
|
||||
appIcon,
|
||||
description,
|
||||
@ -130,7 +150,7 @@ const CreateAppModal = ({
|
||||
handleSubmit()
|
||||
},
|
||||
{
|
||||
enabled: show && !(!isEditModal && isAppsFull) && !!name.trim(),
|
||||
enabled: show && !isAppQuotaUnavailable && !(!isEditModal && isAppsFull) && !!name.trim(),
|
||||
ignoreInputs: false,
|
||||
},
|
||||
)
|
||||
@ -254,7 +274,12 @@ const CreateAppModal = ({
|
||||
</div>
|
||||
<div className="flex flex-row-reverse">
|
||||
<Button
|
||||
disabled={(!isEditModal && isAppsFull) || !name.trim() || confirmDisabled}
|
||||
disabled={
|
||||
isAppQuotaUnavailable ||
|
||||
(!isEditModal && isAppsFull) ||
|
||||
!name.trim() ||
|
||||
confirmDisabled
|
||||
}
|
||||
className="ml-2 w-24"
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
|
||||
@ -198,6 +198,7 @@ describe('AccountSetting', () => {
|
||||
}
|
||||
|
||||
return renderWithConsoleQuery(<StatefulAccountSetting />, {
|
||||
features: { billing: { subscription: { plan: 'sandbox' } } },
|
||||
accountProfile: (mockConsoleState.current as ConsoleStateFixture).userProfile,
|
||||
systemFeatures: {
|
||||
deployment_edition: deploymentEdition,
|
||||
@ -214,7 +215,7 @@ describe('AccountSetting', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: true,
|
||||
|
||||
enableReplaceWebAppLogo: true,
|
||||
})
|
||||
mockConsoleState.current = baseConsoleState
|
||||
@ -444,12 +445,12 @@ describe('AccountSetting', () => {
|
||||
// Arrange
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: false,
|
||||
|
||||
enableReplaceWebAppLogo: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
renderAccountSetting()
|
||||
renderAccountSetting({ deploymentEdition: 'COMMUNITY' })
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
|
||||
@ -56,7 +56,7 @@ export default function AccountSetting({
|
||||
onTabChangeAction,
|
||||
}: IAccountSettingProps) {
|
||||
const { t } = useTranslation()
|
||||
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
|
||||
const { enableReplaceWebAppLogo } = useProviderContext()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
@ -64,7 +64,8 @@ export default function AccountSetting({
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const canManageWorkspaceRoles =
|
||||
isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage')
|
||||
const canViewBilling = enableBilling && !isCurrentWorkspaceDatasetOperator
|
||||
const canViewBilling =
|
||||
systemFeatures.deployment_edition === 'CLOUD' && !isCurrentWorkspaceDatasetOperator
|
||||
const canViewWorkflowLogArchives =
|
||||
systemFeatures.deployment_edition === 'CLOUD' && isCurrentWorkspaceManager
|
||||
const activeMenu = (() => {
|
||||
@ -144,7 +145,8 @@ export default function AccountSetting({
|
||||
|
||||
if (canViewBilling) visibleTabs.push(ACCOUNT_SETTING_TAB.BILLING)
|
||||
|
||||
if (enableReplaceWebAppLogo || enableBilling) visibleTabs.push(ACCOUNT_SETTING_TAB.CUSTOM)
|
||||
if (enableReplaceWebAppLogo || systemFeatures.deployment_edition === 'CLOUD')
|
||||
visibleTabs.push(ACCOUNT_SETTING_TAB.CUSTOM)
|
||||
|
||||
if (canViewWorkflowLogArchives) visibleTabs.push(ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES)
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Role } from '@/models/access-control'
|
||||
import type { Member } from '@/models/common'
|
||||
import type { ConsoleQueryTestOptions } from '@/test/console/query-data'
|
||||
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@ -13,6 +14,9 @@ import { useMembers } from '@/service/use-common'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import MembersPage from '../index'
|
||||
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY'
|
||||
let memberFeatures: ConsoleQueryTestOptions['features'] = {}
|
||||
|
||||
const mockConsoleState = vi.hoisted(() => ({
|
||||
current: {} as Partial<ConsoleStateFixture>,
|
||||
}))
|
||||
@ -34,8 +38,9 @@ vi.mock('@/service/use-common')
|
||||
|
||||
const renderMembersPage = () =>
|
||||
renderWithConsoleQuery(<MembersPage />, {
|
||||
features: memberFeatures,
|
||||
accountProfile: mockConsoleState.current.userProfile,
|
||||
systemFeatures: { is_email_setup: true },
|
||||
systemFeatures: { deployment_edition: deploymentEdition, is_email_setup: true },
|
||||
})
|
||||
|
||||
const getMemberDetailsButton = (memberId: string) =>
|
||||
@ -241,9 +246,9 @@ describe('MembersPage', () => {
|
||||
mutateAsync: mockUpdateRolesOfMember,
|
||||
} as unknown as ReturnType<typeof useUpdateRolesOfMember>)
|
||||
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
vi.mocked(useProviderContext).mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling: false,
|
||||
isAllowTransferWorkspace: true,
|
||||
}),
|
||||
)
|
||||
@ -276,7 +281,9 @@ describe('MembersPage', () => {
|
||||
|
||||
it('should render plural roles column header when RBAC is enabled', () => {
|
||||
renderWithConsoleQuery(<MembersPage />, {
|
||||
features: memberFeatures,
|
||||
systemFeatures: {
|
||||
deployment_edition: deploymentEdition,
|
||||
is_email_setup: true,
|
||||
rbac_enabled: true,
|
||||
},
|
||||
@ -326,9 +333,9 @@ describe('MembersPage', () => {
|
||||
})
|
||||
|
||||
it('should show non-interactive owner role when transfer ownership is not allowed', () => {
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
vi.mocked(useProviderContext).mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling: false,
|
||||
isAllowTransferWorkspace: false,
|
||||
}),
|
||||
)
|
||||
@ -394,17 +401,12 @@ describe('MembersPage', () => {
|
||||
})
|
||||
|
||||
it('should show billing information for limited plan', () => {
|
||||
vi.mocked(useProviderContext).mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
type: 'sandbox',
|
||||
total: { teamMembers: 5 } as unknown as ReturnType<
|
||||
typeof useProviderContext
|
||||
>['plan']['total'],
|
||||
} as unknown as ReturnType<typeof useProviderContext>['plan'],
|
||||
}),
|
||||
)
|
||||
deploymentEdition = 'CLOUD'
|
||||
memberFeatures = {
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
members: { size: 2, limit: 5 },
|
||||
}
|
||||
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({}))
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
@ -415,17 +417,12 @@ describe('MembersPage', () => {
|
||||
})
|
||||
|
||||
it('should show unlimited billing information', () => {
|
||||
vi.mocked(useProviderContext).mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
type: 'sandbox',
|
||||
total: { teamMembers: -1 } as unknown as ReturnType<
|
||||
typeof useProviderContext
|
||||
>['plan']['total'],
|
||||
} as unknown as ReturnType<typeof useProviderContext>['plan'],
|
||||
}),
|
||||
)
|
||||
deploymentEdition = 'CLOUD'
|
||||
memberFeatures = {
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
members: { size: 2, limit: 0 },
|
||||
}
|
||||
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({}))
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
@ -433,17 +430,12 @@ describe('MembersPage', () => {
|
||||
})
|
||||
|
||||
it('should show non-billing member format for team plan even when billing is enabled', () => {
|
||||
vi.mocked(useProviderContext).mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
type: 'team',
|
||||
total: { teamMembers: 50 } as unknown as ReturnType<
|
||||
typeof useProviderContext
|
||||
>['plan']['total'],
|
||||
} as unknown as ReturnType<typeof useProviderContext>['plan'],
|
||||
}),
|
||||
)
|
||||
deploymentEdition = 'CLOUD'
|
||||
memberFeatures = {
|
||||
billing: { subscription: { plan: 'team' } },
|
||||
members: { size: 2, limit: 50 },
|
||||
}
|
||||
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({}))
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
@ -543,17 +535,12 @@ describe('MembersPage', () => {
|
||||
data: { accounts: [mockAccounts[0]] },
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useMembers>)
|
||||
vi.mocked(useProviderContext).mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
type: 'sandbox',
|
||||
total: { teamMembers: 5 } as unknown as ReturnType<
|
||||
typeof useProviderContext
|
||||
>['plan']['total'],
|
||||
} as unknown as ReturnType<typeof useProviderContext>['plan'],
|
||||
}),
|
||||
)
|
||||
deploymentEdition = 'CLOUD'
|
||||
memberFeatures = {
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
members: { size: 2, limit: 5 },
|
||||
}
|
||||
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({}))
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
@ -684,7 +671,9 @@ describe('MembersPage', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithConsoleQuery(<MembersPage />, {
|
||||
features: memberFeatures,
|
||||
systemFeatures: {
|
||||
deployment_edition: deploymentEdition,
|
||||
is_email_setup: true,
|
||||
rbac_enabled: true,
|
||||
},
|
||||
@ -714,17 +703,12 @@ describe('MembersPage', () => {
|
||||
|
||||
it('should show the upgrade action without blocking the backend-authoritative invite flow', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(useProviderContext).mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
type: 'sandbox',
|
||||
total: { teamMembers: 2 } as unknown as ReturnType<
|
||||
typeof useProviderContext
|
||||
>['plan']['total'],
|
||||
} as unknown as ReturnType<typeof useProviderContext>['plan'],
|
||||
}),
|
||||
)
|
||||
deploymentEdition = 'CLOUD'
|
||||
memberFeatures = {
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
members: { size: 2, limit: 2 },
|
||||
}
|
||||
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({}))
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
|
||||
@ -4,12 +4,11 @@ import type { Role } from '@/models/access-control'
|
||||
import type { Member } from '@/models/common'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WorkspaceAvatar } from '@/app/components/base/workspace-avatar'
|
||||
import { NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
@ -19,6 +18,7 @@ import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { getAccessControlTemplateLanguage, LanguagesSupported } from '@/i18n-config/language'
|
||||
import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import EditWorkspaceModal from './edit-workspace-modal'
|
||||
@ -48,10 +48,22 @@ const MembersPage = () => {
|
||||
MemberInviteResponse['invitation_results'] | null
|
||||
>(null)
|
||||
const accounts = data?.accounts || []
|
||||
const { plan, enableBilling, isAllowTransferWorkspace } = useProviderContext()
|
||||
const isNotUnlimitedMemberPlan = enableBilling && plan.type !== 'team'
|
||||
const { isAllowTransferWorkspace } = useProviderContext()
|
||||
const deploymentEdition = systemFeatures.deployment_edition
|
||||
const { data: billing } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => ({ plan: data.billing.subscription.plan, members: data.members }),
|
||||
}),
|
||||
)
|
||||
|
||||
const isNotUnlimitedMemberPlan =
|
||||
deploymentEdition === 'CLOUD' && billing !== undefined && billing.plan !== 'team'
|
||||
// A limit of 0 means unlimited.
|
||||
const isMemberFull =
|
||||
enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers
|
||||
isNotUnlimitedMemberPlan &&
|
||||
billing.members.limit > 0 &&
|
||||
accounts.length >= billing.members.limit
|
||||
const [editWorkspaceModalVisible, setEditWorkspaceModalVisible] = useState(false)
|
||||
const [showTransferOwnershipModal, setShowTransferOwnershipModal] = useState(false)
|
||||
const [detailsMember, setDetailsMember] = useState<Member | null>(null)
|
||||
@ -130,7 +142,7 @@ const MembersPage = () => {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 system-xs-medium text-text-tertiary">
|
||||
{enableBilling && isNotUnlimitedMemberPlan ? (
|
||||
{isNotUnlimitedMemberPlan ? (
|
||||
<div className="flex space-x-1">
|
||||
<div>
|
||||
{t(($) => $['plansCommon.member'], { ns: 'billing' })}
|
||||
@ -139,9 +151,9 @@ const MembersPage = () => {
|
||||
<div className="">{accounts.length}</div>
|
||||
<div>/</div>
|
||||
<div>
|
||||
{plan.total.teamMembers === NUM_INFINITE
|
||||
{billing.members.limit === 0
|
||||
? t(($) => $['plansCommon.unlimited'], { ns: 'billing' })
|
||||
: plan.total.teamMembers}
|
||||
: billing.members.limit}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import type { GetFeaturesResponse } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { MemberInviteResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { vi } from 'vite-plus/test'
|
||||
import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles'
|
||||
import { seedFeatures } from '@/test/console/query-data'
|
||||
import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data'
|
||||
import { QueryClientTestProvider } from '@/test/console/query-provider'
|
||||
import { InviteModal } from '../index'
|
||||
|
||||
const { fetchFeatures, inviteMember } = vi.hoisted(() => ({
|
||||
@ -15,27 +16,33 @@ const { fetchFeatures, inviteMember } = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-workspace-roles')
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
features: {
|
||||
get: {
|
||||
queryKey: () => ['features'],
|
||||
queryOptions: () => ({ queryKey: ['features'], queryFn: fetchFeatures }),
|
||||
vi.mock('@/service/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/client')>()
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: {
|
||||
...actual.consoleQuery,
|
||||
systemFeatures: actual.consoleQuery.systemFeatures,
|
||||
features: {
|
||||
get: {
|
||||
queryKey: () => ['features'],
|
||||
queryOptions: () => ({ queryKey: ['features'], queryFn: fetchFeatures }),
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
current: {
|
||||
members: {
|
||||
inviteEmail: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: inviteMember }),
|
||||
workspaces: {
|
||||
current: {
|
||||
members: {
|
||||
inviteEmail: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: inviteMember }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
describe('InviteModal', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
@ -108,11 +115,12 @@ describe('InviteModal', () => {
|
||||
queryClient?: QueryClient
|
||||
workspaceMembers?: GetFeaturesResponse['workspace_members']
|
||||
} = {}) => {
|
||||
seedSystemFeatures(queryClient, { deployment_edition: 'CLOUD' })
|
||||
const features = seedFeatures(queryClient, { workspace_members: workspaceMembers })
|
||||
fetchFeatures.mockResolvedValue(features)
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientTestProvider queryClient={queryClient}>
|
||||
<InviteModal
|
||||
open={open}
|
||||
trigger={<button type="button">members.invite</button>}
|
||||
@ -120,7 +128,7 @@ describe('InviteModal', () => {
|
||||
onOpenChange={onOpenChange}
|
||||
onSend={onSend}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
</QueryClientTestProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
@ -658,13 +666,14 @@ describe('InviteModal', () => {
|
||||
it('resets the form after a controlled close', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = createQueryClient()
|
||||
seedSystemFeatures(queryClient, { deployment_edition: 'CLOUD' })
|
||||
const features = seedFeatures(queryClient)
|
||||
fetchFeatures.mockResolvedValue(features)
|
||||
const ControlledInviteModal = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientTestProvider queryClient={queryClient}>
|
||||
<InviteModal
|
||||
open={open}
|
||||
trigger={<button type="button">members.invite</button>}
|
||||
@ -672,7 +681,7 @@ describe('InviteModal', () => {
|
||||
onOpenChange={setOpen}
|
||||
onSend={onSend}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</QueryClientTestProvider>
|
||||
)
|
||||
}
|
||||
render(<ControlledInviteModal />)
|
||||
|
||||
@ -14,9 +14,11 @@ import {
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { commonQueryKeys } from '@/service/use-common'
|
||||
import { mergeEmailRecipients } from './email-recipients'
|
||||
@ -48,14 +50,16 @@ function InviteForm({ isEmailSetup, onOpenChange, onSend }: InviteFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const queryClient = useQueryClient()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: features } = useQuery(consoleQuery.features.get.queryOptions())
|
||||
const [recipients, setRecipients] = useState<EmailRecipient[]>([])
|
||||
const [draft, setDraft] = useState('')
|
||||
const [submissionError, setSubmissionError] = useState<SubmissionError>(null)
|
||||
const fieldErrors = submissionError?.kind === 'fields' ? submissionError.errors : undefined
|
||||
// A limit of 0 means unlimited.
|
||||
const memberLimit = features?.workspace_members.enabled
|
||||
? features.workspace_members
|
||||
: features?.billing.enabled && features.members.limit > 0
|
||||
: deploymentEdition === 'CLOUD' && features && features.members.limit > 0
|
||||
? features.members
|
||||
: undefined
|
||||
const remainingSeats =
|
||||
|
||||
@ -2,10 +2,7 @@ import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { GetWorkflowRunArchivesResponse } from '@dify/contracts/api/console/workflow-run-archives/types.gen'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import WorkflowLogArchivesPage from '../index'
|
||||
@ -17,14 +14,6 @@ vi.mock('@/config', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/modal-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/modal-context')>()
|
||||
return {
|
||||
@ -33,7 +22,6 @@ vi.mock('@/context/modal-context', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseModalContext = vi.mocked(useModalContext)
|
||||
|
||||
const archiveData: GetWorkflowRunArchivesResponse = {
|
||||
@ -57,17 +45,7 @@ const archiveData: GetWorkflowRunArchivesResponse = {
|
||||
],
|
||||
}
|
||||
|
||||
function mockPlan(planType: CloudPlan) {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
let plan: CloudPlan = 'professional'
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
@ -76,6 +54,7 @@ function renderPage() {
|
||||
return renderWithConsoleQuery(<WorkflowLogArchivesPage />, {
|
||||
queryClient,
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: { billing: { subscription: { plan } } },
|
||||
})
|
||||
}
|
||||
|
||||
@ -84,7 +63,7 @@ describe('WorkflowLogArchivesPage', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPlan('professional')
|
||||
plan = 'professional'
|
||||
mockUseModalContext.mockReturnValue({
|
||||
setShowPricingModal,
|
||||
} as unknown as ReturnType<typeof useModalContext>)
|
||||
@ -93,7 +72,7 @@ describe('WorkflowLogArchivesPage', () => {
|
||||
describe('Plan access', () => {
|
||||
it('should show upgrade guidance instead of archive content for sandbox workspaces', () => {
|
||||
// Arrange
|
||||
mockPlan('sandbox')
|
||||
plan = 'sandbox'
|
||||
|
||||
// Act
|
||||
renderPage()
|
||||
@ -105,7 +84,7 @@ describe('WorkflowLogArchivesPage', () => {
|
||||
|
||||
it('should open pricing modal from the sandbox upgrade guidance', () => {
|
||||
// Arrange
|
||||
mockPlan('sandbox')
|
||||
plan = 'sandbox'
|
||||
renderPage()
|
||||
|
||||
// Act
|
||||
@ -117,7 +96,7 @@ describe('WorkflowLogArchivesPage', () => {
|
||||
|
||||
it('should show archive content for paid workspaces', () => {
|
||||
// Arrange
|
||||
mockPlan('professional')
|
||||
plan = 'professional'
|
||||
|
||||
// Act
|
||||
renderPage()
|
||||
|
||||
@ -15,7 +15,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
@ -68,21 +67,25 @@ export default function WorkflowLogArchivesPage() {
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const [visibleArchiveMonthCount, setVisibleArchiveMonthCount] = useState(ARCHIVE_MONTH_PAGE_SIZE)
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null)
|
||||
const canViewArchiveContent =
|
||||
deploymentEdition === 'CLOUD' && enableBilling && plan.type !== 'sandbox'
|
||||
const archiveListQuery = useQuery(
|
||||
consoleQuery.workflowRunArchives.get.queryOptions({
|
||||
enabled: canViewArchiveContent,
|
||||
enabled: deploymentEdition === 'CLOUD' && (plan === 'professional' || plan === 'team'),
|
||||
}),
|
||||
)
|
||||
const archiveData = archiveListQuery.data
|
||||
const archiveMonths = archiveData?.months ?? []
|
||||
const visibleArchiveMonths = archiveMonths.slice(0, visibleArchiveMonthCount)
|
||||
const summary = archiveData?.summary
|
||||
const isLoading = archiveListQuery.isLoading
|
||||
const isLoading =
|
||||
(deploymentEdition === 'CLOUD' && plan === undefined) || archiveListQuery.isLoading
|
||||
const hasMoreArchives = visibleArchiveMonths.length < archiveMonths.length
|
||||
|
||||
useEffect(() => {
|
||||
@ -129,7 +132,7 @@ export default function WorkflowLogArchivesPage() {
|
||||
},
|
||||
]
|
||||
|
||||
if (!canViewArchiveContent) {
|
||||
if (deploymentEdition !== 'CLOUD' || plan === 'sandbox') {
|
||||
return (
|
||||
<div className="pb-6">
|
||||
<ArchivedLogsUpgradeBanner />
|
||||
|
||||
@ -604,6 +604,7 @@ const renderMainNav = (
|
||||
</JotaiProvider>,
|
||||
{
|
||||
systemFeatures: resolvedSystemFeatures,
|
||||
features: { billing: { subscription: { plan: 'sandbox' } } },
|
||||
educationStatus: options.educationStatus,
|
||||
workspacePermissionKeys: currentConsoleState.workspacePermissionKeys,
|
||||
queryClient,
|
||||
@ -655,7 +656,6 @@ describe('MainNav', () => {
|
||||
enableSkill: true,
|
||||
}
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableBilling: true,
|
||||
enableEducationPlan: false,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
@ -834,7 +834,6 @@ describe('MainNav', () => {
|
||||
|
||||
it('shows the user education badge in the account popup without adding the workspace plan there', async () => {
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableBilling: true,
|
||||
enableEducationPlan: true,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { Mock } from 'vite-plus/test'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -8,11 +9,13 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { zendeskRuntime } from '@/app/components/base/zendesk/runtime'
|
||||
import { mailToSupport } from '@/app/components/header/utils/util'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { createConsoleQueryClient, createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import SupportMenu from '../support-menu'
|
||||
|
||||
let plan: CloudPlan = 'team'
|
||||
|
||||
const {
|
||||
mockConfig,
|
||||
mockOpenZendeskWindow,
|
||||
@ -46,7 +49,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: { error: mockToastError },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/utils/util', () => ({
|
||||
vi.mock('@/app/components/header/utils/util', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/app/components/header/utils/util')>()),
|
||||
mailToSupport: mockMailToSupport,
|
||||
}))
|
||||
|
||||
@ -67,10 +71,6 @@ vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('SupportMenu', () => {
|
||||
let deploymentEdition: 'COMMUNITY' | 'ENTERPRISE' | 'CLOUD' = 'CLOUD'
|
||||
|
||||
@ -84,23 +84,29 @@ describe('SupportMenu', () => {
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
userProfile: { email: 'user@example.com' },
|
||||
}
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableBilling: true,
|
||||
plan: { type: 'team' },
|
||||
})
|
||||
plan = 'team'
|
||||
;(useModalContext as Mock).mockReturnValue({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
})
|
||||
;(mailToSupport as Mock).mockReturnValue('mailto:support@example.com')
|
||||
})
|
||||
|
||||
const renderSupportMenu = () => {
|
||||
const renderSupportMenu = (withPlan = true) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
if (!withPlan && deploymentEdition === 'CLOUD') {
|
||||
void queryClient.query({
|
||||
...consoleQuery.features.get.queryOptions(),
|
||||
queryFn: () => new Promise(() => {}),
|
||||
})
|
||||
}
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
queryClient,
|
||||
accountProfile: mockConsoleState.current.userProfile,
|
||||
accountProfileMeta: {
|
||||
currentVersion: mockConsoleState.current.langGeniusVersionInfo.current_version,
|
||||
},
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
...(withPlan ? { features: { billing: { subscription: { plan } } } } : {}),
|
||||
})
|
||||
return render(
|
||||
<DropdownMenu open={true} onOpenChange={() => {}}>
|
||||
@ -145,10 +151,7 @@ describe('SupportMenu', () => {
|
||||
})
|
||||
|
||||
it('renders contact us with upgrade badge for Cloud sandbox plan without dedicated support', () => {
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableBilling: true,
|
||||
plan: { type: 'sandbox' },
|
||||
})
|
||||
plan = 'sandbox'
|
||||
|
||||
renderSupportMenu()
|
||||
|
||||
@ -172,26 +175,9 @@ describe('SupportMenu', () => {
|
||||
expect(zendeskRuntime.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides upgrade contact for Cloud sandbox plan when billing is disabled', () => {
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableBilling: false,
|
||||
plan: { type: 'sandbox' },
|
||||
})
|
||||
|
||||
renderSupportMenu()
|
||||
|
||||
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps Zendesk contact us for Cloud sandbox plan with support email and Zendesk configured', () => {
|
||||
mockConfig.supportEmailAddress = 'support@example.com'
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableBilling: true,
|
||||
plan: { type: 'sandbox' },
|
||||
})
|
||||
plan = 'sandbox'
|
||||
|
||||
renderSupportMenu()
|
||||
|
||||
@ -206,10 +192,7 @@ describe('SupportMenu', () => {
|
||||
it('keeps email support for Cloud sandbox plan with support email and no Zendesk configured', () => {
|
||||
mockConfig.supportEmailAddress = 'support@example.com'
|
||||
mockConfig.zendeskWidgetKey = ''
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableBilling: true,
|
||||
plan: { type: 'sandbox' },
|
||||
})
|
||||
plan = 'sandbox'
|
||||
|
||||
renderSupportMenu()
|
||||
|
||||
@ -226,10 +209,7 @@ describe('SupportMenu', () => {
|
||||
|
||||
it('hides dedicated support channels for non-Cloud sandbox plan without support email', () => {
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableBilling: true,
|
||||
plan: { type: 'sandbox' },
|
||||
})
|
||||
plan = 'sandbox'
|
||||
|
||||
renderSupportMenu()
|
||||
|
||||
@ -251,6 +231,27 @@ describe('SupportMenu', () => {
|
||||
).toHaveAttribute('href', 'mailto:support@example.com')
|
||||
})
|
||||
|
||||
it('waits for the Cloud plan before generating a support email', () => {
|
||||
mockConfig.supportEmailAddress = 'support@example.com'
|
||||
mockConfig.zendeskWidgetKey = ''
|
||||
renderSupportMenu(false)
|
||||
|
||||
expect(screen.queryByText('common.userProfile.emailSupport')).not.toBeInTheDocument()
|
||||
expect(mailToSupport).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('common.userProfile.discord')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps configured self-hosted email support independent of Cloud plan data', () => {
|
||||
deploymentEdition = 'ENTERPRISE'
|
||||
mockConfig.supportEmailAddress = 'support@example.com'
|
||||
renderSupportMenu(false)
|
||||
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: 'common.userProfile.emailSupport' }),
|
||||
).toHaveAttribute('href', 'mailto:support@example.com')
|
||||
expect(mailToSupport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has the Discord link and no Forum entry', () => {
|
||||
renderSupportMenu()
|
||||
|
||||
|
||||
@ -177,7 +177,6 @@ describe('WorkspaceCard', () => {
|
||||
mockSwitchWorkspace.mockReturnValue(new Promise(() => {}))
|
||||
mockCurrentWorkspaceQuery()
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
enableBilling: true,
|
||||
enableEducationPlan: false,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
@ -343,7 +342,6 @@ describe('WorkspaceCard', () => {
|
||||
plan: 'team',
|
||||
})
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
enableBilling: false,
|
||||
enableEducationPlan: false,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
import { DropdownMenuItem, DropdownMenuLinkItem } from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { zendeskRuntime } from '@/app/components/base/zendesk/runtime'
|
||||
import {
|
||||
ExternalLinkIndicator,
|
||||
MenuItemContent,
|
||||
} from '@/app/components/header/account-dropdown/menu-item-content'
|
||||
import { mailToSupport } from '@/app/components/header/utils/util'
|
||||
import { generateMailToLink, mailToSupport } from '@/app/components/header/utils/util'
|
||||
import { SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
export default function SupportMenu() {
|
||||
const { t } = useTranslation()
|
||||
@ -20,7 +20,12 @@ export default function SupportMenu() {
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const { enableBilling, plan } = useProviderContext()
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const { data: accountProfile } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: (data) => ({
|
||||
@ -29,12 +34,22 @@ export default function SupportMenu() {
|
||||
}),
|
||||
})
|
||||
const { setShowPricingModal } = useModalContext()
|
||||
const hasDedicatedChannel = plan.type !== 'sandbox' || Boolean(SUPPORT_EMAIL_ADDRESS.trim())
|
||||
const hasDedicatedChannel =
|
||||
(deploymentEdition === 'CLOUD' && (plan === 'professional' || plan === 'team')) ||
|
||||
Boolean(SUPPORT_EMAIL_ADDRESS.trim())
|
||||
const shouldShowUpgradeContact =
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
enableBilling &&
|
||||
plan.type === 'sandbox' &&
|
||||
!hasDedicatedChannel
|
||||
deploymentEdition === 'CLOUD' && plan === 'sandbox' && !hasDedicatedChannel
|
||||
const supportMailLink =
|
||||
deploymentEdition !== 'CLOUD'
|
||||
? generateMailToLink(SUPPORT_EMAIL_ADDRESS)
|
||||
: plan === undefined
|
||||
? undefined
|
||||
: mailToSupport(
|
||||
accountProfile.email,
|
||||
plan,
|
||||
accountProfile.currentVersion ?? '',
|
||||
SUPPORT_EMAIL_ADDRESS,
|
||||
)
|
||||
const hasZendeskWidget = deploymentEdition === 'CLOUD' && Boolean(ZENDESK_WIDGET_KEY.trim())
|
||||
|
||||
return (
|
||||
@ -80,15 +95,10 @@ export default function SupportMenu() {
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!shouldShowUpgradeContact && hasDedicatedChannel && !hasZendeskWidget && (
|
||||
{!shouldShowUpgradeContact && hasDedicatedChannel && !hasZendeskWidget && supportMailLink && (
|
||||
<DropdownMenuLinkItem
|
||||
className="mx-0 h-8 gap-1 px-3 py-1"
|
||||
href={mailToSupport(
|
||||
accountProfile.email,
|
||||
plan.type,
|
||||
accountProfile.currentVersion ?? '',
|
||||
SUPPORT_EMAIL_ADDRESS,
|
||||
)}
|
||||
href={supportMailLink}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@ -66,7 +66,7 @@ describe('EditCustomCollectionModal', () => {
|
||||
plan: {
|
||||
type: 'sandbox',
|
||||
},
|
||||
enableBilling: false,
|
||||
|
||||
webappCopyrightEnabled: true,
|
||||
} as ProviderContextState)
|
||||
})
|
||||
|
||||
@ -70,7 +70,7 @@ import type { Shape as HooksStoreShape } from '../hooks-store/store'
|
||||
import type { Shape } from '../store/workflow'
|
||||
import type { WorkflowHistoryState } from '../store/workflow/history-slice'
|
||||
import type { Edge, Node, WorkflowRunningData } from '../types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { seedAccountProfileQuery } from '@/test/console/account-profile'
|
||||
@ -176,10 +176,7 @@ function createWorkflowWrapper(
|
||||
if (!externalQueryClient) seedSystemFeatures(queryClient)
|
||||
if (!externalQueryClient) seedAppDslVersion(queryClient)
|
||||
if (!externalQueryClient) seedAccountProfileQuery(queryClient)
|
||||
const QueryClientWrapper = externalQueryClient
|
||||
? ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
: createQueryClientWrapper(queryClient)
|
||||
const QueryClientWrapper = createQueryClientWrapper(queryClient)
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => {
|
||||
let inner: React.ReactNode = children
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import {
|
||||
createConsoleQueryClient,
|
||||
createConsoleQueryWrapper,
|
||||
seedFeatures,
|
||||
seedSystemFeatures,
|
||||
} from '@/test/console/query-data'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { renderWorkflowComponent as renderWorkflow } from '../../__tests__/workflow-test-env'
|
||||
import { WorkflowVersion } from '../../types'
|
||||
import HeaderInRestoring from '../header-in-restoring'
|
||||
|
||||
@ -12,14 +18,7 @@ const mockResetWorkflowVersionHistory = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockHandleRefreshWorkflowDraft = vi.fn()
|
||||
let mockPlanType: CloudPlan = 'professional'
|
||||
let mockEnableBilling = true
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: { type: mockPlanType },
|
||||
enableBilling: mockEnableBilling,
|
||||
}),
|
||||
}))
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
@ -89,7 +88,7 @@ describe('HeaderInRestoring', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPlanType = 'professional'
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
})
|
||||
|
||||
it('should disable restore when the flow id is not ready yet', () => {
|
||||
@ -163,3 +162,14 @@ describe('HeaderInRestoring', () => {
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
function renderWorkflowComponent(
|
||||
ui: Parameters<typeof renderWorkflow>[0],
|
||||
options: Parameters<typeof renderWorkflow>[1] = {},
|
||||
) {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
createConsoleQueryWrapper({ queryClient })
|
||||
seedSystemFeatures(queryClient, { deployment_edition: deploymentEdition })
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: mockPlanType } } })
|
||||
return renderWorkflow(ui, { ...options, queryClient })
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Shape } from '../../store/workflow'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { createAccountProfileQueryClient } from '@/test/console/account-profile'
|
||||
import { seedSystemFeatures } from '@/test/console/query-data'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { WorkflowVersion } from '../../types'
|
||||
@ -289,6 +290,8 @@ describe('Header layout components', () => {
|
||||
const deleteAllInspectVars = vi.fn()
|
||||
const currentVersion = createCurrentVersion()
|
||||
const currentUser = { id: 'user-1', name: 'Alice' }
|
||||
const queryClient = createAccountProfileQueryClient(currentUser)
|
||||
seedSystemFeatures(queryClient)
|
||||
|
||||
const { store } = renderWorkflowComponent(
|
||||
<HeaderInRestoring onRestoreSettled={onRestoreSettled} />,
|
||||
@ -307,7 +310,7 @@ describe('Header layout components', () => {
|
||||
fileSettings: {},
|
||||
},
|
||||
},
|
||||
queryClient: createAccountProfileQueryClient(currentUser),
|
||||
queryClient,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -2,14 +2,16 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiHistoryLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
|
||||
import { getWorkflowVersionName } from '@/app/components/workflow/utils/version'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
useInvalidAllLastRun,
|
||||
useResetWorkflowVersionHistory,
|
||||
@ -30,7 +32,13 @@ const HeaderInRestoring = ({ onRestoreSettled }: HeaderInRestoringProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false)
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { data: userProfile } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
@ -48,7 +56,7 @@ const HeaderInRestoring = ({ onRestoreSettled }: HeaderInRestoringProps) => {
|
||||
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
|
||||
const canRestore =
|
||||
!!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft
|
||||
const canUseWorkflowVersionAction = !enableBilling || plan.type !== 'sandbox'
|
||||
const isPlanUnavailable = deploymentEdition === 'CLOUD' && plan === undefined
|
||||
const canEmitCollaborationEvents = configsMap?.flowType === FlowType.appFlow
|
||||
|
||||
const handleCancelRestore = useCallback(() => {
|
||||
@ -115,9 +123,9 @@ const HeaderInRestoring = ({ onRestoreSettled }: HeaderInRestoringProps) => {
|
||||
}, [canEmitCollaborationEvents, configsMap?.flowId])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
if (!canRestore || !currentVersion) return
|
||||
if (isPlanUnavailable || !canRestore || !currentVersion) return
|
||||
|
||||
if (!canUseWorkflowVersionAction) {
|
||||
if (deploymentEdition === 'CLOUD' && plan === 'sandbox') {
|
||||
setIsRestorePlanUpgradeModalOpen(true)
|
||||
return
|
||||
}
|
||||
@ -143,9 +151,11 @@ const HeaderInRestoring = ({ onRestoreSettled }: HeaderInRestoringProps) => {
|
||||
onRestoreSettled?.()
|
||||
}
|
||||
}, [
|
||||
isPlanUnavailable,
|
||||
canRestore,
|
||||
currentVersion,
|
||||
canUseWorkflowVersionAction,
|
||||
deploymentEdition,
|
||||
plan,
|
||||
setShowWorkflowVersionHistoryPanel,
|
||||
emitRestoreIntent,
|
||||
restoreWorkflow,
|
||||
@ -169,7 +179,7 @@ const HeaderInRestoring = ({ onRestoreSettled }: HeaderInRestoringProps) => {
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<Button
|
||||
onClick={handleRestore}
|
||||
disabled={!canRestore}
|
||||
disabled={isPlanUnavailable || !canRestore}
|
||||
variant="primary"
|
||||
className={cn(
|
||||
'rounded-lg inset-ring-1 inset-ring-transparent',
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { getDefaultStore } from 'jotai'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerSavedDraftAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { AgentScope } from '@/features/agent-v2/analytics'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { FlowType } from '@/types/common'
|
||||
@ -636,9 +633,6 @@ describe('useCreateInlineAgentBinding', () => {
|
||||
describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const store = getDefaultStore()
|
||||
store.set(agentComposerSavedDraftAtom, defaultAgentSoulConfigFormState)
|
||||
store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState)
|
||||
})
|
||||
|
||||
it('saves inline agent composer changes through the workflow node composer API', async () => {
|
||||
@ -653,8 +647,8 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
},
|
||||
})
|
||||
const { result } = renderWorkflowHook(
|
||||
() =>
|
||||
useWorkflowInlineAgentConfigureSync({
|
||||
() => ({
|
||||
...useWorkflowInlineAgentConfigureSync({
|
||||
nodeId: 'node-1',
|
||||
baseConfig: {
|
||||
schema_version: 1,
|
||||
@ -665,6 +659,8 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
},
|
||||
enabled: true,
|
||||
}),
|
||||
setDraft: useSetAtom(agentComposerDraftAtom),
|
||||
}),
|
||||
{
|
||||
queryClient,
|
||||
hooksStoreProps: {
|
||||
@ -678,7 +674,7 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
)
|
||||
|
||||
act(() => {
|
||||
getDefaultStore().set(agentComposerDraftAtom, {
|
||||
result.current.setDraft({
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Workflow inline prompt',
|
||||
})
|
||||
@ -732,14 +728,16 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
},
|
||||
})
|
||||
const { result } = renderWorkflowHook(
|
||||
() =>
|
||||
useWorkflowInlineAgentConfigureSync({
|
||||
() => ({
|
||||
...useWorkflowInlineAgentConfigureSync({
|
||||
nodeId: 'node-1',
|
||||
baseConfig: {
|
||||
schema_version: 1,
|
||||
},
|
||||
enabled: true,
|
||||
}),
|
||||
setDraft: useSetAtom(agentComposerDraftAtom),
|
||||
}),
|
||||
{
|
||||
queryClient,
|
||||
hooksStoreProps: {
|
||||
@ -753,7 +751,7 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
)
|
||||
|
||||
act(() => {
|
||||
getDefaultStore().set(agentComposerDraftAtom, {
|
||||
result.current.setDraft({
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Snippet inline prompt',
|
||||
})
|
||||
@ -803,8 +801,8 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
},
|
||||
})
|
||||
const { result } = renderWorkflowHook(
|
||||
() =>
|
||||
useWorkflowInlineAgentConfigureSync({
|
||||
() => ({
|
||||
...useWorkflowInlineAgentConfigureSync({
|
||||
nodeId: 'node-1',
|
||||
baseConfig: {
|
||||
schema_version: 1,
|
||||
@ -812,6 +810,8 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
autoSaveEnabled: false,
|
||||
enabled: true,
|
||||
}),
|
||||
setDraft: useSetAtom(agentComposerDraftAtom),
|
||||
}),
|
||||
{
|
||||
queryClient,
|
||||
hooksStoreProps: {
|
||||
@ -825,7 +825,7 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
)
|
||||
|
||||
act(() => {
|
||||
getDefaultStore().set(agentComposerDraftAtom, {
|
||||
result.current.setDraft({
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Manual inline prompt',
|
||||
})
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Shape } from '../../../store'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
|
||||
|
||||
@ -27,7 +28,7 @@ const mockToast = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
}))
|
||||
let mockPlanType: CloudPlan = 'professional'
|
||||
let mockEnableBilling = true
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD'
|
||||
let mockPublishedEnvironments: VersionHistory['environments']
|
||||
let mockHasNextPage = false
|
||||
let mockIsFetching = false
|
||||
@ -78,13 +79,6 @@ type MockVersionHistoryItemProps = {
|
||||
handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void
|
||||
}
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: { type: mockPlanType },
|
||||
enableBilling: mockEnableBilling,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast }))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
@ -286,7 +280,7 @@ describe('VersionHistoryPanel', () => {
|
||||
mockUpdateWorkflow.mockResolvedValue(undefined)
|
||||
mockCurrentVersion = null
|
||||
mockPlanType = 'professional'
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPublishedEnvironments = undefined
|
||||
mockHasNextPage = false
|
||||
mockIsFetching = false
|
||||
@ -534,3 +528,10 @@ describe('VersionHistoryPanel', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
features: { billing: { subscription: { plan: mockPlanType } } },
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
|
||||
import VersionHistoryItem from '../version-history-item'
|
||||
|
||||
|
||||
@ -1,14 +1,22 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createConsoleQueryClient, seedSystemFeatures } from '@/test/console/query-data'
|
||||
import {
|
||||
createConsoleQueryClient,
|
||||
seedFeatures,
|
||||
seedSystemFeatures,
|
||||
} from '@/test/console/query-data'
|
||||
import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env'
|
||||
import { VersionHistoryContextMenuOptions } from '../../../../types'
|
||||
import ActionMenu from '../index'
|
||||
|
||||
let mockPlanType: CloudPlan = 'professional'
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD'
|
||||
|
||||
const renderActionMenu = (ui: React.ReactElement) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
seedSystemFeatures(queryClient, { deployment_edition: 'CLOUD' })
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: mockPlanType } } })
|
||||
seedSystemFeatures(queryClient, { deployment_edition: deploymentEdition })
|
||||
return renderWorkflowComponent(ui, { queryClient })
|
||||
}
|
||||
|
||||
@ -19,21 +27,11 @@ vi.mock('@/config', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
let mockPlanType: CloudPlan = 'professional'
|
||||
let mockEnableBilling = true
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: { type: mockPlanType },
|
||||
enableBilling: mockEnableBilling,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('ActionMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPlanType = 'professional'
|
||||
mockEnableBilling = true
|
||||
deploymentEdition = 'CLOUD'
|
||||
})
|
||||
|
||||
it('toggles the trigger and forwards menu clicks', async () => {
|
||||
|
||||
@ -51,6 +51,7 @@ describe('useActionMenu', () => {
|
||||
{
|
||||
key: VersionHistoryContextMenuOptions.restore,
|
||||
name: 'workflow.common.restore',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
key: VersionHistoryContextMenuOptions.edit,
|
||||
|
||||
@ -10,6 +10,7 @@ type ActionMenuItemProps = {
|
||||
key: VersionHistoryContextMenuOptions
|
||||
name: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
showUpgrade?: boolean
|
||||
}
|
||||
onClick: (operation: VersionHistoryContextMenuOptions) => void
|
||||
@ -19,6 +20,7 @@ type ActionMenuItemProps = {
|
||||
const ActionMenuItem: FC<ActionMenuItemProps> = ({ item, onClick, isDestructive = false }) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
disabled={item.disabled}
|
||||
variant={isDestructive ? 'destructive' : 'default'}
|
||||
className={cn(
|
||||
'justify-between gap-x-3 px-2 py-1.5 whitespace-nowrap',
|
||||
|
||||
@ -1,16 +1,25 @@
|
||||
import type { ActionMenuProps } from './index'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { VersionHistoryContextMenuOptions } from '../../../types'
|
||||
|
||||
const useActionMenu = (props: ActionMenuProps) => {
|
||||
const { workflowId, isNamedVersion, canImportExportDSL } = props
|
||||
const { t } = useTranslation()
|
||||
const pipelineId = useStore((s) => s.pipelineId)
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const shouldShowUpgrade = enableBilling && plan.type === 'sandbox'
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const shouldShowUpgrade = deploymentEdition === 'CLOUD' && plan === 'sandbox'
|
||||
|
||||
const deleteOperation = {
|
||||
key: VersionHistoryContextMenuOptions.delete,
|
||||
@ -22,6 +31,7 @@ const useActionMenu = (props: ActionMenuProps) => {
|
||||
{
|
||||
key: VersionHistoryContextMenuOptions.restore,
|
||||
name: t(($) => $['common.restore'], { ns: 'workflow' }),
|
||||
disabled: deploymentEdition === 'CLOUD' && plan === undefined,
|
||||
...(shouldShowUpgrade ? { showUpgrade: true } : {}),
|
||||
},
|
||||
isNamedVersion
|
||||
@ -39,6 +49,7 @@ const useActionMenu = (props: ActionMenuProps) => {
|
||||
{
|
||||
key: VersionHistoryContextMenuOptions.exportDSL,
|
||||
name: t(($) => $.export, { ns: 'app' }),
|
||||
disabled: deploymentEdition === 'CLOUD' && plan === undefined,
|
||||
...(shouldShowUpgrade ? { showUpgrade: true } : {}),
|
||||
},
|
||||
]
|
||||
@ -49,7 +60,16 @@ const useActionMenu = (props: ActionMenuProps) => {
|
||||
description: workflowId,
|
||||
},
|
||||
]
|
||||
}, [canImportExportDSL, isNamedVersion, pipelineId, shouldShowUpgrade, t, workflowId])
|
||||
}, [
|
||||
deploymentEdition,
|
||||
plan,
|
||||
canImportExportDSL,
|
||||
isNamedVersion,
|
||||
pipelineId,
|
||||
shouldShowUpgrade,
|
||||
t,
|
||||
workflowId,
|
||||
])
|
||||
|
||||
return {
|
||||
deleteOperation,
|
||||
|
||||
@ -3,8 +3,9 @@
|
||||
import type { AppModeEnum } from '@/types/app'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -12,8 +13,9 @@ import VersionInfoModal from '@/app/components/app/app-publisher/version-info-mo
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
|
||||
import { getWorkflowVersionName } from '@/app/components/workflow/utils/version'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
useDeleteWorkflow,
|
||||
useInvalidAllLastRun,
|
||||
@ -68,8 +70,14 @@ export const VersionHistoryPanel = ({
|
||||
const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false)
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const canUseWorkflowVersionAction = !enableBilling || plan.type !== 'sandbox'
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const isPlanUnavailable = deploymentEdition === 'CLOUD' && plan === undefined
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun()
|
||||
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
|
||||
@ -152,7 +160,8 @@ export const VersionHistoryPanel = ({
|
||||
setOperatedItem(item)
|
||||
switch (operation) {
|
||||
case VersionHistoryContextMenuOptions.restore:
|
||||
if (!canUseWorkflowVersionAction) {
|
||||
if (isPlanUnavailable) return
|
||||
if (deploymentEdition === 'CLOUD' && plan === 'sandbox') {
|
||||
setIsRestorePlanUpgradeModalOpen(true)
|
||||
break
|
||||
}
|
||||
@ -169,7 +178,8 @@ export const VersionHistoryPanel = ({
|
||||
toast.success(t(($) => $['versionHistory.action.copyIdSuccess'], { ns: 'workflow' }))
|
||||
break
|
||||
case VersionHistoryContextMenuOptions.exportDSL:
|
||||
if (!canUseWorkflowVersionAction) {
|
||||
if (isPlanUnavailable) return
|
||||
if (deploymentEdition === 'CLOUD' && plan === 'sandbox') {
|
||||
setIsRestorePlanUpgradeModalOpen(true)
|
||||
break
|
||||
}
|
||||
@ -178,7 +188,7 @@ export const VersionHistoryPanel = ({
|
||||
break
|
||||
}
|
||||
},
|
||||
[canUseWorkflowVersionAction, canImportExportDSL, t, handleExportDSL],
|
||||
[isPlanUnavailable, deploymentEdition, plan, canImportExportDSL, t, handleExportDSL],
|
||||
)
|
||||
|
||||
const handleCancel = useCallback((operation: VersionHistoryContextMenuOptions) => {
|
||||
|
||||
@ -38,8 +38,7 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
|
||||
const { data: supportRetrievalMethods } = useSupportRetrievalMethods()
|
||||
|
||||
const features = featuresQuery.data
|
||||
const enableBilling = features?.billing.enabled ?? false
|
||||
const plan = enableBilling && features ? parseCurrentPlan(features) : defaultPlan
|
||||
const plan = deploymentEdition === 'CLOUD' && features ? parseCurrentPlan(features) : defaultPlan
|
||||
const enableEducationPlan = features?.education.enabled ?? false
|
||||
const enableSkill = features?.enable_skill ?? false
|
||||
const enableReplaceWebAppLogo = features?.can_replace_logo ?? false
|
||||
@ -88,7 +87,6 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
|
||||
),
|
||||
supportRetrievalMethods: supportRetrievalMethods?.retrieval_method || [],
|
||||
plan,
|
||||
enableBilling,
|
||||
enableSkill,
|
||||
enableReplaceWebAppLogo,
|
||||
modelLoadBalancingEnabled,
|
||||
|
||||
@ -26,7 +26,6 @@ export type ProviderContextState = {
|
||||
total: UsagePlanInfo
|
||||
reset: UsageResetInfo
|
||||
}
|
||||
enableBilling: boolean
|
||||
enableSkill: boolean
|
||||
enableReplaceWebAppLogo: boolean
|
||||
modelLoadBalancingEnabled: boolean
|
||||
@ -47,7 +46,6 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: true,
|
||||
plan: defaultPlan,
|
||||
enableBilling: false,
|
||||
enableSkill: false,
|
||||
enableReplaceWebAppLogo: false,
|
||||
modelLoadBalancingEnabled: false,
|
||||
|
||||
@ -5,12 +5,12 @@ import type {
|
||||
import type { AppDetail } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type React from 'react'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { seedAccountProfileQuery } from '@/test/console/account-profile'
|
||||
import { seedSystemFeatures } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { createQueryClientWrapper } from '@/test/console/query-client'
|
||||
import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data'
|
||||
import { ServiceApiAccessCard } from '../service-api-access-card'
|
||||
import { WebAppAccessCard } from '../web-app-access-card'
|
||||
|
||||
@ -99,6 +99,16 @@ vi.mock('@/service/client', () => ({
|
||||
}),
|
||||
},
|
||||
},
|
||||
features: {
|
||||
get: {
|
||||
queryKey: () => ['features'],
|
||||
queryOptions: (options: Record<string, unknown> = {}) => ({
|
||||
queryKey: ['features'],
|
||||
staleTime: Infinity,
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
enterprise: {
|
||||
webAppAuth: {
|
||||
getWebAppWhitelistSubjects: {
|
||||
@ -264,7 +274,7 @@ function renderWithQueryClient(
|
||||
) {
|
||||
const queryClient = createConsoleQueryClient(webAppAuthEnabled)
|
||||
|
||||
render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
|
||||
render(ui, { wrapper: createQueryClientWrapper(queryClient) })
|
||||
|
||||
return queryClient
|
||||
}
|
||||
@ -286,6 +296,7 @@ function createConsoleQueryClient(webAppAuthEnabled = true) {
|
||||
},
|
||||
})
|
||||
seedAccountProfileQuery(queryClient, { id: 'user-1' })
|
||||
seedFeatures(queryClient)
|
||||
return queryClient
|
||||
}
|
||||
|
||||
@ -731,20 +742,15 @@ describe('Agent access surface cards', () => {
|
||||
})
|
||||
const queryClient = createConsoleQueryClient()
|
||||
const { rerender } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WebAppAccessCard agent={agentWithoutApp} agentId="agent-1" isLoading={false} />
|
||||
</QueryClientProvider>,
|
||||
<WebAppAccessCard agent={agentWithoutApp} agentId="agent-1" isLoading={false} />,
|
||||
{ wrapper: createQueryClientWrapper(queryClient) },
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.settings' }),
|
||||
).toBeDisabled()
|
||||
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WebAppAccessCard agent={agentWithoutSite} agentId="agent-1" isLoading={false} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
rerender(<WebAppAccessCard agent={agentWithoutSite} agentId="agent-1" isLoading={false} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.settings' }),
|
||||
|
||||
@ -175,6 +175,7 @@ export type ConsoleQueryTestOptions = {
|
||||
systemFeatures?: DeepPartial<GetSystemFeaturesResponse> | null
|
||||
accountProfile?: Partial<GetAccountProfileResponse> | null
|
||||
accountProfileMeta?: Partial<UserProfileWithMeta['meta']>
|
||||
features?: DeepPartial<GetFeaturesResponse>
|
||||
educationStatus?: Partial<EducationStatusResponse>
|
||||
currentWorkspace?: Partial<GetWorkspacesCurrentSummaryResponse> | null
|
||||
trialModels?: readonly string[] | null
|
||||
@ -202,6 +203,7 @@ export const createConsoleQueryWrapper = (
|
||||
seedAccountProfileQuery(queryClient, options.accountProfile, options.accountProfileMeta)
|
||||
else ensureAccountProfileQuery(queryClient, { timezone: 'UTC' }, options.accountProfileMeta)
|
||||
}
|
||||
if (options.features) seedFeatures(queryClient, options.features)
|
||||
if (options.educationStatus) seedEducationStatus(queryClient, options.educationStatus)
|
||||
if (options.currentWorkspace !== null) {
|
||||
const queryKey = getCurrentWorkspaceQueryKey()
|
||||
@ -242,6 +244,7 @@ export const renderWithConsoleQuery = (
|
||||
systemFeatures: sf,
|
||||
accountProfile,
|
||||
accountProfileMeta,
|
||||
features,
|
||||
educationStatus,
|
||||
currentWorkspace,
|
||||
trialModels,
|
||||
@ -254,6 +257,7 @@ export const renderWithConsoleQuery = (
|
||||
systemFeatures: sf,
|
||||
accountProfile,
|
||||
accountProfileMeta,
|
||||
features,
|
||||
educationStatus,
|
||||
currentWorkspace,
|
||||
trialModels,
|
||||
@ -276,6 +280,7 @@ export const renderHookWithConsoleQuery = <Result, Props = void>(
|
||||
systemFeatures: sf,
|
||||
accountProfile,
|
||||
accountProfileMeta,
|
||||
features,
|
||||
educationStatus,
|
||||
currentWorkspace,
|
||||
trialModels,
|
||||
@ -288,6 +293,7 @@ export const renderHookWithConsoleQuery = <Result, Props = void>(
|
||||
systemFeatures: sf,
|
||||
accountProfile,
|
||||
accountProfileMeta,
|
||||
features,
|
||||
educationStatus,
|
||||
currentWorkspace,
|
||||
trialModels,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user