feat: split plugin settings by category

This commit is contained in:
Jingyi-Dify
2026-05-18 20:54:32 -07:00
parent ca48050666
commit cda348ca10
18 changed files with 1126 additions and 399 deletions

View File

@ -3,26 +3,27 @@
import type { ReactNode } from 'react'
import type { TriggerParams } from '@/app/components/base/date-and-time-picker/types'
import type { AutoUpdateConfig } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import type { ReferenceSetting } from '@/app/components/plugins/types'
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { ToggleGroup, ToggleGroupItem } from '@langgenius/dify-ui/toggle-group'
import { useCallback, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { defaultValue as defaultAutoUpdateValue } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/config'
import PluginsPicker from '@/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds, dayjsToTimeOfDay, timeOfDayToDayjs } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/utils'
import { useAppContext } from '@/context/app-context'
import { useModalContextSelector } from '@/context/modal-context'
import { useMutationPluginAutoUpgradeSettings, usePluginAutoUpgradeSettings } from '@/service/use-plugins'
type Props = {
defaultStrategy?: AUTO_UPDATE_STRATEGY
referenceSetting: ReferenceSetting
onSave: (payload: ReferenceSetting) => void
category: PluginCategoryEnum
disabled?: boolean
}
type SegmentedOption<T extends string> = {
@ -48,62 +49,68 @@ function SettingTimeZone({ children }: { children?: ReactNode }) {
)
}
const getAutoUpgrade = (
referenceSetting: ReferenceSetting,
defaultStrategy: AUTO_UPDATE_STRATEGY,
): AutoUpdateConfig => ({
...defaultAutoUpdateValue,
...referenceSetting.auto_upgrade,
strategy_setting: referenceSetting.auto_upgrade?.strategy_setting ?? defaultStrategy,
exclude_plugins: referenceSetting.auto_upgrade?.exclude_plugins ?? defaultAutoUpdateValue.exclude_plugins,
include_plugins: referenceSetting.auto_upgrade?.include_plugins ?? defaultAutoUpdateValue.include_plugins,
})
const SegmentedControl = <T extends string>({
ariaLabel,
className,
options,
value,
onChange,
}: {
ariaLabel: string
className?: string
options: Array<SegmentedOption<T>>
value: T
onChange: (value: T) => void
}) => {
return (
<div
<ToggleGroup<T>
aria-label={ariaLabel}
className="inline-flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5"
role="radiogroup"
className={cn('flex', className)}
value={[value]}
onValueChange={(nextValue) => {
const selectedValue = nextValue[0]
if (selectedValue)
onChange(selectedValue)
}}
>
{options.map(option => (
<button
<ToggleGroupItem<T>
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
className={cn(
'flex items-center justify-center rounded-lg px-2 py-1 system-sm-medium text-text-secondary hover:bg-state-base-hover-alt',
value === option.value && 'border-[0.5px] border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-xs hover:bg-components-segmented-control-item-active-bg',
)}
onClick={() => onChange(option.value)}
value={option.value}
className="flex-1 hover:bg-state-base-hover-alt data-pressed:hover:bg-components-segmented-control-item-active-bg"
>
<span className="p-0.5 whitespace-nowrap">{option.label}</span>
</button>
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
)
}
const UpdateSettingPopover = ({
defaultStrategy = AUTO_UPDATE_STRATEGY.fixOnly,
referenceSetting,
onSave,
category,
disabled = false,
}: Props) => {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const timezone = userProfile.timezone || 'UTC'
const [autoUpgrade, setAutoUpgrade] = useState(() => getAutoUpgrade(referenceSetting, defaultStrategy))
const {
data: autoUpgradeSetting,
error,
isFetching,
isLoading,
} = usePluginAutoUpgradeSettings(category)
const { mutate: saveAutoUpgrade, isPending: isSavePending } = useMutationPluginAutoUpgradeSettings({
category,
onSuccess: () => {
toast.success(t('api.actionSuccess', { ns: 'common' }))
},
})
const savedAutoUpgrade = autoUpgradeSetting?.auto_upgrade
const [isOpen, setIsOpen] = useState(false)
const [draftAutoUpgrade, setDraftAutoUpgrade] = useState<AutoUpdateConfig>()
const autoUpgrade = draftAutoUpgrade ?? savedAutoUpgrade
const hasSettings = !!autoUpgrade
const isSettingsLoading = !hasSettings && !error && (isLoading || isFetching)
const getStrategyLabel = useCallback((strategy: AUTO_UPDATE_STRATEGY) => {
switch (strategy) {
case AUTO_UPDATE_STRATEGY.disabled:
@ -116,8 +123,11 @@ const UpdateSettingPopover = ({
return ''
}
}, [t])
const selectedStrategyLabel = getStrategyLabel(autoUpgrade.strategy_setting)
const selectedStrategyLabel = autoUpgrade ? getStrategyLabel(autoUpgrade.strategy_setting) : ''
const plugins = useMemo(() => {
if (!autoUpgrade)
return []
switch (autoUpgrade.upgrade_mode) {
case AUTO_UPDATE_MODE.partial:
return autoUpgrade.include_plugins
@ -126,11 +136,14 @@ const UpdateSettingPopover = ({
default:
return []
}
}, [autoUpgrade.exclude_plugins, autoUpgrade.include_plugins, autoUpgrade.upgrade_mode])
}, [autoUpgrade])
const updateTimeValue = useMemo(() => {
if (!autoUpgrade)
return ''
const localSeconds = convertUTCDaySecondsToLocalSeconds(autoUpgrade.upgrade_time_of_day, timezone)
return timeOfDayToDayjs(localSeconds).format('HH:mm')
}, [autoUpgrade.upgrade_time_of_day, timezone])
}, [autoUpgrade, timezone])
const strategyOptions = useMemo<Array<SegmentedOption<AUTO_UPDATE_STRATEGY>>>(() => [
{
value: AUTO_UPDATE_STRATEGY.disabled,
@ -161,18 +174,22 @@ const UpdateSettingPopover = ({
], [t])
const updateAutoUpgrade = useCallback((payload: Partial<AutoUpdateConfig>) => {
const nextAutoUpgrade = {
...autoUpgrade,
...payload,
}
setAutoUpgrade(nextAutoUpgrade)
onSave({
...referenceSetting,
auto_upgrade: nextAutoUpgrade,
setDraftAutoUpgrade((currentAutoUpgrade) => {
const baseAutoUpgrade = currentAutoUpgrade ?? savedAutoUpgrade
if (!baseAutoUpgrade)
return undefined
return {
...baseAutoUpgrade,
...payload,
}
})
}, [autoUpgrade, onSave, referenceSetting])
}, [savedAutoUpgrade])
const handlePluginsChange = useCallback((newPlugins: string[]) => {
if (!autoUpgrade)
return
if (autoUpgrade.upgrade_mode === AUTO_UPDATE_MODE.partial) {
updateAutoUpgrade({
include_plugins: newPlugins,
@ -183,7 +200,7 @@ const UpdateSettingPopover = ({
exclude_plugins: newPlugins,
})
}
}, [autoUpgrade.upgrade_mode, updateAutoUpgrade])
}, [autoUpgrade, updateAutoUpgrade])
const minuteFilter = useCallback((minutes: string[]) => {
return minutes.filter((m) => {
const time = Number.parseInt(m, 10)
@ -195,14 +212,38 @@ const UpdateSettingPopover = ({
upgrade_time_of_day: convertLocalSecondsToUTCDaySeconds(dayjsToTimeOfDay(value), timezone),
})
}, [timezone, updateAutoUpgrade])
const handleOpenChange = useCallback((open: boolean) => {
if (disabled) {
setIsOpen(false)
return
}
setIsOpen(open)
if (open)
setDraftAutoUpgrade(savedAutoUpgrade)
else
setDraftAutoUpgrade(undefined)
}, [disabled, savedAutoUpgrade])
const handleCancel = useCallback(() => {
setDraftAutoUpgrade(undefined)
setIsOpen(false)
}, [])
const handleSave = useCallback(() => {
if (!autoUpgrade)
return
saveAutoUpgrade(autoUpgrade)
setDraftAutoUpgrade(undefined)
setIsOpen(false)
}, [autoUpgrade, saveAutoUpgrade])
const renderTimePickerTrigger = useCallback(({ inputElem, onClick, isOpen }: TriggerParams) => {
return (
<button
type="button"
className="group flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg border-none bg-components-input-bg-normal px-2 text-left hover:bg-state-base-hover-alt focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
className="group flex h-8 w-full cursor-pointer items-center justify-center rounded-lg border-none bg-[#f5f4ee] px-3 text-center shadow-xs hover:bg-state-base-hover-alt focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onClick}
>
<div className="flex w-0 grow items-center gap-1">
<div className="flex min-w-0 items-center gap-1">
<span
aria-hidden
className={cn(
@ -210,105 +251,148 @@ const UpdateSettingPopover = ({
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
)}
/>
{inputElem}
<span className="w-[64px] min-w-[64px] text-center system-sm-medium text-components-button-secondary-text">
{inputElem}
</span>
<div className="system-sm-medium text-components-button-secondary-text">{convertTimezoneToOffsetStr(timezone)}</div>
</div>
<div className="system-sm-regular text-text-tertiary">{convertTimezoneToOffsetStr(timezone)}</div>
</button>
)
}, [timezone])
return (
<Popover>
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
<Button
variant="secondary"
className="h-8 gap-0.5 px-3 system-sm-medium"
disabled={disabled}
>
<span aria-hidden className="i-ri-flashlight-line size-4" />
<span className="px-0.5">{t('modelProvider.updateSetting', { ns: 'common' })}</span>
<span className="flex min-w-4 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{selectedStrategyLabel}
</span>
{selectedStrategyLabel && (
<span className="flex min-w-4 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{selectedStrategyLabel}
</span>
)}
<span aria-hidden className="i-ri-arrow-down-s-line size-4" />
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[320px] overflow-hidden rounded-2xl border-t border-components-panel-border bg-components-panel-bg p-0 shadow-xl"
>
<div className="border-b-[0.5px] border-black/5 py-2">
<div className={cn(updateSettingFormClassName, 'w-full')}>
<div className={updateSettingFormInputSetClassName}>
<div className={updateSettingFormLabelClassName}>
{t('autoUpdate.automaticUpdates', { ns: 'plugin' })}
</div>
<SegmentedControl
ariaLabel={t('autoUpdate.automaticUpdates', { ns: 'plugin' })}
options={strategyOptions}
value={autoUpgrade.strategy_setting}
onChange={strategy_setting => updateAutoUpgrade({ strategy_setting })}
/>
{!disabled && (
<PopoverContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[518px] max-w-[calc(100vw-32px)] overflow-hidden rounded-2xl border-t border-components-panel-border bg-components-panel-bg p-0 shadow-xl"
>
{isSettingsLoading && (
<div
role="status"
className="flex min-h-[96px] items-center justify-center gap-2 px-4 py-6 system-sm-regular text-text-tertiary"
>
<span aria-hidden className="i-ri-loader-2-line size-4 animate-spin motion-reduce:animate-none" />
<span>{t('loading', { ns: 'common' })}</span>
</div>
</div>
{autoUpgrade.strategy_setting !== AUTO_UPDATE_STRATEGY.disabled && (
)}
{!isSettingsLoading && !hasSettings && (
<div className="flex min-h-[96px] items-center justify-center px-4 py-6 text-center system-sm-regular text-text-tertiary">
{t('api.actionFailed', { ns: 'common' })}
</div>
)}
{!isSettingsLoading && hasSettings && autoUpgrade && (
<>
<div className={updateSettingFormClassName}>
<div className={updateSettingFormInputSetClassName}>
<div className={updateSettingFormLabelClassName}>
{t('autoUpdate.updateTime', { ns: 'plugin' })}
</div>
<div className="flex flex-col items-start">
<TimePicker
value={updateTimeValue}
timezone={timezone}
onChange={handleUpdateTimeChange}
onClear={() => updateAutoUpgrade({
upgrade_time_of_day: convertLocalSecondsToUTCDaySeconds(0, timezone),
})}
title={t('autoUpdate.updateTime', { ns: 'plugin' })}
minuteFilter={minuteFilter}
renderTrigger={renderTimePickerTrigger}
placement="bottom-end"
/>
<div className="mt-1 body-xs-regular text-text-tertiary">
<Trans
i18nKey="autoUpdate.changeTimezone"
ns="plugin"
components={{
setTimezone: <SettingTimeZone />,
}}
/>
<div className="border-b-[0.5px] border-black/5 py-2">
<div className={cn(updateSettingFormClassName, 'w-full')}>
<div className={cn(updateSettingFormInputSetClassName, 'w-full')}>
<div className={updateSettingFormLabelClassName}>
{t('autoUpdate.automaticUpdates', { ns: 'plugin' })}
</div>
</div>
</div>
</div>
<div className={cn(updateSettingFormClassName, 'w-full')}>
<div className={cn(updateSettingFormInputSetClassName, 'w-full')}>
<div className={updateSettingFormLabelClassName}>
{t('autoUpdate.scope', { ns: 'plugin' })}
</div>
<SegmentedControl
ariaLabel={t('autoUpdate.scope', { ns: 'plugin' })}
options={scopeOptions}
value={autoUpgrade.upgrade_mode}
onChange={upgrade_mode => updateAutoUpgrade({ upgrade_mode })}
/>
{autoUpgrade.upgrade_mode !== AUTO_UPDATE_MODE.update_all && (
<PluginsPicker
value={plugins}
onChange={handlePluginsChange}
updateMode={autoUpgrade.upgrade_mode}
<SegmentedControl
ariaLabel={t('autoUpdate.automaticUpdates', { ns: 'plugin' })}
className="w-full"
options={strategyOptions}
value={autoUpgrade.strategy_setting}
onChange={strategy_setting => updateAutoUpgrade({ strategy_setting })}
/>
)}
</div>
</div>
{autoUpgrade.strategy_setting !== AUTO_UPDATE_STRATEGY.disabled && (
<>
<div className={updateSettingFormClassName}>
<div className={cn(updateSettingFormInputSetClassName, 'w-full')}>
<div className={updateSettingFormLabelClassName}>
{t('autoUpdate.updateTime', { ns: 'plugin' })}
</div>
<div className="flex w-full flex-col items-start gap-1">
<TimePicker
value={updateTimeValue}
timezone={timezone}
onChange={handleUpdateTimeChange}
onClear={() => updateAutoUpgrade({
upgrade_time_of_day: convertLocalSecondsToUTCDaySeconds(0, timezone),
})}
title={t('autoUpdate.updateTime', { ns: 'plugin' })}
minuteFilter={minuteFilter}
renderTrigger={renderTimePickerTrigger}
placement="bottom-end"
/>
<div className="mt-1 body-xs-regular text-text-tertiary">
<Trans
i18nKey="autoUpdate.changeTimezone"
ns="plugin"
components={{
setTimezone: <SettingTimeZone />,
}}
/>
</div>
</div>
</div>
</div>
<div className={cn(updateSettingFormClassName, 'w-full')}>
<div className={cn(updateSettingFormInputSetClassName, 'w-full')}>
<div className={updateSettingFormLabelClassName}>
{t('autoUpdate.scope', { ns: 'plugin' })}
</div>
<SegmentedControl
ariaLabel={t('autoUpdate.scope', { ns: 'plugin' })}
className="w-full"
options={scopeOptions}
value={autoUpgrade.upgrade_mode}
onChange={upgrade_mode => updateAutoUpgrade({ upgrade_mode })}
/>
{autoUpgrade.upgrade_mode !== AUTO_UPDATE_MODE.update_all && (
<PluginsPicker
value={plugins}
onChange={handlePluginsChange}
updateMode={autoUpgrade.upgrade_mode}
integrationCategory={category}
/>
)}
</div>
</div>
</>
)}
</div>
<div className="flex items-center justify-end gap-2 px-4 pt-2 pb-4">
<Button
variant="secondary"
onClick={handleCancel}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={isSavePending}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</>
)}
</div>
</PopoverContent>
</PopoverContent>
)}
</Popover>
)
}

View File

@ -10,6 +10,10 @@ import { useInvalidateAllBuiltInTools, useInvalidateAllToolProviders, useInvalid
import { useInvalidateAllTriggerPlugins } from '@/service/use-triggers'
import { PluginCategoryEnum } from '../../types'
type PluginCategoryPayload = {
category: PluginCategoryEnum | string
}
const useRefreshPluginList = () => {
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const { mutate: refetchLLMModelList } = useModelList(ModelTypeEnum.textGeneration)
@ -29,7 +33,7 @@ const useRefreshPluginList = () => {
const invalidateRAGRecommendedPlugins = useInvalidateRAGRecommendedPlugins()
return {
refreshPluginList: (manifest?: PluginManifestInMarket | Plugin | PluginDeclaration | null, refreshAllType?: boolean) => {
refreshPluginList: (manifest?: PluginManifestInMarket | Plugin | PluginDeclaration | PluginCategoryPayload | null, refreshAllType?: boolean) => {
// installed list
invalidateInstalledPluginList()

View File

@ -5,8 +5,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderHookWithSystemFeatures as renderHook } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context'
import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins'
import { PermissionType } from '../../types'
import { useInvalidateReferenceSettings, useMutationPluginPermissionSettings, useMutationReferenceSettings, usePluginAutoUpgradeSettings, usePluginPermissionSettings } from '@/service/use-plugins'
import { PermissionType, PluginCategoryEnum } from '../../types'
import useReferenceSetting, { useCanInstallPluginFromMarketplace } from '../use-reference-setting'
vi.mock('@/context/app-context', () => ({
@ -14,7 +14,21 @@ vi.mock('@/context/app-context', () => ({
}))
vi.mock('@/service/use-plugins', () => ({
useReferenceSettings: vi.fn(),
hasPluginPermission: vi.fn((permission: string | undefined, isAdmin: boolean) => {
if (!permission)
return false
if (permission === 'noone')
return false
if (permission === 'everyone')
return true
return isAdmin
}),
usePluginAutoUpgradeSettings: vi.fn(),
usePluginPermissionSettings: vi.fn(),
useMutationPluginPermissionSettings: vi.fn(),
useMutationReferenceSettings: vi.fn(),
useInvalidateReferenceSettings: vi.fn(),
}))
@ -32,67 +46,77 @@ describe('useReferenceSetting Hook', () => {
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
vi.mocked(usePluginAutoUpgradeSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
category: PluginCategoryEnum.tool,
auto_upgrade: {
strategy_setting: 'fix_only',
upgrade_time_of_day: 0,
upgrade_mode: 'all',
exclude_plugins: [],
include_plugins: [],
},
},
} as ReturnType<typeof useReferenceSettings>)
} as unknown as ReturnType<typeof usePluginAutoUpgradeSettings>)
vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
} as ReturnType<typeof usePluginPermissionSettings>)
vi.mocked(useMutationReferenceSettings).mockReturnValue({
mutate: vi.fn(),
isPending: false,
} as unknown as ReturnType<typeof useMutationReferenceSettings>)
vi.mocked(useMutationPluginPermissionSettings).mockReturnValue({
mutate: vi.fn(),
isPending: false,
} as unknown as ReturnType<typeof useMutationPluginPermissionSettings>)
vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn())
})
describe('hasPermission logic', () => {
it('should return false when permission is undefined', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: {
permission: {
install_permission: undefined,
debug_permission: undefined,
},
install_permission: undefined,
debug_permission: undefined,
},
} as unknown as ReturnType<typeof useReferenceSettings>)
} as unknown as ReturnType<typeof usePluginPermissionSettings>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
it('should return false when permission is noOne', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
} as ReturnType<typeof useReferenceSettings>)
} as ReturnType<typeof usePluginPermissionSettings>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
it('should return true when permission is everyone', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
} as ReturnType<typeof useReferenceSettings>)
} as ReturnType<typeof usePluginPermissionSettings>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
@ -104,16 +128,14 @@ describe('useReferenceSetting Hook', () => {
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
} as ReturnType<typeof useReferenceSettings>)
} as ReturnType<typeof usePluginPermissionSettings>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
@ -125,16 +147,14 @@ describe('useReferenceSetting Hook', () => {
isCurrentWorkspaceOwner: true,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
} as ReturnType<typeof useReferenceSettings>)
} as ReturnType<typeof usePluginPermissionSettings>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
@ -146,16 +166,14 @@ describe('useReferenceSetting Hook', () => {
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
} as ReturnType<typeof useReferenceSettings>)
} as ReturnType<typeof usePluginPermissionSettings>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
@ -169,7 +187,7 @@ describe('useReferenceSetting Hook', () => {
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canSetPermissions).toBe(true)
})
@ -180,7 +198,7 @@ describe('useReferenceSetting Hook', () => {
isCurrentWorkspaceOwner: true,
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canSetPermissions).toBe(true)
})
@ -191,7 +209,7 @@ describe('useReferenceSetting Hook', () => {
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canSetPermissions).toBe(false)
})
@ -211,7 +229,7 @@ describe('useReferenceSetting Hook', () => {
} as unknown as ReturnType<typeof useMutationReferenceSettings>
})
renderHook(() => useReferenceSetting())
renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
// Trigger the onSuccess callback
if (onSuccessCallback)
@ -231,12 +249,22 @@ describe('useReferenceSetting Hook', () => {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
auto_upgrade: {
strategy_setting: 'fix_only',
upgrade_time_of_day: 0,
upgrade_mode: 'all',
exclude_plugins: [],
include_plugins: [],
},
}
vi.mocked(useReferenceSettings).mockReturnValue({
data: mockData,
} as ReturnType<typeof useReferenceSettings>)
vi.mocked(usePluginAutoUpgradeSettings).mockReturnValue({
data: {
category: PluginCategoryEnum.tool,
auto_upgrade: mockData.auto_upgrade,
},
} as unknown as ReturnType<typeof usePluginAutoUpgradeSettings>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.referenceSetting).toEqual(mockData)
})
@ -247,20 +275,21 @@ describe('useReferenceSetting Hook', () => {
isPending: true,
} as unknown as ReturnType<typeof useMutationReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.isUpdatePending).toBe(true)
})
it('should handle null data', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: null,
} as unknown as ReturnType<typeof useReferenceSettings>)
it('should keep permissions available when reference setting data is still loading', () => {
vi.mocked(usePluginAutoUpgradeSettings).mockReturnValue({
data: undefined,
} as unknown as ReturnType<typeof usePluginAutoUpgradeSettings>)
const { result } = renderHook(() => useReferenceSetting())
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
expect(result.current.canManagement).toBe(false)
expect(result.current.canDebugger).toBe(false)
expect(result.current.referenceSetting).toBeUndefined()
expect(result.current.canManagement).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
})
})
@ -274,14 +303,12 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
isCurrentWorkspaceOwner: false,
} as ReturnType<typeof useAppContext>)
vi.mocked(useReferenceSettings).mockReturnValue({
vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
} as ReturnType<typeof useReferenceSettings>)
} as ReturnType<typeof usePluginPermissionSettings>)
vi.mocked(useMutationReferenceSettings).mockReturnValue({
mutate: vi.fn(),
@ -308,14 +335,12 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
})
it('should return false when canManagement is false', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
} as ReturnType<typeof useReferenceSettings>)
} as ReturnType<typeof usePluginPermissionSettings>)
const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), {
systemFeatures: { enable_marketplace: true },
@ -325,14 +350,12 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
})
it('should return false when both marketplace is disabled and canManagement is false', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
vi.mocked(usePluginPermissionSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
} as ReturnType<typeof useReferenceSettings>)
} as ReturnType<typeof usePluginPermissionSettings>)
const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), {
systemFeatures: { enable_marketplace: false },
@ -340,4 +363,13 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
})
it('should only read plugin permissions and not fetch category auto-upgrade settings', () => {
renderHook(() => useCanInstallPluginFromMarketplace(), {
systemFeatures: { enable_marketplace: true },
})
expect(usePluginPermissionSettings).toHaveBeenCalled()
expect(usePluginAutoUpgradeSettings).not.toHaveBeenCalled()
})
})

