refactor(web): remove legacy enableBilling consumers (#41913)

This commit is contained in:
yyh 2026-09-07 07:56:01 +00:00 committed by GitHub
parent c7423ffb2d
commit fc0136647f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
96 changed files with 1273 additions and 1291 deletions

View File

@ -16,7 +16,7 @@ export const baseProviderContextValue: ProviderContextState = {
supportRetrievalMethods: [],
isAPIKeySet: true,
plan: defaultPlan,
enableBilling: false,
enableSkill: false,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,

View File

@ -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()
})
})
})

View File

@ -93,7 +93,6 @@ const setupContexts = (
}
mockProviderCtx = {
plan: createPlanData(planOverrides),
enableBilling: true,
enableEducationPlan: false,
...providerOverrides,
}

View File

@ -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()

View File

@ -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()

View File

@ -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>

View File

@ -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()

View File

@ -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
}

View File

@ -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 }

View File

@ -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' })}
&nbsp;
{formatTime(
createdAt,
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
)}
</div>
{!!createdAt && (
<div>
{t(($) => $['editModal.createdAt'], { ns: 'appAnnotation' })}
&nbsp;
{formatTime(
createdAt,
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
)}
</div>
)}
</div>
) : undefined}
)}
</div>
</div>
</DrawerContent>
</DrawerPopup>

View File

@ -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,
}),
}))

View File

@ -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())

View File

@ -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()

View File

@ -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}

View File

@ -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

View File

@ -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,

View File

@ -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()
})

View File

@ -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}>

View File

@ -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()

View File

@ -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

View File

@ -46,7 +46,7 @@ const defaultProviderContext = {
supportRetrievalMethods: [],
isAPIKeySet: false,
plan: defaultPlan,
enableBilling: false,
enableSkill: false,
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,

View File

@ -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,
}),
),
)
})

View File

@ -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={

View File

@ -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 },

View File

@ -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}

View File

@ -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 = {

View File

@ -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)}
/>

View File

@ -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}

View File

@ -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()

View File

@ -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(() =>

View File

@ -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" />

View File

@ -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 && (

View File

@ -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,

View File

@ -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' })}

View File

@ -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()

View File

@ -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',

View File

@ -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>

View File

@ -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 />)

View File

@ -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">

View File

@ -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')

View File

@ -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)
})

View File

@ -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,

View File

@ -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>

View File

@ -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" />)

View File

@ -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()
})
})

View File

@ -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>

View File

@ -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} />

View File

@ -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: {},
})
}

View File

@ -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,

View File

@ -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()]

View File

@ -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}
/>
</>

View File

@ -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',
}))
})

View File

@ -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', () => ({

View File

@ -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: {},
})
}

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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}

View File

@ -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' }),

View File

@ -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>

View File

@ -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', () => {

View File

@ -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">

View File

@ -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} />)

View File

@ -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`,
)}

View File

@ -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,
}),
}))

View File

@ -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())
})

View File

@ -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}

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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>
) : (

View File

@ -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 />)

View File

@ -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 =

View File

@ -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()

View File

@ -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 />

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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"
>

View File

@ -66,7 +66,7 @@ describe('EditCustomCollectionModal', () => {
plan: {
type: 'sandbox',
},
enableBilling: false,
webappCopyrightEnabled: true,
} as ProviderContextState)
})

View File

@ -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

View File

@ -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 })
}

View File

@ -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,
},
)

View File

@ -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',

View File

@ -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',
})

View File

@ -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 } } },
})
}

View File

@ -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'

View File

@ -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 () => {

View File

@ -51,6 +51,7 @@ describe('useActionMenu', () => {
{
key: VersionHistoryContextMenuOptions.restore,
name: 'workflow.common.restore',
disabled: false,
},
{
key: VersionHistoryContextMenuOptions.edit,

View File

@ -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',

View File

@ -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,

View File

@ -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) => {

View File

@ -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,

View File

@ -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,

View File

@ -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' }),

View File

@ -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,