mirror of
https://github.com/langgenius/dify.git
synced 2026-04-23 20:25:56 +08:00
feat(web): billing for evaluation & snippets
This commit is contained in:
@ -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 <Evaluation resourceType="apps" resourceId={appId} />
|
||||
return (
|
||||
<SnippetAndEvaluationPlanGuard fallbackHref={`/app/${appId}/overview`}>
|
||||
<Evaluation resourceType="apps" resourceId={appId} />
|
||||
</SnippetAndEvaluationPlanGuard>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page
|
||||
|
||||
@ -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<IAppDetailLayoutProps> = (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<IAppDetailLayoutProps> = (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<IAppDetailLayoutProps> = (props) => {
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
})
|
||||
return navConfig
|
||||
}, [t])
|
||||
}, [canAccessSnippetsAndEvaluation, t])
|
||||
|
||||
useDocumentTitle(appDetail?.name || t('menus.appDetail', { ns: 'common' }))
|
||||
|
||||
|
||||
@ -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(
|
||||
<DatasetDetailLayout datasetId="test-dataset-id">
|
||||
<div data-testid="dataset-detail-content">content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'common.datasetMenus.evaluation' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 <Evaluation resourceType="datasets" resourceId={datasetId} />
|
||||
return (
|
||||
<SnippetAndEvaluationPlanGuard fallbackHref={`/datasets/${datasetId}/documents`}>
|
||||
<Evaluation resourceType="datasets" resourceId={datasetId} />
|
||||
</SnippetAndEvaluationPlanGuard>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page
|
||||
|
||||
@ -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<IAppDetailLayoutProps> = (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<IAppDetailLayoutProps> = (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<IAppDetailLayoutProps> = (props) => {
|
||||
}
|
||||
|
||||
return baseNavigation
|
||||
}, [t, datasetId, isButtonDisabledWithPipeline, isRagPipelineDataset, datasetRes?.provider])
|
||||
}, [canAccessSnippetsAndEvaluation, t, datasetId, isButtonDisabledWithPipeline, isRagPipelineDataset, datasetRes?.provider])
|
||||
|
||||
useDocumentTitle(datasetRes?.name || t('menus.datasets', { ns: 'common' }))
|
||||
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import Apps from '@/app/components/apps'
|
||||
import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <Apps pageType="snippets" />
|
||||
return (
|
||||
<SnippetAndEvaluationPlanGuard fallbackHref="/apps">
|
||||
<Apps pageType="snippets" />
|
||||
</SnippetAndEvaluationPlanGuard>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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<Props> = ({
|
||||
}) => {
|
||||
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<Props> = ({
|
||||
pageType={pageType}
|
||||
appsLabel={t('studio.apps', { ns: 'app' })}
|
||||
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
|
||||
showSnippets={canAccessSnippetsAndEvaluation}
|
||||
/>
|
||||
{isAppsPage && (
|
||||
<AppTypeFilter
|
||||
@ -278,7 +281,7 @@ const List: FC<Props> = ({
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
)
|
||||
: <SnippetCreateCard />
|
||||
: canAccessSnippetsAndEvaluation && <SnippetCreateCard />
|
||||
)}
|
||||
|
||||
{showSkeleton && <AppCardSkeleton count={6} />}
|
||||
|
||||
@ -8,12 +8,14 @@ type Props = {
|
||||
pageType: StudioPageType
|
||||
appsLabel: string
|
||||
snippetsLabel: string
|
||||
showSnippets?: boolean
|
||||
}
|
||||
|
||||
const StudioRouteSwitch = ({
|
||||
pageType,
|
||||
appsLabel,
|
||||
snippetsLabel,
|
||||
showSnippets = true,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
|
||||
@ -27,16 +29,18 @@ const StudioRouteSwitch = ({
|
||||
>
|
||||
{appsLabel}
|
||||
</Link>
|
||||
<Link
|
||||
href="/snippets"
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
|
||||
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
|
||||
pageType !== 'snippets' && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{snippetsLabel}
|
||||
</Link>
|
||||
{showSnippets && (
|
||||
<Link
|
||||
href="/snippets"
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
|
||||
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
|
||||
pageType !== 'snippets' && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{snippetsLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="flex h-full items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!canAccess)
|
||||
return null
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default SnippetAndEvaluationPlanGuard
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<WorkflowContextProvider>
|
||||
<SnippetPage snippetId={snippetId} />
|
||||
</WorkflowContextProvider>
|
||||
<SnippetAndEvaluationPlanGuard fallbackHref="/apps">
|
||||
<WorkflowContextProvider>
|
||||
<SnippetPage snippetId={snippetId} />
|
||||
</WorkflowContextProvider>
|
||||
</SnippetAndEvaluationPlanGuard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<SnippetLayout
|
||||
snippetId={snippetId}
|
||||
snippet={snippet}
|
||||
section="evaluation"
|
||||
>
|
||||
<Evaluation resourceType="snippets" resourceId={snippetId} />
|
||||
</SnippetLayout>
|
||||
<SnippetAndEvaluationPlanGuard fallbackHref="/apps">
|
||||
<SnippetLayout
|
||||
snippetId={snippetId}
|
||||
snippet={snippet}
|
||||
section="evaluation"
|
||||
>
|
||||
<Evaluation resourceType="snippets" resourceId={snippetId} />
|
||||
</SnippetLayout>
|
||||
</SnippetAndEvaluationPlanGuard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<ActionMenuItem[]>(() => [
|
||||
{
|
||||
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<ActionMenuItem[]>(() => {
|
||||
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)}
|
||||
|
||||
19
web/hooks/use-snippet-and-evaluation-plan-access.ts
Normal file
19
web/hooks/use-snippet-and-evaluation-plan-access.ts
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user