Add plugins management page

This commit is contained in:
Pig Fang 2018-08-19 17:39:33 +08:00
parent 32104da31f
commit f6d022c377
7 changed files with 433 additions and 115 deletions

View File

@ -79,29 +79,21 @@ class PluginController extends Controller
public function getPluginData(PluginManager $plugins)
{
$installed = $plugins->getPlugins();
return Datatables::of($installed)
->setRowId('plugin-{{ $name }}')
->editColumn('title', function ($plugin) {
return trans($plugin->title ?: 'EMPTY');
return $plugins->getPlugins()
->map(function ($plugin) {
return [
'name' => $plugin->name,
'title' => trans($plugin->title ?: 'EMPTY'),
'author' => $plugin->author,
'description' => trans($plugin->description ?: 'EMPTY'),
'version' => $plugin->version,
'url' => $plugin->url,
'enabled' => $plugin->isEnabled(),
'config' => $plugin->hasConfigView(),
'dependencies' => $this->getPluginDependencies($plugin),
];
})
->editColumn('description', function ($plugin) {
return trans($plugin->description ?: 'EMPTY');
})
->editColumn('author', function ($plugin) {
return ['author' => trans($plugin->author ?: 'EMPTY'), 'url' => $plugin->url];
})
->addColumn('dependencies', function ($plugin) {
return $this->getPluginDependencies($plugin);
})
->addColumn('status', function ($plugin) {
return trans('admin.plugins.status.'.($plugin->isEnabled() ? 'enabled' : 'disabled'));
})
->addColumn('operations', function ($plugin) {
return ['enabled' => $plugin->isEnabled(), 'hasConfigView' => $plugin->hasConfigView()];
})
->make(true);
->values();
}
protected function getPluginDependencies(Plugin $plugin)

View File

@ -0,0 +1,235 @@
<template>
<section class="content">
<vue-good-table
:rows="plugins"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
styleClass="vgt-table striped"
:rowStyleClass="rowStyleClassFn"
>
<template slot="table-row" slot-scope="props">
<span v-if="props.column.field === 'title'">
<strong>{{ props.formattedRow[props.column.field] }}</strong>
<div v-if="props.row.enabled" class="actions">
<template v-if="props.row.config">
<a
class="text-primary"
:href="`${baseUrl}/admin/plugins/config/${props.row.name}`"
>{{ $t('admin.configurePlugin') }}</a> |
</template>
<a
href="#"
v-t="'admin.disablePlugin'"
class="text-primary"
@click="disablePlugin(props.row)"
/>
</div>
<div v-else class="actions">
<a
href="#"
v-t="'admin.enablePlugin'"
class="text-primary"
@click="enablePlugin(props.row)"
/> |
<a
href="#"
v-t="'admin.deletePlugin'"
class="text-danger"
@click="deletePlugin(props.row)"
/>
</div>
</span>
<span v-else-if="props.column.field === 'description'">
<div><p>{{ props.formattedRow.description }}</p></div>
<div class="plugin-version-author">
{{ $t('admin.pluginVersion') }}
<span class="text-primary">{{ props.row.version }}</span> |
{{ $t('admin.pluginAuthor') }}
<a :href="props.row.url">{{ props.row.author }}</a> |
{{ $t('admin.pluginName') }}
{{ props.row.name }}
</div>
</span>
<span v-else-if="props.column.field === 'dependencies'">
<span
v-if="props.row.dependencies.requirements.length === 0"
><i v-t="'admin.noDependencies'"></i></span>
<div v-else>
<span
v-for="(semver, dep) in props.row.dependencies.requirements"
:key="dep"
class="label"
:class="`bg-${dep in props.row.dependencies.unsatisfiedRequirements ? 'red' : 'green'}`"
>
{{ dep }}: {{ semver }}
<br>
</span>
</div>
</span>
<span v-else v-text="props.formattedRow[props.column.field]" />
</template>
</vue-good-table>
</section>
</template>
<script>
import { VueGoodTable } from 'vue-good-table';
import 'vue-good-table/dist/vue-good-table.min.css';
import toastr from 'toastr';
import { swal } from '../../js/notify';
export default {
name: 'Plugins',
components: {
VueGoodTable
},
props: {
baseUrl: {
default: blessing.base_url
}
},
data() {
return {
plugins: [],
columns: [
{ field: 'title', label: this.$t('admin.pluginTitle'), width: '17%' },
{
field: 'description',
label: this.$t('admin.pluginDescription'),
sortable: false,
width: '65%'
},
{
field: 'dependencies',
label: this.$t('admin.pluginDependencies'),
sortable: false,
globalSearchDisabled: true
},
],
tableOptions: {
search: {
enabled: true,
placeholder: this.$t('vendor.datatable.search')
},
pagination: {
enabled: true,
nextLabel: this.$t('vendor.datatable.next'),
prevLabel: this.$t('vendor.datatable.prev'),
rowsPerPageLabel: this.$t('vendor.datatable.rowsPerPage'),
allLabel: this.$t('vendor.datatable.all'),
ofLabel: this.$t('vendor.datatable.of')
}
},
};
},
beforeMount() {
this.fetchData();
},
methods: {
async fetchData() {
this.plugins = await this.$http.get('/admin/plugins/data');
},
rowStyleClassFn(row) {
return row.enabled ? 'plugin-enabled' : 'plugin';
},
async enablePlugin({ name, dependencies: { requirements }, originalIndex }) {
if (requirements.length === 0) {
const { dismiss } = await swal({
text: this.$t('admin.noDependenciesNotice'),
type: 'warning',
showCancelButton: true
});
if (dismiss) {
return;
}
}
const { errno, msg, reason } = await this.$http.post(
'/admin/plugins/manage',
{ action: 'enable', name }
);
if (errno === 0) {
toastr.success(msg);
this.$set(this.plugins[originalIndex], 'enabled', true);
} else {
const div = document.createElement('div');
const p = document.createElement('p');
p.textContent = msg;
div.appendChild(p);
const ul = document.createElement('ul');
reason.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
ul.appendChild(li);
});
div.appendChild(ul);
swal({
type: 'warning',
html: div.innerHTML.replace(/`([\w-_]+)`/g, '<code>$1</code>')
});
}
},
async disablePlugin({ name, originalIndex }) {
const { errno, msg } = await this.$http.post(
'/admin/plugins/manage',
{ action: 'disable', name }
);
if (errno === 0) {
toastr.success(msg);
this.plugins[originalIndex].enabled = false;
} else {
swal({ type: 'warning', text: msg });
}
},
async deletePlugin({ name }) {
const { dismiss } = await swal({
text: this.$t('admin.confirmDeletion'),
type: 'warning',
showCancelButton: true
});
if (dismiss) {
return;
}
const { errno, msg } = await this.$http.post(
'/admin/plugins/manage',
{ action: 'delete', name }
);
if (errno === 0) {
toastr.success(msg);
this.plugins = this.plugins.filter(plugin => plugin.name !== name);
} else {
swal({ type: 'warning', text: msg });
}
}
}
};
</script>
<style lang="stylus">
.actions {
margin-top: 5px;
color: #ddd;
}
.plugin-version-author {
color: #777;
font-size: small;
a {
color: #337ab7;
}
}
.plugin > td:first-child {
border-left: 5px solid transparent;
}
.plugin-enabled {
background-color: #f7fcfe;
}
.plugin-enabled > td:first-child {
border-left: 5px solid #3c8dbc;
}
</style>