View File

@ -1,46 +1,73 @@
import type { PluginCategoryEnum } from '../types'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins'
import { PermissionType } from '../types'
import { hasPluginPermission, useInvalidateReferenceSettings, useMutationPluginPermissionSettings, useMutationReferenceSettings, usePluginAutoUpgradeSettings, usePluginPermissionSettings } from '@/service/use-plugins'
const hasPermission = (permission: PermissionType | undefined, isAdmin: boolean) => {
if (!permission)
return false
export const useCanSetPluginSettings = () => {
const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner } = useAppContext()
if (permission === PermissionType.noOne)
return false
if (permission === PermissionType.everyone)
return true
return isAdmin
return {
canSetPermissions: isCurrentWorkspaceManager || isCurrentWorkspaceOwner,
}
}
const useReferenceSetting = () => {
export const usePluginSettingsAccess = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner } = useAppContext()
const { data } = useReferenceSettings()
// console.log(data)
const { permission: permissions } = data || {}
const { canSetPermissions } = useCanSetPluginSettings()
const permissionQuery = usePluginPermissionSettings()
const { data: permissions } = permissionQuery
const { mutate: setPluginPermissionSettings, isPending: isPermissionUpdatePending } = useMutationPluginPermissionSettings({
onSuccess: () => {
toast.success(t('api.actionSuccess', { ns: 'common' }))
},
})
return {
permission: permissions,
setPluginPermissionSettings,
canManagement: hasPluginPermission(permissions?.install_permission, canSetPermissions),
canDebugger: hasPluginPermission(permissions?.debug_permission, canSetPermissions),
canSetPermissions,
isPermissionLoading: permissionQuery.isLoading || permissionQuery.isFetching,
permissionError: permissionQuery.error,
isPermissionUpdatePending,
}
}
const useReferenceSetting = (category: PluginCategoryEnum) => {
const { t } = useTranslation()
const permissionAccess = usePluginSettingsAccess()
const autoUpgradeQuery = usePluginAutoUpgradeSettings(category)
const data = permissionAccess.permission && autoUpgradeQuery.data?.auto_upgrade
? {
permission: permissionAccess.permission,
auto_upgrade: autoUpgradeQuery.data.auto_upgrade,
}
: undefined
const invalidateReferenceSettings = useInvalidateReferenceSettings()
const { mutate: updateReferenceSetting, isPending: isUpdatePending } = useMutationReferenceSettings({
category,
currentReferenceSetting: data,
onSuccess: () => {
invalidateReferenceSettings()
toast.success(t('api.actionSuccess', { ns: 'common' }))
},
})
const isAdmin = isCurrentWorkspaceManager || isCurrentWorkspaceOwner
return {
referenceSetting: data,
setReferenceSettings: updateReferenceSetting,
canManagement: hasPermission(permissions?.install_permission, isAdmin),
canDebugger: hasPermission(permissions?.debug_permission, isAdmin),
canSetPermissions: isAdmin,
canManagement: permissionAccess.canManagement,
canDebugger: permissionAccess.canDebugger,
canSetPermissions: permissionAccess.canSetPermissions,
isPermissionLoading: permissionAccess.isPermissionLoading,
permissionError: permissionAccess.permissionError,
isReferenceSettingLoading: autoUpgradeQuery.isLoading || autoUpgradeQuery.isFetching,
referenceSettingError: autoUpgradeQuery.error,
isUpdatePending,
}
}
@ -50,7 +77,12 @@ export const useCanInstallPluginFromMarketplace = () => {
...systemFeaturesQueryOptions(),
select: s => s.enable_marketplace,
})
const { canManagement } = useReferenceSetting()
const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner } = useAppContext()
const { data: permissions } = usePluginPermissionSettings()
const canManagement = hasPluginPermission(
permissions?.install_permission,
isCurrentWorkspaceManager || isCurrentWorkspaceOwner,
)
const canInstallPluginFromMarketplace = useMemo(() => {
return enable_marketplace && canManagement

View File

@ -80,17 +80,6 @@ vi.mock('../auto-update-setting', () => ({
},
}))
// Mock config default value
vi.mock('../auto-update-setting/config', () => ({
defaultValue: {
strategy_setting: AUTO_UPDATE_STRATEGY.disabled,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.update_all,
exclude_plugins: [],
include_plugins: [],
},
}))
// ================================
// Test Data Factories
// ================================
@ -298,20 +287,6 @@ describe('reference-setting-modal', () => {
// Assert
expect(screen.getByTestId('auto-update-strategy'))!.toHaveTextContent('latest')
})
it('should use fix-only auto_upgrade when payload.auto_upgrade is undefined', () => {
// Arrange
const payload = {
permission: createMockPermissions(),
auto_upgrade: undefined as unknown as AutoUpdateConfig,
}
// Act
render(<ReferenceSettingModal {...defaultProps} payload={payload} />)
// Assert - should use Integrations default value (fix-only)
expect(screen.getByTestId('auto-update-strategy'))!.toHaveTextContent('fix_only')
})
})
describe('User Interactions', () => {
@ -489,15 +464,6 @@ describe('reference-setting-modal', () => {
})
describe('Edge Cases and Error Handling', () => {
it('should handle null payload gracefully', () => {
// Arrange
const payload = null as unknown as ReferenceSetting
// Act & Assert - should not crash
render(<ReferenceSettingModal {...defaultProps} payload={payload} />)
expect(screen.getByText('plugin.privilege.title'))!.toBeInTheDocument()
})
it('should handle undefined permission values', () => {
// Arrange
const payload = {

View File

@ -1,15 +0,0 @@
import { describe, expect, it } from 'vitest'
import { defaultValue } from '../config'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '../types'
describe('auto-update config', () => {
it('provides the expected default auto update value', () => {
expect(defaultValue).toEqual({
strategy_setting: AUTO_UPDATE_STRATEGY.disabled,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.update_all,
exclude_plugins: [],
include_plugins: [],
})
})
})

View File

@ -9,7 +9,6 @@ import utc from 'dayjs/plugin/utc'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum, PluginSource } from '../../../types'
import { defaultValue } from '../config'
import AutoUpdateSetting from '../index'
import NoDataPlaceholder from '../no-data-placeholder'
import NoPluginSelected from '../no-plugin-selected'
@ -352,39 +351,6 @@ describe('auto-update-setting', () => {
})
})
describe('config.ts', () => {
describe('defaultValue', () => {
it('should have disabled strategy by default', () => {
expect(defaultValue.strategy_setting).toBe(AUTO_UPDATE_STRATEGY.disabled)
})
it('should have upgrade_time_of_day as 0', () => {
expect(defaultValue.upgrade_time_of_day).toBe(0)
})
it('should have update_all mode by default', () => {
expect(defaultValue.upgrade_mode).toBe(AUTO_UPDATE_MODE.update_all)
})
it('should have empty exclude_plugins array', () => {
expect(defaultValue.exclude_plugins).toEqual([])
})
it('should have empty include_plugins array', () => {
expect(defaultValue.include_plugins).toEqual([])
})
it('should be a complete AutoUpdateConfig object', () => {
const keys = Object.keys(defaultValue)
expect(keys).toContain('strategy_setting')
expect(keys).toContain('upgrade_time_of_day')
expect(keys).toContain('upgrade_mode')
expect(keys).toContain('exclude_plugins')
expect(keys).toContain('include_plugins')
})
})
})
// ============================================================
// Utils Tests (Extended coverage beyond utils.spec.ts)
// ============================================================

View File

@ -1,9 +1,32 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import PluginsPicker from '../plugins-picker'
import { AUTO_UPDATE_MODE } from '../types'
const mockToolPicker = vi.fn()
const mockInstalledPluginList = vi.hoisted(() => ({
data: {
plugins: [
{
plugin_id: 'dify/model-plugin',
declaration: { category: 'model' },
},
{
plugin_id: 'dify/tool-plugin',
declaration: { category: 'tool' },
},
{
plugin_id: 'dify/datasource-plugin',
declaration: { category: 'datasource' },
},
],
},
}))
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => mockInstalledPluginList,
}))
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
@ -29,6 +52,10 @@ vi.mock('../tool-picker', () => ({
}))
describe('PluginsPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the empty state when no plugins are selected', () => {
render(
<PluginsPicker
@ -71,12 +98,43 @@ describe('PluginsPicker', () => {
updateMode={AUTO_UPDATE_MODE.partial}
value={[]}
onChange={vi.fn()}
integrationCategory={PluginCategoryEnum.model}
/>,
)
expect(screen.getByTestId('tool-picker')).toBeInTheDocument()
expect(mockToolPicker).toHaveBeenCalledWith(expect.objectContaining({
trigger: expect.anything(),
integrationCategory: PluginCategoryEnum.model,
}))
})
it('shows and edits only selected plugins from the provided integration category', () => {
const onChange = vi.fn()
render(
<PluginsPicker
updateMode={AUTO_UPDATE_MODE.exclude}
value={['dify/model-plugin', 'dify/tool-plugin', 'dify/datasource-plugin']}
onChange={onChange}
integrationCategory={PluginCategoryEnum.tool}
/>,
)
expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":1}')).toBeInTheDocument()
expect(screen.getByTestId('plugins-selected')).toHaveTextContent('dify/tool-plugin')
expect(screen.getByTestId('plugins-selected')).not.toHaveTextContent('dify/model-plugin')
const toolPickerProps = mockToolPicker.mock.lastCall?.[0] as {
value: string[]
onChange: (value: string[]) => void
}
expect(toolPickerProps.value).toEqual(['dify/tool-plugin'])
toolPickerProps.onChange(['dify/new-tool-plugin'])
expect(onChange).toHaveBeenCalledWith(['dify/model-plugin', 'dify/datasource-plugin', 'dify/new-tool-plugin'])
fireEvent.click(screen.getByRole('button', { name: 'plugin.autoUpdate.operation.clearAll' }))
expect(onChange).toHaveBeenLastCalledWith(['dify/model-plugin', 'dify/datasource-plugin'])
})
})

View File

@ -1,7 +1,7 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '@/app/components/plugins/types'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import ToolPicker from '../tool-picker'
const mockInstalledPluginList = vi.hoisted(() => ({
@ -226,6 +226,35 @@ describe('ToolPicker', () => {
expect(screen.getByTestId('search-state')).toHaveTextContent('tool-rag')
})
it('limits selectable integrations to the provided integration category', () => {
mockInstalledPluginList.data = {
plugins: [
createPlugin('model-openai', PluginSource.marketplace, 'model', ['llm']),
createPlugin('tool-rag', PluginSource.marketplace, 'tool', ['rag']),
createPlugin('datasource-notion', PluginSource.marketplace, 'datasource', ['docs']),
],
}
render(
<ToolPicker
trigger={<span>trigger</span>}
value={[]}
onChange={vi.fn()}
isShow
onShowChange={vi.fn()}
integrationCategory={PluginCategoryEnum.model}
/>,
)
expect(screen.getByText('plugin.category.models')).toBeInTheDocument()
expect(screen.queryByText('plugin.category.all')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.category.tools')).not.toBeInTheDocument()
expect(screen.getAllByTestId('tool-item')).toHaveLength(1)
expect(screen.getByText('model-openai')).toBeInTheDocument()
expect(screen.queryByText('tool-rag')).not.toBeInTheDocument()
expect(screen.queryByText('datasource-notion')).not.toBeInTheDocument()
})
it('adds and removes plugin ids from the selection', () => {
mockInstalledPluginList.data = {
plugins: [

View File

@ -1,10 +0,0 @@
import type { AutoUpdateConfig } from './types'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from './types'
export const defaultValue: AutoUpdateConfig = {
strategy_setting: AUTO_UPDATE_STRATEGY.disabled,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.update_all,
exclude_plugins: [],
include_plugins: [],
}

View File

@ -14,7 +14,7 @@ const NoPluginSelected: FC<Props> = ({
const { t } = useTranslation()
const text = `${t(`autoUpdate.upgradeModePlaceholder.${updateMode === AUTO_UPDATE_MODE.partial ? 'partial' : 'exclude'}`, { ns: 'plugin' })}`
return (
<div className="rounded-[10px] border border-components-option-card-option-border bg-background-section p-3 text-center system-xs-regular text-text-tertiary">
<div className="text-center system-xs-regular text-text-tertiary">
{text}
</div>
)

View File

@ -1,10 +1,13 @@
'use client'
import type { FC } from 'react'
import type { PluginCategoryEnum } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { RiAddLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useInstalledPluginList } from '@/service/use-plugins'
import NoPluginSelected from './no-plugin-selected'
import PluginsSelected from './plugins-selected'
import ToolPicker from './tool-picker'
@ -16,32 +19,58 @@ type Props = {
updateMode: AUTO_UPDATE_MODE
value: string[] // plugin ids
onChange: (value: string[]) => void
integrationCategory?: PluginCategoryEnum
}
const PluginsPicker: FC<Props> = ({
updateMode,
value,
onChange,
integrationCategory,
}) => {
const { t } = useTranslation()
const hasSelected = value.length > 0
const { data } = useInstalledPluginList()
const pluginCategoryById = useMemo(() => {
return new Map(data?.plugins.map(plugin => [plugin.plugin_id, plugin.declaration.category]))
}, [data?.plugins])
const isCurrentCategoryPlugin = useCallback((pluginId: string) => {
if (!integrationCategory)
return true
return pluginCategoryById.get(pluginId) === integrationCategory
}, [integrationCategory, pluginCategoryById])
const visiblePlugins = useMemo(() => {
return value.filter(isCurrentCategoryPlugin)
}, [isCurrentCategoryPlugin, value])
const hiddenPlugins = useMemo(() => {
if (!integrationCategory)
return []
return value.filter(plugin => !isCurrentCategoryPlugin(plugin))
}, [integrationCategory, isCurrentCategoryPlugin, value])
const hasSelected = visiblePlugins.length > 0
const isExcludeMode = updateMode === AUTO_UPDATE_MODE.exclude
const handleClear = () => {
onChange([])
onChange(hiddenPlugins)
}
const handleVisiblePluginsChange = useCallback((newVisiblePlugins: string[]) => {
onChange(integrationCategory
? [...hiddenPlugins, ...newVisiblePlugins.filter(plugin => !hiddenPlugins.includes(plugin))]
: newVisiblePlugins)
}, [hiddenPlugins, integrationCategory, onChange])
const [isShowToolPicker, {
set: setToolPicker,
}] = useBoolean(false)
return (
<div className="mt-2 rounded-[10px] bg-background-section-burn p-2.5">
<div className="mt-2 flex w-full flex-col gap-2 rounded-[10px] bg-background-section-burn p-2">
{hasSelected
? (
<div className="flex justify-between text-text-tertiary">
<div className="system-xs-medium">{t(`${i18nPrefix}.${isExcludeMode ? 'excludeUpdate' : 'partialUPdate'}`, { ns: 'plugin', num: value.length })}</div>
<div className="flex items-center justify-between gap-3">
<div className="system-xs-regular text-text-tertiary">{t(`${i18nPrefix}.${isExcludeMode ? 'excludeUpdate' : 'partialUPdate'}`, { ns: 'plugin', num: visiblePlugins.length })}</div>
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-medium text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
className="shrink-0 cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={handleClear}
>
{t(`${i18nPrefix}.operation.clearAll`, { ns: 'plugin' })}
@ -54,22 +83,23 @@ const PluginsPicker: FC<Props> = ({
{hasSelected && (
<PluginsSelected
className="mt-2"
plugins={value}
className="gap-2"
plugins={visiblePlugins}
/>
)}
<ToolPicker
trigger={(
<Button className="mt-2 w-full" size="small" variant="secondary-accent">
<Button className="h-6 w-full gap-1" size="small" variant="secondary-accent">
<RiAddLine className="size-3.5" />
{t(`${i18nPrefix}.operation.select`, { ns: 'plugin' })}
</Button>
)}
value={value}
onChange={onChange}
value={visiblePlugins}
onChange={handleVisiblePluginsChange}
isShow={isShowToolPicker}
onShowChange={setToolPicker}
integrationCategory={integrationCategory}
/>
</div>
)

View File

@ -18,7 +18,7 @@ const PluginsSelected: FC<Props> = ({
const isShowAll = plugins.length < MAX_DISPLAY_COUNT
const displayPlugins = plugins.slice(0, MAX_DISPLAY_COUNT)
return (
<div className={cn('flex items-center space-x-1', className)}>
<div className={cn('flex items-center', className)}>
{displayPlugins.map(plugin => (
<Icon key={plugin} size="tiny" src={`${MARKETPLACE_API_PREFIX}/plugins/${plugin}/icon`} />
))}

View File

@ -26,12 +26,12 @@ const ToolItem: FC<Props> = ({
return (
<div className="p-1">
<div
className="flex w-full items-center rounded-lg pr-2 select-none hover:bg-state-base-hover"
className="flex w-full items-center gap-1 rounded-lg py-1 pr-2 pl-3 select-none hover:bg-state-base-hover"
>
<div className="flex h-8 grow items-center space-x-2 pr-2 pl-3">
<div className="flex min-w-0 grow items-center gap-2 pr-2">
<Icon size="tiny" src={`${MARKETPLACE_API_PREFIX}/plugins/${plugin_id}/icon`} />
<div className="max-w-[150px] shrink-0 truncate system-sm-medium text-text-primary">{renderI18nObject(label, language)}</div>
<div className="max-w-[150px] shrink-0 truncate system-xs-regular text-text-quaternary">{org}</div>
<div className="shrink-0 truncate system-sm-medium text-text-secondary">{renderI18nObject(label, language)}</div>
<div className="min-w-0 truncate system-xs-regular text-text-quaternary">{org}</div>
</div>
<Checkbox
checked={isChecked}

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { ActivePluginType } from '../../marketplace/constants'
import type { PluginCategoryEnum } from '../../types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
@ -24,6 +25,7 @@ type Props = {
onChange: (value: string[]) => void
isShow: boolean
onShowChange: (isShow: boolean) => void
integrationCategory?: PluginCategoryEnum
}
const ToolPicker: FC<Props> = ({
@ -32,10 +34,11 @@ const ToolPicker: FC<Props> = ({
onChange,
isShow,
onShowChange,
integrationCategory,
}) => {
const { t } = useTranslation()
const tabs = [
const allTabs = [
{ key: PLUGIN_TYPE_SEARCH_MAP.all, name: t('category.all', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.model, name: t('category.models', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.tool, name: t('category.tools', { ns: 'plugin' }) },
@ -45,8 +48,12 @@ const ToolPicker: FC<Props> = ({
{ key: PLUGIN_TYPE_SEARCH_MAP.trigger, name: t('category.triggers', { ns: 'plugin' }) },
{ key: PLUGIN_TYPE_SEARCH_MAP.bundle, name: t('category.bundles', { ns: 'plugin' }) },
]
const tabs = integrationCategory
? allTabs.filter(tab => tab.key === integrationCategory)
: allTabs
const [pluginType, setPluginType] = useState<ActivePluginType>(PLUGIN_TYPE_SEARCH_MAP.all)
const effectivePluginType = integrationCategory ?? pluginType
const [query, setQuery] = useState('')
const [tags, setTags] = useState<string[]>([])
const { data, isLoading } = useInstalledPluginList()
@ -55,12 +62,12 @@ const ToolPicker: FC<Props> = ({
return list.filter((plugin) => {
const isFromMarketPlace = plugin.source === PluginSource.marketplace
return (
isFromMarketPlace && (pluginType === PLUGIN_TYPE_SEARCH_MAP.all || plugin.declaration.category === pluginType)
isFromMarketPlace && (effectivePluginType === PLUGIN_TYPE_SEARCH_MAP.all || plugin.declaration.category === effectivePluginType)
&& (tags.length === 0 || tags.some(tag => plugin.declaration.tags.includes(tag)))
&& (query === '' || plugin.plugin_id.toLowerCase().includes(query.toLowerCase()))
)
})
}, [data, pluginType, query, tags])
}, [data, effectivePluginType, query, tags])
const handleCheckChange = (pluginId: string) => {
const newValue = value.includes(pluginId)
@ -102,7 +109,7 @@ const ToolPicker: FC<Props> = ({
sideOffset={0}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative min-h-20 w-full rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-2 shadow-lg backdrop-blur-xs">
<div className="relative min-h-20 w-[476px] max-w-[calc(100vw-32px)] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<SearchBox
search={query}
@ -113,14 +120,14 @@ const ToolPicker: FC<Props> = ({
inputClassName="w-full"
/>
</div>
<div className="flex items-center justify-between border-b-[0.5px] border-divider-subtle bg-background-default-hover px-3 shadow-xs">
<div className="flex h-8 items-center space-x-1">
<div className="flex items-center justify-between bg-components-panel-bg px-3 pb-2">
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
{tabs.map(tab => (
<div
className={cn(
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
'flex h-6 shrink-0 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
'text-xs font-medium text-text-secondary',
pluginType === tab.key && 'bg-state-base-hover-alt',
effectivePluginType === tab.key && 'bg-state-base-hover-alt',
)}
key={tab.key}
onClick={() => setPluginType(tab.key)}

View File

@ -12,23 +12,9 @@ import { PermissionType } from '@/app/components/plugins/types'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import AutoUpdateSetting from './auto-update-setting'
import { defaultValue as autoUpdateDefaultValue } from './auto-update-setting/config'
import { AUTO_UPDATE_STRATEGY } from './auto-update-setting/types'
import Label from './label'
const i18nPrefix = 'privilege'
const defaultAutoUpdateConfig: AutoUpdateConfig = {
...autoUpdateDefaultValue,
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
}
const getInitialAutoUpdateConfig = (autoUpdateConfig?: AutoUpdateConfig): AutoUpdateConfig => ({
...defaultAutoUpdateConfig,
...autoUpdateConfig,
strategy_setting: autoUpdateConfig?.strategy_setting ?? defaultAutoUpdateConfig.strategy_setting,
exclude_plugins: autoUpdateConfig?.exclude_plugins ?? defaultAutoUpdateConfig.exclude_plugins,
include_plugins: autoUpdateConfig?.include_plugins ?? defaultAutoUpdateConfig.include_plugins,
})
type Props = {
payload: ReferenceSetting
@ -42,9 +28,9 @@ const PluginSettingModal: FC<Props> = ({
onSave,
}) => {
const { t } = useTranslation()
const { auto_upgrade: autoUpdateConfig, permission: privilege } = payload || {}
const { auto_upgrade: autoUpdateConfig, permission: privilege } = payload
const [tempPrivilege, setTempPrivilege] = useState<Permissions>(privilege)
const [tempAutoUpdateConfig, setTempAutoUpdateConfig] = useState<AutoUpdateConfig>(() => getInitialAutoUpdateConfig(autoUpdateConfig))
const [tempAutoUpdateConfig, setTempAutoUpdateConfig] = useState<AutoUpdateConfig>(autoUpdateConfig)
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: s => s.enable_marketplace,

View File

@ -0,0 +1,323 @@
import type { ReactNode } from 'react'
import type { Permissions } from '@/app/components/plugins/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import { PermissionType, PluginCategoryEnum } from '@/app/components/plugins/types'
import { get, post } from '../base'
import {
useMutationPluginAutoUpgradeSettings,
useMutationPluginPermissionSettings,
usePluginAutoUpgradeSettings,
} from '../use-plugins'
vi.mock('../base', () => ({
get: vi.fn(),
getMarketplace: vi.fn(),
post: vi.fn(),
postMarketplace: vi.fn(),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
default: () => ({
refreshPluginList: vi.fn(),
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
}),
}))
vi.mock('../use-tools', () => ({
useInvalidateAllBuiltInTools: () => vi.fn(),
}))
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
const createWrapper = (queryClient: QueryClient) => {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
}
describe('use-plugins mutations', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('optimistically updates category auto-upgrade cache before the request finishes', async () => {
const queryClient = createQueryClient()
const queryKey = ['plugins', 'referenceSettings', 'autoUpgrade', PluginCategoryEnum.model]
const previousAutoUpgrade = {
strategy_setting: AUTO_UPDATE_STRATEGY.latest,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.exclude,
exclude_plugins: [],
include_plugins: [],
}
const nextAutoUpgrade = {
...previousAutoUpgrade,
upgrade_time_of_day: 3600,
}
let resolvePost: (value: unknown) => void = () => {}
vi.mocked(post).mockReturnValue(new Promise((resolve) => {
resolvePost = resolve
}) as ReturnType<typeof post>)
queryClient.setQueryData(queryKey, {
category: PluginCategoryEnum.model,
auto_upgrade: previousAutoUpgrade,
})
const { result } = renderHook(
() => useMutationPluginAutoUpgradeSettings({ category: PluginCategoryEnum.model }),
{ wrapper: createWrapper(queryClient) },
)
act(() => {
result.current.mutate(nextAutoUpgrade)
})
await waitFor(() => {
expect(post).toHaveBeenCalledWith('/workspaces/current/plugin/auto-upgrade/change', {
body: {
category: PluginCategoryEnum.model,
auto_upgrade: nextAutoUpgrade,
},
})
})
await waitFor(() => {
expect(queryClient.getQueryData(queryKey)).toEqual({
category: PluginCategoryEnum.model,
auto_upgrade: nextAutoUpgrade,
})
})
resolvePost({})
})
it('optimistically updates plugin permission cache before the request finishes', async () => {
const queryClient = createQueryClient()
const queryKey = ['plugins', 'referenceSettings', 'permission']
const previousPermission: Permissions = {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
}
const nextPermission: Permissions = {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.admin,
}
let resolvePost: (value: unknown) => void = () => {}
vi.mocked(post).mockReturnValue(new Promise((resolve) => {
resolvePost = resolve
}) as ReturnType<typeof post>)
queryClient.setQueryData(queryKey, previousPermission)
const { result } = renderHook(
() => useMutationPluginPermissionSettings(),
{ wrapper: createWrapper(queryClient) },
)
act(() => {
result.current.mutate(nextPermission)
})
await waitFor(() => {
expect(post).toHaveBeenCalledWith('/workspaces/current/plugin/permission/change', {
body: nextPermission,
})
})
await waitFor(() => {
expect(queryClient.getQueryData(queryKey)).toEqual(nextPermission)
})
resolvePost({})
})
it('rolls back category auto-upgrade cache when the request fails', async () => {
const queryClient = createQueryClient()
const queryKey = ['plugins', 'referenceSettings', 'autoUpgrade', PluginCategoryEnum.model]
const previousAutoUpgrade = {
strategy_setting: AUTO_UPDATE_STRATEGY.latest,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.exclude,
exclude_plugins: [],
include_plugins: [],
}
const nextAutoUpgrade = {
...previousAutoUpgrade,
upgrade_time_of_day: 3600,
}
let rejectPost: (reason?: unknown) => void = () => {}
vi.mocked(post).mockReturnValue(new Promise((_resolve, reject) => {
rejectPost = reject
}) as ReturnType<typeof post>)
queryClient.setQueryData(queryKey, {
category: PluginCategoryEnum.model,
auto_upgrade: previousAutoUpgrade,
})
const { result } = renderHook(
() => useMutationPluginAutoUpgradeSettings({ category: PluginCategoryEnum.model }),
{ wrapper: createWrapper(queryClient) },
)
const mutation = result.current.mutateAsync(nextAutoUpgrade).catch(() => undefined)
await waitFor(() => {
expect(queryClient.getQueryData(queryKey)).toEqual({
category: PluginCategoryEnum.model,
auto_upgrade: nextAutoUpgrade,
})
})
rejectPost(new Error('auto-upgrade update failed'))
await waitFor(() => {
expect(queryClient.getQueryData(queryKey)).toEqual({
category: PluginCategoryEnum.model,
auto_upgrade: previousAutoUpgrade,
})
})
await mutation
})
it('clears optimistic category auto-upgrade cache when the request fails without previous cache', async () => {
const queryClient = createQueryClient()
const queryKey = ['plugins', 'referenceSettings', 'autoUpgrade', PluginCategoryEnum.model]
const nextAutoUpgrade = {
strategy_setting: AUTO_UPDATE_STRATEGY.latest,
upgrade_time_of_day: 3600,
upgrade_mode: AUTO_UPDATE_MODE.exclude,
exclude_plugins: [],
include_plugins: [],
}
let rejectPost: (reason?: unknown) => void = () => {}
vi.mocked(post).mockReturnValue(new Promise((_resolve, reject) => {
rejectPost = reject
}) as ReturnType<typeof post>)
const { result } = renderHook(
() => useMutationPluginAutoUpgradeSettings({ category: PluginCategoryEnum.model }),
{ wrapper: createWrapper(queryClient) },
)
const mutation = result.current.mutateAsync(nextAutoUpgrade).catch(() => undefined)
await waitFor(() => {
expect(queryClient.getQueryData(queryKey)).toEqual({
category: PluginCategoryEnum.model,
auto_upgrade: nextAutoUpgrade,
})
})
rejectPost(new Error('auto-upgrade update failed'))
await waitFor(() => {
expect(queryClient.getQueryData(queryKey)).toBeUndefined()
})
await mutation
})
it('rolls back plugin permission cache when the request fails', async () => {
const queryClient = createQueryClient()
const queryKey = ['plugins', 'referenceSettings', 'permission']
const previousPermission: Permissions = {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
}
const nextPermission: Permissions = {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.admin,
}
let rejectPost: (reason?: unknown) => void = () => {}
vi.mocked(post).mockReturnValue(new Promise((_resolve, reject) => {
rejectPost = reject
}) as ReturnType<typeof post>)
queryClient.setQueryData(queryKey, previousPermission)
const { result } = renderHook(
() => useMutationPluginPermissionSettings(),
{ wrapper: createWrapper(queryClient) },
)
const mutation = result.current.mutateAsync(nextPermission).catch(() => undefined)
await waitFor(() => {
expect(queryClient.getQueryData(queryKey)).toEqual(nextPermission)
})
rejectPost(new Error('permission update failed'))
await waitFor(() => {
expect(queryClient.getQueryData(queryKey)).toEqual(previousPermission)
})
await mutation
})
})
describe('usePluginAutoUpgradeSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not expose frontend default settings before backend data resolves', () => {
const queryClient = createQueryClient()
vi.mocked(get).mockReturnValue(new Promise(() => {}) as ReturnType<typeof get>)
const { result } = renderHook(
() => usePluginAutoUpgradeSettings(PluginCategoryEnum.model),
{ wrapper: createWrapper(queryClient) },
)
expect(result.current.data).toBeUndefined()
expect(get).toHaveBeenCalledWith('/workspaces/current/plugin/auto-upgrade/fetch', {
params: {
category: PluginCategoryEnum.model,
},
})
})
it('returns backend auto-upgrade settings when the request resolves', async () => {
const queryClient = createQueryClient()
const backendAutoUpgrade = {
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.exclude,
exclude_plugins: [],
include_plugins: [],
}
vi.mocked(get).mockResolvedValue({
category: PluginCategoryEnum.tool,
auto_upgrade: backendAutoUpgrade,
})
const { result } = renderHook(
() => usePluginAutoUpgradeSettings(PluginCategoryEnum.tool),
{ wrapper: createWrapper(queryClient) },
)
await waitFor(() => {
expect(result.current.data).toEqual({
category: PluginCategoryEnum.tool,
auto_upgrade: backendAutoUpgrade,
})
})
})
})

View File

@ -4,7 +4,8 @@ import type {
ModelProvider,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type {
} from '@/app/components/plugins/marketplace/types'
AutoUpdateConfig,
} from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import type {
DebugInfo as DebugInfoTypes,
Dependency,
@ -13,6 +14,7 @@ import type {
InstallPackageResponse,
InstallStatusResponse,
PackageDependency,
Permissions,
Plugin,
PluginDeclaration,
PluginInfoFromMarketPlace,
@ -31,11 +33,11 @@ import {
useQueryClient,
} from '@tanstack/react-query'
import { cloneDeep } from 'es-toolkit/object'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
import useReferenceSetting from '@/app/components/plugins/plugin-page/use-reference-setting'
import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types'
import { PermissionType, PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types'
import { useAppContext } from '@/context/app-context'
import { fetchModelProviderModelList } from '@/service/common'
import { fetchPluginInfoFromMarketPlace, uninstallPlugin } from '@/service/plugins'
import { get, getMarketplace, post, postMarketplace } from './base'
@ -431,14 +433,88 @@ export const useDebugKey = () => {
})
}
type PluginAutoUpgradeSettingsResponse = {
category: PluginCategoryEnum
auto_upgrade: AutoUpdateConfig
}
type MarketplacePluginInfoRequest = {
organization?: string
plugin?: string
version?: string
}
const useReferenceSettingKey = [NAME_SPACE, 'referenceSettings']
export const useReferenceSettings = () => {
const usePluginPermissionSettingsKey = [...useReferenceSettingKey, 'permission']
const usePluginAutoUpgradeSettingsKey = [...useReferenceSettingKey, 'autoUpgrade']
const pluginAutoUpgradeSettingsQueryKey = (category: PluginCategoryEnum) => [...usePluginAutoUpgradeSettingsKey, category]
const areStringArraysEqual = (left: string[], right: string[]) => {
return left.length === right.length && left.every((value, index) => value === right[index])
}
const arePermissionsEqual = (left: Permissions | undefined, right: Permissions | undefined) => {
return left?.install_permission === right?.install_permission
&& left?.debug_permission === right?.debug_permission
}
const areAutoUpgradeSettingsEqual = (left: AutoUpdateConfig | undefined, right: AutoUpdateConfig | undefined) => {
return left?.strategy_setting === right?.strategy_setting
&& left?.upgrade_time_of_day === right?.upgrade_time_of_day
&& left?.upgrade_mode === right?.upgrade_mode
&& areStringArraysEqual(left?.exclude_plugins ?? [], right?.exclude_plugins ?? [])
&& areStringArraysEqual(left?.include_plugins ?? [], right?.include_plugins ?? [])
}
export const hasPluginPermission = (permission: PermissionType | undefined, isAdmin: boolean) => {
if (!permission)
return false
if (permission === PermissionType.noOne)
return false
if (permission === PermissionType.everyone)
return true
return isAdmin
}
export const usePluginPermissionSettings = () => {
return useQuery({
queryKey: useReferenceSettingKey,
queryFn: () => get<ReferenceSetting>('/workspaces/current/plugin/preferences/fetch'),
queryKey: usePluginPermissionSettingsKey,
queryFn: () => get<Permissions>('/workspaces/current/plugin/permission/fetch'),
})
}
export const usePluginAutoUpgradeSettings = (category: PluginCategoryEnum) => {
return useQuery({
queryKey: pluginAutoUpgradeSettingsQueryKey(category),
queryFn: () => get<PluginAutoUpgradeSettingsResponse>(
'/workspaces/current/plugin/auto-upgrade/fetch',
{ params: { category } },
),
staleTime: 60 * 1000,
})
}
export const useReferenceSettings = (category: PluginCategoryEnum) => {
const permissionQuery = usePluginPermissionSettings()
const autoUpgradeQuery = usePluginAutoUpgradeSettings(category)
return {
...autoUpgradeQuery,
data: permissionQuery.data && autoUpgradeQuery.data
? {
permission: permissionQuery.data,
auto_upgrade: autoUpgradeQuery.data.auto_upgrade,
} satisfies ReferenceSetting
: undefined,
error: permissionQuery.error ?? autoUpgradeQuery.error,
isLoading: permissionQuery.isLoading || autoUpgradeQuery.isLoading,
isFetching: permissionQuery.isFetching || autoUpgradeQuery.isFetching,
}
}
export const useInvalidateReferenceSettings = () => {
const queryClient = useQueryClient()
return () => {
@ -450,23 +526,183 @@ export const useInvalidateReferenceSettings = () => {
}
}
export const useMutationReferenceSettings = ({
export const useInvalidatePluginAutoUpgradeSettings = () => {
const queryClient = useQueryClient()
return (category: PluginCategoryEnum) => {
queryClient.invalidateQueries(
{
queryKey: pluginAutoUpgradeSettingsQueryKey(category),
},
)
}
}
export const useMutationPluginPermissionSettings = ({
onSuccess,
}: {
onSuccess?: () => void
} = {}) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: Permissions) => {
return post('/workspaces/current/plugin/permission/change', { body: payload })
},
onMutate: async (payload) => {
await queryClient.cancelQueries({ queryKey: usePluginPermissionSettingsKey })
const previousPermission = queryClient.getQueryData<Permissions>(usePluginPermissionSettingsKey)
const hadPreviousPermission = previousPermission !== undefined
queryClient.setQueryData(usePluginPermissionSettingsKey, payload)
return { previousPermission, hadPreviousPermission }
},
onError: (_error, _payload, context) => {
if (context?.hadPreviousPermission)
queryClient.setQueryData(usePluginPermissionSettingsKey, context.previousPermission)
else
queryClient.removeQueries({ queryKey: usePluginPermissionSettingsKey })
},
onSuccess: () => {
onSuccess?.()
},
})
}
export const useMutationPluginAutoUpgradeSettings = ({
category,
onSuccess,
}: {
category: PluginCategoryEnum
onSuccess?: () => void
}) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: AutoUpdateConfig) => {
return post('/workspaces/current/plugin/auto-upgrade/change', {
body: {
category,
auto_upgrade: payload,
},
})
},
onMutate: async (payload) => {
const queryKey = pluginAutoUpgradeSettingsQueryKey(category)
await queryClient.cancelQueries({ queryKey })
const previousAutoUpgrade = queryClient.getQueryData<PluginAutoUpgradeSettingsResponse>(queryKey)
const hadPreviousAutoUpgrade = previousAutoUpgrade !== undefined
queryClient.setQueryData(pluginAutoUpgradeSettingsQueryKey(category), {
category,
auto_upgrade: payload,
} satisfies PluginAutoUpgradeSettingsResponse)
return { previousAutoUpgrade, hadPreviousAutoUpgrade }
},
onError: (_error, _payload, context) => {
if (context?.hadPreviousAutoUpgrade)
queryClient.setQueryData(pluginAutoUpgradeSettingsQueryKey(category), context.previousAutoUpgrade)
else
queryClient.removeQueries({ queryKey: pluginAutoUpgradeSettingsQueryKey(category) })
},
onSuccess: () => {
onSuccess?.()
},
})
}
export const useMutationReferenceSettings = ({
category,
currentReferenceSetting,
onSuccess,
}: {
category: PluginCategoryEnum
currentReferenceSetting?: ReferenceSetting
onSuccess?: () => void
}) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: ReferenceSetting) => {
return post('/workspaces/current/plugin/preferences/change', { body: payload })
const mutations: Array<Promise<unknown>> = []
if (!arePermissionsEqual(payload.permission, currentReferenceSetting?.permission))
mutations.push(post('/workspaces/current/plugin/permission/change', { body: payload.permission }))
if (!areAutoUpgradeSettingsEqual(payload.auto_upgrade, currentReferenceSetting?.auto_upgrade)) {
mutations.push(post('/workspaces/current/plugin/auto-upgrade/change', {
body: {
category,
auto_upgrade: payload.auto_upgrade,
},
}))
}
return Promise.all(mutations)
},
onMutate: async (payload) => {
const shouldUpdatePermission = !arePermissionsEqual(payload.permission, currentReferenceSetting?.permission)
const shouldUpdateAutoUpgrade = !areAutoUpgradeSettingsEqual(payload.auto_upgrade, currentReferenceSetting?.auto_upgrade)
const autoUpgradeQueryKey = pluginAutoUpgradeSettingsQueryKey(category)
await Promise.all([
shouldUpdatePermission ? queryClient.cancelQueries({ queryKey: usePluginPermissionSettingsKey }) : Promise.resolve(),
shouldUpdateAutoUpgrade ? queryClient.cancelQueries({ queryKey: autoUpgradeQueryKey }) : Promise.resolve(),
])
const previousPermission = queryClient.getQueryData<Permissions>(usePluginPermissionSettingsKey)
const previousAutoUpgrade = queryClient.getQueryData<PluginAutoUpgradeSettingsResponse>(autoUpgradeQueryKey)
const hadPreviousPermission = previousPermission !== undefined
const hadPreviousAutoUpgrade = previousAutoUpgrade !== undefined
if (shouldUpdatePermission)
queryClient.setQueryData(usePluginPermissionSettingsKey, payload.permission)
if (shouldUpdateAutoUpgrade) {
queryClient.setQueryData(autoUpgradeQueryKey, {
category,
auto_upgrade: payload.auto_upgrade,
} satisfies PluginAutoUpgradeSettingsResponse)
}
return {
previousPermission,
previousAutoUpgrade,
hadPreviousPermission,
hadPreviousAutoUpgrade,
shouldUpdatePermission,
shouldUpdateAutoUpgrade,
}
},
onError: (_error, _payload, context) => {
if (context?.shouldUpdatePermission && context.hadPreviousPermission)
queryClient.setQueryData(usePluginPermissionSettingsKey, context.previousPermission)
else if (context?.shouldUpdatePermission)
queryClient.removeQueries({ queryKey: usePluginPermissionSettingsKey })
if (context?.shouldUpdateAutoUpgrade && context.hadPreviousAutoUpgrade)
queryClient.setQueryData(pluginAutoUpgradeSettingsQueryKey(category), context.previousAutoUpgrade)
else if (context?.shouldUpdateAutoUpgrade)
queryClient.removeQueries({ queryKey: pluginAutoUpgradeSettingsQueryKey(category) })
},
onSuccess,
})
}
export const useRemoveAutoUpgrade = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: { plugin_id: string }) => {
return post('/workspaces/current/plugin/preferences/autoupgrade/exclude', { body: payload })
mutationFn: (payload: { plugin_id: string, category: PluginCategoryEnum }) => {
return post('/workspaces/current/plugin/auto-upgrade/exclude', { body: payload })
},
onSuccess: (_data, payload) => {
queryClient.invalidateQueries(
{
queryKey: pluginAutoUpgradeSettingsQueryKey(payload.category),
},
)
},
})
}
@ -485,7 +721,7 @@ export const useFetchPluginsInMarketPlaceByIds = (unique_identifiers: string[],
})
}
export const useFetchPluginsInMarketPlaceByInfo = (infos: Record<string, any>[]) => {
export const useFetchPluginsInMarketPlaceByInfo = (infos: MarketplacePluginInfoRequest[]) => {
return useQuery({
queryKey: [NAME_SPACE, 'fetchPluginsInMarketPlaceByInfo', infos],
queryFn: () => postMarketplace<{ data: PluginsFromMarketplaceByInfoResponse }>('/plugins/versions/batch', {
@ -504,18 +740,15 @@ export const useFetchPluginsInMarketPlaceByInfo = (infos: Record<string, any>[])
const usePluginTaskListKey = [NAME_SPACE, 'pluginTaskList']
export const usePluginTaskList = (category?: PluginCategoryEnum | string) => {
const [initialized, setInitialized] = useState(false)
const {
canManagement,
} = useReferenceSetting()
const initializedRef = useRef(false)
const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner } = useAppContext()
const { data: permissions } = usePluginPermissionSettings()
const canManagement = hasPluginPermission(
permissions?.install_permission,
isCurrentWorkspaceManager || isCurrentWorkspaceOwner,
)
const { refreshPluginList } = useRefreshPluginList()
const {
data,
isFetched,
isRefetching,
refetch,
...rest
} = useQuery({
const query = useQuery({
enabled: canManagement,
queryKey: usePluginTaskListKey,
queryFn: () => get<{ tasks: PluginTask[] }>('/workspaces/current/plugin/tasks?page=1&page_size=100'),
@ -525,23 +758,25 @@ export const usePluginTaskList = (category?: PluginCategoryEnum | string) => {
return taskDone ? false : 5000
},
})
const { data, isFetched, isRefetching, refetch } = query
useEffect(() => {
// After first fetch, refresh plugin list each time all tasks are done
// Skip initialization period, because the query cache is not updated yet
if (!initialized || isRefetching)
if (!initializedRef.current) {
initializedRef.current = true
return
}
if (isRefetching)
return
const lastData = cloneDeep(data)
const taskDone = lastData?.tasks.every(task => task.status === TaskStatus.success || task.status === TaskStatus.failed)
const taskAllFailed = lastData?.tasks.every(task => task.status === TaskStatus.failed)
if (taskDone && lastData?.tasks.length && !taskAllFailed)
refreshPluginList(category ? { category } as any : undefined, !category)
}, [isRefetching])
useEffect(() => {
setInitialized(true)
}, [])
refreshPluginList(category ? { category } : undefined, !category)
}, [category, data, isRefetching, refreshPluginList])
const handleRefetch = useCallback(() => {
refetch()
@ -552,7 +787,6 @@ export const usePluginTaskList = (category?: PluginCategoryEnum | string) => {
pluginTasks: data?.tasks || [],
isFetched,
handleRefetch,
...rest,
}
}
@ -631,7 +865,7 @@ export const usePluginInfo = (providerName?: string) => {
})
}
export const useFetchDynamicOptions = (plugin_id: string, provider: string, action: string, parameter: string, provider_type?: string, extra?: Record<string, any>) => {
export const useFetchDynamicOptions = (plugin_id: string, provider: string, action: string, parameter: string, provider_type?: string, extra?: Record<string, unknown>) => {
return useMutation({
mutationFn: () => get<{ options: FormOption[] }>('/workspaces/current/plugin/parameters/dynamic-options', {
params: {
@ -656,10 +890,11 @@ export const usePluginReadme = ({ plugin_unique_identifier, language }: { plugin
}
export const usePluginReadmeAsset = ({ file_name, plugin_unique_identifier }: { file_name?: string, plugin_unique_identifier?: string }) => {
const normalizedFileName = file_name?.replace(/(^\.\/_assets\/|^_assets\/)/, '')
const normalizedFileName = file_name?.replace(/^\.\/_assets\//, '').replace(/^_assets\//, '')
const isAssetFile = file_name?.startsWith('./_assets') || file_name?.startsWith('_assets')
return useQuery({
queryKey: ['pluginReadmeAsset', plugin_unique_identifier, normalizedFileName],
queryFn: () => get<Blob>('/workspaces/current/plugin/asset', { params: { plugin_unique_identifier, file_name: normalizedFileName } }, { silent: true }),
enabled: !!plugin_unique_identifier && !!file_name && /(^\.\/_assets|^_assets)/.test(file_name),
enabled: !!plugin_unique_identifier && !!isAssetFile,
})
}