Better UX about players

This commit is contained in:
Pig Fang 2019-04-01 21:45:59 +08:00
parent 58437a1b97
commit 1f2d7a98ce
11 changed files with 336 additions and 225 deletions

View File

@ -14,7 +14,14 @@ class ClosetController extends Controller
public function index()
{
return view('user.closet')
->with('extra', ['unverified' => option('require_verification') && ! $user->verified]);
->with('extra', [
'unverified' => option('require_verification') && ! $user->verified,
'rule' => trans('user.player.player-name-rule.'.option('player_name_rule')),
'length' => trans(
'user.player.player-name-length',
['min' => option('player_name_length_min'), 'max' => option('player_name_length_max')]
),
]);
}
public function getClosetData(Request $request)

View File

@ -0,0 +1,77 @@
<template>
<div
id="modal-add-player"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 v-t="'user.player.add-player'" class="modal-title" />
</div>
<div class="modal-body">
<table class="table">
<tbody>
<tr>
<td v-t="'general.player.player-name'" class="key" />
<td class="value">
<el-input v-model="name" type="text" />
</td>
</tr>
</tbody>
</table>
<div class="callout callout-info">
<ul style="padding: 0 0 0 20px; margin: 0;">
<li>{{ rule }}</li>
<li>{{ length }}</li>
</ul>
</div>
</div>
<div class="modal-footer">
<el-button data-dismiss="modal">{{ $t('general.close') }}</el-button>
<el-button type="primary" data-test="addPlayer" @click="addPlayer">
{{ $t('general.submit') }}
</el-button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AddPlayerDialog',
data() {
return {
name: '',
rule: blessing.extra.rule,
length: blessing.extra.length,
}
},
methods: {
async addPlayer() {
const { errno, msg } = await this.$http.post(
'/user/player/add',
{ player_name: this.name }
)
if (errno === 0) {
$('#modal-add-player').modal('hide')
this.$message.success(msg)
this.$emit('add')
} else {
this.$message.warning(msg)
}
},
},
}
</script>

View File

@ -0,0 +1,112 @@
<template>
<div
id="modal-use-as"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 v-t="'user.closet.use-as.title'" class="modal-title" />
</div>
<div class="modal-body">
<template v-if="players.length !== 0">
<div v-for="player in players" :key="player.pid" class="player-item">
<label class="model-label" :for="player.pid">
<input
v-model="selected"
type="radio"
name="player"
:value="player.pid"
>
<img :src="avatarUrl(player)" width="35" height="35">
<span>{{ player.name }}</span>
</label>
</div>
</template>
<p v-else v-t="'user.closet.use-as.empty'" />
</div>
<div class="modal-footer">
<a
v-if="allowAdd"
v-t="'user.closet.use-as.add'"
data-toggle="modal"
data-target="#modal-add-player"
class="el-button pull-left"
/>
<el-button type="primary" data-test="submit" @click="submit">
{{ $t('general.submit') }}
</el-button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ApplyToPlayerDialog',
props: {
skin: Number,
cape: Number,
allowAdd: {
type: Boolean,
default: true,
},
},
data() {
return {
players: [],
selected: 0,
}
},
methods: {
async fetchList() {
this.players = await this.$http.get('/user/player/list')
},
async submit() {
if (!this.selected) {
return this.$message.info(this.$t('user.emptySelectedPlayer'))
}
if (!this.skin && !this.cape) {
return this.$message.info(this.$t('user.emptySelectedTexture'))
}
const { errno, msg } = await this.$http.post(
'/user/player/set',
{
pid: this.selected,
tid: {
skin: this.skin || undefined,
cape: this.cape || undefined,
},
}
)
if (errno === 0) {
this.$message.success(msg)
$('#modal-use-as').modal('hide')
} else {
this.$message.warning(msg)
}
},
avatarUrl(player) {
return `${blessing.base_url}/avatar/35/${player.tid_skin}`
},
},
}
</script>
<style lang="stylus">
.player-item:not(:nth-child(1))
margin-top 10px
</style>

View File