View File

@ -34,6 +34,11 @@ export default [
component: () => import('./admin/Customization'),
el: '#change-color'
},
{
path: 'admin/plugins/manage',
component: () => import('./admin/Plugins'),
el: '.content'
},
{
path: 'admin/plugins/market',
component: () => import('./admin/Market'),

View File

@ -0,0 +1,138 @@
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import Plugins from '@/components/admin/Plugins';
import { flushPromises } from '../../utils';
import { swal } from '@/js/notify';
import toastr from 'toastr';
jest.mock('@/js/notify');
test('render dependencies', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ name: 'a', dependencies: { requirements: [] } },
{ name: 'b', dependencies: {
requirements: { 'a': '^1.0.0', 'c': '^2.0.0' }, unsatisfiedRequirements: { 'c': {} }
} }
]);
const wrapper = mount(Plugins);
await flushPromises();
expect(wrapper.text()).toContain('admin.noDependencies');
expect(wrapper.find('span.label.bg-green').text()).toBe('a: ^1.0.0');
expect(wrapper.find('span.label.bg-red').text()).toBe('c: ^2.0.0');
});
test('render operation buttons', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ name: 'a', dependencies: { requirements: [] }, enabled: true, config: true },
{ name: 'b', dependencies: { requirements: [] }, enabled: true, config: false },
{ name: 'c', dependencies: { requirements: [] }, enabled: false },
]);
const wrapper = mount(Plugins);
await flushPromises();
const tbody = wrapper.find('tbody');
expect(tbody.find('tr:nth-child(1)').text()).toContain('admin.disablePlugin');
expect(tbody.find('tr:nth-child(1)').text()).toContain('admin.configurePlugin');
expect(tbody.find('tr:nth-child(2)').text()).not.toContain('admin.configurePlugin');
expect(tbody.find('tr:nth-child(3)').text()).toContain('admin.enablePlugin');
expect(tbody.find('tr:nth-child(3)').text()).toContain('admin.deletePlugin');
});
test('enable plugin', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ name: 'a', dependencies: { requirements: [] }, enabled: false },
{ name: 'b', dependencies: { requirements: {} }, enabled: false },
]);
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1', reason: ['`a<div></div>`b'] })
.mockResolvedValue({ errno: 0, msg: '0' });
swal.mockResolvedValueOnce({ dismiss: 1 })
.mockResolvedValueOnce({});
const wrapper = mount(Plugins);
await flushPromises();
wrapper.findAll('.actions').at(0).find('a').trigger('click');
await flushPromises();
expect(swal).toBeCalledWith({
text: 'admin.noDependenciesNotice',
type: 'warning',
showCancelButton: true
});
expect(Vue.prototype.$http.post).not.toBeCalled();
wrapper.findAll('.actions').at(0).find('a').trigger('click');
await flushPromises();
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/plugins/manage',
{ action: 'enable', name: 'a' }
);
expect(swal).toBeCalledWith({
type: 'warning',
html: '<p>1</p><ul><li>`a&lt;div&gt;&lt;/div&gt;`b</li></ul>'
});
wrapper.findAll('.actions').at(1).find('a').trigger('click');
await flushPromises();
expect(wrapper.text()).toContain('admin.disablePlugin');
});
test('disable plugin', async () => {
jest.spyOn(toastr, 'success');
Vue.prototype.$http.get.mockResolvedValue([
{ name: 'a', dependencies: { requirements: [] }, enabled: true, config: false }
]);
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1' })
.mockResolvedValue({ errno: 0, msg: '0' });
const wrapper = mount(Plugins);
await flushPromises();
const button = wrapper.find('.actions').find('a');
button.trigger('click');
await flushPromises();
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/plugins/manage',
{ action: 'disable', name: 'a' }
);
button.trigger('click');
await flushPromises();
expect(toastr.success).toBeCalledWith('0');
});
test('delete plugin', async () => {
jest.spyOn(toastr, 'success');
Vue.prototype.$http.get.mockResolvedValue([
{ name: 'a', dependencies: { requirements: [] }, enabled: false },
]);
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1' })
.mockResolvedValue({ errno: 0, msg: '0' });
swal.mockResolvedValueOnce({ dismiss: 1 })
.mockResolvedValue({});
const wrapper = mount(Plugins);
await flushPromises();
const button = wrapper.find('.actions').findAll('a').at(1);
button.trigger('click');
await flushPromises();
expect(swal).toBeCalledWith({
text: 'admin.confirmDeletion',
type: 'warning',
showCancelButton: true
});
expect(Vue.prototype.$http.post).not.toBeCalled();
button.trigger('click');
await flushPromises();
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/plugins/manage',
{ action: 'delete', name: 'a' }
);
expect(swal).toBeCalledWith({ type: 'warning', text: '1' });
button.trigger('click');
await flushPromises();
expect(toastr.success).toBeCalledWith('0');
});

