rewrite plugins market with React

This commit is contained in:
Pig Fang 2020-05-01 23:44:30 +08:00
parent b183dae6bd
commit 437ac5b120
7 changed files with 456 additions and 281 deletions

View File

@ -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',
},
{

View File

@ -1,168 +0,0 @@
<template>
<div class="container-fluid">
<vue-good-table
:rows="plugins"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
style-class="vgt-table striped"
>
<template #table-row="props">
<span v-if="props.column.field === 'title'">
<strong>{{ props.formattedRow[props.column.field] }}</strong>
<div>{{ props.row.name }}</div>
</span>
<span v-else-if="props.column.field === 'dependencies'">
<span v-if="Object.keys(props.row.dependencies.all).length === 0">
<i v-t="'admin.noDependencies'" />
</span>
<div v-else>
<span
v-for="(constraint, name) in props.row.dependencies.all"
:key="name"
class="badge"
:class="`bg-${name in props.row.dependencies.unsatisfied ? 'red' : 'green'}`"
>
{{ name }}: {{ constraint }}
<br>
</span>
</div>
</span>
<span v-else-if="props.column.field === 'operations'">
<template v-if="props.row.installed">
<button
v-if="props.row.can_update"
class="btn btn-success"
:disabled="installing === props.row.name"
@click="updatePlugin(props.row)"
>
<template v-if="installing === props.row.name">
<i class="fas fa-spinner fa-spin" /> {{ $t('admin.pluginUpdating') }}
</template>
<template v-else>
<i class="fas fa-sync-alt" /> {{ $t('admin.updatePlugin') }}
</template>
</button>
<button v-else class="btn btn-default" disabled>
<i class="fas fa-download" /> {{ $t('admin.installPlugin') }}
</button>
</template>
<button
v-else
class="btn btn-default"
:disabled="installing === props.row.name"
@click="installPlugin(props.row)"
>
<template v-if="installing === props.row.name">
<i class="fas fa-spinner fa-spin" /> {{ $t('admin.pluginInstalling') }}
</template>
<template v-else>
<i class="fas fa-download" /> {{ $t('admin.installPlugin') }}
</template>
</button>
</span>
<span v-else v-text="props.formattedRow[props.column.field]" />
</template>
</vue-good-table>
</div>
</template>
<script>
import { VueGoodTable } from 'vue-good-table'
import 'vue-good-table/dist/vue-good-table.min.css'
import alertUnresolvedPlugins from '../../components/mixins/alertUnresolvedPlugins'
import tableOptions from '../../components/mixins/tableOptions'
import emitMounted from '../../components/mixins/emitMounted'
import { showModal, toast } from '../../scripts/notify'
export default {
name: 'Market',
components: {
VueGoodTable,
},
mixins: [
emitMounted,
tableOptions,
],
data() {
return {
plugins: [],
columns: [
{ field: 'title', label: this.$t('admin.pluginTitle') },
{
field: 'description',
label: this.$t('admin.pluginDescription'),
sortable: false,
width: '37%',
},
{ field: 'author', label: this.$t('admin.pluginAuthor') },
{
field: 'version',
label: this.$t('admin.pluginVersion'),
sortable: false,
globalSearchDisabled: true,
width: '5%',
},
{
field: 'dependencies',
label: this.$t('admin.pluginDependencies'),
sortable: false,
globalSearchDisabled: true,
},
{
field: 'operations',
label: this.$t('admin.operationsTitle'),
sortable: false,
globalSearchDisabled: true,
width: '12%',
},
],
installing: '',
}
},
beforeMount() {
this.fetchData()
},
methods: {
async fetchData() {
this.plugins = await this.$http.get('/admin/plugins/market/list')
},
async installPlugin({ name, originalIndex }) {
this.installing = name
const {
code,
message,
data,
} = await this.$http.post(
'/admin/plugins/market/download',
{ name },
)
if (code === 0) {
toast.success(message)
this.plugins[originalIndex].can_update = false
this.plugins[originalIndex].installed = true
} else if (data && data.reason) {
alertUnresolvedPlugins(message, data.reason)
} else {
toast.error(message)
}
this.installing = ''
},
async updatePlugin(plugin) {
try {
await showModal({
text: this.$t('admin.confirmUpdate', {
plugin: plugin.title, old: plugin.installed, new: plugin.version,
}),
})
} catch {
return
}
this.installPlugin(plugin)
},
},
}
</script>

View File

@ -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> = (props) => {
const { plugin, isInstalling } = props
const allDeps = Object.entries(plugin.dependencies.all)
const unsatisfied = Object.keys(plugin.dependencies.unsatisfied)
return (
<tr>
<td style={{ width: '18%' }}>
<div>
<b>{plugin.title}</b>
</div>
<div>{plugin.name}</div>
</td>
<td style={{ width: '37%' }}>{plugin.description}</td>
<td>{plugin.author}</td>
<td>{plugin.version}</td>
<td style={{ width: '100px' }}>
{allDeps.length === 0 ? (
<i>{t('admin.noDependencies')}</i>
) : (
<div className="d-flex flex-column">
{allDeps.map(([name, constraint]) => {
const classes = [
'mb-1',
'badge',
`bg-${unsatisfied.includes(name) ? 'red' : 'green'}`,
]
return (
<span key={name} className={classes.join(' ')}>
{name}: {constraint}
</span>
)
})}
</div>
)}
</td>
<td style={{ width: '12%' }}>
{plugin.can_update ? (
<button
className="btn btn-success"
disabled={isInstalling}
onClick={props.onUpdate}
>
{isInstalling ? (
<>
<i className="fas fa-spinner fa-spin mr-1"></i>
{t('admin.pluginUpdating')}
</>
) : (
<>
<i className="fas fa-sync-alt mr-1"></i>
{t('admin.updatePlugin')}
</>
)}
</button>
) : (
<button
className="btn btn-default"
disabled={props.isInstalling || !!plugin.installed}
onClick={props.onInstall}
>
{isInstalling ? (
<>
<i className="fas fa-spinner fa-spin mr-1"></i>
{t('admin.pluginInstalling')}
</>
) : (
<>
<i className="fas fa-download mr-1"></i>
{t('admin.installPlugin')}
</>
)}
</button>
)}
</td>
</tr>
)
}
export default Row

View File

@ -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<Plugin[]>([])
const [isLoading, setIsLoading] = useState(false)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [installings, setInstallings] = useImmer<Set<string>>(() => 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<Plugin[]>('/admin/plugins/market/list')
setPlugins(() => plugins)
setTotalPages(Math.ceil(plugins.length / 10))
setIsLoading(false)
}
getPlugins()
}, [])
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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: (
<div>
<p>{message}</p>
<ul>
{data.reason.map((t, i) => (
<li key={i}>{t}</li>
))}
</ul>
</div>
),
})
}
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 (
<div className="card">
<div className="card-header">
<input
type="text"
className="form-control"
placeholder={t('vendor.datatable.search')}
value={search}
onChange={handleSearchChange}
/>
</div>
{isLoading ? (
<div className="card-body">
<Loading />
</div>
) : searchedPlugins.length === 0 ? (
<div className="card-body text-center">{t('general.noResult')}</div>
) : (
<div className="card-body table-responsive p-0">
<table className="table table-striped">
<thead>
<tr>
<th>{t('admin.pluginTitle')}</th>
<th>{t('admin.pluginDescription')}</th>
<th>{t('admin.pluginAuthor')}</th>
<th>{t('admin.pluginVersion')}</th>
<th>{t('admin.pluginDependencies')}</th>
<th>{t('admin.operationsTitle')}</th>
</tr>
</thead>
<tbody>
{pagedPlugins.map((plugin, i) => (
<Row
key={plugin.name}
plugin={plugin}
isInstalling={installings.has(plugin.name)}
onInstall={() => handleInstall(plugin, i)}
onUpdate={() => handleUpdate(plugin, i)}
/>
))}
</tbody>
</table>
</div>
)}
<div className="card-footer">
<div className="float-right">
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</div>
</div>
</div>
)
}
export default hot(PluginsMarket)

View File

@ -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<string, string>
unsatisfied: Record<string, string>
}
}

View File

@ -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' },
)
})

View File

@ -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<Plugin> = Object.freeze<Readonly<Plugin>>({
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(<PluginsMarket />)
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(<PluginsMarket />)
await waitFor(() => expect(fetch.get).toBeCalled())
expect(queryByText(t('admin.noDependencies'))).toBeInTheDocument()
})
it('satisfied dependencies', async () => {
fetch.get.mockResolvedValue([fixture])
const { queryByText } = render(<PluginsMarket />)
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(<PluginsMarket />)
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(<PluginsMarket />)
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(<PluginsMarket />)
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(<PluginsMarket />)
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(<PluginsMarket />)
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(<PluginsMarket />)
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()
})
})