feat(web): billing for evaluation & snippets

This commit is contained in:
JzoNg
2026-04-12 11:09:15 +08:00
parent 627fbd2e86
commit f93b287949
18 changed files with 268 additions and 68 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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