diff --git a/web/app/components/header/account-setting/update-setting-popover.tsx b/web/app/components/header/account-setting/update-setting-popover.tsx index 506cee54ad..4c5255e9f2 100644 --- a/web/app/components/header/account-setting/update-setting-popover.tsx +++ b/web/app/components/header/account-setting/update-setting-popover.tsx @@ -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 = { @@ -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 = ({ ariaLabel, + className, options, value, onChange, }: { ariaLabel: string + className?: string options: Array> value: T onChange: (value: T) => void }) => { return ( -
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 => ( - + ))} -
+ ) } 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() + 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>>(() => [ { value: AUTO_UPDATE_STRATEGY.disabled, @@ -161,18 +174,22 @@ const UpdateSettingPopover = ({ ], [t]) const updateAutoUpgrade = useCallback((payload: Partial) => { - 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 ( ) }, [timezone]) return ( - + {t('modelProvider.updateSetting', { ns: 'common' })} - - {selectedStrategyLabel} - + {selectedStrategyLabel && ( + + {selectedStrategyLabel} + + )} )} /> - -
-
-
-
- {t('autoUpdate.automaticUpdates', { ns: 'plugin' })} -
- updateAutoUpgrade({ strategy_setting })} - /> + {!disabled && ( + + {isSettingsLoading && ( +
+ + {t('loading', { ns: 'common' })}
-
- {autoUpgrade.strategy_setting !== AUTO_UPDATE_STRATEGY.disabled && ( + )} + {!isSettingsLoading && !hasSettings && ( +
+ {t('api.actionFailed', { ns: 'common' })} +
+ )} + {!isSettingsLoading && hasSettings && autoUpgrade && ( <> -
-
-
- {t('autoUpdate.updateTime', { ns: 'plugin' })} -
-
- updateAutoUpgrade({ - upgrade_time_of_day: convertLocalSecondsToUTCDaySeconds(0, timezone), - })} - title={t('autoUpdate.updateTime', { ns: 'plugin' })} - minuteFilter={minuteFilter} - renderTrigger={renderTimePickerTrigger} - placement="bottom-end" - /> -
- , - }} - /> +
+
+
+
+ {t('autoUpdate.automaticUpdates', { ns: 'plugin' })}
-
-
-
-
-
-
- {t('autoUpdate.scope', { ns: 'plugin' })} -
- updateAutoUpgrade({ upgrade_mode })} - /> - {autoUpgrade.upgrade_mode !== AUTO_UPDATE_MODE.update_all && ( - updateAutoUpgrade({ strategy_setting })} /> - )} +
+ {autoUpgrade.strategy_setting !== AUTO_UPDATE_STRATEGY.disabled && ( + <> +
+
+
+ {t('autoUpdate.updateTime', { ns: 'plugin' })} +
+
+ updateAutoUpgrade({ + upgrade_time_of_day: convertLocalSecondsToUTCDaySeconds(0, timezone), + })} + title={t('autoUpdate.updateTime', { ns: 'plugin' })} + minuteFilter={minuteFilter} + renderTrigger={renderTimePickerTrigger} + placement="bottom-end" + /> +
+ , + }} + /> +
+
+
+
+
+
+
+ {t('autoUpdate.scope', { ns: 'plugin' })} +
+ updateAutoUpgrade({ upgrade_mode })} + /> + {autoUpgrade.upgrade_mode !== AUTO_UPDATE_MODE.update_all && ( + + )} +
+
+ + )} +
+
+ +
)} -
- + + )} ) } diff --git a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx index d5e1399457..083503211e 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list.tsx @@ -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() diff --git a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts index f195be3e78..639cf41179 100644 --- a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts +++ b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts @@ -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) - 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) + } as unknown as ReturnType) + + vi.mocked(usePluginPermissionSettings).mockReturnValue({ + data: { + install_permission: PermissionType.everyone, + debug_permission: PermissionType.everyone, + }, + } as ReturnType) vi.mocked(useMutationReferenceSettings).mockReturnValue({ mutate: vi.fn(), isPending: false, } as unknown as ReturnType) + vi.mocked(useMutationPluginPermissionSettings).mockReturnValue({ + mutate: vi.fn(), + isPending: false, + } as unknown as ReturnType) + 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) + } as unknown as ReturnType) - 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) + } as ReturnType) - 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) + } as ReturnType) - 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) - 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) + } as ReturnType) - 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) - 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) + } as ReturnType) - 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) - 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) + } as ReturnType) - 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) - 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) - 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) - 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 }) - 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) + vi.mocked(usePluginAutoUpgradeSettings).mockReturnValue({ + data: { + category: PluginCategoryEnum.tool, + auto_upgrade: mockData.auto_upgrade, + }, + } as unknown as ReturnType) - 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) - 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) + it('should keep permissions available when reference setting data is still loading', () => { + vi.mocked(usePluginAutoUpgradeSettings).mockReturnValue({ + data: undefined, + } as unknown as ReturnType) - 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) - 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) + } as ReturnType) 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) + } as ReturnType) 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) + } as ReturnType) 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() + }) }) diff --git a/web/app/components/plugins/plugin-page/use-reference-setting.ts b/web/app/components/plugins/plugin-page/use-reference-setting.ts index 7c62c1879a..6e0f22fdad 100644 --- a/web/app/components/plugins/plugin-page/use-reference-setting.ts +++ b/web/app/components/plugins/plugin-page/use-reference-setting.ts @@ -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 diff --git a/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx index 707b5fda41..22060d4431 100644 --- a/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx @@ -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() - - // 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() - expect(screen.getByText('plugin.privilege.title'))!.toBeInTheDocument() - }) - it('should handle undefined permission values', () => { // Arrange const payload = { diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts deleted file mode 100644 index 36450a4386..0000000000 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts +++ /dev/null @@ -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: [], - }) - }) -}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx index 0e13be6d48..c9a7ab8357 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx @@ -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) // ============================================================ diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx index a9ffe76ae6..5a741a9cd7 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx @@ -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( { 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( + , + ) + + 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']) + }) }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx index 176598158c..a84059ef49 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx @@ -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( + trigger} + 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: [ diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/config.ts b/web/app/components/plugins/reference-setting-modal/auto-update-setting/config.ts deleted file mode 100644 index b9b6dd7e9d..0000000000 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/config.ts +++ /dev/null @@ -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: [], -} diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-plugin-selected.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-plugin-selected.tsx index ccd0423fc3..307c1d50a2 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-plugin-selected.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/no-plugin-selected.tsx @@ -14,7 +14,7 @@ const NoPluginSelected: FC = ({ const { t } = useTranslation() const text = `${t(`autoUpdate.upgradeModePlaceholder.${updateMode === AUTO_UPDATE_MODE.partial ? 'partial' : 'exclude'}`, { ns: 'plugin' })}` return ( -
+
{text}
) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx index ef25c35592..29586e9f38 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker.tsx @@ -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 = ({ 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 ( -
+
{hasSelected ? ( -
-
{t(`${i18nPrefix}.${isExcludeMode ? 'excludeUpdate' : 'partialUPdate'}`, { ns: 'plugin', num: value.length })}
+
+
{t(`${i18nPrefix}.${isExcludeMode ? 'excludeUpdate' : 'partialUPdate'}`, { ns: 'plugin', num: visiblePlugins.length })}
)} - value={value} - onChange={onChange} + value={visiblePlugins} + onChange={handleVisiblePluginsChange} isShow={isShowToolPicker} onShowChange={setToolPicker} + integrationCategory={integrationCategory} />
) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-selected.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-selected.tsx index cdb07b8874..83e6679676 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-selected.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-selected.tsx @@ -18,7 +18,7 @@ const PluginsSelected: FC = ({ const isShowAll = plugins.length < MAX_DISPLAY_COUNT const displayPlugins = plugins.slice(0, MAX_DISPLAY_COUNT) return ( -
+
{displayPlugins.map(plugin => ( ))} diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-item.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-item.tsx index b4dddcf818..bd60c0426e 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-item.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-item.tsx @@ -26,12 +26,12 @@ const ToolItem: FC = ({ return (
-
+
-
{renderI18nObject(label, language)}
-
{org}
+
{renderI18nObject(label, language)}
+
{org}
void isShow: boolean onShowChange: (isShow: boolean) => void + integrationCategory?: PluginCategoryEnum } const ToolPicker: FC = ({ @@ -32,10 +34,11 @@ const ToolPicker: FC = ({ 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 = ({ { 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(PLUGIN_TYPE_SEARCH_MAP.all) + const effectivePluginType = integrationCategory ?? pluginType const [query, setQuery] = useState('') const [tags, setTags] = useState([]) const { data, isLoading } = useInstalledPluginList() @@ -55,12 +62,12 @@ const ToolPicker: FC = ({ 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 = ({ sideOffset={0} popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > -
+
= ({ inputClassName="w-full" />
-
-
+
+
{tabs.map(tab => (
setPluginType(tab.key)} diff --git a/web/app/components/plugins/reference-setting-modal/index.tsx b/web/app/components/plugins/reference-setting-modal/index.tsx index 18a08c4073..684fb4ac00 100644 --- a/web/app/components/plugins/reference-setting-modal/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/index.tsx @@ -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 = ({ onSave, }) => { const { t } = useTranslation() - const { auto_upgrade: autoUpdateConfig, permission: privilege } = payload || {} + const { auto_upgrade: autoUpdateConfig, permission: privilege } = payload const [tempPrivilege, setTempPrivilege] = useState(privilege) - const [tempAutoUpdateConfig, setTempAutoUpdateConfig] = useState(() => getInitialAutoUpdateConfig(autoUpdateConfig)) + const [tempAutoUpdateConfig, setTempAutoUpdateConfig] = useState(autoUpdateConfig) const { data: enable_marketplace } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), select: s => s.enable_marketplace, diff --git a/web/service/__tests__/use-plugins.spec.tsx b/web/service/__tests__/use-plugins.spec.tsx new file mode 100644 index 0000000000..ee9aaeedd6 --- /dev/null +++ b/web/service/__tests__/use-plugins.spec.tsx @@ -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 ( + + {children} + + ) + } +} + +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) + 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) + 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) + 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) + + 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) + 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) + + 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, + }) + }) + }) +}) diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index a80a4d084e..7ae402e764 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -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('/workspaces/current/plugin/preferences/fetch'), + queryKey: usePluginPermissionSettingsKey, + queryFn: () => get('/workspaces/current/plugin/permission/fetch'), }) } +export const usePluginAutoUpgradeSettings = (category: PluginCategoryEnum) => { + return useQuery({ + queryKey: pluginAutoUpgradeSettingsQueryKey(category), + queryFn: () => get( + '/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(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(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> = [] + + 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(usePluginPermissionSettingsKey) + const previousAutoUpgrade = queryClient.getQueryData(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[]) => { +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[]) 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) => { +export const useFetchDynamicOptions = (plugin_id: string, provider: string, action: string, parameter: string, provider_type?: string, extra?: Record) => { 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('/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, }) }