From fc0136647fc7ef45ddcd1da94c0091645a6bf05c Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:56:01 +0000 Subject: [PATCH] refactor(web): remove legacy enableBilling consumers (#41913) --- web/__mocks__/provider-context.ts | 2 +- .../billing/billing-integration.test.tsx | 21 +- .../education-verification-flow.test.tsx | 1 - .../app/annotation/__tests__/index.spec.tsx | 37 ++-- .../__tests__/index.spec.tsx | 31 ++- .../annotation/add-annotation-modal/index.tsx | 24 ++- .../__tests__/index.spec.tsx | 35 ++-- .../batch-add-annotation-modal/index.tsx | 23 ++- .../__tests__/index.spec.tsx | 184 +----------------- .../edit-annotation-modal/index.tsx | 85 +++----- .../header-opts/__tests__/index.spec.tsx | 7 +- web/app/components/app/annotation/index.tsx | 23 ++- .../create-app-modal/__tests__/index.spec.tsx | 56 ++---- .../components/app/create-app-modal/index.tsx | 27 ++- .../__tests__/index.spec.tsx | 40 ++-- .../app/create-from-dsl-modal/index.tsx | 25 ++- .../duplicate-modal/__tests__/index.spec.tsx | 65 +++++-- .../components/app/duplicate-modal/index.tsx | 30 ++- .../__tests__/archived-logs-notice.spec.tsx | 30 +-- .../app/log/archived-logs-notice.tsx | 14 +- .../__tests__/test-utils.tsx | 2 +- .../settings/__tests__/index.spec.tsx | 121 ++++++------ .../app/overview/settings/index.tsx | 60 +++--- .../switch-app-modal/__tests__/index.spec.tsx | 22 ++- .../components/app/switch-app-modal/index.tsx | 22 ++- .../chat/answer/__tests__/operation.spec.tsx | 66 +------ .../base/chat/chat/answer/operation.tsx | 9 +- .../__tests__/index.spec.tsx | 5 +- .../__tests__/annotation-ctrl-button.spec.tsx | 24 +-- .../__tests__/use-annotation-config.spec.ts | 40 +++- .../annotation-ctrl-button.tsx | 23 ++- .../annotation-reply/index.tsx | 3 +- .../annotation-reply/use-annotation-config.ts | 27 ++- .../billing/apps-full-in-dialog/index.tsx | 23 ++- .../billing-page/__tests__/index.spec.tsx | 17 +- .../components/billing/billing-page/index.tsx | 11 +- .../billing/priority-label/index.tsx | 36 ++-- .../custom-page/__tests__/index.spec.tsx | 77 +++----- .../components/custom/custom-page/index.tsx | 19 +- .../__tests__/index.spec.tsx | 4 +- .../__tests__/use-web-app-brand.spec.tsx | 41 +--- .../hooks/use-web-app-brand.ts | 16 +- .../custom/custom-web-app-brand/index.tsx | 11 +- .../__tests__/index.spec.tsx | 28 +-- .../__tests__/indexing-progress-item.spec.tsx | 12 -- .../create/embedding-process/index.tsx | 19 +- .../indexing-progress-item.tsx | 4 +- .../__tests__/upload-dropzone.spec.tsx | 31 +-- .../components/upload-dropzone.tsx | 7 +- .../create/step-one/__tests__/index.spec.tsx | 27 ++- .../datasets/create/step-one/index.tsx | 38 +++- .../__tests__/index.spec.tsx | 17 +- .../local-file/__tests__/index.spec.tsx | 6 +- .../__tests__/upload-dropzone.spec.tsx | 31 +-- .../local-file/components/upload-dropzone.tsx | 7 +- .../__tests__/use-datasource-ui-state.spec.ts | 12 +- .../hooks/use-datasource-ui-state.ts | 5 +- .../documents/create-from-pipeline/index.tsx | 28 ++- .../__tests__/index.spec.tsx | 34 ++-- .../processing/embedding-process/index.tsx | 20 +- .../steps/__tests__/step-one-content.spec.tsx | 19 +- .../steps/step-one-content.tsx | 4 +- .../segment-add/__tests__/index.spec.tsx | 22 ++- .../documents/detail/segment-add/index.tsx | 22 ++- .../settings/form/__tests__/index.spec.tsx | 3 +- .../create-app-modal/__tests__/index.spec.tsx | 61 ++++-- .../explore/create-app-modal/index.tsx | 35 +++- .../account-setting/__tests__/index.spec.tsx | 7 +- .../header/account-setting/index.tsx | 8 +- .../members-page/__tests__/index.spec.tsx | 100 ++++------ .../account-setting/members-page/index.tsx | 28 ++- .../invite-modal/__tests__/index.spec.tsx | 51 +++-- .../members-page/invite-modal/index.tsx | 6 +- .../__tests__/index.spec.tsx | 33 +--- .../workflow-log-archives-page/index.tsx | 17 +- .../main-nav/__tests__/index.spec.tsx | 3 +- .../__tests__/support-menu.spec.tsx | 85 ++++---- .../__tests__/workspace-card.spec.tsx | 2 - .../main-nav/components/support-menu.tsx | 42 ++-- .../__tests__/index.spec.tsx | 2 +- .../workflow/__tests__/workflow-test-env.tsx | 7 +- .../__tests__/header-in-restoring.spec.tsx | 30 ++- .../header/__tests__/header-layouts.spec.tsx | 5 +- .../workflow/header/header-in-restoring.tsx | 26 ++- .../nodes/agent-v2/__tests__/hooks.spec.tsx | 34 ++-- .../__tests__/index.spec.tsx | 21 +- .../__tests__/version-history-item.spec.tsx | 3 +- .../action-menu/__tests__/index.spec.tsx | 24 ++- .../__tests__/use-action-menu.spec.tsx | 1 + .../action-menu/action-menu-item.tsx | 2 + .../action-menu/use-action-menu.ts | 28 ++- .../panel/version-history-panel/index.tsx | 24 ++- web/context/provider-context-provider.tsx | 4 +- web/context/provider-context.ts | 2 - .../__tests__/access-surface-cards.spec.tsx | 32 +-- web/test/console/query-data.ts | 6 + 96 files changed, 1273 insertions(+), 1291 deletions(-) diff --git a/web/__mocks__/provider-context.ts b/web/__mocks__/provider-context.ts index e6136db2e7e..965a73a1ddd 100644 --- a/web/__mocks__/provider-context.ts +++ b/web/__mocks__/provider-context.ts @@ -16,7 +16,7 @@ export const baseProviderContextValue: ProviderContextState = { supportRetrievalMethods: [], isAPIKeySet: true, plan: defaultPlan, - enableBilling: false, + enableSkill: false, enableReplaceWebAppLogo: false, modelLoadBalancingEnabled: false, diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index e3f2f7a961e..919055c07af 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -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 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 total?: Partial reset?: Partial @@ -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() - - expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument() - }) }) }) diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index f9bea585ea4..a9322ece4fe 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -93,7 +93,6 @@ const setupContexts = ( } mockProviderCtx = { plan: createPlanData(planOverrides), - enableBilling: true, enableEducationPlan: false, ...providerOverrides, } diff --git a/web/app/components/app/annotation/__tests__/index.spec.tsx b/web/app/components/app/annotation/__tests__/index.spec.tsx index 54b3dda2075..2f69bf83d7a 100644 --- a/web/app/components/app/annotation/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/__tests__/index.spec.tsx @@ -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 }) => (
{children}
@@ -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 = {}): AnnotationIt const renderComponent = () => render() +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() diff --git a/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx index 7129cfcf885..dfade0ee399 100644 --- a/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx @@ -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: () =>
, })) -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() expect(screen.getByTestId('annotation-full')).toBeInTheDocument() diff --git a/web/app/components/app/annotation/add-annotation-modal/index.tsx b/web/app/components/app/annotation/add-annotation-modal/index.tsx index 69e16fe405d..fc44d0285bc 100644 --- a/web/app/components/app/annotation/add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/index.tsx @@ -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 = ({ 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 = ({ isShow, onHide, onAdd }) => { } const handleSave = async () => { + if (isAnnotationQuotaUnavailable || isAnnotationFull) return const payload = { question, answer, @@ -123,7 +139,7 @@ const AddAnnotationModal: FC = ({ isShow, onHide, onAdd }) => { variant="primary" onClick={handleSave} loading={isSaving} - disabled={isAnnotationFull} + disabled={isAnnotationQuotaUnavailable || isAnnotationFull} > {t(($) => $['operation.add'], { ns: 'common' })} diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx index 521e54a1ed2..7ecd4160d62 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx @@ -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: () =>
, })) @@ -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 = {}) => { } } +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() diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx index 7127758e761..4d6348e817e 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx @@ -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 = ({ 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() const handleFile = (file?: File) => setCurrentCSV(file) @@ -112,7 +127,7 @@ const BatchModal: FC = ({ appId, isShow, onCancel, onAdded })
- {isAnnotationFull && ( -
- +
+
setShowModal(true)} + > + +
{t(($) => $['editModal.removeThisCache'], { ns: 'appAnnotation' })}
- )} - - {annotationId ? ( -
-
setShowModal(true)} - > - -
- {t(($) => $['editModal.removeThisCache'], { ns: 'appAnnotation' })} -
+ {!!createdAt && ( +
+ {t(($) => $['editModal.createdAt'], { ns: 'appAnnotation' })} +   + {formatTime( + createdAt, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + )}
- {!!createdAt && ( -
- {t(($) => $['editModal.createdAt'], { ns: 'appAnnotation' })} -   - {formatTime( - createdAt, - t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, - )} -
- )} -
- ) : undefined} + )} +
diff --git a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx index 723399f2177..0a6056b9dcb 100644 --- a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx @@ -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, }), })) diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx index b50c9db10cf..a42f2e7440e 100644 --- a/web/app/components/app/annotation/index.tsx +++ b/web/app/components/app/annotation/index.tsx @@ -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) => { const [annotationConfig, setAnnotationConfig] = useState(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({}) const [currPage, setCurrPage] = useState(0) @@ -177,9 +192,11 @@ const Annotation: FC = (props) => { { if (value) { + if (isAnnotationQuotaUnavailable) return if (isAnnotationFull) { setIsShowAnnotationFullModal(true) setControlRefreshSwitch(Date.now()) diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index 22b84de2b84..938c1526445 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -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) - mockUseProviderContext.mockReturnValue({ - plan: { - type: AppModeEnum.ADVANCED_CHAT, - usage: defaultPlanUsage, - total: { ...defaultPlanUsage, buildApps: 1 }, - reset: {}, - }, - enableBilling: true, - } as unknown as ReturnType) + 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) + 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) + appQuota = { size: 1, limit: 1 } renderModal() diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 9a03b7ffdfd..278b9be4689 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -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
-
- {webappCopyrightEnabled ? ( + {webappCopyrightEnabled !== false ? ( $[`${prefixSettings}.more.copyright`], { ns: 'appOverview', })} @@ -701,6 +710,7 @@ const SettingsModal: FC = ({ ns: 'appOverview', })} className="mt-2 h-10" + disabled={webappCopyrightEnabled !== true} value={inputInfo.copyright} onChange={onChange('copyright')} placeholder={ diff --git a/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx b/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx index 3868b51505e..f8ade350ced 100644 --- a/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/switch-app-modal/__tests__/index.spec.tsx @@ -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 { 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 }, diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index 821513f2e8f..9d28d705372 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -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 - @@ -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 = { diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx index b19665a2bd4..5efbe278609 100644 --- a/web/app/components/base/chat/chat/answer/operation.tsx +++ b/web/app/components/base/chat/chat/answer/operation.tsx @@ -443,21 +443,18 @@ function Operation({
)}
- {canManageAnnotation && ( + {canManageAnnotation && annotation?.id && isShowReplyModal && ( 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)} /> diff --git a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx index 34cc4225f3a..d5db454180f 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx @@ -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( ({ })) 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() diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts index b001f0542c3..ec76acd16fa 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/use-annotation-config.spec.ts @@ -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(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(() => diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx index 71275d8f39b..a0d4ffd71f0 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.tsx @@ -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 = ({ 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 = ({ render={ $['feature.annotation.add'], { ns: 'appDebug' })} + disabled={isAnnotationQuotaUnavailable} onClick={handleAdd} > diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx index 5dc697148ef..80945a22809 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx @@ -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 && ( diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts index 8cde3021d1f..002670989c9 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.ts @@ -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, diff --git a/web/app/components/billing/apps-full-in-dialog/index.tsx b/web/app/components/billing/apps-full-in-dialog/index.tsx index 0f53af787ef..8e8f55407f1 100644 --- a/web/app/components/billing/apps-full-in-dialog/index.tsx +++ b/web/app/components/billing/apps-full-in-dialog/index.tsx @@ -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 }) = )} - {(plan.type === 'sandbox' || plan.type === 'professional') && ( + {(billing.plan === 'sandbox' || billing.plan === 'professional') && ( )} - {plan.type !== 'sandbox' && plan.type !== 'professional' && ( + {billing.plan !== 'sandbox' && billing.plan !== 'professional' && ( ({ 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 }) =>
, })) @@ -42,7 +36,10 @@ const renderBilling = () => { url: currentBillingUrl, }) } - const { wrapper } = createConsoleQueryWrapper({ queryClient }) + const { wrapper } = createConsoleQueryWrapper({ + queryClient, + systemFeatures: { deployment_edition: deploymentEdition }, + }) return render(, { 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() diff --git a/web/app/components/billing/billing-page/index.tsx b/web/app/components/billing/billing-page/index.tsx index 7b27f632412..ae5aaa51ff6 100644 --- a/web/app/components/billing/billing-page/index.tsx +++ b/web/app/components/billing/billing-page/index.tsx @@ -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 (
- {canManageBilling && ( + {deploymentEdition === 'CLOUD' && isCurrentWorkspaceManager && ( { 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 ( @@ -35,17 +37,15 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => { /> } > - {(plan.type === 'professional' || plan.type === 'team') && ( - - )} - {t(($) => $[`plansCommon.priority.${priority}`], { ns: 'billing' })} + {(plan === 'professional' || plan === 'team') && } + {t(($) => $[`plansCommon.priority.${label}`], { ns: 'billing' })}
{t(($) => $['plansCommon.documentProcessingPriority'], { ns: 'billing' })}:{' '} - {t(($) => $[`plansCommon.priority.${priority}`], { ns: 'billing' })} + {t(($) => $[`plansCommon.priority.${label}`], { ns: 'billing' })}
- {priority !== 'top-priority' && ( + {label !== 'top-priority' && (
{t(($) => $['plansCommon.documentProcessingPriorityTip'], { ns: 'billing' })}
diff --git a/web/app/components/custom/custom-page/__tests__/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx index b25b9856264..5982869cc74 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -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() 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) @@ -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() @@ -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() @@ -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() @@ -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() diff --git a/web/app/components/custom/custom-page/index.tsx b/web/app/components/custom/custom-page/index.tsx index eaa0ae6a66b..c5e08cfa964 100644 --- a/web/app/components/custom/custom-page/index.tsx +++ b/web/app/components/custom/custom-page/index.tsx @@ -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 (
diff --git a/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx index 11f6b3a273f..28b80f5bc02 100644 --- a/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx @@ -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') diff --git a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx index b2e664b3ff0..b4e90ce67a3 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx @@ -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 = {} let customConfig = { replace_webapp_logo: 'https://example.com/replace.png', @@ -32,6 +30,9 @@ const renderHook = (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 => { 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) }) diff --git a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts index c6851a67109..68121c9a969 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts +++ b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts @@ -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) => { + 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, diff --git a/web/app/components/custom/custom-web-app-brand/index.tsx b/web/app/components/custom/custom-web-app-brand/index.tsx index a76ca4eb1af..ad0530d423d 100644 --- a/web/app/components/custom/custom-web-app-brand/index.tsx +++ b/web/app/components/custom/custom-web-app-brand/index.tsx @@ -22,7 +22,7 @@ const CustomWebAppBrand = () => { uploadDisabled, workspaceLogo, canManageCustomBrand, - isSandbox, + canReplaceLogo, handleApply, handleCancel, handleChange, @@ -37,7 +37,7 @@ const CustomWebAppBrand = () => {
@@ -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' })} diff --git a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx index 4aa3641eabb..23bfb78c9a1 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx @@ -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() diff --git a/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx index 825fe39e3ab..cf16e383329 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx @@ -106,16 +106,4 @@ describe('IndexingProgressItem', () => { expect(screen.getByText('common.error')).toBeInTheDocument() }) - - it('should show priority label when billing is enabled', () => { - render() - - expect(screen.getByTestId('priority-label')).toBeInTheDocument() - }) - - it('should not show priority label when billing is disabled', () => { - render() - - expect(screen.queryByTestId('priority-label')).not.toBeInTheDocument() - }) }) diff --git a/web/app/components/datasets/create/embedding-process/index.tsx b/web/app/components/datasets/create/embedding-process/index.tsx index 54719c14628..b8d555ebe61 100644 --- a/web/app/components/datasets/create/embedding-process/index.tsx +++ b/web/app/components/datasets/create/embedding-process/index.tsx @@ -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 = ({ 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 = ({ 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 = ({ name={documentLookup.getName(detail.id)} sourceType={documentLookup.getSourceType(detail.id)} notionIcon={documentLookup.getNotionIcon(detail.id)} - enableBilling={enableBilling} /> ))}
diff --git a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx index 571a8ced063..d1b0dd6d89a 100644 --- a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx +++ b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx @@ -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 = ({ name, sourceType, notionIcon, - enableBilling, }) => { const isEmbedding = isSourceEmbedding(detail) const percent = getSourcePercent(detail) @@ -94,7 +92,7 @@ const IndexingProgressItem: FC = ({
{name}
- {enableBilling && } +
{isEmbedding &&
{`${percent}%`}
} diff --git a/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx b/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx index dfe04d39361..49d4a8a053c 100644 --- a/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx @@ -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: ( - selector: (state: Pick) => T, - ): T => selector({ enableBilling: mockEnableBilling }), -})) +let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY' // Helper to create mock ref objects for testing const createMockRef = (value: T | null = null): RefObject => ({ 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() @@ -97,7 +91,7 @@ describe('UploadDropzone', () => { }) it('should render tip with total count limit when billing is enabled', () => { - mockEnableBilling = true + deploymentEdition = 'CLOUD' render() @@ -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() @@ -122,7 +116,7 @@ describe('UploadDropzone', () => { }) it('should additionally pass total count to tip when billing is enabled', () => { - mockEnableBilling = true + deploymentEdition = 'CLOUD' render() @@ -275,3 +269,10 @@ describe('UploadDropzone', () => { }) }) }) + +function render(ui: ReactElement) { + return renderWithConsoleQuery(ui, { + systemFeatures: { deployment_edition: deploymentEdition }, + features: {}, + }) +} diff --git a/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx b/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx index 0531f399479..bcf3b783e9e 100644 --- a/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx +++ b/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx @@ -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 @@ -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 = ({
- {enableBilling + {deploymentEdition === 'CLOUD' ? t(($) => $['stepOne.uploader.tipWithTotalLimit'], { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit, diff --git a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx index 07d59a7ce8f..1f4bd03b499 100644 --- a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx @@ -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() @@ -441,7 +436,7 @@ describe('StepOne', () => { }) it('should show upgrade card immediately when in sandbox plan', () => { - mockEnableBilling = true + deploymentEdition = 'CLOUD' mockPlan.type = 'sandbox' render() @@ -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()] diff --git a/web/app/components/datasets/create/step-one/index.tsx b/web/app/components/datasets/create/step-one/index.tsx index 65867fdbf37..77114dd44c4 100644 --- a/web/app/components/datasets/create/step-one/index.tsx +++ b/web/app/components/datasets/create/step-one/index.tsx @@ -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 = ({ />
)} - - {enableBilling && plan.type === 'sandbox' && ( + + {deploymentEdition === 'CLOUD' && plan === 'sandbox' && (
@@ -292,7 +307,10 @@ const StepOne = ({ )} @@ -322,7 +340,7 @@ const StepOne = ({
)} diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx index d56098c62f1..e7641cb3a4c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx @@ -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', })) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx index 9a975ec0a4e..b30a97ad00e 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx @@ -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', () => ({ diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx index e7e1c43b766..a3ac19aa750 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx @@ -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: ( - selector: (state: Pick) => T, - ): T => selector({ enableBilling: mockEnableBilling }), -})) +let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY' // Helper to create mock ref objects for testing const createMockRef = (value: T | null = null): RefObject => ({ 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() @@ -103,7 +97,7 @@ describe('UploadDropzone', () => { }) it('should render tip with total count limit when billing is enabled', () => { - mockEnableBilling = true + deploymentEdition = 'CLOUD' render() @@ -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() @@ -128,7 +122,7 @@ describe('UploadDropzone', () => { }) it('should additionally pass total count to tip when billing is enabled', () => { - mockEnableBilling = true + deploymentEdition = 'CLOUD' render() @@ -294,3 +288,10 @@ describe('UploadDropzone', () => { }) }) }) + +function render(ui: ReactElement) { + return renderWithConsoleQuery(ui, { + systemFeatures: { deployment_edition: deploymentEdition }, + features: {}, + }) +} diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx index d1d0f06af05..fb8e363bc45 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx @@ -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 = ({
- {enableBilling + {deploymentEdition === 'CLOUD' ? t(($) => $['stepOne.uploader.tipWithTotalLimit'], { ns: 'datasetCreation', size: fileUploadConfig.file_size_limit, diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts index ae821912cd4..fdb40b23a0c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts @@ -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) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts index a84de2e1f95..52a8ad86182 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/use-datasource-ui-state.ts @@ -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 diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx index e8e959cfc88..e671f0b4afb 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx @@ -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 = { [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[]} 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} diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index 3318b62f7e2..2dd678103b0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -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() @@ -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' }), diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx index d9e95b5be8a..3b41241b2c4 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx @@ -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( [], ) @@ -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') && (
@@ -216,7 +226,7 @@ const EmbeddingProcess = ({
{getSourceName(indexingStatusDetail.id)}
- {enableBilling && } +
{isSourceEmbedding(indexingStatusDetail) && (
{`${getSourcePercent(indexingStatusDetail)}%`}
diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx index 44daa66f682..9fb3775372a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx @@ -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( + , + ) + // UpgradeCard contains an upgrade button + expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument() + }) + + it('does not infer an upgrade requirement from unavailable batch upload', () => { render( { 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', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx index 440c8a33b7d..13cbd1c6e67 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.tsx @@ -20,6 +20,7 @@ type StepOneContentProps = { datasourceType: string | undefined pipelineNodes: Node[] 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 (
diff --git a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx index b7b2db0d237..da69780ae22 100644 --- a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx @@ -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() diff --git a/web/app/components/datasets/documents/detail/segment-add/index.tsx b/web/app/components/datasets/documents/detail/segment-add/index.tsx index 81c6fb4fb84..5c273cdff1e 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx @@ -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} > $['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`, )} diff --git a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx index 09aebd507cc..ce0c21d6cd6 100644 --- a/web/app/components/datasets/settings/form/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx @@ -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, }), })) diff --git a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx index 6c94b14ae0f..1f917566dd1 100644 --- a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx @@ -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[0] const setup = async (overrides: Partial = {}) => { @@ -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( + , + { 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()) +}) diff --git a/web/app/components/explore/create-app-modal/index.tsx b/web/app/components/explore/create-app-modal/index.tsx index e0c30bdeb68..541cdfb6c37 100644 --- a/web/app/components/explore/create-app-modal/index.tsx +++ b/web/app/components/explore/create-app-modal/index.tsx @@ -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 = ({
- {enableBilling && isNotUnlimitedMemberPlan ? ( + {isNotUnlimitedMemberPlan ? (
{t(($) => $['plansCommon.member'], { ns: 'billing' })} @@ -139,9 +151,9 @@ const MembersPage = () => {
{accounts.length}
/
- {plan.total.teamMembers === NUM_INFINITE + {billing.members.limit === 0 ? t(($) => $['plansCommon.unlimited'], { ns: 'billing' }) - : plan.total.teamMembers} + : billing.members.limit}
) : ( diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx index eb760277092..1f66c34d75c 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx @@ -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() + 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( - + members.invite} @@ -120,7 +128,7 @@ describe('InviteModal', () => { onOpenChange={onOpenChange} onSend={onSend} /> - , + , ) } @@ -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 ( - + members.invite} @@ -672,7 +681,7 @@ describe('InviteModal', () => { onOpenChange={setOpen} onSend={onSend} /> - + ) } render() diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx index b36c4443de8..9d16137c944 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx @@ -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([]) const [draft, setDraft] = useState('') const [submissionError, setSubmissionError] = useState(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 = diff --git a/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx index fabfdb749bf..28f35568b5d 100644 --- a/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx @@ -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() - return { - ...actual, - useProviderContext: vi.fn(), - } -}) - vi.mock('@/context/modal-context', async (importOriginal) => { const actual = await importOriginal() 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(, { 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) @@ -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() diff --git a/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx b/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx index decf2427795..55d3fef9adf 100644 --- a/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx +++ b/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx @@ -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(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 (
diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index e59439197bb..0dd4bfe5949 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -604,6 +604,7 @@ const renderMainNav = ( , { 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) diff --git a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx index 2d56e31c559..736d479e137 100644 --- a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx @@ -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()), 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( {}}> @@ -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() diff --git a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx index 15d5f40c297..0d53f884595 100644 --- a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx @@ -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) diff --git a/web/app/components/main-nav/components/support-menu.tsx b/web/app/components/main-nav/components/support-menu.tsx index 1dc559955f4..5cf8b5251e6 100644 --- a/web/app/components/main-nav/components/support-menu.tsx +++ b/web/app/components/main-nav/components/support-menu.tsx @@ -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() { /> )} - {!shouldShowUpgradeContact && hasDedicatedChannel && !hasZendeskWidget && ( + {!shouldShowUpgradeContact && hasDedicatedChannel && !hasZendeskWidget && supportMailLink && ( diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx index aa084bfaf38..3ea1a51be75 100644 --- a/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx @@ -66,7 +66,7 @@ describe('EditCustomCollectionModal', () => { plan: { type: 'sandbox', }, - enableBilling: false, + webappCopyrightEnabled: true, } as ProviderContextState) }) diff --git a/web/app/components/workflow/__tests__/workflow-test-env.tsx b/web/app/components/workflow/__tests__/workflow-test-env.tsx index ea3a84d0302..caba2808b04 100644 --- a/web/app/components/workflow/__tests__/workflow-test-env.tsx +++ b/web/app/components/workflow/__tests__/workflow-test-env.tsx @@ -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 diff --git a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx index 2f225ebc99b..df2ab245422 100644 --- a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx @@ -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[0], + options: Parameters[1] = {}, +) { + const queryClient = createConsoleQueryClient() + createConsoleQueryWrapper({ queryClient }) + seedSystemFeatures(queryClient, { deployment_edition: deploymentEdition }) + seedFeatures(queryClient, { billing: { subscription: { plan: mockPlanType } } }) + return renderWorkflow(ui, { ...options, queryClient }) +} diff --git a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx index 630f665df81..ff976be6a23 100644 --- a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx @@ -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( , @@ -307,7 +310,7 @@ describe('Header layout components', () => { fileSettings: {}, }, }, - queryClient: createAccountProfileQueryClient(currentUser), + queryClient, }, ) diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index 20a75844dc7..fbb50231808 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -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) => {