add two new ways to install plugin
This commit is contained in:
parent
ba757d98a8
commit
271c950ad9
|
|
@ -4,7 +4,10 @@ namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Services\Plugin;
|
use App\Services\Plugin;
|
||||||
use App\Services\PluginManager;
|
use App\Services\PluginManager;
|
||||||
|
use App\Services\Unzip;
|
||||||
|
use Composer\CaBundle\CaBundle;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
use Parsedown;
|
use Parsedown;
|
||||||
|
|
||||||
class PluginController extends Controller
|
class PluginController extends Controller
|
||||||
|
|
@ -105,4 +108,33 @@ class PluginController extends Controller
|
||||||
})
|
})
|
||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function upload(Request $request, PluginManager $manager, Unzip $unzip)
|
||||||
|
{
|
||||||
|
$request->validate(['file' => 'required|file|mimetypes:application/zip']);
|
||||||
|
|
||||||
|
$path = $request->file('file')->getPathname();
|
||||||
|
$unzip->extract($path, $manager->getPluginsDirs()->first());
|
||||||
|
|
||||||
|
return json(trans('admin.plugins.market.install-success'), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function wget(Request $request, PluginManager $manager, Unzip $unzip)
|
||||||
|
{
|
||||||
|
$data = $request->validate(['url' => 'required|url']);
|
||||||
|
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'wget-plugin');
|
||||||
|
$response = Http::withOptions([
|
||||||
|
'sink' => $path,
|
||||||
|
'verify' => CaBundle::getSystemCaRootBundlePath(),
|
||||||
|
])->get($data['url']);
|
||||||
|
|
||||||
|
if ($response->ok()) {
|
||||||
|
$unzip->extract($path, $manager->getPluginsDirs()->first());
|
||||||
|
|
||||||
|
return json(trans('admin.plugins.market.install-success'), 0);
|
||||||
|
} else {
|
||||||
|
return json(trans('admin.download.errors.download', ['error' => $response->status()]), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,39 @@
|
||||||
|
@use '../../../styles/utils';
|
||||||
|
|
||||||
.box {
|
.box {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
transition-property: box-shadow;
|
transition-property: box-shadow;
|
||||||
transition-duration: 0.3s;
|
transition-duration: 0.3s;
|
||||||
width: 32%;
|
|
||||||
@media (max-width: 1280px) {
|
|
||||||
width: 47%;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: 0 0.5rem 1rem rgba(#000, 0.15);
|
box-shadow: 0 0.5rem 1rem rgba(#000, 0.15);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
max-width: 85%;
|
max-width: calc(100% - 70px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.header {
|
||||||
margin-top: -7px;
|
max-width: calc(100% - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
.title {
|
||||||
transition-property: color;
|
@include utils.truncate-text;
|
||||||
transition-duration: 0.3s;
|
}
|
||||||
color: #000;
|
|
||||||
&:hover {
|
.actions a {
|
||||||
color: #999;
|
transition-property: color;
|
||||||
}
|
transition-duration: 0.3s;
|
||||||
&:not(:last-child) {
|
color: #000;
|
||||||
margin-right: 9px;
|
&:hover {
|
||||||
}
|
color: #999;
|
||||||
|
}
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-right: 9px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
white-space: nowrap;
|
@include utils.truncate-text;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { trans } from '../../../scripts/i18n'
|
import { t } from '@/scripts/i18n'
|
||||||
import { Plugin } from './types'
|
import { Plugin } from './types'
|
||||||
import styles from './InfoBox.scss'
|
import styles from './InfoBox.scss'
|
||||||
|
|
||||||
|
|
@ -33,26 +33,30 @@ const InfoBox: React.FC<Props> = props => {
|
||||||
</span>
|
</span>
|
||||||
<div className={`info-box-content ${styles.content}`}>
|
<div className={`info-box-content ${styles.content}`}>
|
||||||
<div className="d-flex justify-content-between">
|
<div className="d-flex justify-content-between">
|
||||||
<div>
|
<div className={`d-flex ${styles.header}`}>
|
||||||
<input
|
<input
|
||||||
className="mr-2"
|
className="mr-2 d-inline-block"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={plugin.enabled}
|
checked={plugin.enabled}
|
||||||
title={
|
title={
|
||||||
plugin.enabled
|
plugin.enabled
|
||||||
? trans('admin.disablePlugin')
|
? t('admin.disablePlugin')
|
||||||
: trans('admin.enablePlugin')
|
: t('admin.enablePlugin')
|
||||||
}
|
}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<strong className="mr-2">{plugin.title}</strong>
|
<strong className={`d-inline-block mr-2 ${styles.title}`}>
|
||||||
<span className="text-gray">v{plugin.version}</span>
|
{plugin.title}
|
||||||
|
</strong>
|
||||||
|
<span className="d-none d-sm-inline-block text-gray">
|
||||||
|
v{plugin.version}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
{plugin.readme && (
|
{plugin.readme && (
|
||||||
<a
|
<a
|
||||||
href={`${props.baseUrl}/admin/plugins/readme/${plugin.name}`}
|
href={`${props.baseUrl}/admin/plugins/readme/${plugin.name}`}
|
||||||
title={trans('admin.pluginReadme')}
|
title={t('admin.pluginReadme')}
|
||||||
>
|
>
|
||||||
<i className="fas fa-question" />
|
<i className="fas fa-question" />
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -60,16 +64,12 @@ const InfoBox: React.FC<Props> = props => {
|
||||||
{plugin.enabled && plugin.config && (
|
{plugin.enabled && plugin.config && (
|
||||||
<a
|
<a
|
||||||
href={`${props.baseUrl}/admin/plugins/config/${plugin.name}`}
|
href={`${props.baseUrl}/admin/plugins/config/${plugin.name}`}
|
||||||
title={trans('admin.configurePlugin')}
|
title={t('admin.configurePlugin')}
|
||||||
>
|
>
|
||||||
<i className="fas fa-cog" />
|
<i className="fas fa-cog" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<a
|
<a href="#" title={t('admin.deletePlugin')} onClick={handleDelete}>
|
||||||
href="#"
|
|
||||||
title={trans('admin.deletePlugin')}
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
<i className="fas fa-trash" />
|
<i className="fas fa-trash" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -80,4 +80,4 @@ const InfoBox: React.FC<Props> = props => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(InfoBox)
|
export default InfoBox
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { hot } from 'react-hot-loader/root'
|
import { hot } from 'react-hot-loader/root'
|
||||||
import { trans } from '../../../scripts/i18n'
|
import { t } from '@/scripts/i18n'
|
||||||
import * as fetch from '../../../scripts/net'
|
import * as fetch from '@/scripts/net'
|
||||||
import { toast, showModal } from '../../../scripts/notify'
|
import { toast, showModal } from '@/scripts/notify'
|
||||||
import Loading from '../../../components/Loading'
|
import FileInput from '@/components/FileInput'
|
||||||
|
import Loading from '@/components/Loading'
|
||||||
import InfoBox from './InfoBox'
|
import InfoBox from './InfoBox'
|
||||||
import { Plugin } from './types'
|
import { Plugin } from './types'
|
||||||
|
|
||||||
const PluginsManagement: React.FC = () => {
|
const PluginsManagement: React.FC = () => {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [plugins, setPlugins] = useState<Plugin[]>([])
|
const [plugins, setPlugins] = useState<Plugin[]>([])
|
||||||
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getPlugins = async () => {
|
const getPlugins = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -73,7 +79,7 @@ const PluginsManagement: React.FC = () => {
|
||||||
try {
|
try {
|
||||||
await showModal({
|
await showModal({
|
||||||
title: plugin.title,
|
title: plugin.title,
|
||||||
text: trans('admin.confirmDeletion'),
|
text: t('admin.confirmDeletion'),
|
||||||
okButtonType: 'danger',
|
okButtonType: 'danger',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -93,20 +99,132 @@ const PluginsManagement: React.FC = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return loading ? (
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
<Loading />
|
setFile(event.target.files![0])
|
||||||
) : (
|
}
|
||||||
<div className="d-flex flex-wrap">
|
|
||||||
{plugins.map((plugin, i) => (
|
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
<InfoBox
|
setUrl(event.target.value)
|
||||||
key={plugin.name}
|
}
|
||||||
plugin={plugin}
|
|
||||||
onEnable={plugin => handleEnable(plugin, i)}
|
const handleUpload = async () => {
|
||||||
onDisable={plugin => handleDisable(plugin, i)}
|
if (!file) {
|
||||||
onDelete={handleDelete}
|
return
|
||||||
baseUrl={blessing.base_url}
|
}
|
||||||
/>
|
|
||||||
))}
|
setIsUploading(true)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file, file.name)
|
||||||
|
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||||
|
'/admin/plugins/upload',
|
||||||
|
formData,
|
||||||
|
)
|
||||||
|
|
||||||
|
setIsUploading(false)
|
||||||
|
if (code === 0) {
|
||||||
|
toast.success(message)
|
||||||
|
setFile(null)
|
||||||
|
setPlugins(await fetch.get('/admin/plugins/data'))
|
||||||
|
} else {
|
||||||
|
toast.error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitUrl = async () => {
|
||||||
|
setIsDownloading(true)
|
||||||
|
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||||
|
'/admin/plugins/wget',
|
||||||
|
{ url },
|
||||||
|
)
|
||||||
|
|
||||||
|
setIsDownloading(false)
|
||||||
|
if (code === 0) {
|
||||||
|
toast.success(message)
|
||||||
|
setUrl('')
|
||||||
|
setPlugins(await fetch.get('/admin/plugins/data'))
|
||||||
|
} else {
|
||||||
|
toast.error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = Array(Math.ceil(plugins.length / 2))
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => plugins.slice(i * 2, (i + 1) * 2))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-8">
|
||||||
|
{loading ? (
|
||||||
|
<Loading />
|
||||||
|
) : (
|
||||||
|
chunks.map((chunk, i) => (
|
||||||
|
<div className="row" key={`${chunk[0].name}&${chunk[1]?.name}`}>
|
||||||
|
{chunk.map((plugin, j) => (
|
||||||
|
<div className="col-md-6" key={plugin.name}>
|
||||||
|
<InfoBox
|
||||||
|
plugin={plugin}
|
||||||
|
onEnable={plugin => handleEnable(plugin, i * 2 + j)}
|
||||||
|
onDisable={plugin => handleDisable(plugin, i * 2 + j)}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
baseUrl={blessing.base_url}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<div className="card card-primary card-outline">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">{t('admin.uploadArchive')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p>{t('admin.uploadArchiveNotice')}</p>
|
||||||
|
<FileInput
|
||||||
|
file={file}
|
||||||
|
accept="application/zip"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="card-footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary float-right"
|
||||||
|
disabled={isUploading}
|
||||||
|
onClick={handleUpload}
|
||||||
|
>
|
||||||
|
{isUploading ? <Loading /> : t('general.submit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card card-primary card-outline">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">{t('admin.downloadRemote')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p>{t('admin.downloadRemoteNotice')}</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="zip-url">URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="zip-url"
|
||||||
|
className="form-control"
|
||||||
|
value={url}
|
||||||
|
onChange={handleUrlChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary float-right"
|
||||||
|
disabled={isDownloading}
|
||||||
|
onClick={handleSubmitUrl}
|
||||||
|
>
|
||||||
|
{isDownloading ? <Loading /> : t('general.submit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { render, wait, fireEvent } from '@testing-library/react'
|
import { render, wait, fireEvent } from '@testing-library/react'
|
||||||
import { trans } from '@/scripts/i18n'
|
import { t } from '@/scripts/i18n'
|
||||||
import * as fetch from '@/scripts/net'
|
import * as fetch from '@/scripts/net'
|
||||||
import PluginsManagement from '@/views/admin/PluginsManagement'
|
import PluginsManagement from '@/views/admin/PluginsManagement'
|
||||||
|
|
||||||
|
|
@ -24,13 +24,23 @@ test('plugin info box', async () => {
|
||||||
icon: {},
|
icon: {},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'b',
|
||||||
|
title: 'Another Plugin',
|
||||||
|
version: '0.1.0',
|
||||||
|
description: '',
|
||||||
|
config: true,
|
||||||
|
readme: true,
|
||||||
|
icon: {},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const { queryByTitle, queryByText } = render(<PluginsManagement />)
|
const { queryByTitle, queryByText } = render(<PluginsManagement />)
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
expect(queryByTitle(trans('admin.configurePlugin'))).not.toBeNull()
|
expect(queryByTitle(t('admin.configurePlugin'))).not.toBeNull()
|
||||||
expect(queryByTitle(trans('admin.pluginReadme'))).not.toBeNull()
|
expect(queryByTitle(t('admin.pluginReadme'))).not.toBeNull()
|
||||||
expect(queryByText('My Plugin')).not.toBeNull()
|
expect(queryByText('My Plugin')).not.toBeNull()
|
||||||
expect(queryByText('v1.0.0')).not.toBeNull()
|
expect(queryByText('v1.0.0')).not.toBeNull()
|
||||||
expect(queryByText('desc')).not.toBeNull()
|
expect(queryByText('desc')).not.toBeNull()
|
||||||
|
|
@ -47,7 +57,7 @@ describe('enable plugin', () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('successfully', async () => {
|
it('succeeded', async () => {
|
||||||
fetch.get.mockResolvedValue([
|
fetch.get.mockResolvedValue([
|
||||||
{
|
{
|
||||||
name: 'a',
|
name: 'a',
|
||||||
|
|
@ -60,7 +70,7 @@ describe('enable plugin', () => {
|
||||||
const { getByTitle, getByRole, queryByText } = render(<PluginsManagement />)
|
const { getByTitle, getByRole, queryByText } = render(<PluginsManagement />)
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
fireEvent.click(getByTitle(trans('admin.enablePlugin')))
|
fireEvent.click(getByTitle(t('admin.enablePlugin')))
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
||||||
|
|
@ -69,7 +79,7 @@ describe('enable plugin', () => {
|
||||||
})
|
})
|
||||||
expect(queryByText('success')).toBeInTheDocument()
|
expect(queryByText('success')).toBeInTheDocument()
|
||||||
expect(getByRole('status')).toHaveClass('alert-success')
|
expect(getByRole('status')).toHaveClass('alert-success')
|
||||||
expect(getByTitle(trans('admin.disablePlugin'))).toBeChecked()
|
expect(getByTitle(t('admin.disablePlugin'))).toBeChecked()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('failed', async () => {
|
it('failed', async () => {
|
||||||
|
|
@ -82,7 +92,7 @@ describe('enable plugin', () => {
|
||||||
const { getByTitle, getByText, queryByText } = render(<PluginsManagement />)
|
const { getByTitle, getByText, queryByText } = render(<PluginsManagement />)
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
fireEvent.click(getByTitle(trans('admin.enablePlugin')))
|
fireEvent.click(getByTitle(t('admin.enablePlugin')))
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
||||||
|
|
@ -92,7 +102,7 @@ describe('enable plugin', () => {
|
||||||
expect(queryByText('unresolved')).toBeInTheDocument()
|
expect(queryByText('unresolved')).toBeInTheDocument()
|
||||||
expect(queryByText('abc')).toBeInTheDocument()
|
expect(queryByText('abc')).toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(getByText(trans('general.confirm')))
|
fireEvent.click(getByText(t('general.confirm')))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -107,13 +117,13 @@ describe('disable plugin', () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('successfully', async () => {
|
it('succeeded', async () => {
|
||||||
fetch.post.mockResolvedValue({ code: 0, message: 'success' })
|
fetch.post.mockResolvedValue({ code: 0, message: 'success' })
|
||||||
|
|
||||||
const { getByTitle, getByRole, queryByText } = render(<PluginsManagement />)
|
const { getByTitle, getByRole, queryByText } = render(<PluginsManagement />)
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
fireEvent.click(getByTitle(trans('admin.disablePlugin')))
|
fireEvent.click(getByTitle(t('admin.disablePlugin')))
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
||||||
|
|
@ -122,7 +132,7 @@ describe('disable plugin', () => {
|
||||||
})
|
})
|
||||||
expect(queryByText('success')).toBeInTheDocument()
|
expect(queryByText('success')).toBeInTheDocument()
|
||||||
expect(getByRole('status')).toHaveClass('alert-success')
|
expect(getByRole('status')).toHaveClass('alert-success')
|
||||||
expect(getByTitle(trans('admin.enablePlugin'))).not.toBeChecked()
|
expect(getByTitle(t('admin.enablePlugin'))).not.toBeChecked()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('failed', async () => {
|
it('failed', async () => {
|
||||||
|
|
@ -131,7 +141,7 @@ describe('disable plugin', () => {
|
||||||
const { getByTitle, getByRole, queryByText } = render(<PluginsManagement />)
|
const { getByTitle, getByRole, queryByText } = render(<PluginsManagement />)
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
fireEvent.click(getByTitle(trans('admin.disablePlugin')))
|
fireEvent.click(getByTitle(t('admin.disablePlugin')))
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
||||||
|
|
@ -159,14 +169,14 @@ describe('delete plugin', () => {
|
||||||
const { getByTitle, getByText } = render(<PluginsManagement />)
|
const { getByTitle, getByText } = render(<PluginsManagement />)
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
fireEvent.click(getByTitle(trans('admin.deletePlugin')))
|
fireEvent.click(getByTitle(t('admin.deletePlugin')))
|
||||||
fireEvent.click(getByText(trans('general.cancel')))
|
fireEvent.click(getByText(t('general.cancel')))
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
expect(fetch.post).not.toBeCalled()
|
expect(fetch.post).not.toBeCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('successfully', async () => {
|
it('succeeded', async () => {
|
||||||
fetch.post.mockResolvedValue({ code: 0, message: 'success' })
|
fetch.post.mockResolvedValue({ code: 0, message: 'success' })
|
||||||
|
|
||||||
const { getByTitle, getByText, getByRole, queryByText } = render(
|
const { getByTitle, getByText, getByRole, queryByText } = render(
|
||||||
|
|
@ -174,8 +184,8 @@ describe('delete plugin', () => {
|
||||||
)
|
)
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
fireEvent.click(getByTitle(trans('admin.deletePlugin')))
|
fireEvent.click(getByTitle(t('admin.deletePlugin')))
|
||||||
fireEvent.click(getByText(trans('general.confirm')))
|
fireEvent.click(getByText(t('general.confirm')))
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
||||||
|
|
@ -195,8 +205,8 @@ describe('delete plugin', () => {
|
||||||
)
|
)
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
fireEvent.click(getByTitle(trans('admin.deletePlugin')))
|
fireEvent.click(getByTitle(t('admin.deletePlugin')))
|
||||||
fireEvent.click(getByText(trans('general.confirm')))
|
fireEvent.click(getByText(t('general.confirm')))
|
||||||
await wait()
|
await wait()
|
||||||
|
|
||||||
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
expect(fetch.post).toBeCalledWith('/admin/plugins/manage', {
|
||||||
|
|
@ -208,3 +218,129 @@ describe('delete plugin', () => {
|
||||||
expect(queryByText('My Plugin')).not.toBeNull()
|
expect(queryByText('My Plugin')).not.toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('upload plugin archive', () => {
|
||||||
|
it('no selected file', async () => {
|
||||||
|
fetch.get.mockResolvedValue([])
|
||||||
|
|
||||||
|
const { getAllByText } = render(<PluginsManagement />)
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
fireEvent.click(getAllByText(t('general.submit'))[0])
|
||||||
|
expect(fetch.post).not.toBeCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('succeeded', async () => {
|
||||||
|
fetch.get.mockResolvedValue([])
|
||||||
|
fetch.post.mockResolvedValue({ code: 0, message: 'ok' })
|
||||||
|
|
||||||
|
const { getByTitle, getAllByText, getByRole, queryByText } = render(
|
||||||
|
<PluginsManagement />,
|
||||||
|
)
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
const file = new File([], 'plugin.zip')
|
||||||
|
fireEvent.change(getByTitle(t('skinlib.upload.select-file')), {
|
||||||
|
target: { files: [file] },
|
||||||
|
})
|
||||||
|
fireEvent.click(getAllByText(t('general.submit'))[0])
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
expect(fetch.get).toBeCalledTimes(2)
|
||||||
|
expect(fetch.post).toBeCalledWith(
|
||||||
|
'/admin/plugins/upload',
|
||||||
|
expect.any(FormData),
|
||||||
|
)
|
||||||
|
const formData: FormData = fetch.post.mock.calls[0][1]
|
||||||
|
expect(formData.get('file')).toStrictEqual(file)
|
||||||
|
expect(queryByText('plugin.zip')).not.toBeInTheDocument()
|
||||||
|
expect(queryByText('ok')).toBeInTheDocument()
|
||||||
|
expect(getByRole('status')).toHaveClass('alert-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('failed', async () => {
|
||||||
|
fetch.get.mockResolvedValue([])
|
||||||
|
fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
|
||||||
|
|
||||||
|
const { getByTitle, getAllByText, getByRole, queryByText } = render(
|
||||||
|
<PluginsManagement />,
|
||||||
|
)
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
const file = new File([], 'plugin.zip')
|
||||||
|
fireEvent.change(getByTitle(t('skinlib.upload.select-file')), {
|
||||||
|
target: { files: [file] },
|
||||||
|
})
|
||||||
|
fireEvent.click(getAllByText(t('general.submit'))[0])
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
expect(fetch.get).toBeCalledTimes(1)
|
||||||
|
expect(fetch.post).toBeCalledWith(
|
||||||
|
'/admin/plugins/upload',
|
||||||
|
expect.any(FormData),
|
||||||
|
)
|
||||||
|
expect(queryByText('plugin.zip')).toBeInTheDocument()
|
||||||
|
expect(queryByText('failed')).toBeInTheDocument()
|
||||||
|
expect(getByRole('alert')).toHaveClass('alert-danger')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submit remote URL', () => {
|
||||||
|
it('succeeded', async () => {
|
||||||
|
fetch.get.mockResolvedValue([])
|
||||||
|
fetch.post.mockResolvedValue({ code: 0, message: 'ok' })
|
||||||
|
|
||||||
|
const {
|
||||||
|
getByLabelText,
|
||||||
|
getAllByText,
|
||||||
|
getByRole,
|
||||||
|
queryByText,
|
||||||
|
queryByDisplayValue,
|
||||||
|
} = render(<PluginsManagement />)
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
fireEvent.input(getByLabelText('URL'), {
|
||||||
|
target: { value: 'https://example.com/a.zip' },
|
||||||
|
})
|
||||||
|
fireEvent.click(getAllByText(t('general.submit'))[1])
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
expect(fetch.get).toBeCalledTimes(2)
|
||||||
|
expect(fetch.post).toBeCalledWith('/admin/plugins/wget', {
|
||||||
|
url: 'https://example.com/a.zip',
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
queryByDisplayValue('https://example.com/a.zip'),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
expect(queryByText('ok')).toBeInTheDocument()
|
||||||
|
expect(getByRole('status')).toHaveClass('alert-success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('failed', async () => {
|
||||||
|
fetch.get.mockResolvedValue([])
|
||||||
|
fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
|
||||||
|
|
||||||
|
const {
|
||||||
|
getByLabelText,
|
||||||
|
getAllByText,
|
||||||
|
getByRole,
|
||||||
|
queryByText,
|
||||||
|
queryByDisplayValue,
|
||||||
|
} = render(<PluginsManagement />)
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
fireEvent.input(getByLabelText('URL'), {
|
||||||
|
target: { value: 'https://example.com/a.zip' },
|
||||||
|
})
|
||||||
|
fireEvent.click(getAllByText(t('general.submit'))[1])
|
||||||
|
await wait()
|
||||||
|
|
||||||
|
expect(fetch.get).toBeCalledTimes(1)
|
||||||
|
expect(fetch.post).toBeCalledWith('/admin/plugins/wget', {
|
||||||
|
url: 'https://example.com/a.zip',
|
||||||
|
})
|
||||||
|
expect(queryByDisplayValue('https://example.com/a.zip')).toBeInTheDocument()
|
||||||
|
expect(queryByText('failed')).toBeInTheDocument()
|
||||||
|
expect(getByRole('alert')).toHaveClass('alert-danger')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,10 @@ admin:
|
||||||
enablePlugin: Enable
|
enablePlugin: Enable
|
||||||
disablePlugin: Disable
|
disablePlugin: Disable
|
||||||
confirmDeletion: Are you sure to delete this plugin?
|
confirmDeletion: Are you sure to delete this plugin?
|
||||||
|
uploadArchive: Upload Archive
|
||||||
|
uploadArchiveNotice: Install a plugin by uploading a Zip archive.
|
||||||
|
downloadRemote: Download From Remote
|
||||||
|
downloadRemoteNotice: Install a plugin by downloading a Zip archive from remote URL.
|
||||||
noDependenciesNotice: >-
|
noDependenciesNotice: >-
|
||||||
There is no dependency definition in the plugin. It means that the plugin
|
There is no dependency definition in the plugin. It means that the plugin
|
||||||
may be not compatible with the current version of Blessing Skin, and
|
may be not compatible with the current version of Blessing Skin, and
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@
|
||||||
- Added Blessing Skin Shell.
|
- Added Blessing Skin Shell.
|
||||||
- Support specifying "from" email address and name when sending email.
|
- Support specifying "from" email address and name when sending email.
|
||||||
- 3D skin viewer can be with background now.
|
- 3D skin viewer can be with background now.
|
||||||
|
- Added support of installing plugin by uploading archive.
|
||||||
|
- Added support of installing plugin by submitting remote URL.
|
||||||
|
|
||||||
## Tweaked
|
## Tweaked
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@
|
||||||
- 新增 Blessing Skin Shell
|
- 新增 Blessing Skin Shell
|
||||||
- 支持单独指定邮件发件人的地址和名称
|
- 支持单独指定邮件发件人的地址和名称
|
||||||
- 3D 皮肤预览现在是带背景的
|
- 3D 皮肤预览现在是带背景的
|
||||||
|
- 可通过上传压缩包来安装插件
|
||||||
|
- 可通过提交 URL 来安装插件
|
||||||
|
|
||||||
## 调整
|
## 调整
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,8 @@ Route::prefix('admin')
|
||||||
Route::post('manage', 'PluginController@manage');
|
Route::post('manage', 'PluginController@manage');
|
||||||
Route::any('config/{name}', 'PluginController@config');
|
Route::any('config/{name}', 'PluginController@config');
|
||||||
Route::get('readme/{name}', 'PluginController@readme');
|
Route::get('readme/{name}', 'PluginController@readme');
|
||||||
|
Route::post('upload', 'PluginController@upload');
|
||||||
|
Route::post('wget', 'PluginController@wget');
|
||||||
|
|
||||||
Route::prefix('market')->group(function () {
|
Route::prefix('market')->group(function () {
|
||||||
Route::view('', 'admin.market');
|
Route::view('', 'admin.market');
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@ namespace Tests;
|
||||||
|
|
||||||
use App\Services\Plugin;
|
use App\Services\Plugin;
|
||||||
use App\Services\PluginManager;
|
use App\Services\PluginManager;
|
||||||
|
use App\Services\Unzip;
|
||||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
class PluginControllerTest extends TestCase
|
class PluginControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -263,4 +267,71 @@ class PluginControllerTest extends TestCase
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testUpload()
|
||||||
|
{
|
||||||
|
// Missing file.
|
||||||
|
$this->postJson('/admin/plugins/upload')->assertJsonValidationErrors('file');
|
||||||
|
|
||||||
|
// Not a file.
|
||||||
|
$this->postJson('/admin/plugins/upload', ['file' => 'f'])
|
||||||
|
->assertJsonValidationErrors('file');
|
||||||
|
|
||||||
|
// Not a zip.
|
||||||
|
$file = UploadedFile::fake()->create('plugin.zip', 0, 'application/x-tar');
|
||||||
|
$this->postJson('/admin/plugins/upload', ['file' => $file])
|
||||||
|
->assertJsonValidationErrors('file');
|
||||||
|
|
||||||
|
// Success.
|
||||||
|
$file = UploadedFile::fake()->create('plugin.zip', 0, 'application/zip');
|
||||||
|
$this->mock(Unzip::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('extract')->withArgs(function ($path, $dest) {
|
||||||
|
$this->assertEquals(
|
||||||
|
resolve(PluginManager::class)->getPluginsDirs()->first(),
|
||||||
|
$dest
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})->once();
|
||||||
|
});
|
||||||
|
$this->postJson('/admin/plugins/upload', ['file' => $file])
|
||||||
|
->assertJson([
|
||||||
|
'code' => 0,
|
||||||
|
'message' => trans('admin.plugins.market.install-success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWget()
|
||||||
|
{
|
||||||
|
// Missing url.
|
||||||
|
$this->postJson('/admin/plugins/wget')->assertJsonValidationErrors('url');
|
||||||
|
|
||||||
|
// Not a url.
|
||||||
|
$this->postJson('/admin/plugins/wget', ['url' => 'f'])
|
||||||
|
->assertJsonValidationErrors('url');
|
||||||
|
|
||||||
|
Http::fakeSequence()->pushStatus(404)->pushStatus(200);
|
||||||
|
|
||||||
|
$this->postJson('/admin/plugins/wget', ['url' => 'https://down.org/a.zip'])
|
||||||
|
->assertJson([
|
||||||
|
'code' => 1,
|
||||||
|
'message' => trans('admin.download.errors.download', ['error' => 404]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(Unzip::class, function (MockInterface $mock) {
|
||||||
|
$mock->shouldReceive('extract')->withArgs(function ($path, $dest) {
|
||||||
|
$this->assertEquals(
|
||||||
|
resolve(PluginManager::class)->getPluginsDirs()->first(),
|
||||||
|
$dest
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})->once();
|
||||||
|
});
|
||||||
|
$this->postJson('/admin/plugins/wget', ['url' => 'https://down.org/a.zip'])
|
||||||
|
->assertJson([
|
||||||
|
'code' => 0,
|
||||||
|
'message' => trans('admin.plugins.market.install-success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user