feat: plugin uninstall & plugin list filtering

This commit is contained in:
twwu 2024-10-31 16:20:25 +08:00
parent 4adb61d6c7
commit 36ab121b87
9 changed files with 186 additions and 66 deletions

View File

@ -2,6 +2,7 @@ import type { FC } from 'react'
import { useEffect, useState } from 'react'
import cn from '@/utils/classnames'
import Badge, { BadgeState } from '@/app/components/base/badge/index'
import { usePluginPageContext } from '../../plugins/plugin-page/context'
type Option = {
value: string
text: string
@ -22,6 +23,7 @@ const TabSlider: FC<TabSliderProps> = ({
}) => {
const [activeIndex, setActiveIndex] = useState(options.findIndex(option => option.value === value))
const [sliderStyle, setSliderStyle] = useState({})
const pluginList = usePluginPageContext(v => v.installedPluginList)
const updateSliderStyle = (index: number) => {
const tabElement = document.getElementById(`tab-${index}`)
@ -71,7 +73,7 @@ const TabSlider: FC<TabSliderProps> = ({
uppercase={true}
state={BadgeState.Default}
>
6
{pluginList.length}
</Badge>
}
</div>

View File

@ -9,6 +9,7 @@ import Install from './steps/install'
import Installed from '../base/installed'
import { useTranslation } from 'react-i18next'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import { usePluginPageContext } from '../../plugin-page/context'
const i18nPrefix = 'plugin.installModal'
@ -28,6 +29,8 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
const [uniqueIdentifier, setUniqueIdentifier] = useState<string | null>(null)
const [manifest, setManifest] = useState<PluginDeclaration | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const mutateInstalledPluginList = usePluginPageContext(v => v.mutateInstalledPluginList)
const getTitle = useCallback(() => {
if (step === InstallStep.uploadFailed)
return t(`${i18nPrefix}.uploadFailed`)
@ -63,9 +66,10 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
setStep(InstallStep.uploadFailed)
}, [])
const handleInstalled = useCallback(async () => {
const handleInstalled = useCallback(() => {
mutateInstalledPluginList()
setStep(InstallStep.installed)
}, [])
}, [mutateInstalledPluginList])
const handleFailed = useCallback((errorMsg?: string) => {
setStep(InstallStep.installFailed)

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useRouter } from 'next/navigation'
import React, { useCallback } from 'react'
import type { MetaData } from '../types'
import { RiDeleteBinLine, RiInformation2Line, RiLoopLeftLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
@ -9,6 +9,8 @@ import PluginInfo from '../plugin-page/plugin-info'
import ActionButton from '../../base/action-button'
import Tooltip from '../../base/tooltip'
import Confirm from '../../base/confirm'
import { uninstallPlugin } from '@/service/plugins'
import { usePluginPageContext } from '../plugin-page/context'
const i18nPrefix = 'plugin.action'
@ -20,22 +22,23 @@ type Props = {
isShowInfo: boolean
isShowDelete: boolean
onDelete: () => void
meta: MetaData
}
const Action: FC<Props> = ({
pluginId,
pluginName,
usedInApps,
isShowFetchNewVersion,
isShowInfo,
isShowDelete,
onDelete,
meta,
}) => {
const { t } = useTranslation()
const router = useRouter()
const [isShowPluginInfo, {
setTrue: showPluginInfo,
setFalse: hidePluginInfo,
}] = useBoolean(false)
const mutateInstalledPluginList = usePluginPageContext(v => v.mutateInstalledPluginList)
const handleFetchNewVersion = () => { }
@ -44,7 +47,14 @@ const Action: FC<Props> = ({
setFalse: hideDeleteConfirm,
}] = useBoolean(false)
// const handleDelete = () => { }
const handleDelete = useCallback(async () => {
const res = await uninstallPlugin(pluginId)
if (res.success) {
hideDeleteConfirm()
mutateInstalledPluginList()
onDelete()
}
}, [pluginId, onDelete])
return (
<div className='flex space-x-1'>
{/* Only plugin installed from GitHub need to check if it's the new version */}
@ -83,9 +93,9 @@ const Action: FC<Props> = ({
{isShowPluginInfo && (
<PluginInfo
repository='https://github.com/langgenius/dify-github-plugin'
release='1.2.5'
packageName='notion-sync.difypkg'
repository={meta.repo}
release={meta.version}
packageName={meta.package}
onHide={hidePluginInfo}
/>
)}
@ -97,11 +107,12 @@ const Action: FC<Props> = ({
content={
<div>
{t(`${i18nPrefix}.deleteContentLeft`)}<span className='system-md-semibold'>{pluginName}</span>{t(`${i18nPrefix}.deleteContentRight`)}<br />
{usedInApps > 0 && t(`${i18nPrefix}.usedInApps`, { num: usedInApps })}
{/* // todo: add usedInApps */}
{/* {usedInApps > 0 && t(`${i18nPrefix}.usedInApps`, { num: usedInApps })} */}
</div>
}
onCancel={hideDeleteConfirm}
onConfirm={onDelete}
onConfirm={handleDelete}
/>
)
}

View File

@ -1,67 +1,94 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import React, { useMemo } from 'react'
import { useContext } from 'use-context-selector'
import { RiArrowRightUpLine, RiBugLine, RiHardDrive3Line, RiLoginCircleLine, RiVerifiedBadgeLine } from '@remixicon/react'
import {
RiArrowRightUpLine,
RiBugLine,
RiHardDrive3Line,
RiLoginCircleLine,
RiVerifiedBadgeLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Github } from '../../base/icons/src/public/common'
import Badge from '../../base/badge'
import type { Plugin } from '../types'
import { type InstalledPlugin, PluginSource } from '../types'
import CornerMark from '../card/base/corner-mark'
import Description from '../card/base/description'
import Icon from '../card/base/card-icon'
import OrgInfo from '../card/base/org-info'
import Title from '../card/base/title'
import Action from './action'
import cn from '@/utils/classnames'
import I18n from '@/context/i18n'
import { API_PREFIX } from '@/config'
type Props = {
className?: string
payload: Plugin
source: 'github' | 'marketplace' | 'local' | 'debug'
onDelete: () => void
plugin: InstalledPlugin
}
const PluginItem: FC<Props> = ({
className,
payload,
source,
onDelete,
plugin,
}) => {
const { locale } = useContext(I18n)
const { t } = useTranslation()
const { type, name, org, label } = payload
const hasNewVersion = payload.latest_version !== payload.version
const {
source,
tenant_id,
installation_id,
endpoints_active,
meta,
version,
latest_version,
} = plugin
const { category, author, name, label, description, icon, verified } = plugin.declaration
// Only plugin installed from GitHub need to check if it's the new version
const hasNewVersion = useMemo(() => {
return source === PluginSource.github && latest_version !== version
}, [source, latest_version, version])
const orgName = useMemo(() => {
return [PluginSource.github, PluginSource.marketplace].includes(source) ? author : ''
}, [source, author])
const tLocale = useMemo(() => {
return locale.replace('-', '_')
}, [locale])
return (
<div className={`p-1 ${source === 'debug'
<div className={`p-1 ${source === PluginSource.debugging
? 'bg-[repeating-linear-gradient(-45deg,rgba(16,24,40,0.04),rgba(16,24,40,0.04)_5px,rgba(0,0,0,0.02)_5px,rgba(0,0,0,0.02)_10px)]'
: 'bg-background-section-burn'}
rounded-xl`}
>
<div className={cn('relative p-4 pb-3 border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg hover-bg-components-panel-on-panel-item-bg rounded-xl shadow-xs', className)}>
<CornerMark text={type} />
<CornerMark text={category} />
{/* Header */}
<div className="flex">
<Icon src={payload.icon} />
<div className='flex items-center justify-center w-10 h-10 overflow-hidden border-components-panel-border-subtle border-[1px] rounded-xl'>
<img
src={`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${icon}`}
alt={`plugin-${installation_id}-logo`}
/>
</div>
<div className="ml-3 w-0 grow">
<div className="flex items-center h-5">
<Title title={label[locale]} />
<RiVerifiedBadgeLine className="shrink-0 ml-0.5 w-4 h-4 text-text-accent" />
<Badge className='ml-1' text={payload.version} hasRedCornerMark={hasNewVersion} />
<Title title={label[tLocale]} />
{verified && <RiVerifiedBadgeLine className="shrink-0 ml-0.5 w-4 h-4 text-text-accent" />}
<Badge className='ml-1' text={plugin.version} hasRedCornerMark={hasNewVersion} />
</div>
<div className='flex items-center justify-between'>
<Description text={payload.brief[locale]} descriptionLineRows={1}></Description>
<Description text={description[tLocale]} descriptionLineRows={1}></Description>
<Action
pluginId='xxx'
pluginName={label[locale]}
pluginId={installation_id}
pluginName={label[tLocale]}
usedInApps={5}
isShowFetchNewVersion={hasNewVersion}
isShowInfo
isShowInfo={source === PluginSource.github}
isShowDelete
onDelete={onDelete}
meta={meta}
onDelete={() => {}}
/>
</div>
</div>
@ -71,19 +98,19 @@ const PluginItem: FC<Props> = ({
<div className='flex items-center'>
<OrgInfo
className="mt-0.5"
orgName={org}
orgName={orgName}
packageName={name}
packageNameClassName='w-auto max-w-[150px]'
/>
<div className='mx-2 text-text-quaternary system-xs-regular'>·</div>
<div className='flex text-text-tertiary system-xs-regular space-x-1'>
<RiLoginCircleLine className='w-4 h-4' />
<span>{t('plugin.endpointsEnabled', { num: 2 })}</span>
<span>{t('plugin.endpointsEnabled', { num: endpoints_active })}</span>
</div>
</div>
<div className='flex items-center'>
{source === 'github'
{source === PluginSource.github
&& <>
<a href='' target='_blank' className='flex items-center gap-1'>
<div className='text-text-tertiary system-2xs-medium-uppercase'>{t('plugin.from')}</div>
@ -95,7 +122,7 @@ const PluginItem: FC<Props> = ({
</a>
</>
}
{source === 'marketplace'
{source === PluginSource.marketplace
&& <>
<a href='' target='_blank' className='flex items-center gap-0.5'>
<div className='text-text-tertiary system-2xs-medium-uppercase'>{t('plugin.from')} <span className='text-text-secondary'>marketplace</span></div>
@ -103,7 +130,7 @@ const PluginItem: FC<Props> = ({
</a>
</>
}
{source === 'local'
{source === PluginSource.local
&& <>
<div className='flex items-center gap-1'>
<RiHardDrive3Line className='text-text-tertiary w-3 h-3' />
@ -111,7 +138,7 @@ const PluginItem: FC<Props> = ({
</div>
</>
}
{source === 'debug'
{source === PluginSource.debugging
&& <>
<div className='flex items-center gap-1'>
<RiBugLine className='w-3 h-3 text-text-warning' />

View File

@ -9,14 +9,20 @@ import {
createContext,
useContextSelector,
} from 'use-context-selector'
import type { Permissions } from '../types'
import type { InstalledPlugin, Permissions } from '../types'
import type { FilterState } from './filter-management'
import { PermissionType } from '../types'
import { fetchInstalledPluginList } from '@/service/plugins'
import useSWR from 'swr'
export type PluginPageContextValue = {
containerRef: React.RefObject<HTMLDivElement>
permissions: Permissions
setPermissions: (permissions: PluginPageContextValue['permissions']) => void
installedPluginList: InstalledPlugin[]
mutateInstalledPluginList: () => void
filters: FilterState
setFilters: (filter: FilterState) => void
}
export const PluginPageContext = createContext<PluginPageContextValue>({
@ -26,6 +32,14 @@ export const PluginPageContext = createContext<PluginPageContextValue>({
debug_permission: PermissionType.noOne,
},
setPermissions: () => { },
installedPluginList: [],
mutateInstalledPluginList: () => {},
filters: {
categories: [],
tags: [],
searchQuery: '',
},
setFilters: () => {},
})
type PluginPageContextProviderProps = {
@ -44,6 +58,12 @@ export const PluginPageContextProvider = ({
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
})
const [filters, setFilters] = useState<FilterState>({
categories: [],
tags: [],
searchQuery: '',
})
const { data, mutate: mutateInstalledPluginList } = useSWR({ url: '/workspaces/current/plugin/list' }, fetchInstalledPluginList)
return (
<PluginPageContext.Provider
@ -51,6 +71,10 @@ export const PluginPageContextProvider = ({
containerRef,
permissions,
setPermissions,
installedPluginList: data?.plugins || [],
mutateInstalledPluginList,
filters,
setFilters,
}}
>
{children}

View File

@ -1,22 +1,21 @@
import type { FC } from 'react'
import PluginItem from '../../plugin-item'
import { customTool, extensionDallE, modelGPT4, toolNotion } from '@/app/components/plugins/card/card-mock'
import type { InstalledPlugin } from '../../types'
const PluginList = () => {
const pluginList = [toolNotion, extensionDallE, modelGPT4, customTool]
type IPluginListProps = {
pluginList: InstalledPlugin[]
}
const PluginList: FC<IPluginListProps> = ({ pluginList }) => {
return (
<div className='pb-3 bg-white'>
<div>
<div className='grid grid-cols-2 gap-3'>
{pluginList.map((plugin, index) => (
<PluginItem
key={index}
payload={plugin as any}
onDelete={() => {}}
source={'debug'}
/>
))}
</div>
<div className='grid grid-cols-2 gap-3'>
{pluginList.map(plugin => (
<PluginItem
key={plugin.plugin_id}
plugin={plugin}
/>
))}
</div>
</div>
)

View File

@ -1,16 +1,33 @@
'use client'
import { useState } from 'react'
import type { EndpointListItem, PluginDetail } from '../types'
import { useMemo, useState } from 'react'
import type { EndpointListItem, InstalledPlugin, PluginDetail } from '../types'
import type { FilterState } from './filter-management'
import FilterManagement from './filter-management'
import List from './list'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { toolNotion, toolNotionEndpoints } from '@/app/components/plugins/plugin-detail-panel/mock'
import { usePluginPageContext } from './context'
import { useDebounceFn } from 'ahooks'
const PluginsPanel = () => {
const handleFilterChange = (filters: FilterState) => {
//
}
const [filters, setFilters] = usePluginPageContext(v => [v.filters, v.setFilters])
const pluginList = usePluginPageContext(v => v.installedPluginList) as InstalledPlugin[]
const { run: handleFilterChange } = useDebounceFn((filters: FilterState) => {
setFilters(filters)
}, { wait: 500 })
const filteredList = useMemo(() => {
// todo: filter by tags
const { categories, searchQuery } = filters
const filteredList = pluginList.filter((plugin) => {
return (
(categories.length === 0 || categories.includes(plugin.declaration.category))
&& (searchQuery === '' || plugin.plugin_id.toLowerCase().includes(searchQuery.toLowerCase()))
)
})
return filteredList
}, [pluginList, filters])
const [currentPluginDetail, setCurrentPluginDetail] = useState<PluginDetail | undefined>(toolNotion as any)
const [currentPluginEndpoints, setCurrentEndpoints] = useState<EndpointListItem[]>(toolNotionEndpoints as any)
@ -24,7 +41,7 @@ const PluginsPanel = () => {
</div>
<div className='flex px-12 items-start content-start gap-2 flex-grow self-stretch flex-wrap'>
<div className='w-full'>
<List />
<List pluginList={filteredList} />
</div>
</div>
<PluginDetailPanel

View File

@ -112,7 +112,7 @@ export type Plugin = {
// Repo readme.md content
introduction: string
repository: string
category: string
category: PluginType
install_count: number
endpoint: {
settings: CredentialFormSchemaBase[]
@ -235,3 +235,29 @@ export type TaskStatusResponse = {
plugins: PluginStatus[]
}
}
export type MetaData = {
repo: string
version: string
package: string
}
export type InstalledPlugin = {
plugin_id: string
installation_id: string
declaration: PluginDeclaration
source: PluginSource
tenant_id: string
version: string
latest_version: string
endpoints_active: number
meta: MetaData
}
export type InstalledPluginListResponse = {
plugins: InstalledPlugin[]
}
export type UninstallPluginResponse = {
success: boolean
}

View File

@ -6,10 +6,12 @@ import type {
EndpointsRequest,
EndpointsResponse,
InstallPackageResponse,
InstalledPluginListResponse,
Permissions,
PluginDeclaration,
PluginManifestInMarket,
TaskStatusResponse,
UninstallPluginResponse,
UpdateEndpointRequest,
} from '@/app/components/plugins/types'
import type { DebugInfo as DebugInfoTypes } from '@/app/components/plugins/types'
@ -110,3 +112,11 @@ export const fetchPermission = async () => {
export const updatePermission = async (permissions: Permissions) => {
return post('/workspaces/current/plugin/permission/change', { body: permissions })
}
export const fetchInstalledPluginList: Fetcher<InstalledPluginListResponse, { url: string }> = ({ url }) => {
return get<InstalledPluginListResponse>(url)
}
export const uninstallPlugin = async (pluginId: string) => {
return post<UninstallPluginResponse>('/workspaces/current/plugin/uninstall', { body: { plugin_installation_id: pluginId } })
}