build closet page with React
This commit is contained in:
parent
9dca4c1585
commit
19ad09920b
|
|
@ -23,6 +23,7 @@
|
|||
"admin-lte": "^3.0.1",
|
||||
"echarts": "^4.6.0",
|
||||
"jquery": "^3.4.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"nanoid": "^2.1.11",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
|
|
@ -48,6 +49,7 @@
|
|||
"@types/jest": "^24.0.25",
|
||||
"@types/jquery": "^3.3.29",
|
||||
"@types/js-yaml": "^3.12.2",
|
||||
"@types/lodash.debounce": "^4.0.6",
|
||||
"@types/nanoid": "^2.1.0",
|
||||
"@types/react": "^16.9.17",
|
||||
"@types/react-dom": "^16.9.4",
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
<template>
|
||||
<modal
|
||||
id="modal-add-player"
|
||||
:title="$t('user.player.add-player')"
|
||||
:ok-button-text="$t('general.submit')"
|
||||
flex-footer
|
||||
center
|
||||
@confirm="addPlayer"
|
||||
>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td v-t="'general.player.player-name'" class="key" />
|
||||
<td class="value">
|
||||
<input v-model="name" class="form-control" type="text">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="callout callout-info">
|
||||
<ul class="m-0 p-0 pl-3">
|
||||
<li>{{ rule }}</li>
|
||||
<li>{{ length }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from './Modal.vue'
|
||||
import { toast } from '../scripts/notify'
|
||||
|
||||
export default {
|
||||
name: 'AddPlayerDialog',
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
rule: blessing.extra.rule,
|
||||
length: blessing.extra.length,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async addPlayer() {
|
||||
const { code, message } = await this.$http.post(
|
||||
'/user/player/add',
|
||||
{ name: this.name },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
this.$emit('add')
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
<template>
|
||||
<div class="card mr-3 mb-3 closet-item" :class="{ shadow: selected }">
|
||||
<div class="card-body texture-img" @click="$emit('select')">
|
||||
<img class="card-img-top" :src="previewLink">
|
||||
</div>
|
||||
<div class="card-footer pb-2 pt-2 pl-1 pr-1">
|
||||
<div class="container d-flex justify-content-between">
|
||||
<span data-test="name" :title="name">
|
||||
{{ textureName|truncate }} <small>({{ type }})</small>
|
||||
</span>
|
||||
|
||||
<a class="float-right dropdown">
|
||||
<span
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i class="fas fa-cog text-gray" />
|
||||
</span>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="#" @click="rename">
|
||||
{{ $t('user.renameItem') }}
|
||||
</a>
|
||||
<a class="dropdown-item" href="#" @click="removeClosetItem">
|
||||
{{ $t('user.removeItem') }}
|
||||
</a>
|
||||
<a :href="linkToSkinlib" class="dropdown-item">
|
||||
{{ $t('user.viewInSkinlib') }}
|
||||
</a>
|
||||
<a
|
||||
v-if="type !== 'cape'"
|
||||
class="dropdown-item"
|
||||
href="#"
|
||||
@click="setAsAvatar"
|
||||
>{{ $t('user.setAsAvatar') }}</a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import setAsAvatar from './mixins/setAsAvatar'
|
||||
import removeClosetItem from './mixins/removeClosetItem'
|
||||
import truncateText from './mixins/truncateText'
|
||||
import { showModal, toast } from '../scripts/notify'
|
||||
import { truthy } from '../scripts/validators'
|
||||
|
||||
export default {
|
||||
name: 'ClosetItem',
|
||||
mixins: [
|
||||
removeClosetItem,
|
||||
setAsAvatar,
|
||||
truncateText,
|
||||
],
|
||||
props: {
|
||||
tid: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
validator: value => ['steve', 'alex', 'cape'].includes(value),
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
selected: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
textureName: this.name,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
previewLink() {
|
||||
return `${blessing.base_url}/preview/${this.tid}?height=150`
|
||||
},
|
||||
linkToSkinlib() {
|
||||
return `${blessing.base_url}/skinlib/show/${this.tid}`
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async rename() {
|
||||
let newTextureName
|
||||
try {
|
||||
({ value: newTextureName } = await showModal(
|
||||
{
|
||||
mode: 'prompt',
|
||||
text: this.$t('user.renameClosetItem'),
|
||||
input: this.textureName,
|
||||
validator: truthy(this.$t('skinlib.emptyNewTextureName')),
|
||||
},
|
||||
))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await this.$http.post(
|
||||
`/user/closet/rename/${this.tid}`,
|
||||
{ name: newTextureName },
|
||||
)
|
||||
if (code === 0) {
|
||||
this.textureName = newTextureName
|
||||
toast.success(message)
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus">
|
||||
.closet-item
|
||||
width 235px
|
||||
transition-property box-shadow
|
||||
transition-duration 0.3s
|
||||
&:hover
|
||||
box-shadow 0 .5rem 1rem rgba(0,0,0,.15)
|
||||
cursor pointer
|
||||
|
||||
.fa-cog:hover
|
||||
color #000
|
||||
|
||||
.texture-img
|
||||
background #eff1f0
|
||||
</style>
|
||||
|
|
@ -103,7 +103,11 @@ const Viewer: React.FC<Props> = props => {
|
|||
|
||||
useEffect(() => {
|
||||
const viewer = viewRef.current
|
||||
viewer.capeUrl = props.cape || ''
|
||||
if (props.cape) {
|
||||
viewer.capeUrl = props.cape
|
||||
} else {
|
||||
viewer.playerObject.cape.visible = false
|
||||
}
|
||||
}, [props.cape])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -138,7 +142,7 @@ const Viewer: React.FC<Props> = props => {
|
|||
<h3 className="card-title">
|
||||
<span>{trans('general.texturePreview')}</span>
|
||||
{props.showIndicator && (
|
||||
<span className="badge bg-olive">{indicator}</span>
|
||||
<span className="badge bg-olive ml-1">{indicator}</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className={styles.actions}>
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ export default [
|
|||
},
|
||||
{
|
||||
path: 'user/closet',
|
||||
component: () => import('../views/user/Closet.vue'),
|
||||
el: virtual,
|
||||
react: () => import('../views/user/Closet'),
|
||||
el: '#closet-list',
|
||||
},
|
||||
{
|
||||
path: 'user/player',
|
||||
|
|
|
|||
|
|
@ -7,8 +7,18 @@ export type Player = {
|
|||
|
||||
export type Texture = {
|
||||
tid: number
|
||||
hash: string
|
||||
name: string
|
||||
type: TextureType
|
||||
hash: string
|
||||
size: number
|
||||
uploader: number
|
||||
public: boolean
|
||||
upload_at: string
|
||||
likes: number
|
||||
}
|
||||
|
||||
export type TextureType = 'steve' | 'alex' | 'cape'
|
||||
|
||||
export type ClosetItem = Texture & {
|
||||
pivot: { user_uid: number; texture_tid: number; item_name: string }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,3 @@
|
|||
export function debounce(func: CallableFunction, delay: number) {
|
||||
let timer: number
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(func, delay)
|
||||
}
|
||||
}
|
||||
|
||||
export function queryString(key: string, defaultValue: string = ''): string {
|
||||
const result = new RegExp(`[?&]${key}=([^&]+)`, 'i').exec(location.search)
|
||||
|
||||
|
|
@ -16,8 +8,7 @@ export function queryString(key: string, defaultValue: string = ''): string {
|
|||
}
|
||||
|
||||
export function queryStringify(params: { [key: string]: string }): string {
|
||||
return Object
|
||||
.keys(params)
|
||||
return Object.keys(params)
|
||||
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
|
||||
.join('&')
|
||||
}
|
||||
|
|
|
|||
5
resources/assets/src/styles/_utils.scss
Normal file
5
resources/assets/src/styles/_utils.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@mixin truncate-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<portal selector="#email-verification" :disabled="disablePortal">
|
||||
<email-verification />
|
||||
</portal>
|
||||
|
||||
<portal selector="#closet-list" :disabled="disablePortal">
|
||||
<div class="card card-primary card-tabs">
|
||||
<div class="card-header p-0 pt-1 pl-1">
|
||||
<div class="d-flex justify-content-between">
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link"
|
||||
:class="{ active: category === 'skin' }"
|
||||
href="#"
|
||||
data-toggle="pill"
|
||||
role="tab"
|
||||
@click="switchCategory"
|
||||
>
|
||||
{{ $t('general.skin') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link"
|
||||
:class="{ active: category === 'cape' }"
|
||||
href="#"
|
||||
@click="switchCategory"
|
||||
>
|
||||
{{ $t('general.cape') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item d-none d-md-block">
|
||||
<a
|
||||
v-t="'user.closet.upload'"
|
||||
:href="`${baseUrl}/skinlib/upload`"
|
||||
class="nav-link"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mr-3 my-2 my-lg-0">
|
||||
<input
|
||||
v-model="query"
|
||||
class="form-control mr-sm-2"
|
||||
type="search"
|
||||
aria-label="Search"
|
||||
:placeholder="$t('user.typeToSearch')"
|
||||
@input="search"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div
|
||||
v-if="category === 'skin'"
|
||||
id="skin-category"
|
||||
:class="{ active: category === 'skin' }"
|
||||
>
|
||||
<div v-if="skinItems.length === 0" class="text-center p-3">
|
||||
<div v-if="query !== ''" v-t="'general.noResult'" />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-else v-html="$t('user.emptyClosetMsg', { url: linkToSkin })" />
|
||||
</div>
|
||||
<div v-else class="d-flex flex-wrap">
|
||||
<closet-item
|
||||
v-for="(item, index) in skinItems"
|
||||
:key="item.tid"
|
||||
:tid="item.tid"
|
||||
:name="item.name"
|
||||
:type="item.type"
|
||||
:selected="selectedSkin === item.tid"
|
||||
@select="selectTexture(item.tid)"
|
||||
@item-removed="removeSkinItem(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
id="cape-category"
|
||||
:class="{ active: category === 'cape' }"
|
||||
>
|
||||
<div v-if="capeItems.length === 0" class="text-center p-3">
|
||||
<div v-if="query !== ''" v-t="'general.noResult'" />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-else v-html="$t('user.emptyClosetMsg', { url: linkToCape })" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<closet-item
|
||||
v-for="(item, index) in capeItems"
|
||||
:key="item.tid"
|
||||
:tid="item.tid"
|
||||
:name="item.name"
|
||||
:type="item.type"
|
||||
:selected="selectedCape === item.tid"
|
||||
@select="selectTexture(item.tid)"
|
||||
@item-removed="removeCapeItem(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<paginate
|
||||
v-if="category === 'skin'"
|
||||
v-model="skinCurrentPage"
|
||||
:page-count="skinTotalPages"
|
||||
class="float-right"
|
||||
container-class="pagination pagination-sm no-margin"
|
||||
page-class="page-item"
|
||||
page-link-class="page-link"
|
||||
prev-class="page-item"
|
||||
prev-link-class="page-link"
|
||||
next-class="page-item"
|
||||
next-link-class="page-link"
|
||||
first-button-text="«"
|
||||
prev-text="‹"
|
||||
next-text="›"
|
||||
last-button-text="»"
|
||||
:click-handler="pageChanged"
|
||||
:first-last-button="true"
|
||||
/>
|
||||
<paginate
|
||||
v-else
|
||||
v-model="capeCurrentPages"
|
||||
:page-count="capeTotalPages"
|
||||
class="float-right"
|
||||
container-class="pagination pagination-sm no-margin"
|
||||
page-class="page-item"
|
||||
page-link-class="page-link"
|
||||
prev-class="page-item"
|
||||
prev-link-class="page-link"
|
||||
next-class="page-item"
|
||||
next-link-class="page-link"
|
||||
first-button-text="«"
|
||||
prev-text="‹"
|
||||
next-text="›"
|
||||
last-button-text="»"
|
||||
:click-handler="pageChanged"
|
||||
:first-last-button="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</portal>
|
||||
|
||||
<portal selector="#previewer" :disabled="disablePortal">
|
||||
<previewer
|
||||
closet-mode
|
||||
:skin="skinUrl"
|
||||
:cape="capeUrl"
|
||||
:model="model"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="d-flex justify-content-between">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
data-toggle="modal"
|
||||
data-target="#modal-use-as"
|
||||
@click="fetchPlayersList"
|
||||
>
|
||||
{{ $t('user.useAs') }}
|
||||
</button>
|
||||
<button class="btn btn-default" data-test="resetSelected" @click="resetSelected">
|
||||
{{ $t('user.resetSelected') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</previewer>
|
||||
</portal>
|
||||
|
||||
<portal selector="#modals" :disabled="disablePortal">
|
||||
<apply-to-player-dialog ref="useAs" :skin="selectedSkin" :cape="selectedCape" />
|
||||
<add-player-dialog @add="fetchPlayersList" />
|
||||
</portal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Paginate from 'vuejs-paginate'
|
||||
import { debounce, queryString } from '../../scripts/utils'
|
||||
import Portal from '../../components/Portal'
|
||||
import ClosetItem from '../../components/ClosetItem.vue'
|
||||
import EmailVerification from '../../components/EmailVerification.vue'
|
||||
import AddPlayerDialog from '../../components/AddPlayerDialog.vue'
|
||||
import ApplyToPlayerDialog from '../../components/ApplyToPlayerDialog.vue'
|
||||
import emitMounted from '../../components/mixins/emitMounted'
|
||||
|
||||
export default {
|
||||
name: 'Closet',
|
||||
components: {
|
||||
Portal,
|
||||
Paginate,
|
||||
ClosetItem,
|
||||
Previewer: () => import('../../components/Previewer.vue'),
|
||||
EmailVerification,
|
||||
AddPlayerDialog,
|
||||
ApplyToPlayerDialog,
|
||||
},
|
||||
mixins: [
|
||||
emitMounted,
|
||||
],
|
||||
props: {
|
||||
baseUrl: {
|
||||
type: String,
|
||||
default: blessing.base_url,
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
category: 'skin',
|
||||
query: '',
|
||||
skinItems: [],
|
||||
skinCurrentPage: 1,
|
||||
skinTotalPages: 1,
|
||||
capeItems: [],
|
||||
capeCurrentPages: 1,
|
||||
capeTotalPages: 1,
|
||||
selectedSkin: 0,
|
||||
skinUrl: '',
|
||||
model: 'steve',
|
||||
selectedCape: 0,
|
||||
capeUrl: '',
|
||||
linkToSkin: `${blessing.base_url}/skinlib?filter=skin`,
|
||||
linkToCape: `${blessing.base_url}/skinlib?filter=cape`,
|
||||
disablePortal: process.env.NODE_ENV === 'test',
|
||||
}),
|
||||
created() {
|
||||
this.search = debounce(this.loadCloset, 350)
|
||||
},
|
||||
beforeMount() {
|
||||
this.loadCloset()
|
||||
},
|
||||
mounted() {
|
||||
const tid = +queryString('tid', 0)
|
||||
if (tid) {
|
||||
this.selectTexture(tid)
|
||||
this.fetchPlayersList()
|
||||
$('#modal-use-as').modal()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/* istanbul ignore next */
|
||||
search() {}, // eslint-disable-line no-empty-function
|
||||
async loadCloset(page = 1) {
|
||||
const {
|
||||
data: {
|
||||
items, category, total_pages: totalPages,
|
||||
},
|
||||
} = await this.$http.get(
|
||||
'/user/closet/list',
|
||||
{
|
||||
category: this.category,
|
||||
q: this.query,
|
||||
page,
|
||||
},
|
||||
)
|
||||
this[`${category}TotalPages`] = totalPages
|
||||
this[`${category}Items`] = items
|
||||
this[`${category}CurrentPages`] = page
|
||||
},
|
||||
removeSkinItem(index) {
|
||||
this.$delete(this.skinItems, index)
|
||||
},
|
||||
removeCapeItem(index) {
|
||||
this.$delete(this.capeItems, index)
|
||||
},
|
||||
switchCategory() {
|
||||
this.category = this.category === 'skin' ? 'cape' : 'skin'
|
||||
this.loadCloset(this[`${this.category}CurrentPages`])
|
||||
},
|
||||
pageChanged(page) {
|
||||
this.loadCloset(page)
|
||||
},
|
||||
async selectTexture(tid) {
|
||||
const { data: { type, hash } } = await this.$http.get(`/skinlib/info/${tid}`)
|
||||
if (type === 'cape') {
|
||||
this.capeUrl = `/textures/${hash}`
|
||||
this.selectedCape = tid
|
||||
} else {
|
||||
this.skinUrl = `/textures/${hash}`
|
||||
this.selectedSkin = tid
|
||||
this.model = type
|
||||
}
|
||||
},
|
||||
resetSelected() {
|
||||
this.selectedSkin = 0
|
||||
this.selectedCape = 0
|
||||
this.skinUrl = ''
|
||||
this.capeUrl = ''
|
||||
},
|
||||
fetchPlayersList() {
|
||||
this.$refs.useAs.fetchList()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
@use '../../../styles/utils';
|
||||
|
||||
.item {
|
||||
width: 235px;
|
||||
transition-property: box-shadow;
|
||||
transition-duration: 0.3s;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.bg {
|
||||
background-color: #eff1f0;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
@include utils.truncate-text;
|
||||
}
|
||||
98
resources/assets/src/views/user/Closet/ClosetItem.tsx
Normal file
98
resources/assets/src/views/user/Closet/ClosetItem.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import React from 'react'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { showModal, toast } from '@/scripts/notify'
|
||||
import { ClosetItem } from '@/scripts/types'
|
||||
import styles from './ClosetItem.module.scss'
|
||||
|
||||
interface Props {
|
||||
item: ClosetItem
|
||||
selected: boolean
|
||||
onClick(item: ClosetItem): void
|
||||
onRename(): void
|
||||
onRemove(): void
|
||||
}
|
||||
|
||||
const ClosetItem: React.FC<Props> = props => {
|
||||
const { item } = props
|
||||
|
||||
const handleItemClick = () => {
|
||||
props.onClick(item)
|
||||
}
|
||||
|
||||
const setAsAvatar = async () => {
|
||||
try {
|
||||
await showModal({
|
||||
title: t('user.setAvatar'),
|
||||
text: t('user.setAvatarNotice'),
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
'/user/profile/avatar',
|
||||
{ tid: item.tid },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
document
|
||||
.querySelectorAll<HTMLImageElement>('[alt="User Image"]')
|
||||
.forEach(el => (el.src += `?${new Date().getTime()}`))
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`card mr-3 mb-3 ${styles.item} ${
|
||||
props.selected ? 'shadow' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`card-body ${styles.bg}`} onClick={handleItemClick}>
|
||||
<img
|
||||
src={`${blessing.base_url}/preview/${item.tid}?height=150`}
|
||||
alt={item.pivot.item_name}
|
||||
className="card-img-top"
|
||||
/>
|
||||
</div>
|
||||
<div className="card-footer pb-2 pt-2 pl-1 pr-1">
|
||||
<div className="container d-flex justify-content-between">
|
||||
<span className={styles.truncate} title={item.pivot.item_name}>
|
||||
{item.pivot.item_name}
|
||||
</span>
|
||||
<span className="d-inline-block drop-down">
|
||||
<span
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i className={`fas fa-cog text-gray ${styles.icon}`} />
|
||||
</span>
|
||||
<div className="dropdown-menu">
|
||||
<a href="#" className="dropdown-item" onClick={props.onRename}>
|
||||
{t('user.renameItem')}
|
||||
</a>
|
||||
<a href="#" className="dropdown-item" onClick={props.onRemove}>
|
||||
{t('user.removeItem')}
|
||||
</a>
|
||||
<a
|
||||
href={`${blessing.base_url}/skinlib/show/${item.tid}`}
|
||||
className="dropdown-item"
|
||||
target="_blank"
|
||||
>
|
||||
{t('user.viewInSkinlib')}
|
||||
</a>
|
||||
<a href="#" className="dropdown-item" onClick={setAsAvatar}>
|
||||
{t('user.setAsAvatar')}
|
||||
</a>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClosetItem
|
||||
108
resources/assets/src/views/user/Closet/ModalApply.tsx
Normal file
108
resources/assets/src/views/user/Closet/ModalApply.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import $ from 'jquery'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { toast } from '@/scripts/notify'
|
||||
import { Player } from '@/scripts/types'
|
||||
import Loading from '@/components/Loading'
|
||||
import Modal from '@/components/Modal'
|
||||
|
||||
const baseUrl = blessing.base_url
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
canAdd: boolean
|
||||
skin?: number
|
||||
cape?: number
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
const ModalApply: React.FC<Props> = props => {
|
||||
const [players, setPlayers] = useState<Player[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.show) {
|
||||
return
|
||||
}
|
||||
|
||||
const getPlayers = async () => {
|
||||
setIsLoading(true)
|
||||
const { data } = await fetch.get<fetch.ResponseBody<Player[]>>(
|
||||
'/user/player/list',
|
||||
)
|
||||
setPlayers(data)
|
||||
setIsLoading(false)
|
||||
}
|
||||
getPlayers()
|
||||
}, [props.show])
|
||||
|
||||
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(event.target.value)
|
||||
}
|
||||
|
||||
const handleSelect = async (player: Player) => {
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
`/user/player/set/${player.pid}`,
|
||||
{
|
||||
skin: props.skin,
|
||||
cape: props.cape,
|
||||
},
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
$('#modal-apply').modal('hide')
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show={props.show}
|
||||
id="modal-apply"
|
||||
title={t('user.closet.use-as.title')}
|
||||
flexFooter
|
||||
footer={<></>}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : players.length === 0 ? (
|
||||
<p>{t('user.closet.use-as.empty')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={t('user.typeToSearch')}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
{players
|
||||
.filter(player => player.name.includes(search))
|
||||
.map(player => (
|
||||
<button
|
||||
key={player.pid}
|
||||
className="btn btn-block btn-outline-info text-left"
|
||||
title={player.name}
|
||||
onClick={() => handleSelect(player)}
|
||||
>
|
||||
<img
|
||||
src={`${baseUrl}/avatar/${player.tid_skin}?3d&size=45`}
|
||||
alt={player.name}
|
||||
width={45}
|
||||
height={45}
|
||||
/>
|
||||
<span className="ml-1">{player.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalApply
|
||||
38
resources/assets/src/views/user/Closet/Previewer.tsx
Normal file
38
resources/assets/src/views/user/Closet/Previewer.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React, { useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import ViewerSkeleton from '@/components/ViewerSkeleton'
|
||||
|
||||
const Viewer = React.lazy(() => import('@/components/Viewer'))
|
||||
|
||||
interface Props {
|
||||
skin?: string
|
||||
cape?: string
|
||||
isAlex: boolean
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
|
||||
const Previewer: React.FC<Props> = props => {
|
||||
useEffect(() => {
|
||||
const mount = document.querySelector('#previewer')!
|
||||
mount.appendChild(container)
|
||||
|
||||
return () => {
|
||||
mount.removeChild(container)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const skin = props.skin ? `${blessing.base_url}/textures/${props.skin}` : ''
|
||||
const cape = props.cape ? `${blessing.base_url}/textures/${props.cape}` : ''
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<React.Suspense fallback={<ViewerSkeleton />}>
|
||||
<Viewer skin={skin} cape={cape} showIndicator>
|
||||
{props.children}
|
||||
</Viewer>
|
||||
</React.Suspense>,
|
||||
container,
|
||||
)
|
||||
}
|
||||
|
||||
export default Previewer
|
||||
275
resources/assets/src/views/user/Closet/index.tsx
Normal file
275
resources/assets/src/views/user/Closet/index.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { hot } from 'react-hot-loader/root'
|
||||
import debounce from 'lodash.debounce'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { showModal, toast } from '@/scripts/notify'
|
||||
import { ClosetItem as Item, Texture } from '@/scripts/types'
|
||||
import Loading from '@/components/Loading'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import ClosetItem from './ClosetItem'
|
||||
import Previewer from './Previewer'
|
||||
import ModalApply from './ModalApply'
|
||||
|
||||
type Category = 'skin' | 'cape'
|
||||
|
||||
const updater = debounce(
|
||||
<T extends unknown>(
|
||||
value: React.SetStateAction<T>,
|
||||
setter: React.Dispatch<React.SetStateAction<T>>,
|
||||
) => setter(value),
|
||||
350,
|
||||
)
|
||||
|
||||
const Closet: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [category, setCategory] = useState<Category>('skin')
|
||||
const [search, setSearch] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const [skin, setSkin] = useState<Texture | null>(null)
|
||||
const [cape, setCape] = useState<Texture | null>(null)
|
||||
const [showModalApply, setShowModalApply] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const getItems = async () => {
|
||||
setIsLoading(true)
|
||||
const {
|
||||
data: { items, category: c, total_pages: totalPages },
|
||||
} = await fetch.get<
|
||||
fetch.ResponseBody<{
|
||||
items: Item[]
|
||||
category: Category
|
||||
total_pages: number
|
||||
}>
|
||||
>('/user/closet/list', {
|
||||
category,
|
||||
q: query,
|
||||
page,
|
||||
})
|
||||
|
||||
setItems(items)
|
||||
setCategory(c)
|
||||
setTotalPages(totalPages)
|
||||
setIsLoading(false)
|
||||
}
|
||||
getItems()
|
||||
}, [category, query, page])
|
||||
|
||||
const switchCategory = () => {
|
||||
setCategory(category => (category === 'skin' ? 'cape' : 'skin'))
|
||||
}
|
||||
|
||||
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setSearch(value)
|
||||
updater(value, setQuery)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => setPage(page)
|
||||
|
||||
const isSelected = (item: Item): boolean => {
|
||||
if (category === 'skin') {
|
||||
return item.tid === skin?.tid
|
||||
} else {
|
||||
return item.tid === cape?.tid
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (item: Item) => {
|
||||
if (item.type === 'cape') {
|
||||
setCape(item)
|
||||
} else {
|
||||
setSkin(item)
|
||||
}
|
||||
}
|
||||
|
||||
const resetSelected = () => {
|
||||
setSkin(null)
|
||||
setCape(null)
|
||||
}
|
||||
|
||||
const renameItem = async (item: Item, index: number) => {
|
||||
let name: string
|
||||
try {
|
||||
const { value } = await showModal({
|
||||
mode: 'prompt',
|
||||
text: t('user.renameClosetItem'),
|
||||
input: item.pivot.item_name,
|
||||
validator: (value: string) => {
|
||||
if (!value) {
|
||||
return t('skinlib.emptyNewTextureName')
|
||||
}
|
||||
},
|
||||
})
|
||||
name = value
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
`/user/closet/rename/${item.tid}`,
|
||||
{ name },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
setItems(items => {
|
||||
items[index] = Object.assign({}, item, {
|
||||
pivot: Object.assign({}, item.pivot, { item_name: name }),
|
||||
})
|
||||
return items.slice()
|
||||
})
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const removeItem = async (item: Item) => {
|
||||
try {
|
||||
await showModal({
|
||||
text: t('user.removeFromClosetNotice'),
|
||||
okButtonType: 'danger',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||
`/user/closet/remove/${item.tid}`,
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
const { tid } = item
|
||||
setItems(items => items.filter(item => item.tid !== tid))
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const applyToPlayer = () => {
|
||||
if (!skin && !cape) {
|
||||
toast.info(t('user.emptySelectedTexture'))
|
||||
return
|
||||
}
|
||||
setShowModalApply(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card card-primary card-tabs">
|
||||
<div className="card-header p-0 pt-1 pl-1">
|
||||
<div className="d-flex justify-content-between">
|
||||
<ul className="nav nav-tabs" role="tablist">
|
||||
<li className="nav-item">
|
||||
<a
|
||||
href="#"
|
||||
className={`nav-link ${category === 'skin' ? 'active' : ''}`}
|
||||
data-toggle="pill"
|
||||
role="tab"
|
||||
onClick={switchCategory}
|
||||
>
|
||||
{t('general.skin')}
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a
|
||||
href="#"
|
||||
className={`nav-link ${category === 'cape' ? 'active' : ''}`}
|
||||
data-toggle="pill"
|
||||
role="tab"
|
||||
onClick={switchCategory}
|
||||
>
|
||||
{t('general.cape')}
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item d-none d-md-block">
|
||||
<a
|
||||
href={`${blessing.base_url}/skinlib/upload`}
|
||||
className="nav-link"
|
||||
>
|
||||
{t('user.closet.upload')}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mr-3 my-2 my-lg-0">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
className="form-control mr-sm-2"
|
||||
aria-label="Search"
|
||||
placeholder={t('user.typeToSearch')}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center p-3">
|
||||
{search ? (
|
||||
t('general.noResult')
|
||||
) : (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('user.emptyClosetMsg', {
|
||||
url: `${blessing.base_url}/skinlib?filter=${category}`,
|
||||
}),
|
||||
}}
|
||||
></span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="d-flex flex-wrap">
|
||||
{items.map((item, i) => (
|
||||
<ClosetItem
|
||||
key={item.tid}
|
||||
item={item}
|
||||
selected={isSelected(item)}
|
||||
onClick={handleSelect}
|
||||
onRename={() => renameItem(item, i)}
|
||||
onRemove={() => removeItem(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<div className="float-right">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Previewer
|
||||
skin={skin?.hash}
|
||||
cape={cape?.hash}
|
||||
isAlex={skin?.type === 'alex'}
|
||||
>
|
||||
<div className="d-flex justify-content-between">
|
||||
<button className="btn btn-primary" onClick={applyToPlayer}>
|
||||
{t('user.useAs')}
|
||||
</button>
|
||||
<button className="btn btn-default" onClick={resetSelected}>
|
||||
{t('user.resetSelected')}
|
||||
</button>
|
||||
</div>
|
||||
</Previewer>
|
||||
<ModalApply
|
||||
show={showModalApply}
|
||||
canAdd
|
||||
skin={skin?.tid}
|
||||
cape={cape?.tid}
|
||||
onClose={() => setShowModalApply(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default hot(Closet)
|
||||
3
resources/assets/tests/__mocks__/lodash.debounce.ts
Normal file
3
resources/assets/tests/__mocks__/lodash.debounce.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function debounce(fn: Function, _?: number) {
|
||||
return fn
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '../utils'
|
||||
import { toast } from '@/scripts/notify'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import AddPlayerDialog from '@/components/AddPlayerDialog.vue'
|
||||
|
||||
jest.mock('@/scripts/notify')
|
||||
|
||||
window.blessing.extra = {
|
||||
rule: 'rule',
|
||||
length: 'length',
|
||||
}
|
||||
|
||||
test('add player', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValueOnce([])
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ code: 1, message: 'fail' })
|
||||
.mockResolvedValue({ code: 0, message: 'ok' })
|
||||
const wrapper = mount(AddPlayerDialog)
|
||||
const modal = wrapper.find(Modal)
|
||||
wrapper.find('input[type="text"]').setValue('the-new')
|
||||
|
||||
modal.vm.$emit('confirm')
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith(
|
||||
'/user/player/add',
|
||||
{ name: 'the-new' },
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('the-new')
|
||||
expect(toast.error).toBeCalledWith('fail')
|
||||
|
||||
modal.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted().add).toBeDefined()
|
||||
expect(toast.success).toBeCalledWith('ok')
|
||||
})
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '../utils'
|
||||
import { showModal } from '@/scripts/notify'
|
||||
import ClosetItem from '@/components/ClosetItem.vue'
|
||||
|
||||
jest.mock('@/scripts/notify')
|
||||
|
||||
function factory(opt = {}) {
|
||||
return {
|
||||
tid: 1,
|
||||
name: 'texture',
|
||||
type: 'steve',
|
||||
...opt,
|
||||
}
|
||||
}
|
||||
|
||||
test('computed values', () => {
|
||||
const wrapper = mount(ClosetItem, { propsData: factory() })
|
||||
expect(wrapper.find('img').attributes('src')).toBe('/preview/1?height=150')
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('.dropdown-item')
|
||||
.at(2)
|
||||
.attributes('href'),
|
||||
).toBe('/skinlib/show/1')
|
||||
})
|
||||
|
||||
test('selected item', () => {
|
||||
const wrapper = mount(ClosetItem, { propsData: factory({ selected: true }) })
|
||||
expect(wrapper.find('.card').classes('shadow')).toBeTrue()
|
||||
})
|
||||
|
||||
test('click item body', () => {
|
||||
const wrapper = mount(ClosetItem, { propsData: factory() })
|
||||
|
||||
wrapper.find('.card').trigger('click')
|
||||
expect(wrapper.emitted().select).toBeUndefined()
|
||||
|
||||
wrapper.find('.card-body').trigger('click')
|
||||
expect(wrapper.emitted().select).toBeTruthy()
|
||||
})
|
||||
|
||||
test('rename texture', async () => {
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ code: 0 })
|
||||
.mockResolvedValueOnce({ code: 1 })
|
||||
showModal
|
||||
.mockRejectedValueOnce(null)
|
||||
.mockResolvedValue({ value: 'new-name' })
|
||||
const wrapper = mount(ClosetItem, { propsData: factory() })
|
||||
const button = wrapper.findAll('.dropdown-item').at(0)
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).not.toBeCalled()
|
||||
|
||||
// Warning message
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-test="name"]').text()).toBe('new-name (steve)')
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith(
|
||||
'/user/closet/rename/1',
|
||||
{ name: 'new-name' },
|
||||
)
|
||||
})
|
||||
|
||||
test('remove texture', async () => {
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ code: 0 })
|
||||
.mockResolvedValueOnce({ code: 1 })
|
||||
showModal
|
||||
.mockRejectedValueOnce(null)
|
||||
.mockResolvedValue({ value: '' })
|
||||
|
||||
const wrapper = mount(ClosetItem, { propsData: factory() })
|
||||
const button = wrapper.findAll('.dropdown-item').at(1)
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).not.toBeCalled()
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted()['item-removed']).toBeTruthy()
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith('/user/closet/remove/1')
|
||||
})
|
||||
|
||||
test('set as avatar', async () => {
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ code: 0 })
|
||||
.mockResolvedValueOnce({ code: 1 })
|
||||
showModal
|
||||
.mockRejectedValueOnce(null)
|
||||
.mockResolvedValue({ value: '' })
|
||||
|
||||
const wrapper = mount(ClosetItem, { propsData: factory() })
|
||||
const button = wrapper.findAll('.dropdown-item').at(3)
|
||||
document.body.innerHTML += '<img alt="User Image" src="a">'
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).not.toBeCalled()
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith('/user/profile/avatar', { tid: 1 })
|
||||
expect(document.querySelector('img')!.src).toMatch(/\d+$/)
|
||||
})
|
||||
|
||||
test('no avatar option if texture is cape', () => {
|
||||
const wrapper = mount(ClosetItem, { propsData: factory({ type: 'cape' }) })
|
||||
expect(wrapper.findAll('.dropdown-item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('truncate too long texture name', () => {
|
||||
const wrapper = mount(ClosetItem, {
|
||||
propsData: factory({ name: 'very-very-long-texture-name' }),
|
||||
})
|
||||
expect(wrapper.text()).toContain('very-very-long-...')
|
||||
})
|
||||
|
|
@ -1,16 +1,5 @@
|
|||
import * as utils from '@/scripts/utils'
|
||||
|
||||
test('debounce', () => {
|
||||
const stub = jest.fn()
|
||||
const debounced = utils.debounce(stub, 2000)
|
||||
|
||||
debounced()
|
||||
debounced()
|
||||
expect(stub).not.toBeCalled()
|
||||
jest.runAllTimers()
|
||||
expect(stub).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
test('queryString', () => {
|
||||
history.pushState({}, 'page', `${location.href}?key=value`)
|
||||
expect(utils.queryString('key')).toBe('value')
|
||||
|
|
|
|||
|
|
@ -1,255 +0,0 @@
|
|||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '../../utils'
|
||||
import { trans } from '@/scripts/i18n'
|
||||
import Closet from '@/views/user/Closet.vue'
|
||||
import ClosetItem from '@/components/ClosetItem.vue'
|
||||
import Previewer from '@/components/Previewer.vue'
|
||||
|
||||
beforeEach(() => {
|
||||
window.blessing.extra = { unverified: false }
|
||||
})
|
||||
|
||||
test('fetch closet data before mount', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: {} })
|
||||
mount(Closet)
|
||||
jest.runAllTicks()
|
||||
expect(Vue.prototype.$http.get).toBeCalledWith(
|
||||
'/user/closet/list',
|
||||
{
|
||||
category: 'skin',
|
||||
q: '',
|
||||
page: 1,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('switch tabs', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({
|
||||
data: {
|
||||
items: [],
|
||||
category: 'skin',
|
||||
total_pages: 1,
|
||||
},
|
||||
}).mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [],
|
||||
category: 'cape',
|
||||
total_pages: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(Closet)
|
||||
|
||||
wrapper
|
||||
.findAll('.nav-link')
|
||||
.at(0)
|
||||
.trigger('click')
|
||||
jest.runAllTicks()
|
||||
expect(Vue.prototype.$http.get).toBeCalledWith(
|
||||
'/user/closet/list',
|
||||
{
|
||||
category: 'skin',
|
||||
q: '',
|
||||
page: 1,
|
||||
},
|
||||
)
|
||||
wrapper
|
||||
.findAll('.nav-link')
|
||||
.at(1)
|
||||
.trigger('click')
|
||||
jest.runAllTicks()
|
||||
expect(Vue.prototype.$http.get).toBeCalledWith(
|
||||
'/user/closet/list',
|
||||
{
|
||||
category: 'cape',
|
||||
q: '',
|
||||
page: 1,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('different categories', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: {} })
|
||||
|
||||
const wrapper = mount(Closet)
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('.nav-link')
|
||||
.at(0)
|
||||
.classes('active'),
|
||||
).toBeTrue()
|
||||
expect(wrapper.find('#skin-category').classes('active')).toBeTrue()
|
||||
|
||||
wrapper.setData({ category: 'cape' })
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('.nav-link')
|
||||
.at(1)
|
||||
.classes('active'),
|
||||
).toBeTrue()
|
||||
expect(wrapper.find('#cape-category').classes('active')).toBeTrue()
|
||||
})
|
||||
|
||||
test('search textures', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: {} })
|
||||
|
||||
const wrapper = mount(Closet)
|
||||
const input = wrapper.find('input')
|
||||
;(input.element as HTMLInputElement).value = 'q'
|
||||
input.trigger('input')
|
||||
jest.runAllTimers()
|
||||
jest.runAllTicks()
|
||||
expect(Vue.prototype.$http.get).toBeCalledWith(
|
||||
'/user/closet/list',
|
||||
{
|
||||
category: 'skin',
|
||||
q: 'q',
|
||||
page: 1,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('empty closet', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: {} })
|
||||
const wrapper = mount(Closet)
|
||||
// expect(wrapper.find('#skin-category').text()).toContain(trans('user.emptyClosetMsg'))
|
||||
wrapper.setData({ category: 'cape' })
|
||||
// expect(wrapper.find('#cape-category').text()).toContain(trans('user.emptyClosetMsg'))
|
||||
})
|
||||
|
||||
test('no matched search result', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: {} })
|
||||
const wrapper = mount(Closet)
|
||||
wrapper.setData({ query: 'q' })
|
||||
expect(wrapper.find('#skin-category').text()).toContain(trans('general.noResult'))
|
||||
wrapper.setData({ category: 'cape' })
|
||||
expect(wrapper.find('#cape-category').text()).toContain(trans('general.noResult'))
|
||||
})
|
||||
|
||||
test('render items', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({
|
||||
data: {
|
||||
items: [
|
||||
{ tid: 1 },
|
||||
{ tid: 2 },
|
||||
],
|
||||
category: 'skin',
|
||||
total_pages: 1,
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Closet)
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll(ClosetItem)).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('reload closet when page changed', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: {} })
|
||||
const wrapper = mount<Vue & { pageChanged(): void }>(Closet)
|
||||
wrapper.vm.pageChanged()
|
||||
jest.runAllTicks()
|
||||
expect(Vue.prototype.$http.get).toBeCalledTimes(2)
|
||||
})
|
||||
|
||||
test('remove skin item', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: {} })
|
||||
const wrapper = mount<Vue & { removeSkinItem(tid: number): void }>(Closet)
|
||||
wrapper.setData({ skinItems: [{ tid: 1 }] })
|
||||
wrapper.vm.removeSkinItem(0)
|
||||
// expect(wrapper.find('#skin-category').text()).toContain(trans('user.emptyClosetMsg'))
|
||||
})
|
||||
|
||||
test('remove cape item', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: {} })
|
||||
const wrapper = mount<Vue & { removeCapeItem(tid: number): void }>(Closet)
|
||||
wrapper.setData({ capeItems: [{ tid: 1 }], category: 'cape' })
|
||||
wrapper.vm.removeCapeItem(0)
|
||||
// expect(wrapper.find('#cape-category').text()).toContain(trans('user.emptyClosetMsg'))
|
||||
})
|
||||
|
||||
test('select texture', async () => {
|
||||
Vue.prototype.$http.get
|
||||
.mockResolvedValueOnce({ data: {} })
|
||||
.mockResolvedValueOnce({ data: { type: 'steve', hash: 'a' } })
|
||||
.mockResolvedValueOnce({ data: { type: 'cape', hash: 'b' } })
|
||||
|
||||
const wrapper = mount<Vue & { skinUrl: string, capeUrl: string }>(Closet)
|
||||
wrapper.setData({ skinItems: [{ tid: 1 }] })
|
||||
wrapper.find(ClosetItem).vm.$emit('select')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.get).toBeCalledWith('/skinlib/info/1')
|
||||
expect(wrapper.vm.skinUrl).toBe('/textures/a')
|
||||
|
||||
wrapper.setData({
|
||||
skinItems: [], capeItems: [{ tid: 2 }], category: 'cape',
|
||||
})
|
||||
wrapper.find(ClosetItem).vm.$emit('select')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.get).toBeCalledWith('/skinlib/info/2')
|
||||
expect(wrapper.vm.capeUrl).toBe('/textures/b')
|
||||
})
|
||||
|
||||
test('apply texture', async () => {
|
||||
Vue.prototype.$http.get
|
||||
.mockResolvedValueOnce({ data: {} })
|
||||
.mockResolvedValueOnce({ data: [] })
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
pid: 1, name: 'name', tid_skin: 10,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const wrapper = mount(Closet)
|
||||
const button = wrapper.find(Previewer).findAll('button')
|
||||
.at(0)
|
||||
button.trigger('click')
|
||||
jest.runAllTicks()
|
||||
expect(wrapper.find('.modal-body').text()).toContain(trans('user.closet.use-as.empty'))
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.btn-outline-info').text()).toContain('name')
|
||||
expect(wrapper.find('.btn-outline-info > img').attributes('src'))
|
||||
.toBe('/avatar/10?3d&size=45')
|
||||
})
|
||||
|
||||
test('reset selected texture', () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: {} })
|
||||
const wrapper = mount(Closet)
|
||||
wrapper.setData({
|
||||
selectedSkin: 1,
|
||||
selectedCape: 2,
|
||||
skinUrl: 'a',
|
||||
capeUrl: 'b',
|
||||
})
|
||||
wrapper.find('[data-test="resetSelected"]').trigger('click')
|
||||
expect(wrapper.vm).toEqual(expect.objectContaining({
|
||||
selectedSkin: 0,
|
||||
selectedCape: 0,
|
||||
skinUrl: '',
|
||||
capeUrl: '',
|
||||
}))
|
||||
})
|
||||
|
||||
test('select specified texture initially', async () => {
|
||||
window.history.pushState({}, 'title', `${location.href}?tid=1`)
|
||||
window.$ = jest.fn(() => ({
|
||||
modal() {},
|
||||
}))
|
||||
Vue.prototype.$http.get
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [],
|
||||
category: 'skin',
|
||||
total_pages: 1,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { type: 'cape', hash: '' } })
|
||||
.mockResolvedValueOnce([])
|
||||
mount(Closet)
|
||||
jest.runAllTimers()
|
||||
await flushPromises()
|
||||
jest.unmock('@/scripts/utils')
|
||||
})
|
||||
521
resources/assets/tests/views/user/Closet.test.tsx
Normal file
521
resources/assets/tests/views/user/Closet.test.tsx
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import React from 'react'
|
||||
import { render, fireEvent, wait } from '@testing-library/react'
|
||||
import $ from 'jquery'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { ClosetItem, Player } from '@/scripts/types'
|
||||
import Closet from '@/views/user/Closet'
|
||||
|
||||
jest.mock('@/scripts/net')
|
||||
|
||||
const fixtureSkin: Readonly<ClosetItem> = Object.freeze<ClosetItem>({
|
||||
tid: 1,
|
||||
name: 'skin',
|
||||
type: 'steve',
|
||||
hash: 'abc',
|
||||
size: 2,
|
||||
uploader: 1,
|
||||
public: true,
|
||||
upload_at: new Date().toString(),
|
||||
likes: 1,
|
||||
pivot: {
|
||||
user_uid: 1,
|
||||
texture_tid: 1,
|
||||
item_name: 'closet_skin',
|
||||
},
|
||||
})
|
||||
|
||||
const fixtureCape: Readonly<ClosetItem> = Object.freeze<ClosetItem>({
|
||||
tid: 2,
|
||||
name: 'cape',
|
||||
type: 'cape',
|
||||
hash: 'def',
|
||||
size: 2,
|
||||
uploader: 1,
|
||||
public: true,
|
||||
upload_at: new Date().toString(),
|
||||
likes: 1,
|
||||
pivot: {
|
||||
user_uid: 1,
|
||||
texture_tid: 2,
|
||||
item_name: 'closet_cape',
|
||||
},
|
||||
})
|
||||
|
||||
const fixturePlayer: Readonly<Player> = Object.freeze<Player>({
|
||||
pid: 1,
|
||||
name: 'kumiko',
|
||||
tid_skin: 1,
|
||||
tid_cape: 2,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
const container = document.createElement('div')
|
||||
container.id = 'previewer'
|
||||
document.body.appendChild(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector('#previewer')!.remove()
|
||||
})
|
||||
|
||||
test('loading indicator', () => {
|
||||
fetch.get.mockResolvedValue({
|
||||
data: { items: [], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
const { queryByTitle } = render(<Closet />)
|
||||
expect(queryByTitle('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('empty closet', async () => {
|
||||
fetch.get.mockResolvedValue({
|
||||
data: { items: [], category: 'skin', total_pages: 0 },
|
||||
})
|
||||
|
||||
const { queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
expect(queryByText(/skin library/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('categories', async () => {
|
||||
fetch.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [fixtureCape], category: 'cape', total_pages: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
|
||||
const { getByText, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
|
||||
expect(queryByText(fixtureCape.pivot.item_name)).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(getByText(t('general.cape')))
|
||||
await wait()
|
||||
expect(queryByText(fixtureSkin.pivot.item_name)).not.toBeInTheDocument()
|
||||
expect(queryByText(fixtureCape.pivot.item_name)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(getByText(t('general.skin')))
|
||||
await wait()
|
||||
expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
|
||||
expect(queryByText(fixtureCape.pivot.item_name)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('search textures', async () => {
|
||||
fetch.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [], category: 'skin', total_pages: 0 },
|
||||
})
|
||||
|
||||
const { getByPlaceholderText, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.input(getByPlaceholderText(t('user.typeToSearch')), {
|
||||
target: { value: 'abc' },
|
||||
})
|
||||
await wait()
|
||||
|
||||
expect(queryByText(/no result/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('switch page', async () => {
|
||||
fetch.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [], category: 'skin', total_pages: 2 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 2 },
|
||||
})
|
||||
|
||||
const { getByText, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
fireEvent.click(getByText('2'))
|
||||
await wait()
|
||||
|
||||
expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('rename item', () => {
|
||||
beforeEach(() => {
|
||||
fetch.get.mockResolvedValue({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('succeeded', async () => {
|
||||
fetch.post.mockResolvedValue({ code: 0, message: 'success' })
|
||||
|
||||
const { getByText, getByDisplayValue, getByRole, queryByText } = render(
|
||||
<Closet />,
|
||||
)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('user.renameItem')))
|
||||
fireEvent.input(getByDisplayValue(fixtureSkin.pivot.item_name), {
|
||||
target: { value: 'my skin' },
|
||||
})
|
||||
fireEvent.click(getByText(t('general.confirm')))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).toBeCalledWith(
|
||||
`/user/closet/rename/${fixtureSkin.tid}`,
|
||||
{ name: 'my skin' },
|
||||
)
|
||||
expect(queryByText('my skin')).toBeInTheDocument()
|
||||
expect(queryByText('success')).toBeInTheDocument()
|
||||
expect(getByRole('status')).toHaveClass('alert-success')
|
||||
})
|
||||
|
||||
it('empty name', async () => {
|
||||
fetch.post.mockResolvedValue({ code: 0, message: 'success' })
|
||||
|
||||
const { getByText, getByDisplayValue, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('user.renameItem')))
|
||||
fireEvent.input(getByDisplayValue(fixtureSkin.pivot.item_name), {
|
||||
target: { value: '' },
|
||||
})
|
||||
fireEvent.click(getByText(t('general.confirm')))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).not.toBeCalled()
|
||||
expect(queryByText(t('skinlib.emptyNewTextureName'))).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(getByText(t('general.cancel')))
|
||||
})
|
||||
|
||||
it('failed', async () => {
|
||||
fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
|
||||
|
||||
const { getByText, getByDisplayValue, getByRole, queryByText } = render(
|
||||
<Closet />,
|
||||
)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('user.renameItem')))
|
||||
fireEvent.input(getByDisplayValue(fixtureSkin.pivot.item_name), {
|
||||
target: { value: 'my skin' },
|
||||
})
|
||||
fireEvent.click(getByText(t('general.confirm')))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).toBeCalledWith(
|
||||
`/user/closet/rename/${fixtureSkin.tid}`,
|
||||
{ name: 'my skin' },
|
||||
)
|
||||
expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
|
||||
expect(queryByText('failed')).toBeInTheDocument()
|
||||
expect(getByRole('alert')).toHaveClass('alert-danger')
|
||||
})
|
||||
|
||||
it('cancelled', async () => {
|
||||
const { getByText, getByDisplayValue, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('user.renameItem')))
|
||||
fireEvent.input(getByDisplayValue(fixtureSkin.pivot.item_name), {
|
||||
target: { value: 'my skin' },
|
||||
})
|
||||
fireEvent.click(getByText(t('general.cancel')))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).not.toBeCalled()
|
||||
expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('remove item', () => {
|
||||
beforeEach(() => {
|
||||
fetch.get.mockResolvedValue({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('succeeded', async () => {
|
||||
fetch.post.mockResolvedValue({ code: 0, message: 'success' })
|
||||
|
||||
const { getByText, getByRole, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('user.removeItem')))
|
||||
fireEvent.click(getByText(t('general.confirm')))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).toBeCalledWith(`/user/closet/remove/${fixtureSkin.tid}`)
|
||||
expect(queryByText(/skin library/i)).toBeInTheDocument()
|
||||
expect(queryByText('success')).toBeInTheDocument()
|
||||
expect(getByRole('status')).toHaveClass('alert-success')
|
||||
})
|
||||
|
||||
it('failed', async () => {
|
||||
fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
|
||||
|
||||
const { getByText, getByRole, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('user.removeItem')))
|
||||
fireEvent.click(getByText(t('general.confirm')))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).toBeCalledWith(`/user/closet/remove/${fixtureSkin.tid}`)
|
||||
expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
|
||||
expect(queryByText('failed')).toBeInTheDocument()
|
||||
expect(getByRole('alert')).toHaveClass('alert-danger')
|
||||
})
|
||||
|
||||
it('cancelled', async () => {
|
||||
const { getByText, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('user.removeItem')))
|
||||
fireEvent.click(getByText(t('general.cancel')))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).not.toBeCalled()
|
||||
expect(queryByText(fixtureSkin.pivot.item_name)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('select textures', () => {
|
||||
beforeEach(() => {
|
||||
fetch.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [fixtureCape], category: 'cape', total_pages: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('select skin', async () => {
|
||||
const { getByAltText, queryAllByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
|
||||
expect(queryAllByText(t('general.skin'))).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('select cape', async () => {
|
||||
const { getByText, getByAltText, queryAllByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('general.cape')))
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByAltText(fixtureCape.pivot.item_name))
|
||||
expect(queryAllByText(t('general.cape'))).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reset selected', async () => {
|
||||
const { getByText, getByAltText, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
|
||||
fireEvent.click(getByText(t('general.cape')))
|
||||
await wait()
|
||||
fireEvent.click(getByAltText(fixtureCape.pivot.item_name))
|
||||
|
||||
expect(
|
||||
queryByText(`${t('general.skin')} & ${t('general.cape')}`),
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(getByText(t('user.resetSelected')))
|
||||
expect(
|
||||
queryByText(`${t('general.skin')} & ${t('general.cape')}`),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('set avatar', () => {
|
||||
beforeEach(() => {
|
||||
fetch.get.mockResolvedValue({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
|
||||
const img = document.createElement('img')
|
||||
img.alt = 'User Image'
|
||||
document.body.appendChild(img)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector('[alt="User Image"]')!.remove()
|
||||
})
|
||||
|
||||
it('succeeded', async () => {
|
||||
fetch.post.mockResolvedValue({ code: 0, message: 'success' })
|
||||
|
||||
const { getByText, getByRole, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('user.setAsAvatar')))
|
||||
fireEvent.click(getByText(t('general.confirm')))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).toBeCalledWith('/user/profile/avatar', {
|
||||
tid: fixtureSkin.tid,
|
||||
})
|
||||
expect(queryByText('success')).toBeInTheDocument()
|
||||
expect(getByRole('status')).toHaveClass('alert-success')
|
||||
expect(document.querySelector('[alt="User Image"]')).toHaveAttribute('src')
|
||||
})
|
||||
|
||||
it('failed', async () => {
|
||||
fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
|
||||
|
||||
const { getByText, getByRole, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('user.setAsAvatar')))
|
||||
fireEvent.click(getByText(t('general.confirm')))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).toBeCalledWith('/user/profile/avatar', {
|
||||
tid: fixtureSkin.tid,
|
||||
})
|
||||
expect(queryByText('failed')).toBeInTheDocument()
|
||||
expect(getByRole('alert')).toHaveClass('alert-danger')
|
||||
})
|
||||
|
||||
it('cancelled', async () => {
|
||||
const { getByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('user.setAsAvatar')))
|
||||
fireEvent.click(getByText(t('general.cancel')))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('apply textures to player', () => {
|
||||
it('selected nothing', async () => {
|
||||
fetch.get.mockResolvedValue({
|
||||
data: { items: [], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
|
||||
const { getByText, getByRole, queryByText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByText(t('user.useAs')))
|
||||
|
||||
expect(queryByText(t('user.emptySelectedTexture'))).toBeInTheDocument()
|
||||
expect(getByRole('status')).toHaveClass('alert-info')
|
||||
})
|
||||
|
||||
it('search players', async () => {
|
||||
fetch.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: [fixturePlayer] })
|
||||
|
||||
const {
|
||||
getByText,
|
||||
getByAltText,
|
||||
getAllByPlaceholderText,
|
||||
queryByText,
|
||||
} = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
|
||||
fireEvent.click(getByText(t('user.useAs')))
|
||||
|
||||
await wait()
|
||||
expect(queryByText(fixturePlayer.name)).toBeInTheDocument()
|
||||
|
||||
fireEvent.input(getAllByPlaceholderText(t('user.typeToSearch'))[1], {
|
||||
target: { value: 'reina' },
|
||||
})
|
||||
expect(queryByText(fixturePlayer.name)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('succeeded', async () => {
|
||||
fetch.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: [fixturePlayer] })
|
||||
fetch.post.mockResolvedValue({ code: 0, message: 'success' })
|
||||
|
||||
const {
|
||||
getByText,
|
||||
getByAltText,
|
||||
getByTitle,
|
||||
getByRole,
|
||||
queryByText,
|
||||
} = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
|
||||
fireEvent.click(getByText(t('user.useAs')))
|
||||
await wait()
|
||||
fireEvent.click(getByTitle(fixturePlayer.name))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).toBeCalledWith(`/user/player/set/${fixturePlayer.pid}`, {
|
||||
skin: fixtureSkin.tid,
|
||||
})
|
||||
expect(queryByText('success')).toBeInTheDocument()
|
||||
expect(getByRole('status')).toHaveClass('alert-success')
|
||||
})
|
||||
|
||||
it('failed', async () => {
|
||||
fetch.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: [fixturePlayer] })
|
||||
fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
|
||||
|
||||
const {
|
||||
getByText,
|
||||
getByAltText,
|
||||
getByTitle,
|
||||
getByRole,
|
||||
queryByText,
|
||||
} = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
|
||||
fireEvent.click(getByText(t('user.useAs')))
|
||||
await wait()
|
||||
fireEvent.click(getByTitle(fixturePlayer.name))
|
||||
await wait()
|
||||
|
||||
expect(fetch.post).toBeCalledWith(`/user/player/set/${fixturePlayer.pid}`, {
|
||||
skin: fixtureSkin.tid,
|
||||
})
|
||||
expect(queryByText('failed')).toBeInTheDocument()
|
||||
expect(getByRole('alert')).toHaveClass('alert-danger')
|
||||
})
|
||||
|
||||
it('close dialog', async () => {
|
||||
fetch.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { items: [fixtureSkin], category: 'skin', total_pages: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: [fixturePlayer] })
|
||||
|
||||
const { getByText, getByAltText } = render(<Closet />)
|
||||
await wait()
|
||||
|
||||
fireEvent.click(getByAltText(fixtureSkin.pivot.item_name))
|
||||
fireEvent.click(getByText(t('user.useAs')))
|
||||
await wait()
|
||||
|
||||
$('#modal-apply')
|
||||
.modal('hide')
|
||||
.trigger('hidden.bs.modal')
|
||||
|
||||
expect(fetch.post).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
17
yarn.lock
17
yarn.lock
|
|
@ -1335,6 +1335,18 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636"
|
||||
integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==
|
||||
|
||||
"@types/lodash.debounce@^4.0.6":
|
||||
version "4.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60"
|
||||
integrity sha512-4WTmnnhCfDvvuLMaF3KV4Qfki93KebocUF45msxhYyjMttZDQYzHkO639ohhk8+oco2cluAFL3t5+Jn4mleylQ==
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash@*":
|
||||
version "4.14.149"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440"
|
||||
integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==
|
||||
|
||||
"@types/minimatch@*":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
|
||||
|
|
@ -6548,6 +6560,11 @@ lodash.clonedeep@^4.5.0:
|
|||
resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
|
||||
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
|
||||
|
||||
lodash.debounce@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
|
||||
|
||||
lodash.filter@^4.6.0:
|
||||
version "4.6.0"
|
||||
resolved "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz#668b1d4981603ae1cc5a6fa760143e480b4c4ace"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user