View File

@ -260,9 +260,9 @@ admin:
noSuchUser: 没有这个用户哦~
changePlayerNameNotice: 请输入新的角色名:
emptyPlayerName: 您还没填写角色名呢
configurePlugin: 插件配置
configurePlugin: 配置
noPluginConfigNotice: 插件已被禁用或无配置页
deletePlugin: 删除插件
deletePlugin: 删除
noDependencies: 无要求
whyDependencies: 为什么会这样?
statusEnabled: 已启用
@ -278,8 +278,8 @@ admin:
updatePlugin: 更新
pluginUpdating: 正在更新...
confirmUpdate: 确定将「:plugin」从 :old 升级至 :new
enablePlugin: 启用插件
disablePlugin: 禁用插件
enablePlugin: 启用
disablePlugin: 禁用
confirmDeletion: 真的要删除这个插件吗?
noDependenciesNotice: >-
此插件没有声明任何依赖关系,这代表它有可能并不兼容此版本的 Blessing

View File

@ -2,10 +2,6 @@
@section('title', trans('general.plugin-manage'))
@section('style')
<style> .btn { margin-right: 4px; } </style>
@endsection
@section('content')
<!-- Content Wrapper. Contains page content -->
@ -18,33 +14,7 @@
</section>
<!-- Main content -->
<section class="content">
@if (session()->has('message'))
<div class="callout callout-success" role="alert">
{{ session('message') }}
</div>
@endif
<div class="box">
<div class="box-body table-bordered">
<table id="plugin-table" class="table table-hover">
<thead>
<tr>
<th>@lang('admin.plugins.name')</th>
<th>@lang('admin.plugins.description')</th>
<th>@lang('admin.plugins.author')</th>
<th>@lang('admin.plugins.version')</th>
<th>@lang('admin.plugins.dependencies')</th>
<th>@lang('admin.plugins.status.title')</th>
<th>@lang('admin.plugins.operations.title')</th>
</tr>
</thead>
</table>
</div>
</div>
</section><!-- /.content -->
<section class="content"></section><!-- /.content -->
</div><!-- /.content-wrapper -->
@endsection

