diff --git a/app/Http/Controllers/PluginController.php b/app/Http/Controllers/PluginController.php index 8b3641df..3991775b 100644 --- a/app/Http/Controllers/PluginController.php +++ b/app/Http/Controllers/PluginController.php @@ -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) diff --git a/resources/assets/src/components/admin/Plugins.vue b/resources/assets/src/components/admin/Plugins.vue new file mode 100644 index 00000000..73cb7cae --- /dev/null +++ b/resources/assets/src/components/admin/Plugins.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/resources/assets/src/components/route.js b/resources/assets/src/components/route.js index 158d35cd..4636feeb 100644 --- a/resources/assets/src/components/route.js +++ b/resources/assets/src/components/route.js @@ -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'), diff --git a/resources/assets/tests/components/admin/Plugins.test.js b/resources/assets/tests/components/admin/Plugins.test.js new file mode 100644 index 00000000..5100ecfe --- /dev/null +++ b/resources/assets/tests/components/admin/Plugins.test.js @@ -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
`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: '

1

' + }); + + 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'); +}); diff --git a/resources/lang/zh_CN/front-end.yml b/resources/lang/zh_CN/front-end.yml index b89d525c..968b3591 100644 --- a/resources/lang/zh_CN/front-end.yml +++ b/resources/lang/zh_CN/front-end.yml @@ -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 diff --git a/resources/views/admin/plugins.blade.php b/resources/views/admin/plugins.blade.php index e08bedee..7b244e3c 100644 --- a/resources/views/admin/plugins.blade.php +++ b/resources/views/admin/plugins.blade.php @@ -2,10 +2,6 @@ @section('title', trans('general.plugin-manage')) -@section('style') - -@endsection - @section('content') @@ -18,33 +14,7 @@ -
- - @if (session()->has('message')) - - @endif - -
-
- - - - - - - - - - - - -
@lang('admin.plugins.name')@lang('admin.plugins.description')@lang('admin.plugins.author')@lang('admin.plugins.version')@lang('admin.plugins.dependencies')@lang('admin.plugins.status.title')@lang('admin.plugins.operations.title')
-
-
- -
+
@endsection diff --git a/tests/PluginControllerTest.php b/tests/PluginControllerTest.php index da3507fa..f7038930 100644 --- a/tests/PluginControllerTest.php +++ b/tests/PluginControllerTest.php @@ -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(); + } }