@ -11,14 +11,16 @@
{{ $t('skinlib.addToCloset') }}
</el-button>
<template v-else>
<a
<el-button
v-if="liked"
native-type="a"
:href="`${baseUrl}/user/closet?tid=${tid}`"
class="el-button el-button--success el-button--medium"
type="success"
size="medium"
data-toggle="modal"
data-target="#modal-use-as"
@click="fetchPlayersList"
>
{{ $t('skinlib.apply') }}
</a>
</el-button>
<el-button
v-if="liked"
type="primary"
@ -150,6 +152,13 @@
</div><!-- /.box-footer -->
</div>
</div>
<apply-to-player-dialog
ref="useAs"
:allow-add="false"
:skin="type !== 'cape' ? tid : 0"
:cape="type === 'cape' ? tid : 0"
/>
</div>
</template>
@ -157,10 +166,12 @@
import setAsAvatar from '../../components/mixins/setAsAvatar'
import addClosetItem from '../../components/mixins/addClosetItem'
import removeClosetItem from '../../components/mixins/removeClosetItem'
import ApplyToPlayerDialog from '../../components/ApplyToPlayerDialog.vue'
export default {
name: 'Show',
components: {
ApplyToPlayerDialog,
Previewer: () => import('../../components/Previewer.vue'),
},
mixins: [
@ -366,6 +377,9 @@ export default {
this.$message.warning(msg)
}
},
fetchPlayersList() {
this.$refs.useAs.fetchList()
},
},
}
</script>

View File

