build closet page with React

This commit is contained in:
Pig Fang 2020-02-11 16:40:26 +08:00
parent 9dca4c1585
commit 19ad09920b
21 changed files with 1110 additions and 933 deletions

View File

@ -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",

View File

@ -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>

View File

@ -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>

View File

@ -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}>

View File

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

View File

@ -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 }
}

View File

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

View File

@ -0,0 +1,5 @@
@mixin truncate-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -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>

View File

@ -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;
}

View 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

View 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

View 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

View 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)

View File

@ -0,0 +1,3 @@
export default function debounce(fn: Function, _?: number) {
return fn
}

View File

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

View File

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

View File

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

View File

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

View 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()
})
})

View File

@ -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"