View File

@ -4,6 +4,8 @@ namespace Tests;
use App\Events;
use ZipArchive;
use Illuminate\Support\Facades\File;
use Tests\Concerns\GeneratesFakePlugins;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\DatabaseTransactions;
@ -11,39 +13,16 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
class PluginControllerTest extends TestCase
{
use DatabaseTransactions;
use GeneratesFakePlugins;
protected function setUp()
{
parent::setUp();
$plugins = [
'example-plugin' => 'example-plugin_v1.0.0.zip',
'avatar-api' => 'avatar-api_v1.1.1.zip'
];
$this->generateFakePlugin(['name' => 'fake-plugin-for-test', 'version' => '1.1.4']);
$this->generateFakePlugin(['name' => 'fake-plugin-with-config-view', 'version' => '5.1.4', 'config' => 'config.blade.php']);
foreach ($plugins as $plugin_name => $filename) {
if (! file_exists(base_path('plugins/'.$plugin_name))) {
$user_agent = menv('USER_AGENT', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36');
$context = stream_context_create(['http' => [
'method' => 'GET',
'header' => "User-Agent: $user_agent"
]]);
file_put_contents(
storage_path('testing/'.$filename),
file_get_contents("https://coding.net/u/printempw/p/bs-plugins-archive/git/raw/master/$filename", false, $context)
);
$zip = new ZipArchive();
$zip->open(storage_path('testing/'.$filename));
$zip->extractTo(base_path('plugins/'));
$zip->close();
unlink(storage_path('testing/'.$filename));
}
}
return $this->actAs('admin');
return $this->actAs('superAdmin');
}
public function testShowManage()
@ -55,13 +34,14 @@ class PluginControllerTest extends TestCase
public function testConfig()
{
// Plugin is disabled
$this->get('/admin/plugins/config/example-plugin')
->assertStatus(404);
$this->get('/admin/plugins/config/fake-plugin-with-config-view')
->assertNotFound();
// Plugin is enabled but it doesn't have config view
plugin('avatar-api')->setEnabled(true);
plugin('fake-plugin-for-test')->setEnabled(true);
$this->get('/admin/plugins/config/avatar-api')
->assertStatus(404);
->assertSee(trans('admin.plugins.operations.no-config-notice'))
->assertNotFound();
// Plugin has config view
plugin('example-plugin')->setEnabled(true);
@ -79,38 +59,28 @@ class PluginControllerTest extends TestCase
]);
// Invalid action
$this->postJson('/admin/plugins/manage', ['name' => 'avatar-api'])
$this->postJson('/admin/plugins/manage', ['name' => 'fake-plugin-for-test'])
->assertJson([
'errno' => 1,
'msg' => trans('admin.invalid-action')
]);
// Retrieve requirements
$this->postJson('/admin/plugins/manage', [
'name' => 'avatar-api',
'action' => 'requirements'
])->assertJson([
'isRequirementsSatisfied' => true,
'requirements' => [],
'unsatisfiedRequirements' => []
]);
// Enable a plugin with unsatisfied dependencies
app('plugins')->getPlugin('avatar-api')->setRequirements([
app('plugins')->getPlugin('fake-plugin-for-test')->setRequirements([
'blessing-skin-server' => '^3.4.0',
'example-plugin' => '^6.6.6',
'fake-plugin-with-config-view' => '^6.6.6',
'whatever' => '^1.0.0'
]);
app('plugins')->enable('example-plugin');
app('plugins')->enable('fake-plugin-with-config-view');
$this->postJson('/admin/plugins/manage', [
'name' => 'avatar-api',
'name' => 'fake-plugin-for-test',
'action' => 'enable'
])->assertJson([
'errno' => 1,
'msg' => trans('admin.plugins.operations.unsatisfied.notice'),
'reason' => [
trans('admin.plugins.operations.unsatisfied.version', [
'name' => 'example-plugin',
'name' => 'fake-plugin-with-config-view',
'constraint' => '^6.6.6'
]),
trans('admin.plugins.operations.unsatisfied.disabled', [
@ -120,60 +90,68 @@ class PluginControllerTest extends TestCase
]);
// Enable a plugin
app('plugins')->getPlugin('avatar-api')->setRequirements([]);
app('plugins')->getPlugin('fake-plugin-for-test')->setRequirements([]);
$this->expectsEvents(Events\PluginWasEnabled::class);
$this->postJson('/admin/plugins/manage', [
'name' => 'avatar-api',
'name' => 'fake-plugin-for-test',
'action' => 'enable'
])->assertJson([
'errno' => 0,
'msg' => trans(
'admin.plugins.operations.enabled',
['plugin' => plugin('avatar-api')->title]
['plugin' => plugin('fake-plugin-for-test')->title]
)
]);
// Disable a plugin
$this->postJson('/admin/plugins/manage', [
'name' => 'avatar-api',
'name' => 'fake-plugin-for-test',
'action' => 'disable'
])->assertJson([
'errno' => 0,
'msg' => trans(
'admin.plugins.operations.disabled',
['plugin' => plugin('avatar-api')->title]
['plugin' => plugin('fake-plugin-for-test')->title]
)
]);
$this->expectsEvents(Events\PluginWasDisabled::class);
// Delete a plugin
$this->postJson('/admin/plugins/manage', [
'name' => 'avatar-api',
'name' => 'fake-plugin-for-test',
'action' => 'delete'
])->assertJson([
'errno' => 0,
'msg' => trans('admin.plugins.operations.deleted')
]);
$this->expectsEvents(Events\PluginWasDeleted::class);
$this->assertFalse(file_exists(base_path('plugins/avatar-api/')));
$this->assertFalse(file_exists(base_path('plugins/fake-plugin-for-test/')));
}
public function testGetPluginData()
{
$this->getJson('/admin/plugins/data')
->assertJsonStructure([
'data' => [[
[
'name',
'version',
'path',
'title',
'description',
'author',
'url',
'namespace',
'enabled',
'config',
'dependencies'
]]
]
]);
}
protected function tearDown()
{
// Clean fake plugins
File::deleteDirectory(base_path('plugins/fake-plugin-for-test'));
File::deleteDirectory(base_path('plugins/fake-plugin-with-config-view'));
parent::tearDown();
}
}