@ -131,7 +131,7 @@
type="primary"
data-toggle="modal"
data-target="#modal-use-as"
@click="applyTexture"
@click="fetchPlayersList"
>
{{ $t('user.useAs') }}
</el-button>
@ -142,52 +142,8 @@
</previewer>
</div>
</div>
<div
id="modal-use-as"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 v-t="'user.closet.use-as.title'" class="modal-title" />
</div>
<div class="modal-body">
<template v-if="players.length !== 0">
<div v-for="player in players" :key="player.pid" class="player-item">
<label class="model-label" :for="player.pid">
<input
v-model="selectedPlayer"
type="radio"
name="player"
:value="player.pid"
>
<img :src="avatarUrl(player)" width="35" height="35">
<span>{{ player.name }}</span>
</label>
</div>
</template>
<p v-else v-t="'user.closet.use-as.empty'" />
</div>
<div class="modal-footer">
<a v-t="'user.closet.use-as.add'" href="./player" class="el-button pull-left" />
<el-button type="primary" data-test="submitApplyTexture" @click="submitApplyTexture">
{{ $t('general.submit') }}
</el-button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<apply-to-player-dialog ref="useAs" :skin="selectedSkin" :cape="selectedCape" />
<add-player-dialog @add="fetchPlayersList" />
</section><!-- /.content -->
</template>
@ -196,6 +152,8 @@ import Paginate from 'vuejs-paginate'
import { debounce, queryString } from '../../scripts/utils'
import ClosetItem from '../../components/ClosetItem.vue'
import EmailVerification from '../../components/EmailVerification.vue'
import AddPlayerDialog from '../../components/AddPlayerDialog.vue'
import ApplyToPlayerDialog from '../../components/ApplyToPlayerDialog.vue'
export default {
name: 'Closet',
@ -204,6 +162,8 @@ export default {
ClosetItem,
Previewer: () => import('../../components/Previewer.vue'),
EmailVerification,
AddPlayerDialog,
ApplyToPlayerDialog,
},
data: () => ({
category: 'skin',
@ -218,8 +178,6 @@ export default {
skinUrl: '',
selectedCape: 0,
capeUrl: '',
players: [],
selectedPlayer: 0,
linkToSkin: `${blessing.base_url}/skinlib?filter=skin`,
linkToCape: `${blessing.base_url}/skinlib?filter=cape`,
}),
@ -233,7 +191,7 @@ export default {
const tid = +queryString('tid', 0)
if (tid) {
this.selectTexture(tid)
this.applyTexture()
this.fetchPlayersList()
$('#modal-use-as').modal()
}
},
@ -267,9 +225,6 @@ export default {
pageChanged(page) {
this.loadCloset(page)
},
avatarUrl(player) {
return `${blessing.base_url}/avatar/35/${player.tid_skin}`
},
async selectTexture(tid) {
const { type, hash } = await this.$http.get(`/skinlib/info/${tid}`)
if (type === 'cape') {
@ -280,41 +235,15 @@ export default {
this.selectedSkin = tid
}
},
async applyTexture() {
this.players = await this.$http.get('/user/player/list')
},
async submitApplyTexture() {
if (!this.selectedPlayer) {
return this.$message.info(this.$t('user.emptySelectedPlayer'))
}
if (!this.selectedSkin && !this.selectedCape) {
return this.$message.info(this.$t('user.emptySelectedTexture'))
}
const { errno, msg } = await this.$http.post(
'/user/player/set',
{
pid: this.selectedPlayer,
tid: {
skin: this.selectedSkin || undefined,
cape: this.selectedCape || undefined,
},
}
)
if (errno === 0) {
this.$message.success(msg)
$('#modal-use-as').modal('hide')
} else {
this.$message.warning(msg)
}
},
resetSelected() {
this.selectedSkin = 0
this.selectedCape = 0
this.skinUrl = ''
this.capeUrl = ''
},
fetchPlayersList() {
this.$refs.useAs.fetchList()
},
},
}
</script>
@ -345,9 +274,6 @@ export default {
a.selected
color #3c8dbc
.player-item:not(:nth-child(1))
margin-top 10px
.breadcrumb
a
margin-right 10px

View File

@ -116,57 +116,7 @@
</div>
</div>
<div
id="modal-add-player"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 v-t="'user.player.add-player'" class="modal-title" />
</div>
<div class="modal-body">
<table class="table">
<tbody>
<tr>
<td v-t="'general.player.player-name'" class="key" />
<td class="value">
<input
v-model="newPlayer"
type="text"
class="form-control"
>
</td>
</tr>
</tbody>
</table>
<div class="callout callout-info">
<ul style="padding: 0 0 0 20px; margin: 0;">
<li>{{ playerNameRule }}</li>
<li>{{ playerNameLength }}</li>
</ul>
</div>
</div>
<div class="modal-footer">
<el-button data-dismiss="modal">{{ $t('general.close') }}</el-button>
<el-button type="primary" data-test="addPlayer" @click="addPlayer">
{{ $t('general.submit') }}
</el-button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div>
<add-player-dialog @add="fetchPlayers" />
<div
id="modal-clear-texture"
@ -209,9 +159,12 @@
</template>
<script>
import AddPlayerDialog from '../../components/AddPlayerDialog.vue'
export default {
name: 'Players',
components: {
AddPlayerDialog,
Previewer: () => import('../../components/Previewer.vue'),
},
props: {
@ -231,13 +184,10 @@ export default {
skin: 0,
cape: 0,
},
newPlayer: '',
clear: {
skin: false,
cape: false,
},
playerNameRule: blessing.extra.rule,
playerNameLength: blessing.extra.length,
}
},
beforeMount() {
@ -332,19 +282,6 @@ export default {
this.$message.warning(msg)
}
},
async addPlayer() {
$('.modal').modal('hide')
const { errno, msg } = await this.$http.post(
'/user/player/add',
{ player_name: this.newPlayer }
)
if (errno === 0) {
this.$message.success(msg)
this.fetchPlayers()
} else {
this.$message.warning(msg)
}
},
},
}
</script>
@ -363,9 +300,6 @@ export default {
.player-selected
background-color #f5f5f5
.modal-body > label
margin-bottom 10px
.skin2d
float right
max-height 64px

View File

@ -0,0 +1,35 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import { flushPromises } from '../utils'
import AddPlayerDialog from '@/components/AddPlayerDialog.vue'
window.blessing.extra = {
rule: 'rule',
length: 'length',
}
test('add player', async () => {
window.$ = jest.fn(() => ({ modal() {} }))
Vue.prototype.$http.get.mockResolvedValueOnce([])
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: 'fail' })
.mockResolvedValue({ errno: 0, msg: 'ok' })
const wrapper = mount(AddPlayerDialog)
const button = wrapper.find('[data-test=addPlayer]')
wrapper.find('input[type="text"]').setValue('the-new')
button.trigger('click')
expect(Vue.prototype.$http.post).toBeCalledWith(
'/user/player/add',
{ player_name: 'the-new' }
)
await flushPromises()
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('the-new')
expect(Vue.prototype.$message.warning).toBeCalledWith('fail')
button.trigger('click')
await flushPromises()
expect(wrapper.emitted().add).toBeDefined()
expect(Vue.prototype.$message.success).toBeCalledWith('ok')
})

View File

