mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 17:18:40 +08:00
feat: add trigger limit modal (#28257)
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
This commit is contained in:
parent
1730572498
commit
de2a469048
@ -50,9 +50,15 @@ const PlanComp: FC<Props> = ({
|
|||||||
const triggerEventsResetInDays = type === Plan.professional && total.triggerEvents !== NUM_INFINITE
|
const triggerEventsResetInDays = type === Plan.professional && total.triggerEvents !== NUM_INFINITE
|
||||||
? reset.triggerEvents ?? undefined
|
? reset.triggerEvents ?? undefined
|
||||||
: undefined
|
: undefined
|
||||||
const apiRateLimitResetInDays = type === Plan.sandbox && total.apiRateLimit !== NUM_INFINITE
|
const apiRateLimitResetInDays = (() => {
|
||||||
? getDaysUntilEndOfMonth()
|
if (total.apiRateLimit === NUM_INFINITE)
|
||||||
: undefined
|
return undefined
|
||||||
|
if (typeof reset.apiRateLimit === 'number')
|
||||||
|
return reset.apiRateLimit
|
||||||
|
if (type === Plan.sandbox)
|
||||||
|
return getDaysUntilEndOfMonth()
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
|
||||||
const [showModal, setShowModal] = React.useState(false)
|
const [showModal, setShowModal] = React.useState(false)
|
||||||
const { mutateAsync } = useEducationVerify()
|
const { mutateAsync } = useEducationVerify()
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
.surface {
|
||||||
|
border: 0.5px solid var(--color-components-panel-border, rgba(16, 24, 40, 0.08));
|
||||||
|
background:
|
||||||
|
linear-gradient(109deg, var(--color-background-section, #f9fafb) 0%, var(--color-background-section-burn, #f2f4f7) 100%),
|
||||||
|
var(--color-components-panel-bg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroOverlay {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='54' height='54' fill='none'%3E%3Crect x='1' y='1' width='48' height='48' rx='12' stroke='rgba(16, 24, 40, 0.3)' stroke-width='1' opacity='0.08'/%3E%3C/svg%3E");
|
||||||
|
background-size: 54px 54px;
|
||||||
|
background-position: 31px -23px;
|
||||||
|
background-repeat: repeat;
|
||||||
|
mask-image: linear-gradient(180deg, rgba(255, 255, 255, 1) 45%, rgba(255, 255, 255, 0) 75%);
|
||||||
|
-webkit-mask-image: linear-gradient(180deg, rgba(255, 255, 255, 1) 45%, rgba(255, 255, 255, 0) 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
border: 0.5px solid transparent;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, var(--color-components-avatar-bg-mask-stop-0, rgba(255, 255, 255, 0.12)) 0%, var(--color-components-avatar-bg-mask-stop-100, rgba(255, 255, 255, 0.08)) 100%),
|
||||||
|
var(--color-util-colors-blue-brand-blue-brand-500, #296dff);
|
||||||
|
box-shadow: 0 10px 20px color-mix(in srgb, var(--color-util-colors-blue-brand-blue-brand-500, #296dff) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
background: linear-gradient(97deg, var(--color-components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -4%, var(--color-components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import i18next from 'i18next'
|
||||||
|
import { I18nextProvider } from 'react-i18next'
|
||||||
|
import TriggerEventsLimitModal from '.'
|
||||||
|
import { Plan } from '../type'
|
||||||
|
|
||||||
|
const i18n = i18next.createInstance()
|
||||||
|
i18n.init({
|
||||||
|
lng: 'en',
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
translation: {
|
||||||
|
billing: {
|
||||||
|
triggerLimitModal: {
|
||||||
|
title: 'Upgrade to unlock more trigger events',
|
||||||
|
description: 'You’ve reached the limit of workflow event triggers for this plan.',
|
||||||
|
dismiss: 'Dismiss',
|
||||||
|
upgrade: 'Upgrade',
|
||||||
|
usageTitle: 'TRIGGER EVENTS',
|
||||||
|
},
|
||||||
|
usagePage: {
|
||||||
|
triggerEvents: 'Trigger Events',
|
||||||
|
resetsIn: 'Resets in {{count, number}} days',
|
||||||
|
},
|
||||||
|
upgradeBtn: {
|
||||||
|
encourage: 'Upgrade Now',
|
||||||
|
encourageShort: 'Upgrade',
|
||||||
|
plain: 'View Plan',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Template = (args: React.ComponentProps<typeof TriggerEventsLimitModal>) => {
|
||||||
|
const [visible, setVisible] = useState<boolean>(args.show ?? true)
|
||||||
|
useEffect(() => {
|
||||||
|
setVisible(args.show ?? true)
|
||||||
|
}, [args.show])
|
||||||
|
const handleHide = () => setVisible(false)
|
||||||
|
return (
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-divider-subtle px-4 py-2 text-sm text-text-secondary hover:border-divider-deep hover:text-text-primary"
|
||||||
|
onClick={() => setVisible(true)}
|
||||||
|
>
|
||||||
|
Open Modal
|
||||||
|
</button>
|
||||||
|
<TriggerEventsLimitModal
|
||||||
|
{...args}
|
||||||
|
show={visible}
|
||||||
|
onDismiss={handleHide}
|
||||||
|
onUpgrade={handleHide}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</I18nextProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Billing/TriggerEventsLimitModal',
|
||||||
|
component: TriggerEventsLimitModal,
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
show: true,
|
||||||
|
usage: 120,
|
||||||
|
total: 120,
|
||||||
|
resetInDays: 5,
|
||||||
|
planType: Plan.professional,
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof TriggerEventsLimitModal>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Professional: Story = {
|
||||||
|
args: {
|
||||||
|
onDismiss: () => { /* noop */ },
|
||||||
|
onUpgrade: () => { /* noop */ },
|
||||||
|
},
|
||||||
|
render: args => <Template {...args} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sandbox: Story = {
|
||||||
|
render: args => <Template {...args} />,
|
||||||
|
args: {
|
||||||
|
onDismiss: () => { /* noop */ },
|
||||||
|
onUpgrade: () => { /* noop */ },
|
||||||
|
resetInDays: undefined,
|
||||||
|
planType: Plan.sandbox,
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Modal from '@/app/components/base/modal'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
|
||||||
|
import UsageInfo from '@/app/components/billing/usage-info'
|
||||||
|
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||||
|
import type { Plan } from '@/app/components/billing/type'
|
||||||
|
import styles from './index.module.css'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
show: boolean
|
||||||
|
onDismiss: () => void
|
||||||
|
onUpgrade: () => void
|
||||||
|
usage: number
|
||||||
|
total: number
|
||||||
|
resetInDays?: number
|
||||||
|
planType: Plan
|
||||||
|
}
|
||||||
|
|
||||||
|
const TriggerEventsLimitModal: FC<Props> = ({
|
||||||
|
show,
|
||||||
|
onDismiss,
|
||||||
|
onUpgrade,
|
||||||
|
usage,
|
||||||
|
total,
|
||||||
|
resetInDays,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isShow={show}
|
||||||
|
onClose={onDismiss}
|
||||||
|
closable={false}
|
||||||
|
clickOutsideNotClose
|
||||||
|
className={`${styles.surface} flex h-[360px] w-[580px] flex-col overflow-hidden rounded-2xl !p-0 shadow-xl`}
|
||||||
|
>
|
||||||
|
<div className='relative flex w-full flex-1 items-stretch justify-center'>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={`${styles.heroOverlay} pointer-events-none absolute inset-0`}
|
||||||
|
/>
|
||||||
|
<div className='relative z-10 flex w-full flex-col items-start gap-4 px-8 pt-8'>
|
||||||
|
<div className={`${styles.icon} flex h-12 w-12 items-center justify-center rounded-[12px]`}>
|
||||||
|
<TriggerAll className='h-5 w-5 text-text-primary-on-surface' />
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col items-start gap-2'>
|
||||||
|
<div className={`${styles.highlight} title-lg-semi-bold`}>
|
||||||
|
{t('billing.triggerLimitModal.title')}
|
||||||
|
</div>
|
||||||
|
<div className='body-md-regular text-text-secondary'>
|
||||||
|
{t('billing.triggerLimitModal.description')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UsageInfo
|
||||||
|
className='mb-5 w-full rounded-[12px] bg-components-panel-on-panel-item-bg'
|
||||||
|
Icon={TriggerAll}
|
||||||
|
name={t('billing.triggerLimitModal.usageTitle')}
|
||||||
|
usage={usage}
|
||||||
|
total={total}
|
||||||
|
resetInDays={resetInDays}
|
||||||
|
hideIcon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex h-[76px] w-full items-center justify-end gap-2 px-8 pb-8 pt-5'>
|
||||||
|
<Button
|
||||||
|
className='h-8 w-[77px] min-w-[72px] !rounded-lg !border-[0.5px] px-3 py-2'
|
||||||
|
onClick={onDismiss}
|
||||||
|
>
|
||||||
|
{t('billing.triggerLimitModal.dismiss')}
|
||||||
|
</Button>
|
||||||
|
<UpgradeBtn
|
||||||
|
isShort
|
||||||
|
onClick={onUpgrade}
|
||||||
|
className='flex w-[93px] items-center justify-center !rounded-lg !px-2'
|
||||||
|
style={{ height: 32 }}
|
||||||
|
labelKey='billing.triggerLimitModal.upgrade'
|
||||||
|
loc='trigger-events-limit-modal'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(TriggerEventsLimitModal)
|
||||||
@ -16,6 +16,7 @@ type Props = {
|
|||||||
isShort?: boolean
|
isShort?: boolean
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
loc?: string
|
loc?: string
|
||||||
|
labelKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const UpgradeBtn: FC<Props> = ({
|
const UpgradeBtn: FC<Props> = ({
|
||||||
@ -25,6 +26,7 @@ const UpgradeBtn: FC<Props> = ({
|
|||||||
isShort = false,
|
isShort = false,
|
||||||
onClick: _onClick,
|
onClick: _onClick,
|
||||||
loc,
|
loc,
|
||||||
|
labelKey,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { setShowPricingModal } = useModalContext()
|
const { setShowPricingModal } = useModalContext()
|
||||||
@ -43,6 +45,9 @@ const UpgradeBtn: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultBadgeLabel = t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}`)
|
||||||
|
const label = labelKey ? t(labelKey) : defaultBadgeLabel
|
||||||
|
|
||||||
if (isPlain) {
|
if (isPlain) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@ -50,7 +55,7 @@ const UpgradeBtn: FC<Props> = ({
|
|||||||
style={style}
|
style={style}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{t('billing.upgradeBtn.plain')}
|
{labelKey ? label : t('billing.upgradeBtn.plain')}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -67,7 +72,7 @@ const UpgradeBtn: FC<Props> = ({
|
|||||||
<SparklesSoft className='flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0' />
|
<SparklesSoft className='flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0' />
|
||||||
<div className='system-xs-medium'>
|
<div className='system-xs-medium'>
|
||||||
<span className='p-1'>
|
<span className='p-1'>
|
||||||
{t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}`)}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</PremiumBadge>
|
</PremiumBadge>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ type Props = {
|
|||||||
unitPosition?: 'inline' | 'suffix'
|
unitPosition?: 'inline' | 'suffix'
|
||||||
resetHint?: string
|
resetHint?: string
|
||||||
resetInDays?: number
|
resetInDays?: number
|
||||||
|
hideIcon?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const WARNING_THRESHOLD = 80
|
const WARNING_THRESHOLD = 80
|
||||||
@ -33,6 +34,7 @@ const UsageInfo: FC<Props> = ({
|
|||||||
unitPosition = 'suffix',
|
unitPosition = 'suffix',
|
||||||
resetHint,
|
resetHint,
|
||||||
resetInDays,
|
resetInDays,
|
||||||
|
hideIcon = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@ -60,7 +62,9 @@ const UsageInfo: FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col gap-2 rounded-xl bg-components-panel-bg p-4', className)}>
|
<div className={cn('flex flex-col gap-2 rounded-xl bg-components-panel-bg p-4', className)}>
|
||||||
<Icon className='h-4 w-4 text-text-tertiary' />
|
{!hideIcon && Icon && (
|
||||||
|
<Icon className='h-4 w-4 text-text-tertiary' />
|
||||||
|
)}
|
||||||
<div className='flex items-center gap-1'>
|
<div className='flex items-center gap-1'>
|
||||||
<div className='system-xs-medium text-text-tertiary'>{name}</div>
|
<div className='system-xs-medium text-text-tertiary'>{name}</div>
|
||||||
{tooltip && (
|
{tooltip && (
|
||||||
|
|||||||
130
web/context/hooks/use-trigger-events-limit-modal.ts
Normal file
130
web/context/hooks/use-trigger-events-limit-modal.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { NUM_INFINITE } from '@/app/components/billing/config'
|
||||||
|
import { Plan } from '@/app/components/billing/type'
|
||||||
|
import { IS_CLOUD_EDITION } from '@/config'
|
||||||
|
import type { ModalState } from '../modal-context'
|
||||||
|
|
||||||
|
export type TriggerEventsLimitModalPayload = {
|
||||||
|
usage: number
|
||||||
|
total: number
|
||||||
|
resetInDays?: number
|
||||||
|
planType: Plan
|
||||||
|
storageKey?: string
|
||||||
|
persistDismiss?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type TriggerPlanInfo = {
|
||||||
|
type: Plan
|
||||||
|
usage: { triggerEvents: number }
|
||||||
|
total: { triggerEvents: number }
|
||||||
|
reset: { triggerEvents?: number | null }
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseTriggerEventsLimitModalOptions = {
|
||||||
|
plan: TriggerPlanInfo
|
||||||
|
isFetchedPlan: boolean
|
||||||
|
currentWorkspaceId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseTriggerEventsLimitModalResult = {
|
||||||
|
showTriggerEventsLimitModal: ModalState<TriggerEventsLimitModalPayload> | null
|
||||||
|
setShowTriggerEventsLimitModal: Dispatch<SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null>>
|
||||||
|
persistTriggerEventsLimitModalDismiss: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIGGER_EVENTS_LOCALSTORAGE_PREFIX = 'trigger-events-limit-dismissed'
|
||||||
|
|
||||||
|
export const useTriggerEventsLimitModal = ({
|
||||||
|
plan,
|
||||||
|
isFetchedPlan,
|
||||||
|
currentWorkspaceId,
|
||||||
|
}: UseTriggerEventsLimitModalOptions): UseTriggerEventsLimitModalResult => {
|
||||||
|
const [showTriggerEventsLimitModal, setShowTriggerEventsLimitModal] = useState<ModalState<TriggerEventsLimitModalPayload> | null>(null)
|
||||||
|
const dismissedTriggerEventsLimitStorageKeysRef = useRef<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!IS_CLOUD_EDITION)
|
||||||
|
return
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return
|
||||||
|
if (!currentWorkspaceId)
|
||||||
|
return
|
||||||
|
if (!isFetchedPlan) {
|
||||||
|
setShowTriggerEventsLimitModal(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, usage, total, reset } = plan
|
||||||
|
const isUnlimited = total.triggerEvents === NUM_INFINITE
|
||||||
|
const reachedLimit = total.triggerEvents > 0 && usage.triggerEvents >= total.triggerEvents
|
||||||
|
|
||||||
|
if (type === Plan.team || isUnlimited || !reachedLimit) {
|
||||||
|
if (showTriggerEventsLimitModal)
|
||||||
|
setShowTriggerEventsLimitModal(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerResetInDays = type === Plan.professional && total.triggerEvents !== NUM_INFINITE
|
||||||
|
? reset.triggerEvents ?? undefined
|
||||||
|
: undefined
|
||||||
|
const cycleTag = (() => {
|
||||||
|
if (typeof reset.triggerEvents === 'number')
|
||||||
|
return dayjs().startOf('day').add(reset.triggerEvents, 'day').format('YYYY-MM-DD')
|
||||||
|
if (type === Plan.sandbox)
|
||||||
|
return dayjs().endOf('month').format('YYYY-MM-DD')
|
||||||
|
return 'none'
|
||||||
|
})()
|
||||||
|
const storageKey = `${TRIGGER_EVENTS_LOCALSTORAGE_PREFIX}-${currentWorkspaceId}-${type}-${total.triggerEvents}-${cycleTag}`
|
||||||
|
if (dismissedTriggerEventsLimitStorageKeysRef.current[storageKey])
|
||||||
|
return
|
||||||
|
|
||||||
|
let persistDismiss = true
|
||||||
|
let hasDismissed = false
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(storageKey) === '1')
|
||||||
|
hasDismissed = true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
persistDismiss = false
|
||||||
|
}
|
||||||
|
if (hasDismissed)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (showTriggerEventsLimitModal?.payload.storageKey === storageKey)
|
||||||
|
return
|
||||||
|
|
||||||
|
setShowTriggerEventsLimitModal({
|
||||||
|
payload: {
|
||||||
|
usage: usage.triggerEvents,
|
||||||
|
total: total.triggerEvents,
|
||||||
|
planType: type,
|
||||||
|
resetInDays: triggerResetInDays,
|
||||||
|
storageKey,
|
||||||
|
persistDismiss,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [plan, isFetchedPlan, showTriggerEventsLimitModal, currentWorkspaceId])
|
||||||
|
|
||||||
|
const persistTriggerEventsLimitModalDismiss = useCallback(() => {
|
||||||
|
const storageKey = showTriggerEventsLimitModal?.payload.storageKey
|
||||||
|
if (!storageKey)
|
||||||
|
return
|
||||||
|
if (showTriggerEventsLimitModal?.payload.persistDismiss) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, '1')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// ignore error and fall back to in-memory guard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dismissedTriggerEventsLimitStorageKeysRef.current[storageKey] = true
|
||||||
|
}, [showTriggerEventsLimitModal])
|
||||||
|
|
||||||
|
return {
|
||||||
|
showTriggerEventsLimitModal,
|
||||||
|
setShowTriggerEventsLimitModal,
|
||||||
|
persistTriggerEventsLimitModalDismiss,
|
||||||
|
}
|
||||||
|
}
|
||||||
181
web/context/modal-context.test.tsx
Normal file
181
web/context/modal-context.test.tsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { ModalContextProvider } from '@/context/modal-context'
|
||||||
|
import { Plan } from '@/app/components/billing/type'
|
||||||
|
import { defaultPlan } from '@/app/components/billing/config'
|
||||||
|
|
||||||
|
jest.mock('@/config', () => {
|
||||||
|
const actual = jest.requireActual('@/config')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
IS_CLOUD_EDITION: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
useSearchParams: jest.fn(() => new URLSearchParams()),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockUseProviderContext = jest.fn()
|
||||||
|
jest.mock('@/context/provider-context', () => ({
|
||||||
|
useProviderContext: () => mockUseProviderContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockUseAppContext = jest.fn()
|
||||||
|
jest.mock('@/context/app-context', () => ({
|
||||||
|
useAppContext: () => mockUseAppContext(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let latestTriggerEventsModalProps: any = null
|
||||||
|
const triggerEventsLimitModalMock = jest.fn((props: any) => {
|
||||||
|
latestTriggerEventsModalProps = props
|
||||||
|
return (
|
||||||
|
<div data-testid="trigger-limit-modal">
|
||||||
|
<button type="button" onClick={props.onDismiss}>dismiss</button>
|
||||||
|
<button type="button" onClick={props.onUpgrade}>upgrade</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.mock('@/app/components/billing/trigger-events-limit-modal', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: any) => triggerEventsLimitModalMock(props),
|
||||||
|
}))
|
||||||
|
|
||||||
|
type DefaultPlanShape = typeof defaultPlan
|
||||||
|
type PlanOverrides = Partial<Omit<DefaultPlanShape, 'usage' | 'total' | 'reset'>> & {
|
||||||
|
usage?: Partial<DefaultPlanShape['usage']>
|
||||||
|
total?: Partial<DefaultPlanShape['total']>
|
||||||
|
reset?: Partial<DefaultPlanShape['reset']>
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPlan = (overrides: PlanOverrides = {}): DefaultPlanShape => ({
|
||||||
|
...defaultPlan,
|
||||||
|
...overrides,
|
||||||
|
usage: {
|
||||||
|
...defaultPlan.usage,
|
||||||
|
...(overrides.usage ?? {}),
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
...defaultPlan.total,
|
||||||
|
...(overrides.total ?? {}),
|
||||||
|
},
|
||||||
|
reset: {
|
||||||
|
...defaultPlan.reset,
|
||||||
|
...(overrides.reset ?? {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderProvider = () => render(
|
||||||
|
<ModalContextProvider>
|
||||||
|
<div data-testid="modal-context-test-child" />
|
||||||
|
</ModalContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('ModalContextProvider trigger events limit modal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
latestTriggerEventsModalProps = null
|
||||||
|
triggerEventsLimitModalMock.mockClear()
|
||||||
|
mockUseAppContext.mockReset()
|
||||||
|
mockUseProviderContext.mockReset()
|
||||||
|
window.localStorage.clear()
|
||||||
|
mockUseAppContext.mockReturnValue({
|
||||||
|
currentWorkspace: {
|
||||||
|
id: 'workspace-1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the trigger events limit modal and persists dismissal in localStorage', async () => {
|
||||||
|
const plan = createPlan({
|
||||||
|
type: Plan.professional,
|
||||||
|
usage: { triggerEvents: 3000 },
|
||||||
|
total: { triggerEvents: 3000 },
|
||||||
|
reset: { triggerEvents: 5 },
|
||||||
|
})
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
plan,
|
||||||
|
isFetchedPlan: true,
|
||||||
|
})
|
||||||
|
const setItemSpy = jest.spyOn(Storage.prototype, 'setItem')
|
||||||
|
|
||||||
|
renderProvider()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal')).toBeInTheDocument())
|
||||||
|
expect(latestTriggerEventsModalProps).toMatchObject({
|
||||||
|
usage: 3000,
|
||||||
|
total: 3000,
|
||||||
|
resetInDays: 5,
|
||||||
|
planType: Plan.professional,
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
latestTriggerEventsModalProps.onDismiss()
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
|
||||||
|
const [key, value] = setItemSpy.mock.calls[0]
|
||||||
|
expect(key).toContain('trigger-events-limit-dismissed-workspace-1-professional-3000-')
|
||||||
|
expect(value).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('relies on the in-memory guard when localStorage reads throw', async () => {
|
||||||
|
const plan = createPlan({
|
||||||
|
type: Plan.professional,
|
||||||
|
usage: { triggerEvents: 200 },
|
||||||
|
total: { triggerEvents: 200 },
|
||||||
|
reset: { triggerEvents: 3 },
|
||||||
|
})
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
plan,
|
||||||
|
isFetchedPlan: true,
|
||||||
|
})
|
||||||
|
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('Storage disabled')
|
||||||
|
})
|
||||||
|
const setItemSpy = jest.spyOn(Storage.prototype, 'setItem')
|
||||||
|
|
||||||
|
renderProvider()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal')).toBeInTheDocument())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
latestTriggerEventsModalProps.onDismiss()
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
|
||||||
|
expect(setItemSpy).not.toHaveBeenCalled()
|
||||||
|
await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the in-memory guard when localStorage.setItem fails', async () => {
|
||||||
|
const plan = createPlan({
|
||||||
|
type: Plan.professional,
|
||||||
|
usage: { triggerEvents: 120 },
|
||||||
|
total: { triggerEvents: 120 },
|
||||||
|
reset: { triggerEvents: 2 },
|
||||||
|
})
|
||||||
|
mockUseProviderContext.mockReturnValue({
|
||||||
|
plan,
|
||||||
|
isFetchedPlan: true,
|
||||||
|
})
|
||||||
|
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new Error('Quota exceeded')
|
||||||
|
})
|
||||||
|
|
||||||
|
renderProvider()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal')).toBeInTheDocument())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
latestTriggerEventsModalProps.onDismiss()
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
|
||||||
|
await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1))
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -36,6 +36,12 @@ import { noop } from 'lodash-es'
|
|||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import type { ExpireNoticeModalPayloadProps } from '@/app/education-apply/expire-notice-modal'
|
import type { ExpireNoticeModalPayloadProps } from '@/app/education-apply/expire-notice-modal'
|
||||||
import type { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
import type { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||||
|
import { useProviderContext } from '@/context/provider-context'
|
||||||
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import {
|
||||||
|
type TriggerEventsLimitModalPayload,
|
||||||
|
useTriggerEventsLimitModal,
|
||||||
|
} from './hooks/use-trigger-events-limit-modal'
|
||||||
|
|
||||||
const AccountSetting = dynamic(() => import('@/app/components/header/account-setting'), {
|
const AccountSetting = dynamic(() => import('@/app/components/header/account-setting'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@ -74,6 +80,9 @@ const UpdatePlugin = dynamic(() => import('@/app/components/plugins/update-plugi
|
|||||||
const ExpireNoticeModal = dynamic(() => import('@/app/education-apply/expire-notice-modal'), {
|
const ExpireNoticeModal = dynamic(() => import('@/app/education-apply/expire-notice-modal'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
})
|
})
|
||||||
|
const TriggerEventsLimitModal = dynamic(() => import('@/app/components/billing/trigger-events-limit-modal'), {
|
||||||
|
ssr: false,
|
||||||
|
})
|
||||||
|
|
||||||
export type ModalState<T> = {
|
export type ModalState<T> = {
|
||||||
payload: T
|
payload: T
|
||||||
@ -113,6 +122,7 @@ export type ModalContextState = {
|
|||||||
}> | null>>
|
}> | null>>
|
||||||
setShowUpdatePluginModal: Dispatch<SetStateAction<ModalState<UpdatePluginPayload> | null>>
|
setShowUpdatePluginModal: Dispatch<SetStateAction<ModalState<UpdatePluginPayload> | null>>
|
||||||
setShowEducationExpireNoticeModal: Dispatch<SetStateAction<ModalState<ExpireNoticeModalPayloadProps> | null>>
|
setShowEducationExpireNoticeModal: Dispatch<SetStateAction<ModalState<ExpireNoticeModalPayloadProps> | null>>
|
||||||
|
setShowTriggerEventsLimitModal: Dispatch<SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null>>
|
||||||
}
|
}
|
||||||
const PRICING_MODAL_QUERY_PARAM = 'pricing'
|
const PRICING_MODAL_QUERY_PARAM = 'pricing'
|
||||||
const PRICING_MODAL_QUERY_VALUE = 'open'
|
const PRICING_MODAL_QUERY_VALUE = 'open'
|
||||||
@ -130,6 +140,7 @@ const ModalContext = createContext<ModalContextState>({
|
|||||||
setShowOpeningModal: noop,
|
setShowOpeningModal: noop,
|
||||||
setShowUpdatePluginModal: noop,
|
setShowUpdatePluginModal: noop,
|
||||||
setShowEducationExpireNoticeModal: noop,
|
setShowEducationExpireNoticeModal: noop,
|
||||||
|
setShowTriggerEventsLimitModal: noop,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const useModalContext = () => useContext(ModalContext)
|
export const useModalContext = () => useContext(ModalContext)
|
||||||
@ -168,6 +179,7 @@ export const ModalContextProvider = ({
|
|||||||
}> | null>(null)
|
}> | null>(null)
|
||||||
const [showUpdatePluginModal, setShowUpdatePluginModal] = useState<ModalState<UpdatePluginPayload> | null>(null)
|
const [showUpdatePluginModal, setShowUpdatePluginModal] = useState<ModalState<UpdatePluginPayload> | null>(null)
|
||||||
const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState<ModalState<ExpireNoticeModalPayloadProps> | null>(null)
|
const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState<ModalState<ExpireNoticeModalPayloadProps> | null>(null)
|
||||||
|
const { currentWorkspace } = useAppContext()
|
||||||
|
|
||||||
const [showPricingModal, setShowPricingModal] = useState(
|
const [showPricingModal, setShowPricingModal] = useState(
|
||||||
searchParams.get(PRICING_MODAL_QUERY_PARAM) === PRICING_MODAL_QUERY_VALUE,
|
searchParams.get(PRICING_MODAL_QUERY_PARAM) === PRICING_MODAL_QUERY_VALUE,
|
||||||
@ -228,6 +240,17 @@ export const ModalContextProvider = ({
|
|||||||
window.history.replaceState(null, '', url.toString())
|
window.history.replaceState(null, '', url.toString())
|
||||||
}, [showPricingModal])
|
}, [showPricingModal])
|
||||||
|
|
||||||
|
const { plan, isFetchedPlan } = useProviderContext()
|
||||||
|
const {
|
||||||
|
showTriggerEventsLimitModal,
|
||||||
|
setShowTriggerEventsLimitModal,
|
||||||
|
persistTriggerEventsLimitModalDismiss,
|
||||||
|
} = useTriggerEventsLimitModal({
|
||||||
|
plan,
|
||||||
|
isFetchedPlan,
|
||||||
|
currentWorkspaceId: currentWorkspace?.id,
|
||||||
|
})
|
||||||
|
|
||||||
const handleCancelModerationSettingModal = () => {
|
const handleCancelModerationSettingModal = () => {
|
||||||
setShowModerationSettingModal(null)
|
setShowModerationSettingModal(null)
|
||||||
if (showModerationSettingModal?.onCancelCallback)
|
if (showModerationSettingModal?.onCancelCallback)
|
||||||
@ -334,6 +357,7 @@ export const ModalContextProvider = ({
|
|||||||
setShowOpeningModal,
|
setShowOpeningModal,
|
||||||
setShowUpdatePluginModal,
|
setShowUpdatePluginModal,
|
||||||
setShowEducationExpireNoticeModal,
|
setShowEducationExpireNoticeModal,
|
||||||
|
setShowTriggerEventsLimitModal,
|
||||||
}}>
|
}}>
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
@ -455,6 +479,25 @@ export const ModalContextProvider = ({
|
|||||||
onClose={() => setShowEducationExpireNoticeModal(null)}
|
onClose={() => setShowEducationExpireNoticeModal(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{
|
||||||
|
!!showTriggerEventsLimitModal && (
|
||||||
|
<TriggerEventsLimitModal
|
||||||
|
show
|
||||||
|
usage={showTriggerEventsLimitModal.payload.usage}
|
||||||
|
total={showTriggerEventsLimitModal.payload.total}
|
||||||
|
planType={showTriggerEventsLimitModal.payload.planType}
|
||||||
|
resetInDays={showTriggerEventsLimitModal.payload.resetInDays}
|
||||||
|
onDismiss={() => {
|
||||||
|
persistTriggerEventsLimitModalDismiss()
|
||||||
|
setShowTriggerEventsLimitModal(null)
|
||||||
|
}}
|
||||||
|
onUpgrade={() => {
|
||||||
|
persistTriggerEventsLimitModalDismiss()
|
||||||
|
setShowTriggerEventsLimitModal(null)
|
||||||
|
handleShowPricingModal()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
</ModalContext.Provider>
|
</ModalContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -12,6 +12,13 @@ const translation = {
|
|||||||
resetsIn: 'Resets in {{count,number}} days',
|
resetsIn: 'Resets in {{count,number}} days',
|
||||||
},
|
},
|
||||||
teamMembers: 'Team Members',
|
teamMembers: 'Team Members',
|
||||||
|
triggerLimitModal: {
|
||||||
|
title: 'Upgrade to unlock more trigger events',
|
||||||
|
description: 'You’ve reached the limit of workflow event triggers for this plan.',
|
||||||
|
dismiss: 'Dismiss',
|
||||||
|
upgrade: 'Upgrade',
|
||||||
|
usageTitle: 'TRIGGER EVENTS',
|
||||||
|
},
|
||||||
upgradeBtn: {
|
upgradeBtn: {
|
||||||
plain: 'View Plan',
|
plain: 'View Plan',
|
||||||
encourage: 'Upgrade Now',
|
encourage: 'Upgrade Now',
|
||||||
|
|||||||
@ -11,6 +11,13 @@ const translation = {
|
|||||||
perMonth: '月あたり',
|
perMonth: '月あたり',
|
||||||
resetsIn: '{{count,number}}日後にリセット',
|
resetsIn: '{{count,number}}日後にリセット',
|
||||||
},
|
},
|
||||||
|
triggerLimitModal: {
|
||||||
|
title: 'さらにトリガーイベントを利用するにはアップグレードしてください',
|
||||||
|
description: 'このプランのワークフロー・トリガーイベントの上限に達しました。',
|
||||||
|
dismiss: '閉じる',
|
||||||
|
upgrade: 'アップグレード',
|
||||||
|
usageTitle: 'TRIGGER EVENTS',
|
||||||
|
},
|
||||||
upgradeBtn: {
|
upgradeBtn: {
|
||||||
plain: 'プランをアップグレード',
|
plain: 'プランをアップグレード',
|
||||||
encourage: '今すぐアップグレード',
|
encourage: '今すぐアップグレード',
|
||||||
|
|||||||
@ -11,6 +11,13 @@ const translation = {
|
|||||||
perMonth: '每月',
|
perMonth: '每月',
|
||||||
resetsIn: '{{count,number}} 天后重置',
|
resetsIn: '{{count,number}} 天后重置',
|
||||||
},
|
},
|
||||||
|
triggerLimitModal: {
|
||||||
|
title: '升级以解锁更多触发事件额度',
|
||||||
|
description: '当前套餐的工作流触发事件额度已达上限。',
|
||||||
|
dismiss: '知道了',
|
||||||
|
upgrade: '升级',
|
||||||
|
usageTitle: '触发事件额度',
|
||||||
|
},
|
||||||
upgradeBtn: {
|
upgradeBtn: {
|
||||||
plain: '查看套餐',
|
plain: '查看套餐',
|
||||||
encourage: '立即升级',
|
encourage: '立即升级',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user