From 437ac5b1200925a2f390ec4ab957a8eb1387f618 Mon Sep 17 00:00:00 2001 From: Pig Fang Date: Fri, 1 May 2020 23:44:30 +0800 Subject: [PATCH] rewrite plugins market with React --- resources/assets/src/scripts/route.tsx | 2 +- resources/assets/src/views/admin/Market.vue | 168 ---------------- .../src/views/admin/PluginsMarket/Row.tsx | 92 +++++++++ .../src/views/admin/PluginsMarket/index.tsx | 162 +++++++++++++++ .../src/views/admin/PluginsMarket/types.ts | 13 ++ .../assets/tests/views/admin/Market.test.ts | 112 ----------- .../tests/views/admin/PluginsMarket.test.tsx | 188 ++++++++++++++++++ 7 files changed, 456 insertions(+), 281 deletions(-) delete mode 100644 resources/assets/src/views/admin/Market.vue create mode 100644 resources/assets/src/views/admin/PluginsMarket/Row.tsx create mode 100644 resources/assets/src/views/admin/PluginsMarket/index.tsx create mode 100644 resources/assets/src/views/admin/PluginsMarket/types.ts delete mode 100644 resources/assets/tests/views/admin/Market.test.ts create mode 100644 resources/assets/tests/views/admin/PluginsMarket.test.tsx diff --git a/resources/assets/src/scripts/route.tsx b/resources/assets/src/scripts/route.tsx index 9789659c..310878f5 100644 --- a/resources/assets/src/scripts/route.tsx +++ b/resources/assets/src/scripts/route.tsx @@ -82,7 +82,7 @@ export default [ }, { path: 'admin/plugins/market', - component: () => import('../views/admin/Market.vue'), + react: () => import('../views/admin/PluginsMarket'), el: '.content > .container-fluid', }, { diff --git a/resources/assets/src/views/admin/Market.vue b/resources/assets/src/views/admin/Market.vue deleted file mode 100644 index 040428ca..00000000 --- a/resources/assets/src/views/admin/Market.vue +++ /dev/null @@ -1,168 +0,0 @@ - - - diff --git a/resources/assets/src/views/admin/PluginsMarket/Row.tsx b/resources/assets/src/views/admin/PluginsMarket/Row.tsx new file mode 100644 index 00000000..e2e17cde --- /dev/null +++ b/resources/assets/src/views/admin/PluginsMarket/Row.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { t } from '@/scripts/i18n' +import { Plugin } from './types' + +interface Props { + plugin: Plugin + isInstalling: boolean + onInstall(): void + onUpdate(): void +} + +const Row: React.FC = (props) => { + const { plugin, isInstalling } = props + + const allDeps = Object.entries(plugin.dependencies.all) + const unsatisfied = Object.keys(plugin.dependencies.unsatisfied) + + return ( + + +
+ {plugin.title} +
+
{plugin.name}
+ + {plugin.description} + {plugin.author} + {plugin.version} + + {allDeps.length === 0 ? ( + {t('admin.noDependencies')} + ) : ( +
+ {allDeps.map(([name, constraint]) => { + const classes = [ + 'mb-1', + 'badge', + `bg-${unsatisfied.includes(name) ? 'red' : 'green'}`, + ] + return ( + + {name}: {constraint} + + ) + })} +
+ )} + + + {plugin.can_update ? ( + + ) : ( + + )} + + + ) +} + +export default Row diff --git a/resources/assets/src/views/admin/PluginsMarket/index.tsx b/resources/assets/src/views/admin/PluginsMarket/index.tsx new file mode 100644 index 00000000..bbd40deb --- /dev/null +++ b/resources/assets/src/views/admin/PluginsMarket/index.tsx @@ -0,0 +1,162 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { hot } from 'react-hot-loader/root' +import { enableMapSet } from 'immer' +import { useImmer } from 'use-immer' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import { toast, showModal } from '@/scripts/notify' +import Loading from '@/components/Loading' +import Pagination from '@/components/Pagination' +import { Plugin } from './types' +import Row from './Row' + +enableMapSet() + +const PluginsMarket: React.FC = () => { + const [plugins, setPlugins] = useImmer([]) + const [isLoading, setIsLoading] = useState(false) + const [search, setSearch] = useState('') + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const [installings, setInstallings] = useImmer>(() => new Set()) + + const searchedPlugins = useMemo( + () => + plugins.filter( + (plugin) => + plugin.name.includes(search) || plugin.title.includes(search), + ), + [plugins, search], + ) + + useEffect(() => { + const getPlugins = async () => { + setIsLoading(true) + const plugins = await fetch.get('/admin/plugins/market/list') + setPlugins(() => plugins) + setTotalPages(Math.ceil(plugins.length / 10)) + setIsLoading(false) + } + getPlugins() + }, []) + + const handleSearchChange = (event: React.ChangeEvent) => { + const search = event.target.value + setSearch(search) + setPage(1) + + const searchedPlugins = plugins.filter( + (plugin) => plugin.name.includes(search) || plugin.title.includes(search), + ) + setTotalPages(Math.ceil(searchedPlugins.length / 10)) + } + + const handleInstall = async (plugin: Plugin, index: number) => { + setInstallings((installings) => { + installings.add(plugin.name) + }) + + const { code, message, data = { reason: [] } } = await fetch.post< + fetch.ResponseBody<{ reason: string[] }> + >('/admin/plugins/market/download', { + name: plugin.name, + }) + if (code === 0) { + toast.success(message) + setPlugins((plugins) => { + plugins[index].can_update = false + plugins[index].installed = plugins[index].version + }) + } else { + showModal({ + mode: 'alert', + children: ( +
+

{message}

+
    + {data.reason.map((t, i) => ( +
  • {t}
  • + ))} +
+
+ ), + }) + } + + setInstallings((installings) => { + installings.delete(plugin.name) + }) + } + + const handleUpdate = async (plugin: Plugin, index: number) => { + try { + await showModal({ + text: t('admin.confirmUpdate', { + plugin: plugin.title, + old: plugin.installed, + new: plugin.version, + }), + }) + } catch { + return + } + + handleInstall(plugin, index) + } + + const pagedPlugins = searchedPlugins.slice((page - 1) * 10, page * 10) + + return ( +
+
+ +
+ {isLoading ? ( +
+ +
+ ) : searchedPlugins.length === 0 ? ( +
{t('general.noResult')}
+ ) : ( +
+ + + + + + + + + + + + + {pagedPlugins.map((plugin, i) => ( + handleInstall(plugin, i)} + onUpdate={() => handleUpdate(plugin, i)} + /> + ))} + +
{t('admin.pluginTitle')}{t('admin.pluginDescription')}{t('admin.pluginAuthor')}{t('admin.pluginVersion')}{t('admin.pluginDependencies')}{t('admin.operationsTitle')}
+
+ )} +
+
+ +
+
+
+ ) +} + +export default hot(PluginsMarket) diff --git a/resources/assets/src/views/admin/PluginsMarket/types.ts b/resources/assets/src/views/admin/PluginsMarket/types.ts new file mode 100644 index 00000000..c97ef8a9 --- /dev/null +++ b/resources/assets/src/views/admin/PluginsMarket/types.ts @@ -0,0 +1,13 @@ +export type Plugin = { + name: string + version: string + title: string + description: string + author: string + installed: string | false + can_update?: boolean + dependencies: { + all: Record + unsatisfied: Record + } +} diff --git a/resources/assets/tests/views/admin/Market.test.ts b/resources/assets/tests/views/admin/Market.test.ts deleted file mode 100644 index 5c376394..00000000 --- a/resources/assets/tests/views/admin/Market.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import Vue from 'vue' -import { mount } from '@vue/test-utils' -import { flushPromises } from '../../utils' -import { showModal } from '@/scripts/notify' -import Market from '@/views/admin/Market.vue' - -jest.mock('@/scripts/notify') - -test('render dependencies', async () => { - Vue.prototype.$http.get.mockResolvedValue([ - { name: 'a', dependencies: { all: {}, unsatisfied: {} } }, - { - name: 'b', - dependencies: { - all: { a: '^1.0.0', c: '^2.0.0' }, unsatisfied: { c: {} }, - }, - }, - ]) - const wrapper = mount(Market) - await flushPromises() - - expect(wrapper.text()).toContain('admin.noDependencies') - expect(wrapper.find('span.badge.bg-green').text()).toBe('a: ^1.0.0') - expect(wrapper.find('span.badge.bg-red').text()).toBe('c: ^2.0.0') -}) - -test('render operation buttons', async () => { - Vue.prototype.$http.get.mockResolvedValue([ - { - name: 'a', dependencies: { all: {}, unsatisfied: {} }, installed: true, can_update: true, - }, - { - name: 'b', dependencies: { all: {}, unsatisfied: {} }, installed: true, - }, - { - name: 'c', dependencies: { all: {}, unsatisfied: {} }, installed: false, - }, - ]) - const wrapper = mount(Market) - await flushPromises() - const tbody = wrapper.find('tbody') - - expect(tbody.find('tr:nth-child(1)').text()).toContain('admin.updatePlugin') - expect(tbody.find('tr:nth-child(2)').text()).toContain('admin.installPlugin') - expect(tbody.find('tr:nth-child(2) button').attributes('disabled')).toBeTruthy() - expect(tbody.find('tr:nth-child(3)').text()).toContain('admin.installPlugin') -}) - -test('install plugin', async () => { - Vue.prototype.$http.get.mockResolvedValue([ - { - name: 'd', dependencies: { all: {}, unsatisfied: {} }, installed: false, - }, - ]) - Vue.prototype.$http.post - .mockResolvedValueOnce({ code: 1, message: '1' }) - .mockResolvedValueOnce({ - code: 1, - message: 'unresolved', - data: { reason: ['u'] }, - }) - .mockResolvedValueOnce({ code: 0, message: '0' }) - const wrapper = mount(Market) - await flushPromises() - const button = wrapper.find('button') - - button.trigger('click') - await flushPromises() - expect(Vue.prototype.$http.post).toBeCalledWith( - '/admin/plugins/market/download', - { name: 'd' }, - ) - - button.trigger('click') - await flushPromises() - expect(showModal).toBeCalledWith(expect.objectContaining({ mode: 'alert' })) - - button.trigger('click') - await flushPromises() - expect(wrapper.find('.btn-default').attributes('disabled')).toBeTruthy() -}) - -test('update plugin', async () => { - Vue.prototype.$http.get.mockResolvedValue([ - { - name: 'a', - version: '2.0.0', - dependencies: { all: {}, unsatisfied: {} }, - installed: '1.0.0', - can_update: true, - }, - ]) - Vue.prototype.$http.post - .mockResolvedValueOnce({ code: 1, message: '1' }) - showModal - .mockRejectedValueOnce(null) - .mockResolvedValue({ value: '' }) - const wrapper = mount(Market) - await flushPromises() - const button = wrapper.find('button') - - button.trigger('click') - await flushPromises() - expect(Vue.prototype.$http.post).not.toBeCalled() - - button.trigger('click') - await flushPromises() - expect(Vue.prototype.$http.post).toBeCalledWith( - '/admin/plugins/market/download', - { name: 'a' }, - ) -}) diff --git a/resources/assets/tests/views/admin/PluginsMarket.test.tsx b/resources/assets/tests/views/admin/PluginsMarket.test.tsx new file mode 100644 index 00000000..016a8cb8 --- /dev/null +++ b/resources/assets/tests/views/admin/PluginsMarket.test.tsx @@ -0,0 +1,188 @@ +import React from 'react' +import { render, waitFor, fireEvent } from '@testing-library/react' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import PluginsMarket from '@/views/admin/PluginsMarket' +import { Plugin } from '@/views/admin/PluginsMarket/types' + +jest.mock('@/scripts/net') + +const fixture: Readonly = Object.freeze>({ + name: 'yggdrasil-api', + title: 'Yggdrasil API', + description: 'Auth System', + version: '1.0.0', + author: 'Blessing Skin', + installed: false, + dependencies: { + all: { + 'blessing-skin-server': '^5.0.0', + }, + unsatisfied: {}, + }, +}) + +test('search plugins', async () => { + fetch.get.mockResolvedValue([fixture]) + + const { getByPlaceholderText, queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalled()) + + fireEvent.input(getByPlaceholderText(t('vendor.datatable.search')), { + target: { value: 'test' }, + }) + expect(queryByText('yggdrasil-api')).not.toBeInTheDocument() +}) + +describe('dependencies', () => { + it('no dependencies', async () => { + fetch.get.mockResolvedValue([ + { ...fixture, dependencies: { all: {}, unsatisfied: {} } }, + ]) + + const { queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalled()) + expect(queryByText(t('admin.noDependencies'))).toBeInTheDocument() + }) + + it('satisfied dependencies', async () => { + fetch.get.mockResolvedValue([fixture]) + + const { queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalled()) + expect( + queryByText( + `blessing-skin-server: ${fixture.dependencies.all['blessing-skin-server']}`, + ), + ).toHaveClass('bg-green') + }) + + it('unsatisfied dependencies', async () => { + fetch.get.mockResolvedValue([ + { + ...fixture, + dependencies: { + all: { 'blessing-skin-server': '^5.0.0' }, + unsatisfied: { 'blessing-skin-server': '4.0.0' }, + }, + }, + ]) + + const { queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalled()) + expect( + queryByText( + `blessing-skin-server: ${fixture.dependencies.all['blessing-skin-server']}`, + ), + ).toHaveClass('bg-red') + }) +}) + +describe('install plugin', async () => { + beforeEach(() => { + fetch.get.mockResolvedValue([fixture]) + }) + + it('succeeded', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'ok' }) + + const { getByText, queryByRole, queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalled()) + + fireEvent.click(getByText(t('admin.installPlugin'))) + await waitFor(() => + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: fixture.name, + }), + ) + expect(queryByText(t('admin.installPlugin'))).toBeDisabled() + expect(queryByText('ok')).toBeInTheDocument() + expect(queryByRole('status')).toHaveClass('alert-success') + }) + + it('failed', async () => { + fetch.post.mockResolvedValue({ code: 1, message: 'failed' }) + + const { getByText, queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalled()) + + fireEvent.click(getByText(t('admin.installPlugin'))) + await waitFor(() => + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: fixture.name, + }), + ) + expect(queryByText('failed')).toBeInTheDocument() + expect(queryByText(t('admin.installPlugin'))).toBeEnabled() + + fireEvent.click(getByText(t('general.confirm'))) + }) + + it('failed with unsatisfied', async () => { + fetch.post.mockResolvedValue({ + code: 1, + message: 'failed', + data: { reason: ['version is too low'] }, + }) + + const { getByText, queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalled()) + + fireEvent.click(getByText(t('admin.installPlugin'))) + await waitFor(() => + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: fixture.name, + }), + ) + expect(queryByText('failed')).toBeInTheDocument() + expect(queryByText('version is too low')).toBeInTheDocument() + expect(queryByText(t('admin.installPlugin'))).toBeEnabled() + + fireEvent.click(getByText(t('general.confirm'))) + }) +}) + +describe('update plugin', () => { + beforeEach(() => { + fetch.get.mockResolvedValue([ + { ...fixture, can_update: true, installed: '0.5.0' }, + ]) + }) + + it('cancelled', async () => { + const { getByText, queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalled()) + + fireEvent.click(getByText(t('admin.updatePlugin'))) + expect( + queryByText( + t('admin.confirmUpdate', { + plugin: fixture.title, + old: '0.5.0', + new: fixture.version, + }), + ), + ).toBeInTheDocument() + + fireEvent.click(getByText(t('general.cancel'))) + expect(fetch.post).not.toBeCalled() + }) + + it('confirm to update', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'ok' }) + + const { getByText, queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalled()) + + fireEvent.click(getByText(t('admin.updatePlugin'))) + fireEvent.click(getByText(t('general.confirm'))) + + await waitFor(() => + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: fixture.name, + }), + ) + expect(queryByText('ok')).toBeInTheDocument() + expect(queryByText(t('admin.installPlugin'))).toBeDisabled() + }) +})