@ -0,0 +1,54 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import ApplyToPlayerDialog from '@/components/ApplyToPlayerDialog.vue'
test('submit applying texture', async () => {
window.$ = jest.fn(() => ({ modal() {} }))
Vue.prototype.$http.get.mockResolvedValue([{ pid: 1 }])
Vue.prototype.$http.post.mockResolvedValueOnce({ errno: 1 })
.mockResolvedValue({ errno: 0, msg: 'ok' })
const wrapper = mount(ApplyToPlayerDialog)
const button = wrapper.find('[data-test=submit]')
button.trigger('click')
expect(Vue.prototype.$message.info).toBeCalledWith('user.emptySelectedPlayer')
wrapper.setData({ selected: 1 })
button.trigger('click')
expect(Vue.prototype.$message.info).toBeCalledWith('user.emptySelectedTexture')
wrapper.setProps({ skin: 1 })
button.trigger('click')
expect(Vue.prototype.$http.post).toBeCalledWith(
'/user/player/set',
{
pid: 1,
tid: {
skin: 1,
cape: undefined,
},
}
)
wrapper.setProps({ skin: 0, cape: 1 })
button.trigger('click')
expect(Vue.prototype.$http.post).toBeCalledWith(
'/user/player/set',
{
pid: 1,
tid: {
skin: undefined,
cape: 1,
},
}
)
await wrapper.vm.$nextTick()
expect(Vue.prototype.$message.success).toBeCalledWith('ok')
})
test('compute avatar URL', () => {
Vue.prototype.$http.get.mockResolvedValue({})
// eslint-disable-next-line camelcase
const wrapper = mount<Vue & { avatarUrl(player: { tid_skin: number }): string }>(ApplyToPlayerDialog)
const { avatarUrl } = wrapper.vm
expect(avatarUrl({ tid_skin: 1 })).toBe('/avatar/35/1')
})

View File

@ -419,3 +419,17 @@ test('report texture', async () => {
await flushPromises()
expect(Vue.prototype.$message.success).toBeCalledWith('success')
})
test('apply texture to player', () => {
Vue.prototype.$http.get
.mockResolvedValue({})
.mockResolvedValue([])
const wrapper = mount(Show, {
mocks: {
$route: ['/skinlib/show/1', '1'],
},
stubs: { previewer },
})
wrapper.find('[data-target="#modal-use-as"]').trigger('click')
expect(wrapper.find('[data-target="#modal-add-player"]').exists()).toBeFalse()
})

View File

@ -145,14 +145,6 @@ test('remove cape item', () => {
expect(wrapper.find('#cape-category').text()).toContain('user.emptyClosetMsg')
})
test('compute avatar URL', () => {
Vue.prototype.$http.get.mockResolvedValue({})
// eslint-disable-next-line camelcase
const wrapper = mount<Vue & { avatarUrl(player: { tid_skin: number }): string }>(Closet)
const { avatarUrl } = wrapper.vm
expect(avatarUrl({ tid_skin: 1 })).toBe('/avatar/35/1')
})
test('select texture', async () => {
Vue.prototype.$http.get
.mockResolvedValueOnce({})
@ -200,49 +192,6 @@ test('apply texture', async () => {
jest.runAllTimers()
})
test('submit applying texture', async () => {
window.$ = jest.fn(() => ({ modal() {} }))
Vue.prototype.$http.get.mockResolvedValue({})
Vue.prototype.$http.post.mockResolvedValueOnce({ errno: 1 })
.mockResolvedValue({ errno: 0, msg: 'ok' })
const wrapper = mount(Closet)
const button = wrapper.find('[data-test=submitApplyTexture]')
button.trigger('click')
expect(Vue.prototype.$message.info).toBeCalledWith('user.emptySelectedPlayer')
wrapper.setData({ selectedPlayer: 1 })
button.trigger('click')
expect(Vue.prototype.$message.info).toBeCalledWith('user.emptySelectedTexture')
wrapper.setData({ selectedSkin: 1 })
button.trigger('click')
expect(Vue.prototype.$http.post).toBeCalledWith(
'/user/player/set',
{
pid: 1,
tid: {
skin: 1,
cape: undefined,
},
}
)
wrapper.setData({ selectedSkin: 0, selectedCape: 1 })
button.trigger('click')
expect(Vue.prototype.$http.post).toBeCalledWith(
'/user/player/set',
{
pid: 1,
tid: {
skin: undefined,
cape: 1,
},
}
)
await wrapper.vm.$nextTick()
expect(Vue.prototype.$message.success).toBeCalledWith('ok')
})
test('reset selected texture', () => {
Vue.prototype.$http.get.mockResolvedValue({})
const wrapper = mount(Closet)

View File

@ -127,22 +127,11 @@ test('toggle preview mode', () => {
test('add player', async () => {
window.$ = jest.fn(() => ({ modal() {} }))
Vue.prototype.$http.get.mockResolvedValueOnce([])
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1 })
.mockResolvedValue({ errno: 0 })
Vue.prototype.$http.post.mockResolvedValue({ errno: 0 })
const wrapper = mount(Players)
const button = wrapper.find('[data-test=addPlayer]')
wrapper.find('input[type="text"]').setValue('the-new')
button.trigger('click')
expect(Vue.prototype.$http.post).toBeCalledWith(
'/user/player/add',
{ player_name: 'the-new' }
)
await flushPromises()
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('the-new')
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.get).toBeCalledTimes(2)