From f93b287949e53cda5e897c591dca0996a6016085 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Sun, 12 Apr 2026 11:09:15 +0800 Subject: [PATCH] feat(web): billing for evaluation & snippets --- .../[appId]/evaluation/page.tsx | 7 ++- .../(appDetailLayout)/[appId]/layout-main.tsx | 18 +++--- .../__tests__/layout-main.spec.tsx | 21 +++++++ .../[datasetId]/evaluation/page.tsx | 7 ++- .../[datasetId]/layout-main.tsx | 6 +- web/app/(commonLayout)/snippets/page.tsx | 7 ++- .../components/app/app-publisher/index.tsx | 14 ++++- .../components/apps/__tests__/list.spec.tsx | 34 +++++++++-- web/app/components/apps/list.tsx | 5 +- .../components/apps/studio-route-switch.tsx | 24 +++++--- .../snippet-and-evaluation-plan-guard.tsx | 40 ++++++++++++ web/app/components/billing/utils/index.ts | 19 ++++++ web/app/components/snippets/index.tsx | 9 ++- .../snippets/snippet-evaluation-page.tsx | 17 +++--- .../block-selector/__tests__/hooks.spec.tsx | 22 +++++++ .../workflow/block-selector/hooks.ts | 6 +- .../workflow/selection-contextmenu.tsx | 61 +++++++++++-------- .../use-snippet-and-evaluation-plan-access.ts | 19 ++++++ 18 files changed, 268 insertions(+), 68 deletions(-) create mode 100644 web/app/components/billing/snippet-and-evaluation-plan-guard.tsx create mode 100644 web/hooks/use-snippet-and-evaluation-plan-access.ts diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/evaluation/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/evaluation/page.tsx index d602c15d5e..899667ad3c 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/evaluation/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/evaluation/page.tsx @@ -1,3 +1,4 @@ +import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard' import Evaluation from '@/app/components/evaluation' const Page = async (props: { @@ -5,7 +6,11 @@ const Page = async (props: { }) => { const { appId } = await props.params - return + return ( + + + + ) } export default Page diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 5b924a4c5a..96cacfc556 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -26,6 +26,7 @@ import { useStore as useTagStore } from '@/app/components/base/tag-management/st import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' +import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' import dynamic from '@/next/dynamic' import { usePathname, useRouter } from '@/next/navigation' import { fetchAppDetailDirect } from '@/service/apps' @@ -52,6 +53,7 @@ const AppDetailLayout: FC = (props) => { const pathname = usePathname() const media = useBreakpoints() const isMobile = media === MediaType.mobile + const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess() const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext() const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({ appDetail: state.appDetail, @@ -78,12 +80,14 @@ const AppDetailLayout: FC = (props) => { icon: RiTerminalWindowLine, selectedIcon: RiTerminalWindowFill, }) - navConfig.push({ - name: t('appMenus.evaluation', { ns: 'common' }), - href: `/app/${appId}/evaluation`, - icon: RiFlaskLine, - selectedIcon: RiFlaskFill, - }) + if (canAccessSnippetsAndEvaluation) { + navConfig.push({ + name: t('appMenus.evaluation', { ns: 'common' }), + href: `/app/${appId}/evaluation`, + icon: RiFlaskLine, + selectedIcon: RiFlaskFill, + }) + } } navConfig.push({ @@ -111,7 +115,7 @@ const AppDetailLayout: FC = (props) => { selectedIcon: RiDashboard2Fill, }) return navConfig - }, [t]) + }, [canAccessSnippetsAndEvaluation, t]) useDocumentTitle(appDetail?.name || t('menus.appDetail', { ns: 'common' })) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx index 74dc55efb8..2831b0d464 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx @@ -8,6 +8,7 @@ import DatasetDetailLayout from '../layout-main' let mockPathname = '/datasets/test-dataset-id/documents' let mockDataset: DataSet | undefined +let mockCanAccessSnippetsAndEvaluation = true const mockSetAppSidebarExpand = vi.fn() const mockMutateDatasetRes = vi.fn() @@ -44,6 +45,13 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({ + useSnippetAndEvaluationPlanAccess: () => ({ + canAccess: mockCanAccessSnippetsAndEvaluation, + isReady: true, + }), +})) + vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) @@ -164,6 +172,7 @@ describe('DatasetDetailLayout', () => { vi.clearAllMocks() mockPathname = '/datasets/test-dataset-id/documents' mockDataset = createDataset() + mockCanAccessSnippetsAndEvaluation = true }) describe('Evaluation navigation', () => { @@ -205,5 +214,17 @@ describe('DatasetDetailLayout', () => { expect(screen.getByRole('button', { name: 'common.datasetMenus.evaluation' })).toBeEnabled() }) + + it('should hide the evaluation menu when snippet and evaluation access is unavailable', () => { + mockCanAccessSnippetsAndEvaluation = false + + render( + +
content
+
, + ) + + expect(screen.queryByRole('button', { name: 'common.datasetMenus.evaluation' })).not.toBeInTheDocument() + }) }) }) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/evaluation/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/evaluation/page.tsx index 97ba166391..ea8fc0ea82 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/evaluation/page.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/evaluation/page.tsx @@ -1,3 +1,4 @@ +import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard' import Evaluation from '@/app/components/evaluation' const Page = async (props: { @@ -5,7 +6,11 @@ const Page = async (props: { }) => { const { datasetId } = await props.params - return + return ( + + + + ) } export default Page diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index b7a6347b4c..906d63e66e 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -24,6 +24,7 @@ import DatasetDetailContext from '@/context/dataset-detail' import { useEventEmitterContextContext } from '@/context/event-emitter' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' +import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' import { usePathname } from '@/next/navigation' import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset' import { cn } from '@/utils/classnames' @@ -51,6 +52,7 @@ const DatasetDetailLayout: FC = (props) => { setHideHeader(v.payload) }) const { isCurrentWorkspaceDatasetOperator } = useAppContext() + const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess() const media = useBreakpoints() const isMobile = media === MediaType.mobile @@ -104,7 +106,7 @@ const DatasetDetailLayout: FC = (props) => { selectedIcon: PipelineFill as RemixiconComponentType, disabled: false, }, - ...(isRagPipelineDataset + ...(isRagPipelineDataset && canAccessSnippetsAndEvaluation ? [{ name: t('datasetMenus.evaluation', { ns: 'common' }), href: `/datasets/${datasetId}/evaluation`, @@ -118,7 +120,7 @@ const DatasetDetailLayout: FC = (props) => { } return baseNavigation - }, [t, datasetId, isButtonDisabledWithPipeline, isRagPipelineDataset, datasetRes?.provider]) + }, [canAccessSnippetsAndEvaluation, t, datasetId, isButtonDisabledWithPipeline, isRagPipelineDataset, datasetRes?.provider]) useDocumentTitle(datasetRes?.name || t('menus.datasets', { ns: 'common' })) diff --git a/web/app/(commonLayout)/snippets/page.tsx b/web/app/(commonLayout)/snippets/page.tsx index 660df108c5..73fadb0d27 100644 --- a/web/app/(commonLayout)/snippets/page.tsx +++ b/web/app/(commonLayout)/snippets/page.tsx @@ -1,7 +1,12 @@ import Apps from '@/app/components/apps' +import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard' const SnippetsPage = () => { - return + return ( + + + + ) } export default SnippetsPage diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index becdf368ee..3b6ba7b19f 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -24,6 +24,7 @@ import { import { useGlobalPublicStore } from '@/context/global-public-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' +import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' import { fetchAppDetailDirect } from '@/service/apps' @@ -133,15 +134,22 @@ const AppPublisher = ({ const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(s => s.setAppDetail) const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess() const { formatTimeFromNow } = useFormatTimeFromNow() const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation() const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode }) const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) - const workflowTypeSwitchConfig = isWorkflowTypeConversionTarget(appDetail?.type) - ? WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type] - : undefined + const workflowTypeSwitchConfig = useMemo(() => { + if (!isWorkflowTypeConversionTarget(appDetail?.type)) + return undefined + + if (appDetail.type !== AppTypeEnum.EVALUATION && !canAccessSnippetsAndEvaluation) + return undefined + + return WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type] + }, [appDetail?.type, canAccessSnippetsAndEvaluation]) const isEvaluationWorkflowType = appDetail?.type === AppTypeEnum.EVALUATION const { refetch: refetchEvaluationWorkflowAssociatedTargets, diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index acc2abb1ff..feb1519e57 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -16,6 +16,7 @@ vi.mock('@/next/navigation', () => ({ const mockIsCurrentWorkspaceEditor = vi.fn(() => true) const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false) const mockIsLoadingCurrentWorkspace = vi.fn(() => false) +const mockCanAccessSnippetsAndEvaluation = vi.fn(() => true) vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ @@ -33,6 +34,13 @@ vi.mock('@/context/global-public-context', () => ({ }), })) +vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({ + useSnippetAndEvaluationPlanAccess: () => ({ + canAccess: mockCanAccessSnippetsAndEvaluation(), + isReady: true, + }), +})) + const mockSetQuery = vi.fn() const mockQueryState = { tagIDs: [] as string[], @@ -135,12 +143,18 @@ const defaultSnippetData = { id: 'snippet-1', name: 'Tone Rewriter', description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.', + type: 'node', + is_published: false, + use_count: 19, + icon_info: { + icon_type: 'emoji', + icon: '🪄', + icon_background: '#E0EAFF', + icon_url: '', + }, + created_at: 1704067200, + updated_at: '2024-01-02 10:00', author: '', - updatedAt: '2024-01-02 10:00', - usage: '19', - icon: '🪄', - iconBackground: '#E0EAFF', - status: undefined, }, ], total: 1, @@ -269,6 +283,7 @@ describe('List', () => { mockIsCurrentWorkspaceEditor.mockReturnValue(true) mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false) mockIsLoadingCurrentWorkspace.mockReturnValue(false) + mockCanAccessSnippetsAndEvaluation.mockReturnValue(true) mockDragging = false mockOnDSLFileDropped = null mockServiceState.error = null @@ -336,6 +351,15 @@ describe('List', () => { fireEvent.click(screen.getByTestId('close-dsl-modal')) expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument() }) + + it('should hide the snippets route switch when snippet access is unavailable', () => { + mockCanAccessSnippetsAndEvaluation.mockReturnValue(false) + + renderList() + + expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps') + expect(screen.queryByRole('link', { name: 'workflow.tabs.snippets' })).not.toBeInTheDocument() + }) }) describe('Snippets Mode', () => { diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 736a7b4a3d..a0cfb653ad 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -14,6 +14,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { CheckModal } from '@/hooks/use-pay' +import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' import dynamic from '@/next/dynamic' import { useInfiniteAppList } from '@/service/use-apps' import { useInfiniteSnippetList } from '@/service/use-snippets' @@ -50,6 +51,7 @@ const List: FC = ({ }) => { const { t } = useTranslation() const isAppsPage = pageType === 'apps' + const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess() const { systemFeatures } = useGlobalPublicStore() const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) @@ -234,6 +236,7 @@ const List: FC = ({ pageType={pageType} appsLabel={t('studio.apps', { ns: 'app' })} snippetsLabel={t('tabs.snippets', { ns: 'workflow' })} + showSnippets={canAccessSnippetsAndEvaluation} /> {isAppsPage && ( = ({ className={cn(!hasAnyApp && 'z-10')} /> ) - : + : canAccessSnippetsAndEvaluation && )} {showSkeleton && } diff --git a/web/app/components/apps/studio-route-switch.tsx b/web/app/components/apps/studio-route-switch.tsx index 7aea1cebbd..53f1337d06 100644 --- a/web/app/components/apps/studio-route-switch.tsx +++ b/web/app/components/apps/studio-route-switch.tsx @@ -8,12 +8,14 @@ type Props = { pageType: StudioPageType appsLabel: string snippetsLabel: string + showSnippets?: boolean } const StudioRouteSwitch = ({ pageType, appsLabel, snippetsLabel, + showSnippets = true, }: Props) => { return (
@@ -27,16 +29,18 @@ const StudioRouteSwitch = ({ > {appsLabel} - - {snippetsLabel} - + {showSnippets && ( + + {snippetsLabel} + + )}
) } diff --git a/web/app/components/billing/snippet-and-evaluation-plan-guard.tsx b/web/app/components/billing/snippet-and-evaluation-plan-guard.tsx new file mode 100644 index 0000000000..39ed41a348 --- /dev/null +++ b/web/app/components/billing/snippet-and-evaluation-plan-guard.tsx @@ -0,0 +1,40 @@ +'use client' + +import type { ReactNode } from 'react' +import { useEffect } from 'react' +import Loading from '@/app/components/base/loading' +import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' +import { useRouter } from '@/next/navigation' + +type SnippetAndEvaluationPlanGuardProps = { + children: ReactNode + fallbackHref: string +} + +const SnippetAndEvaluationPlanGuard = ({ + children, + fallbackHref, +}: SnippetAndEvaluationPlanGuardProps) => { + const router = useRouter() + const { canAccess, isReady } = useSnippetAndEvaluationPlanAccess() + + useEffect(() => { + if (isReady && !canAccess) + router.replace(fallbackHref) + }, [canAccess, fallbackHref, isReady, router]) + + if (!isReady) { + return ( +
+ +
+ ) + } + + if (!canAccess) + return null + + return <>{children} +} + +export default SnippetAndEvaluationPlanGuard diff --git a/web/app/components/billing/utils/index.ts b/web/app/components/billing/utils/index.ts index 6974f89c8b..024b32b346 100644 --- a/web/app/components/billing/utils/index.ts +++ b/web/app/components/billing/utils/index.ts @@ -1,6 +1,7 @@ import type { BasicPlan, BillingQuota, CurrentPlanInfoBackend } from '../type' import dayjs from 'dayjs' import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config' +import { Plan } from '../type' /** * Parse vectorSpace string from ALL_PLANS config and convert to MB @@ -116,3 +117,21 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => { }, } } + +export const canAccessSnippetsAndEvaluation = ({ + enableBilling, + isFetchedPlan, + planType, +}: { + enableBilling: boolean + isFetchedPlan: boolean + planType: Plan +}) => { + if (!isFetchedPlan) + return !enableBilling + + if (!enableBilling) + return true + + return planType === Plan.professional || planType === Plan.team || planType === Plan.enterprise +} diff --git a/web/app/components/snippets/index.tsx b/web/app/components/snippets/index.tsx index 6d3bcb570e..a69dee643b 100644 --- a/web/app/components/snippets/index.tsx +++ b/web/app/components/snippets/index.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import Loading from '@/app/components/base/loading' +import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard' import WorkflowWithDefaultContext from '@/app/components/workflow' import { WorkflowContextProvider } from '@/app/components/workflow/context' import { @@ -68,9 +69,11 @@ const SnippetPage = ({ snippetId }: SnippetPageProps) => { const SnippetPageWrapper = ({ snippetId }: SnippetPageProps) => { return ( - - - + + + + + ) } diff --git a/web/app/components/snippets/snippet-evaluation-page.tsx b/web/app/components/snippets/snippet-evaluation-page.tsx index 5691be1977..f6aeeef335 100644 --- a/web/app/components/snippets/snippet-evaluation-page.tsx +++ b/web/app/components/snippets/snippet-evaluation-page.tsx @@ -1,6 +1,7 @@ 'use client' import { useMemo } from 'react' +import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard' import Evaluation from '@/app/components/evaluation' import { getSnippetDetailMock } from '@/service/use-snippets.mock' import SnippetLayout from './components/snippet-layout' @@ -17,13 +18,15 @@ const SnippetEvaluationPage = ({ snippetId }: SnippetEvaluationPageProps) => { return null return ( - - - + + + + + ) } diff --git a/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx index 6d27560802..67a271d49f 100644 --- a/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/hooks.spec.tsx @@ -2,7 +2,21 @@ import { act, renderHook } from '@testing-library/react' import { useTabs, useToolTabs } from '../hooks' import { TabsEnum, ToolTypeEnum } from '../types' +const mockCanAccessSnippetsAndEvaluation = vi.fn(() => true) + +vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({ + useSnippetAndEvaluationPlanAccess: () => ({ + canAccess: mockCanAccessSnippetsAndEvaluation(), + isReady: true, + }), +})) + describe('block-selector hooks', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCanAccessSnippetsAndEvaluation.mockReturnValue(true) + }) + it('falls back to the first valid tab when the preferred start tab is disabled', () => { const { result } = renderHook(() => useTabs({ noStart: false, @@ -49,4 +63,12 @@ describe('block-selector hooks', () => { expect(visible.current.some(tab => tab.key === ToolTypeEnum.MCP)).toBe(true) expect(hidden.current.some(tab => tab.key === ToolTypeEnum.MCP)).toBe(false) }) + + it('hides the snippets tab when snippet access is unavailable', () => { + mockCanAccessSnippetsAndEvaluation.mockReturnValue(false) + + const { result } = renderHook(() => useTabs({})) + + expect(result.current.tabs.some(tab => tab.key === TabsEnum.Snippets)).toBe(false) + }) }) diff --git a/web/app/components/workflow/block-selector/hooks.ts b/web/app/components/workflow/block-selector/hooks.ts index c645d0e30d..ac1026f96b 100644 --- a/web/app/components/workflow/block-selector/hooks.ts +++ b/web/app/components/workflow/block-selector/hooks.ts @@ -5,6 +5,7 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' +import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' import { BLOCKS } from './constants' import { TabsEnum, @@ -42,6 +43,7 @@ export const useTabs = ({ forceEnableStartTab?: boolean }) => { const { t } = useTranslation() + const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess() const shouldShowStartTab = !noStart const shouldDisableStartTab = disableStartTab || (!forceEnableStartTab && hasUserInputNode) const startDisabledTip = disableStartTab @@ -69,11 +71,11 @@ export const useTabs = ({ }, { key: TabsEnum.Snippets, name: t('tabs.snippets', { ns: 'workflow' }), - show: true, + show: canAccessSnippetsAndEvaluation, }] return tabConfigs.filter(tab => tab.show) - }, [t, noBlocks, noSources, noTools, shouldShowStartTab, shouldDisableStartTab, startDisabledTip]) + }, [canAccessSnippetsAndEvaluation, t, noBlocks, noSources, noTools, shouldShowStartTab, shouldDisableStartTab, startDisabledTip]) const getValidTabKey = useCallback((targetKey?: TabsEnum) => { if (!targetKey) diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 8286b7006e..bb72db67fa 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -18,6 +18,7 @@ import { ContextMenuSeparator, } from '@/app/components/base/ui/context-menu' import { toast } from '@/app/components/base/ui/toast' +import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access' import { useRouter } from '@/next/navigation' import { consoleClient } from '@/service/client' import { useCreateSnippetMutation } from '@/service/use-snippets' @@ -296,6 +297,7 @@ const getSelectedSnippetGraph = ( const SelectionContextmenu = () => { const { t } = useTranslation() const { push } = useRouter() + const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess() const createSnippetMutation = useCreateSnippetMutation() const { getNodesReadOnly } = useNodesReadOnly() const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions() @@ -342,7 +344,7 @@ const SelectionContextmenu = () => { }, [selectedNodes]) const handleOpenCreateSnippetDialog = useCallback(() => { - if (isAddToSnippetDisabled) + if (!canAccessSnippetsAndEvaluation || isAddToSnippetDisabled) return const nodes = store.getState().getNodes() @@ -351,7 +353,7 @@ const SelectionContextmenu = () => { setSelectedGraphSnapshot(getSelectedSnippetGraph(nodes, edges, selectedNodes)) setIsCreateSnippetDialogOpen(true) handleSelectionContextmenuCancel() - }, [handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store]) + }, [canAccessSnippetsAndEvaluation, handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store]) const handleCloseCreateSnippetDialog = useCallback(() => { setIsCreateSnippetDialogOpen(false) @@ -397,28 +399,37 @@ const SelectionContextmenu = () => { } }, [createSnippetMutation, handleCloseCreateSnippetDialog, push, t]) - const menuActions = useMemo(() => [ - { - action: 'createSnippet', - disabled: isAddToSnippetDisabled, - translationKey: 'snippet.addToSnippet', - }, - { - action: 'copy', - shortcutKeys: ['ctrl', 'c'], - translationKey: 'common.copy', - }, - { - action: 'duplicate', - shortcutKeys: ['ctrl', 'd'], - translationKey: 'common.duplicate', - }, - { - action: 'delete', - shortcutKeys: ['del'], - translationKey: 'operation.delete', - }, - ], [isAddToSnippetDisabled]) + const menuActions = useMemo(() => { + const nextActions: ActionMenuItem[] = [] + + if (canAccessSnippetsAndEvaluation) { + nextActions.push({ + action: 'createSnippet', + disabled: isAddToSnippetDisabled, + translationKey: 'snippet.addToSnippet', + }) + } + + nextActions.push( + { + action: 'copy', + shortcutKeys: ['ctrl', 'c'], + translationKey: 'common.copy', + }, + { + action: 'duplicate', + shortcutKeys: ['ctrl', 'd'], + translationKey: 'common.duplicate', + }, + { + action: 'delete', + shortcutKeys: ['del'], + translationKey: 'operation.delete', + }, + ) + + return nextActions + }, [canAccessSnippetsAndEvaluation, isAddToSnippetDisabled]) const getActionLabel = useCallback((translationKey: string) => { if (translationKey === 'operation.delete') @@ -532,7 +543,7 @@ const SelectionContextmenu = () => { data-testid={`selection-contextmenu-item-${item.action}`} disabled={item.disabled} className={cn( - 'mx-0 h-8 justify-between gap-3 rounded-lg px-2 text-[14px] font-normal leading-5 text-text-secondary', + 'mx-0 h-8 justify-between gap-3 rounded-lg px-2 text-[14px] leading-5 font-normal text-text-secondary', item.action === 'delete' && 'data-[highlighted]:bg-state-destructive-hover data-[highlighted]:text-text-destructive', )} onClick={() => handleMenuAction(item.action)} diff --git a/web/hooks/use-snippet-and-evaluation-plan-access.ts b/web/hooks/use-snippet-and-evaluation-plan-access.ts new file mode 100644 index 0000000000..984a30fced --- /dev/null +++ b/web/hooks/use-snippet-and-evaluation-plan-access.ts @@ -0,0 +1,19 @@ +'use client' + +import { canAccessSnippetsAndEvaluation } from '@/app/components/billing/utils' +import { useProviderContextSelector } from '@/context/provider-context' + +export const useSnippetAndEvaluationPlanAccess = () => { + const planType = useProviderContextSelector(state => state.plan.type) + const enableBilling = useProviderContextSelector(state => state.enableBilling) + const isFetchedPlan = useProviderContextSelector(state => state.isFetchedPlan) + + return { + canAccess: canAccessSnippetsAndEvaluation({ + enableBilling, + isFetchedPlan, + planType, + }), + isReady: !enableBilling || isFetchedPlan, + } +}