rewrite players management page with React

This commit is contained in:
Pig Fang 2020-04-30 18:47:37 +08:00
parent c6ef42d477
commit af351d211b
24 changed files with 1458 additions and 678 deletions

View File

@ -6,7 +6,6 @@ use App\Models\Player;
use App\Models\Texture;
use App\Models\User;
use App\Notifications;
use App\Rules;
use App\Services\OptionForm;
use App\Services\PluginManager;
use Auth;
@ -599,80 +598,4 @@ class AdminController extends Controller
return json(trans('admin.users.operations.invalid'), 1);
}
}
public function playerAjaxHandler(Request $request)
{
$action = $request->input('action');
$currentUser = Auth::user();
$player = Player::find($request->input('pid'));
if (!$player) {
return json(trans('general.unexistent-player'), 1);
}
$owner = $player->user;
if (
$owner && $owner->uid !== $currentUser->uid &&
$owner->permission >= $currentUser->permission
) {
return json(trans('admin.players.no-permission'), 1);
}
if ($action == 'texture') {
$this->validate($request, [
'type' => 'required',
'tid' => 'required|integer',
]);
if (!Texture::find($request->tid) && $request->tid != 0) {
return json(trans('admin.players.textures.non-existent', ['tid' => $request->tid]), 1);
}
$field = 'tid_'.$request->type;
$player->$field = $request->tid;
$player->save();
return json(trans('admin.players.textures.success', ['player' => $player->name]), 0);
} elseif ($action == 'owner') {
$this->validate($request, [
'uid' => 'required|integer',
]);
$user = User::find($request->uid);
if (!$user) {
return json(trans('admin.users.operations.non-existent'), 1);
}
$player->uid = $request->input('uid');
$player->save();
return json(trans('admin.players.owner.success', ['player' => $player->name, 'user' => $user->nickname]), 0);
} elseif ($action == 'delete') {
$player->delete();
return json(trans('admin.players.delete.success'), 0);
} elseif ($action == 'name') {
$name = $this->validate($request, [
'name' => [
'required',
new Rules\PlayerName(),
'min:'.option('player_name_length_min'),
'max:'.option('player_name_length_max'),
],
])['name'];
$player->name = $name;
$player->save();
if (option('single_player', false) && $owner) {
$owner->nickname = $name;
$owner->save();
}
return json(trans('admin.players.name.success', ['player' => $player->name]), 0);
} else {
return json(trans('admin.users.operations.invalid'), 1);
}
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers;
use App\Models\Player;
use App\Models\Texture;
use App\Models\User;
use App\Rules;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class PlayersManagementController extends Controller
{
public function __construct()
{
$this->middleware(function (Request $request, $next) {
/** @var Player */
$player = $request->route('player');
$owner = $player->user;
/** @var User */
$currentUser = $request->user();
if (
$owner && $owner->uid !== $currentUser->uid &&
$owner->permission >= $currentUser->permission
) {
return json(trans('admin.players.no-permission'), 1)
->setStatusCode(403);
}
return $next($request);
})->except(['list']);
}
public function list(Request $request)
{
$query = $request->query('q');
return Player::usingSearchString($query)->paginate(10);
}
public function name(
Player $player,
Request $request,
Dispatcher $dispatcher
) {
$name = $this->validate($request, [
'player_name' => [
'required',
new Rules\PlayerName(),
'min:'.option('player_name_length_min'),
'max:'.option('player_name_length_max'),
'unique:players,name',
],
])['player_name'];
$dispatcher->dispatch('player.name.updating', [$player, $name]);
$oldName = $player->name;
$player->name = $name;
$player->save();
$dispatcher->dispatch('player.name.updated', [$player, $oldName]);
return json(trans('admin.players.name.success', ['player' => $player->name]), 0);
}
public function owner(
Player $player,
Request $request,
Dispatcher $dispatcher
) {
$uid = $this->validate($request, ['uid' => 'required|integer'])['uid'];
$dispatcher->dispatch('player.owner.updating', [$player, $uid]);
/** @var User */
$user = User::find($request->uid);
if (empty($user)) {
return json(trans('admin.users.operations.non-existent'), 1);
}
$player->uid = $uid;
$player->save();
$dispatcher->dispatch('player.owner.updated', [$player, $user]);
return json(trans('admin.players.owner.success', [
'player' => $player->name,
'user' => $user->nickname,
]), 0);
}
public function texture(
Player $player,
Request $request,
Dispatcher $dispatcher
) {
$data = $this->validate($request, [
'tid' => 'required|integer',
'type' => ['required', Rule::in(['skin', 'cape'])],
]);
$tid = (int) $data['tid'];
$type = $data['type'];
$dispatcher->dispatch('player.texture.updating', [$player, $type, $tid]);
if (!Texture::find($tid) && $tid !== 0) {
return json(trans('admin.players.textures.non-existent', ['tid' => $tid]), 1);
}
$field = 'tid_'.$type;
$previousTid = $player->$field;
$player->$field = $tid;
$player->save();
$dispatcher->dispatch('player.texture.updated', [$player, $type, $previousTid]);
return json(trans('admin.players.textures.success', ['player' => $player->name]), 0);
}
public function delete(
Player $player,
Dispatcher $dispatcher
) {
$dispatcher->dispatch('player.deleting', [$player]);
$player->delete();
$dispatcher->dispatch('player.deleted', [$player]);
return json(trans('admin.players.delete.success'), 0);
}
}

View File

@ -32,6 +32,7 @@ class Kernel extends HttpKernel
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\EnforceEverGreen::class,
\App\Http\Middleware\RedirectToSetup::class,
],

View File

@ -0,0 +1,21 @@
<?php
namespace App\Listeners\SinglePlayer;
use App\Models\Player;
class UpdateOwnerNickName
{
/**
* @param Player $player
*/
public function handle($player)
{
$owner = $player->user;
if (option('single_player', false) && $owner) {
$owner->nickname = $player->name;
$owner->save();
}
}
}

View File

@ -7,6 +7,7 @@ use App\Models;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Lorisleiva\LaravelSearchString\Concerns\SearchString;
/**
* @property int $pid
@ -22,6 +23,8 @@ use Illuminate\Support\Carbon;
*/
class Player extends Model
{
use SearchString;
public const CREATED_AT = null;
public const UPDATED_AT = 'last_modified';
@ -40,6 +43,14 @@ class Player extends Model
'updated' => PlayerProfileUpdated::class,
];
protected $searchStringColumns = [
'pid', 'uid',
'tid_skin' => '/^(?:tid_)?skin$/',
'tid_cape' => '/^(?:tid_)?cape$/',
'name' => ['searchable' => true],
'last_modified' => ['date' => true],
];
public function user()
{
return $this->belongsTo(Models\User::class, 'uid');

View File

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Listeners;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
@ -13,24 +14,27 @@ class EventServiceProvider extends ServiceProvider
*/
protected $listen = [
'App\Events\PlayerRetrieved' => [
'App\Listeners\ResetInvalidTextureForPlayer',
Listeners\ResetInvalidTextureForPlayer::class,
],
'App\Events\TextureDeleting' => [
'App\Listeners\TextureRemoved',
],
'App\Events\PluginWasEnabled' => [
'App\Listeners\CopyPluginAssets',
'App\Listeners\GeneratePluginTranslations',
Listeners\CopyPluginAssets::class,
Listeners\GeneratePluginTranslations::class,
],
'plugin.versionChanged' => [
'App\Listeners\CopyPluginAssets',
'App\Listeners\GeneratePluginTranslations',
],
'App\Events\PluginBootFailed' => [
'App\Listeners\NotifyFailedPlugin',
Listeners\NotifyFailedPlugin::class,
],
'App\Events\RenderingHeader' => [
'App\Listeners\SerializeGlobals',
Listeners\SerializeGlobals::class,
],
'player.name.updated' => [
Listeners\SinglePlayer\UpdateOwnerNickName::class,
],
];
}

View File

@ -27,6 +27,7 @@
"intervention/image": "^2.5",
"laravel/framework": "^7.0",
"laravel/passport": "^8.4",
"lorisleiva/laravel-search-string": "^0.1.6",
"nesbot/carbon": "^2.0",
"nunomaduro/collision": "^4.1",
"rcrowe/twigbridge": "^0.11.3",

48
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e9621ab58940ab0702842b6ba6c80ccb",
"content-hash": "d1615cec2f58f3b4eb54ac477e348b42",
"packages": [
{
"name": "blessing/filter",
@ -2330,6 +2330,52 @@
],
"time": "2019-07-13T18:58:26+00:00"
},
{
"name": "lorisleiva/laravel-search-string",
"version": "v0.1.6",
"source": {
"type": "git",
"url": "https://github.com/lorisleiva/laravel-search-string.git",
"reference": "68189cad7614d0c9cb09a83315e1ccfe49e7f0c1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lorisleiva/laravel-search-string/zipball/68189cad7614d0c9cb09a83315e1ccfe49e7f0c1",
"reference": "68189cad7614d0c9cb09a83315e1ccfe49e7f0c1",
"shasum": ""
},
"require": {
"illuminate/support": "^5.5|^6.0|^7.0"
},
"require-dev": {
"orchestra/testbench": "^5.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Lorisleiva\\LaravelSearchString\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Lorisleiva\\LaravelSearchString\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Loris Leiva",
"email": "loris.leiva@gmail.com"
}
],
"description": "Generates database queries based on one unique string using a simple and customizable syntax.",
"time": "2020-04-05T15:40:30+00:00"
},
{
"name": "monolog/monolog",
"version": "2.0.2",

View File

@ -58,7 +58,7 @@ export default [
},
{
path: 'admin/players',
component: () => import('../views/admin/Players.vue'),
react: () => import('../views/admin/PlayersManagement'),
el: '.content > .container-fluid',
},
{

View File

@ -14,8 +14,10 @@ export type User = {
export type Player = {
pid: number
name: string
uid: number
tid_skin: number
tid_cape: number
last_modified: string
}
export type Texture = {

View File

@ -1,4 +1,5 @@
@import './avatar';
@import './dropdown';
a {
outline: none;

View File

@ -0,0 +1,15 @@
.dropdown-item {
&:hover {
color: #fff;
background-color: var(--blue);
}
&.dropdown-item-danger {
color: var(--danger);
&:hover, &:active {
color: #fff;
background-color: var(--danger);
}
}
}

View File

@ -1,246 +0,0 @@
<template>
<div class="container-fluid">
<vue-good-table
mode="remote"
:rows="players"
:total-rows="totalRecords || players.length"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
style-class="vgt-table striped"
@on-page-change="onPageChange"
@on-sort-change="onSortChange"
@on-search="onSearch"
@on-per-page-change="onPerPageChange"
>
<template #table-row="props">
<span v-if="props.column.field === 'name'">
{{ props.formattedRow[props.column.field] }}
<a :title="$t('admin.changePlayerName')" data-test="name" @click="changeName(props.row)">
<i class="fas fa-edit btn-edit" />
</a>
</span>
<span v-else-if="props.column.field === 'uid'">
<a
:href="`${baseUrl}/admin/users?uid=${props.row.uid}`"
:title="$t('admin.inspectHisOwner')"
data-toggle="tooltip"
data-placement="right"
>{{ props.formattedRow[props.column.field] }}</a>
<a :title="$t('admin.changeOwner')" data-test="owner" @click="changeOwner(props.row)">
<i class="fas fa-edit btn-edit" />
</a>
</span>
<span v-else-if="props.column.field === 'preview'">
<a
v-if="props.row.tid_skin"
:href="`${baseUrl}/skinlib/show/${props.row.tid_skin}`"
>
<img :src="`${baseUrl}/preview/${props.row.tid_skin}?height=64`" width="64">
</a>
<a
v-if="props.row.tid_cape"
:href="`${baseUrl}/skinlib/show/${props.row.tid_cape}`"
>
<img :src="`${baseUrl}/preview/${props.row.tid_cape}?height=64`" width="64">
</a>
</span>
<span v-else-if="props.column.field === 'operations'">
<button
data-toggle="modal"
data-target="#modal-change-texture"
class="btn btn-default"
@click="textureChanges.originalIndex = props.row.originalIndex"
>
{{ $t('admin.changeTexture') }}
</button>
<button class="btn btn-danger" @click="deletePlayer(props.row)">
{{ $t('admin.deletePlayer') }}
</button>
</span>
<span v-else v-text="props.formattedRow[props.column.field]" />
</template>
</vue-good-table>
<modal
id="modal-change-texture"
:title="$t('admin.changeTexture')"
center
@confirm="changeTexture"
>
<div class="form-group">
<label v-t="'admin.textureType'" />
<select v-model="textureChanges.type" class="form-control">
<option v-t="'general.skin'" value="skin" />
<option v-t="'general.cape'" value="cape" />
</select>
</div>
<div class="form-group">
<label>TID</label>
<input
v-model.number="textureChanges.tid"
class="form-control"
type="text"
:placeholder="$t('admin.pidNotice')"
>
</div>
</modal>
</div>
</template>
<script>
import { VueGoodTable } from 'vue-good-table'
import 'vue-good-table/dist/vue-good-table.min.css'
import Modal from '../../components/Modal.vue'
import tableOptions from '../../components/mixins/tableOptions'
import serverTable from '../../components/mixins/serverTable'
import emitMounted from '../../components/mixins/emitMounted'
import { showModal, toast } from '../../scripts/notify'
import { truthy } from '../../scripts/validators'
export default {
name: 'PlayersManagement',
components: {
Modal,
VueGoodTable,
},
mixins: [
emitMounted,
tableOptions,
serverTable,
],
props: {
baseUrl: {
type: String,
default: blessing.base_url,
},
},
data() {
return {
players: [],
columns: [
{
field: 'pid', label: 'PID', type: 'number',
},
{ field: 'name', label: this.$t('general.player.player-name') },
{
field: 'uid', label: this.$t('general.player.owner'), type: 'number', sortable: false,
},
{
field: 'preview', label: this.$t('general.player.previews'), globalSearchDisabled: true, sortable: false,
},
{
field: 'last_modified', label: this.$t('general.player.last-modified'), sortable: false,
},
{
field: 'operations', label: this.$t('admin.operationsTitle'), globalSearchDisabled: true, sortable: false,
},
],
textureChanges: {
originalIndex: -1,
type: 'skin',
tid: '',
},
}
},
beforeMount() {
this.fetchData()
},
methods: {
async fetchData() {
const { data, totalRecords } = await this.$http.get(
`/admin/players/list${location.search}`,
!location.search && this.serverParams,
)
this.totalRecords = totalRecords
this.players = data
},
async changeTexture() {
const player = this.players[this.textureChanges.originalIndex]
const { type, tid } = this.textureChanges
const { code, message } = await this.$http.post(
'/admin/players?action=texture',
{
pid: player.pid, type, tid,
},
)
if (code === 0) {
player[`tid_${type}`] = tid
toast.success(message)
} else {
toast.error(message)
}
},
async changeName(player) {
let value
try {
({ value } = await showModal({
mode: 'prompt',
text: this.$t('admin.changePlayerNameNotice'),
input: player.name,
validator: truthy(this.$t('admin.emptyPlayerName')),
}))
} catch {
return
}
const { code, message } = await this.$http.post(
'/admin/players?action=name',
{ pid: player.pid, name: value },
)
if (code === 0) {
player.name = value
toast.success(message)
} else {
toast.error(message)
}
},
async changeOwner(player) {
let value
try {
({ value } = await showModal({
mode: 'prompt',
text: this.$t('admin.changePlayerOwner'),
input: player.uid,
}))
} catch {
return
}
value = Number.parseInt(value)
const { code, message } = await this.$http.post(
'/admin/players?action=owner',
{ pid: player.pid, uid: value },
)
if (code === 0) {
player.uid = value
toast.success(message)
} else {
toast.error(message)
}
},
async deletePlayer({ pid, originalIndex }) {
try {
await showModal({
text: this.$t('admin.deletePlayerNotice'),
okButtonType: 'danger',
})
} catch {
return
}
const { code, message } = await this.$http.post(
'/admin/players?action=delete',
{ pid },
)
if (code === 0) {
this.$delete(this.players, originalIndex)
toast.success(message)
} else {
toast.error(message)
}
},
},
}
</script>

View File

@ -0,0 +1,10 @@
@use '../../../styles/breakpoints';
.box {
width: 48%;
margin: 7px;
@include breakpoints.less-than('lg') {
width: 98%;
}
}

View File

@ -0,0 +1,148 @@
import React from 'react'
import { t } from '@/scripts/i18n'
import { showModal } from '@/scripts/notify'
import { Player } from '@/scripts/types'
import styles from './Card.module.scss'
interface Props {
player: Player
onUpdateName(): void
onUpdateOwner(): void
onUpdateTexture(): void
onDelete(): void
}
const Card: React.FC<Props> = (props) => {
const { player } = props
const handlePreviewTextures = () => {
showModal({
mode: 'alert',
title: t('general.player.previews'),
children: (
<div className="row">
<div className="col-6 d-flex justify-content-center">
{player.tid_skin > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_skin}`}
target="_blank"
>
<img
src={`${blessing.base_url}/preview/${player.tid_skin}`}
alt={`${player.name} - ${t('general.skin')}`}
width="128"
/>
</a>
)}
</div>
<div className="col-6 d-flex justify-content-center">
{player.tid_cape > 0 && (
<a
href={`${blessing.base_url}/skinlib/show/${player.tid_cape}`}
target="_blank"
>
<img
src={`${blessing.base_url}/preview/${player.tid_cape}`}
alt={`${player.name} - ${t('general.cape')}`}
width="128"
/>
</a>
)}
</div>
</div>
),
})
}
return (
<div className={`info-box ${styles.box}`}>
<div className="info-box-icon">
<img
className="bs-avatar"
src={`${blessing.base_url}/avatar/player/${player.name}`}
/>
</div>
<div className="info-box-content">
<div className="row">
<div className="col-10">
<div>
<b>{player.name}</b>
</div>
</div>
<div className="col-2">
<div className="float-right dropdown">
<a
className="text-gray"
href="#"
data-toggle="dropdown"
aria-expanded="false"
>
<i className="fas fa-cog"></i>
</a>
<div className="dropdown-menu dropdown-menu-right">
<a
href="#"
className="dropdown-item"
onClick={handlePreviewTextures}
>
<i className="fas fa-eye mr-2"></i>
{t('general.player.previews')}
</a>
<div className="dropdown-divider"></div>
<a
href="#"
className="dropdown-item"
onClick={props.onUpdateName}
>
<i className="fas fa-signature mr-2"></i>
{t('admin.changePlayerName')}
</a>
<a
href="#"
className="dropdown-item"
onClick={props.onUpdateOwner}
>
<i className="fas fa-user-edit mr-2"></i>
{t('admin.changeOwner')}
</a>
<a
href="#"
className="dropdown-item"
onClick={props.onUpdateTexture}
>
<i className="fas fa-tshirt mr-2"></i>
{t('admin.changeTexture')}
</a>
<div className="dropdown-divider"></div>
<a
href="#"
className="dropdown-item dropdown-item-danger"
onClick={props.onDelete}
>
<i className="fas fa-trash mr-2"></i>
{t('admin.deletePlayer')}
</a>
</div>
</div>
</div>
</div>
<div>
<div>
<span className="mr-2">PID: {player.pid}</span>
<span>
{t('general.player.owner')}: {player.uid}
</span>
</div>
<div>
<small className="text-gray">
{`${t('general.player.last-modified')}: `}
{player.last_modified}
</small>
</div>
</div>
</div>
</div>
)
}
export default Card

View File

@ -0,0 +1,82 @@
import React, { useState } from 'react'
import { t } from '@/scripts/i18n'
import Modal from '@/components/Modal'
interface Props {
open: boolean
onSubmit(type: 'skin' | 'cape', tid: number): void
onClose(): void
}
const ModalUpdateTexture: React.FC<Props> = (props) => {
const [type, setType] = useState<'skin' | 'cape'>('skin')
const [tid, setTid] = useState('')
const handleTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setType(event.target.value as 'skin' | 'cape')
}
const handleTidChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTid(event.target.value)
}
const handleConfirm = () => {
props.onSubmit(type, Number.parseInt(tid))
setType('skin')
setTid('')
}
const handleClose = () => {
setType('skin')
setTid('')
props.onClose()
}
return (
<Modal
show={props.open}
center
onConfirm={handleConfirm}
onClose={handleClose}
>
<div className="form-group">
<label>{t('admin.textureType')}</label>
<div>
<label className="mr-5">
<input
className="mr-1"
type="radio"
value="skin"
checked={type === 'skin'}
onChange={handleTypeChange}
/>
{t('general.skin')}
</label>
<label>
<input
className="mr-1"
type="radio"
value="cape"
checked={type === 'cape'}
onChange={handleTypeChange}
/>
{t('general.cape')}
</label>
</div>
</div>
<div className="form-group">
<label htmlFor="update-texture-tid">TID</label>
<input
type="number"
id="update-texture-tid"
className="form-control"
placeholder={t('admin.pidNotice')}
value={tid}
onChange={handleTidChange}
/>
</div>
</Modal>
)
}
export default ModalUpdateTexture

View File

@ -0,0 +1,200 @@
import React, { useState, useEffect } from 'react'
import { hot } from 'react-hot-loader/root'
import { useImmer } from 'use-immer'
import { t } from '@/scripts/i18n'
import * as fetch from '@/scripts/net'
import { Player, Paginator } from '@/scripts/types'
import { toast, showModal } from '@/scripts/notify'
import Loading from '@/components/Loading'
import Pagination from '@/components/Pagination'
import Card from './Card'
import ModalUpdateTexture from './ModalUpdateTexture'
const PlayersManagement: React.FC = () => {
const [players, setPlayers] = useImmer<Player[]>([])
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [query, setQuery] = useState('')
const [textureUpdating, setTextureUpdating] = useState(-1)
const getPlayers = async () => {
setIsLoading(true)
const { data, last_page }: Paginator<Player> = await fetch.get(
'/admin/players/list',
{
q: query,
page,
},
)
setTotalPages(last_page)
setPlayers(() => data)
setIsLoading(false)
}
useEffect(() => {
getPlayers()
}, [page])
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value)
}
const handleSubmitQuery = (event: React.FormEvent) => {
event.preventDefault()
getPlayers()
}
const handleUpdateName = async (player: Player, index: number) => {
let name: string
try {
const { value } = await showModal({
mode: 'prompt',
text: t('admin.changePlayerNameNotice'),
input: player.name,
validator: (value: string) => {
if (!value) {
return t('admin.emptyPlayerName')
}
},
})
name = value
} catch {
return
}
const { code, message } = await fetch.put<fetch.ResponseBody>(
`/admin/players/${player.pid}/name`,
{ player_name: name },
)
if (code === 0) {
toast.success(message)
setPlayers((players) => {
players[index].name = name
})
} else {
toast.error(message)
}
}
const handleUpdateOwner = async (player: Player, index: number) => {
let uid: number
try {
const { value } = await showModal({
mode: 'prompt',
text: t('admin.changePlayerOwner'),
input: player.uid.toString(),
inputType: 'number',
})
uid = Number.parseInt(value)
} catch {
return
}
const { code, message } = await fetch.put<fetch.ResponseBody>(
`/admin/players/${player.pid}/owner`,
{ uid },
)
if (code === 0) {
toast.success(message)
setPlayers((players) => {
players[index].uid = uid
})
} else {
toast.error(message)
}
}
const handleCloseModalUpdateTexture = () => setTextureUpdating(-1)
const handleUpdateTexture = async (type: 'skin' | 'cape', tid: number) => {
const { code, message } = await fetch.put<fetch.ResponseBody>(
`/admin/players/${players[textureUpdating].pid}/textures`,
{ type, tid },
)
if (code === 0) {
toast.success(message)
setPlayers((players) => {
const field = `tid_${type}` as 'tid_skin' | 'tid_cape'
players[textureUpdating][field] = tid
})
} else {
toast.error(message)
}
}
const handleDelete = async (player: Player) => {
try {
await showModal({
text: t('admin.deletePlayerNotice'),
okButtonType: 'danger',
})
} catch {
return
}
const { code, message } = await fetch.del<fetch.ResponseBody>(
`/admin/players/${player.pid}`,
)
if (code === 0) {
setPlayers((players) => players.filter(({ pid }) => pid !== player.pid))
toast.success(message)
} else {
toast.error(message)
}
}
return (
<div className="card">
<div className="card-header">
<form className="input-group" onSubmit={handleSubmitQuery}>
<input
type="text"
className="form-control"
title={t('vendor.datatable.search')}
value={query}
onChange={handleQueryChange}
/>
<div className="input-group-append">
<button className="btn btn-primary" type="submit">
{t('vendor.datatable.search')}
</button>
</div>
</form>
</div>
<div className="card-body">
{isLoading ? (
<Loading />
) : players.length === 0 ? (
<div className="text-center">No players.</div>
) : (
<div className="d-flex flex-wrap">
{players.map((player, i) => (
<Card
key={player.pid}
player={player}
onUpdateName={() => handleUpdateName(player, i)}
onUpdateOwner={() => handleUpdateOwner(player, i)}
onUpdateTexture={() => setTextureUpdating(i)}
onDelete={() => handleDelete(player)}
/>
))}
</div>
)}
</div>
<div className="card-footer">
<div className="float-right">
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</div>
</div>
<ModalUpdateTexture
open={textureUpdating > -1}
onSubmit={handleUpdateTexture}
onClose={handleCloseModalUpdateTexture}
/>
</div>
)
}
export default hot(PlayersManagement)

View File

@ -1,143 +0,0 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import { flushPromises } from '../../utils'
import { showModal } from '@/scripts/notify'
import Modal from '@/components/Modal.vue'
import Players from '@/views/admin/Players.vue'
jest.mock('@/scripts/notify')
test('fetch data after initializing', () => {
Vue.prototype.$http.get.mockResolvedValue({ data: [] })
mount(Players)
expect(Vue.prototype.$http.get).toBeCalledWith(
'/admin/players/list',
{
page: 1, perPage: 10, search: '', sortField: 'pid', sortType: 'asc',
},
)
})
test('change texture', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ pid: 1, tid_skin: 0 },
],
})
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: '1' })
.mockResolvedValueOnce({ code: 0, message: '0' })
const wrapper = mount(Players)
await flushPromises()
const modal = wrapper.find(Modal)
wrapper.findAll('.btn-default').trigger('click')
wrapper.find('.modal-body input').setValue('5')
modal.vm.$emit('confirm')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/players?action=texture',
{
pid: 1, tid: 5, type: 'skin',
},
)
modal.vm.$emit('confirm')
await flushPromises()
expect(wrapper.html()).toContain('/preview/5?height=64')
})
test('change player name', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ pid: 1, name: 'old' },
],
})
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: '1' })
.mockResolvedValueOnce({ code: 0, message: '0' })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: 'new' })
const wrapper = mount(Players)
await flushPromises()
const button = wrapper.find('[data-test="name"]')
button.trigger('click')
expect(Vue.prototype.$http.post).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/players?action=name',
{ pid: 1, name: 'new' },
)
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('new')
})
test('change owner', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ pid: 1, uid: 2 },
],
})
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: '1' })
.mockResolvedValueOnce({ code: 0, message: '0' })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: '3' })
const wrapper = mount(Players)
await flushPromises()
const button = wrapper.find('[data-test="owner"]')
button.trigger('click')
expect(Vue.prototype.$http.post).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/players?action=owner',
{ pid: 1, uid: 3 },
)
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('3')
})
test('delete player', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ pid: 1, name: 'to-be-deleted' },
],
})
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: '1' })
.mockResolvedValueOnce({ code: 0, message: '0' })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: '' })
const wrapper = mount(Players)
await flushPromises()
const button = wrapper.find('.btn-danger')
button.trigger('click')
expect(Vue.prototype.$http.post).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/players?action=delete',
{ pid: 1 },
)
expect(wrapper.text()).toContain('to-be-deleted')
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('No data')
})

View File

@ -0,0 +1,362 @@
import React from 'react'
import { render, waitFor, fireEvent } from '@testing-library/react'
import { t } from '@/scripts/i18n'
import * as fetch from '@/scripts/net'
import { Player, Paginator } from '@/scripts/types'
import PlayersManagement from '@/views/admin/PlayersManagement'
jest.mock('@/scripts/net')
const fixture: Readonly<Player> = Object.freeze<Player>({
pid: 1,
name: 'kumiko',
uid: 1,
tid_skin: 1,
tid_cape: 2,
last_modified: new Date().toString(),
})
function createPaginator(data: Player[]): Paginator<Player> {
return {
data,
total: data.length,
from: 1,
to: data.length,
current_page: 1,
last_page: 1,
}
}
test('search players', async () => {
fetch.get.mockResolvedValue(createPaginator([]))
const { getByTitle, getByText } = render(<PlayersManagement />)
await waitFor(() =>
expect(fetch.get).toBeCalledWith('/admin/players/list', { q: '', page: 1 }),
)
fireEvent.input(getByTitle(t('vendor.datatable.search')), {
target: { value: 's' },
})
fireEvent.click(getByText(t('vendor.datatable.search')))
await waitFor(() =>
expect(fetch.get).toBeCalledWith('/admin/players/list', {
q: 's',
page: 1,
}),
)
})
test('preview textures', async () => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
const { getByText, queryByAltText } = render(<PlayersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('general.player.previews')))
expect(
queryByAltText(`${fixture.name} - ${t('general.skin')}`),
).toHaveAttribute('src', `${blessing.base_url}/preview/${fixture.tid_skin}`)
expect(
queryByAltText(`${fixture.name} - ${t('general.cape')}`),
).toHaveAttribute('src', `${blessing.base_url}/preview/${fixture.tid_cape}`)
fireEvent.click(getByText(t('general.confirm')))
})
describe('update player name', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('empty value', async () => {
const { getByText, getByDisplayValue, queryByText } = render(
<PlayersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changePlayerName')))
fireEvent.input(getByDisplayValue(fixture.name), {
target: { value: '' },
})
fireEvent.click(getByText(t('general.confirm')))
expect(queryByText(t('admin.emptyPlayerName'))).toBeInTheDocument()
expect(fetch.put).not.toBeCalled()
fireEvent.click(getByText(t('general.cancel')))
expect(queryByText(fixture.name)).toBeInTheDocument()
})
it('succeeded', async () => {
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, getByDisplayValue, queryByText, queryByRole } = render(
<PlayersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changePlayerName')))
fireEvent.input(getByDisplayValue(fixture.name), {
target: { value: 'reina' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/players/${fixture.pid}/name`, {
player_name: 'reina',
}),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
expect(queryByText('reina')).toBeInTheDocument()
})
it('failed', async () => {
fetch.put.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, getByDisplayValue, queryByText, queryByRole } = render(
<PlayersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changePlayerName')))
fireEvent.input(getByDisplayValue(fixture.name), {
target: { value: 'reina' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/players/${fixture.pid}/name`, {
player_name: 'reina',
}),
)
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByRole('alert')).toHaveClass('alert-danger')
expect(queryByText(fixture.name)).toBeInTheDocument()
})
})
describe('update owner', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('cancelled', async () => {
const { getByText, queryByText } = render(<PlayersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeOwner')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
expect(
queryByText(`${t('general.player.owner')}: ${fixture.uid}`),
).toBeInTheDocument()
})
it('succeeded', async () => {
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, getByDisplayValue, queryByText, queryByRole } = render(
<PlayersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeOwner')))
fireEvent.input(getByDisplayValue(fixture.uid.toString()), {
target: { value: '2' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/players/${fixture.pid}/owner`, {
uid: 2,
}),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
expect(queryByText(`${t('general.player.owner')}: 2`)).toBeInTheDocument()
})
it('failed', async () => {
fetch.put.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, getByDisplayValue, queryByText, queryByRole } = render(
<PlayersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeOwner')))
fireEvent.input(getByDisplayValue(fixture.uid.toString()), {
target: { value: '2' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/players/${fixture.pid}/owner`, {
uid: 2,
}),
)
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByRole('alert')).toHaveClass('alert-danger')
expect(
queryByText(`${t('general.player.owner')}: ${fixture.uid}`),
).toBeInTheDocument()
})
})
describe('update texture', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('cancelled', async () => {
const { getByText } = render(<PlayersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeTexture')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
})
it('skin', async () => {
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, getByLabelText, queryByText, queryByRole } = render(
<PlayersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeTexture')))
fireEvent.input(getByLabelText('TID'), {
target: { value: '2' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(
`/admin/players/${fixture.pid}/textures`,
{
type: 'skin',
tid: 2,
},
),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
})
it('cape', async () => {
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, getByLabelText, queryByText, queryByRole } = render(
<PlayersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeTexture')))
fireEvent.click(getByLabelText(t('general.cape')))
fireEvent.input(getByLabelText('TID'), {
target: { value: '2' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(
`/admin/players/${fixture.pid}/textures`,
{
type: 'cape',
tid: 2,
},
),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
})
it('failed', async () => {
fetch.put.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, getByLabelText, queryByText, queryByRole } = render(
<PlayersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeTexture')))
fireEvent.input(getByLabelText('TID'), {
target: { value: '2' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(
`/admin/players/${fixture.pid}/textures`,
{
type: 'skin',
tid: 2,
},
),
)
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByRole('alert')).toHaveClass('alert-danger')
})
})
describe('delete player', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('cancelled', async () => {
const { getByText, queryByText } = render(<PlayersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.deletePlayer')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.del).not.toBeCalled()
expect(queryByText(fixture.name)).toBeInTheDocument()
})
it('succeeded', async () => {
fetch.del.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, queryByText, queryByRole } = render(
<PlayersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.deletePlayer')))
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.del).toBeCalledWith(`/admin/players/${fixture.pid}`),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
expect(queryByText(fixture.name)).not.toBeInTheDocument()
})
it('failed', async () => {
fetch.del.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, queryByText, queryByRole } = render(
<PlayersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.deletePlayer')))
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.del).toBeCalledWith(`/admin/players/${fixture.pid}`),
)
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByRole('alert')).toHaveClass('alert-danger')
expect(queryByText(fixture.name)).toBeInTheDocument()
})
})

View File

@ -45,8 +45,10 @@ const fixtureCape: Readonly<ClosetItem> = Object.freeze<ClosetItem>({
const fixturePlayer: Readonly<Player> = Object.freeze<Player>({
pid: 1,
name: 'kumiko',
uid: 1,
tid_skin: 1,
tid_cape: 2,
last_modified: new Date().toString(),
})
function createPaginator(data: ClosetItem[]): Paginator<ClosetItem> {

View File

@ -10,8 +10,10 @@ jest.mock('@/scripts/net')
const fixture: Readonly<Player> = Object.freeze<Player>({
pid: 1,
name: 'kumiko',
uid: 1,
tid_skin: 1,
tid_cape: 2,
last_modified: new Date().toString(),
})
beforeEach(() => {
@ -38,7 +40,14 @@ test('loading indicator', () => {
})
test('search players', async () => {
const fixture2: Player = { pid: 2, name: 'reina', tid_skin: 3, tid_cape: 4 }
const fixture2: Player = {
pid: 2,
name: 'reina',
uid: 2,
tid_skin: 3,
tid_cape: 4,
last_modified: new Date().toString(),
}
fetch.get.mockResolvedValue({ data: [fixture, fixture2] })
const { getByPlaceholderText, queryByText } = render(<Players />)
@ -63,7 +72,14 @@ describe('select player automatically', () => {
})
it('more players', async () => {
const fixture2: Player = { pid: 2, name: 'reina', tid_skin: 3, tid_cape: 4 }
const fixture2: Player = {
pid: 2,
name: 'reina',
uid: 2,
tid_skin: 3,
tid_cape: 4,
last_modified: new Date().toString(),
}
fetch.get.mockResolvedValue({ data: [fixture, fixture2] })
render(<Players />)
await waitFor(() => expect(fetch.get).toBeCalledTimes(1))

View File

@ -133,11 +133,16 @@ Route::prefix('admin')
Route::get('list', 'AdminController@getUserData');
});
Route::prefix('players')->group(function () {
Route::view('', 'admin.players');
Route::post('', 'AdminController@playerAjaxHandler');
Route::get('list', 'AdminController@getPlayerData');
});
Route::prefix('players')
->name('players.')
->group(function () {
Route::view('', 'admin.players');
Route::get('list', 'PlayersManagementController@list')->name('list');
Route::put('{player}/name', 'PlayersManagementController@name')->name('name');
Route::put('{player}/owner', 'PlayersManagementController@owner')->name('owner');
Route::put('{player}/textures', 'PlayersManagementController@texture')->name('texture');
Route::delete('{player}', 'PlayersManagementController@delete')->name('delete');
});
Route::prefix('closet')->group(function () {
Route::post('{uid}', 'ClosetManagementController@add');

View File

@ -387,202 +387,4 @@ class AdminControllerTest extends TestCase
]);
$this->assertNull(User::find($user->uid));
}
public function testPlayerAjaxHandler()
{
$player = factory(Player::class)->create();
// Operate on a not-existed player
$this->postJson('/admin/players', ['pid' => -1])
->assertJson([
'code' => 1,
'message' => trans('general.unexistent-player'),
]);
// An admin cannot operate another admin's player
$admin = factory(User::class)->states('admin')->create();
$this->postJson(
'/admin/players',
['pid' => factory(Player::class)->create(['uid' => $admin->uid])->pid]
)->assertJson([
'code' => 1,
'message' => trans('admin.players.no-permission'),
]);
$superAdmin = factory(User::class)->states('superAdmin')->create();
$this->postJson(
'/admin/players',
['pid' => factory(Player::class)->create(['uid' => $superAdmin->uid])->pid]
)->assertJson([
'code' => 1,
'message' => trans('admin.players.no-permission'),
]);
// For self is OK
$this->actingAs($admin)->postJson(
'/admin/players',
['pid' => factory(Player::class)->create(['uid' => $admin->uid])->pid]
)->assertJson([
'code' => 1,
'message' => trans('admin.users.operations.invalid'),
]);
// Change texture without `type` field
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'texture',
])->assertJsonValidationErrors(['type']);
// Change texture without `tid` field
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'texture',
'type' => 'skin',
])->assertJsonValidationErrors(['tid']);
// Change texture with a not-integer value
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'texture',
'type' => 'skin',
'tid' => 'string',
])->assertJsonValidationErrors(['tid']);
// Invalid texture
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'texture',
'type' => 'skin',
'tid' => -1,
])->assertJson([
'code' => 1,
'message' => trans('admin.players.textures.non-existent', ['tid' => -1]),
]);
$skin = factory(Texture::class)->create();
$cape = factory(Texture::class)->states('cape')->create();
// Skin
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'texture',
'type' => 'skin',
'tid' => $skin->tid,
])->assertJson([
'code' => 0,
'message' => trans('admin.players.textures.success', ['player' => $player->name]),
]);
$player = Player::find($player->pid);
$this->assertEquals($skin->tid, $player->tid_skin);
// Cape
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'texture',
'type' => 'cape',
'tid' => $cape->tid,
])->assertJson([
'code' => 0,
'message' => trans('admin.players.textures.success', ['player' => $player->name]),
]);
$player = Player::find($player->pid);
$this->assertEquals($cape->tid, $player->tid_cape);
// Reset texture
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'texture',
'type' => 'skin',
'tid' => 0,
])->assertJson([
'code' => 0,
'message' => trans('admin.players.textures.success', ['player' => $player->name]),
]);
$player = Player::find($player->pid);
$this->assertEquals(0, $player->tid_skin);
$this->assertNotEquals(0, $player->tid_cape);
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'texture',
'type' => 'cape',
'tid' => 0,
])->assertJson([
'code' => 0,
'message' => trans('admin.players.textures.success', ['player' => $player->name]),
]);
$player = Player::find($player->pid);
$this->assertEquals(0, $player->tid_cape);
// Change owner without `uid` field
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'owner',
])->assertJsonValidationErrors(['uid']);
// Change owner with a not-integer `uid` value
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'owner',
'uid' => 'string',
])->assertJsonValidationErrors(['uid']);
// Change owner to a not-existed user
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'owner',
'uid' => -1,
])->assertJson([
'code' => 1,
'message' => trans('admin.users.operations.non-existent'),
]);
// Change owner successfully
$user = factory(User::class)->create();
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'owner',
'uid' => $user->uid,
])->assertJson([
'code' => 0,
'message' => trans(
'admin.players.owner.success',
['player' => $player->name, 'user' => $user->nickname]
),
]);
// Rename a player without `name` field
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'name',
])->assertJsonValidationErrors(['name']);
// Rename a player successfully
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'name',
'name' => 'new_name',
])->assertJson([
'code' => 0,
'message' => trans('admin.players.name.success', ['player' => 'new_name']),
]);
// Single player
option(['single_player' => true]);
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'name',
'name' => 'abc',
])->assertJson(['code' => 0]);
$player->refresh();
$this->assertEquals('abc', $player->user->nickname);
// Delete a player
$this->postJson('/admin/players', [
'pid' => $player->pid,
'action' => 'delete',
])->assertJson([
'code' => 0,
'message' => trans('admin.players.delete.success'),
]);
$this->assertNull(Player::find($player->pid));
}
}

View File

@ -0,0 +1,381 @@
<?php
namespace Tests;
use App\Models\Player;
use App\Models\Texture;
use App\Models\User;
use Event;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class PlayersManagementControllerTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->actingAs(factory(\App\Models\User::class)->states('admin')->create());
}
public function testList()
{
$player = factory(Player::class)->create();
$this->getJson(route('admin.players.list'))
->assertJson(['data' => [$player->toArray()]]);
}
public function testAccessControl()
{
// an admin can't operate another admin's player
$admin = factory(User::class)->states('admin')->create();
/** @var Player */
$player = factory(Player::class)->create(['uid' => $admin->uid]);
$this->putJson(
route('admin.players.name', ['player' => $player->pid]),
['player_name' => 'abcd']
)->assertJson([
'code' => 1,
'message' => trans('admin.players.no-permission'),
])->assertForbidden();
// for self is OK
$this->actingAs($admin)
->putJson(
route('admin.players.name', ['player' => $player->pid]),
['player_name' => 'abcd']
)->assertJson(['code' => 0]);
// super admin
$superAdmin = factory(User::class)->states('superAdmin')->create();
/** @var Player */
$player = factory(Player::class)->create(['uid' => $superAdmin->uid]);
$this->putJson(
route('admin.players.name', ['player' => $player->pid]),
['player_name' => 'abcd']
)->assertJson([
'code' => 1,
'message' => trans('admin.players.no-permission'),
])->assertForbidden();
}
public function testName()
{
/** @var Player */
$player = factory(Player::class)->create();
// missing `player_name` field
$this->putJson(
route('admin.players.name', ['player' => $player->pid])
)->assertJsonValidationErrors(['player_name']);
// duplicated player name
$this->putJson(
route('admin.players.name', ['player' => $player->pid]),
['player_name' => $player->name]
)->assertJsonValidationErrors(['player_name']);
// single player
option(['single_player' => true]);
$this->putJson(
route('admin.players.name', ['player' => $player->pid]),
['player_name' => 'abc']
)->assertJson(['code' => 0]);
$player->refresh();
$this->assertEquals('abc', $player->user->nickname);
option(['single_player' => false]);
// rename a player successfully
Event::fake();
$this->putJson(
route('admin.players.name', ['player' => $player->pid]),
['player_name' => 'new_name']
)->assertJson([
'code' => 0,
'message' => trans('admin.players.name.success', ['player' => 'new_name']),
]);
$oldName = $player->name;
$player->refresh();
$this->assertEquals('new_name', $player->name);
Event::assertDispatched(
'player.name.updating',
function ($eventName, $payload) use ($player) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals('new_name', $payload[1]);
return true;
}
);
Event::assertDispatched(
'player.name.updated',
function ($eventName, $payload) use ($player, $oldName) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals($oldName, $payload[1]);
return true;
}
);
}
public function testOwner()
{
Event::fake();
/** @var Player */
$player = factory(Player::class)->create();
// missing `uid` field
$this->putJson(route('admin.players.owner', ['player' => $player->pid]))
->assertJsonValidationErrors(['uid']);
// with a non-integer `uid` value
$this->putJson(
route('admin.players.owner', ['player' => $player->pid]),
['uid' => 's']
)->assertJsonValidationErrors(['uid']);
// change owner to a not-existed user
$this->putJson(
route('admin.players.owner', ['player' => $player->pid]),
['uid' => -1]
)->assertJson([
'code' => 1,
'message' => trans('admin.users.operations.non-existent'),
]);
Event::assertDispatched(
'player.owner.updating',
function ($eventName, $payload) use ($player) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals(-1, $payload[1]);
return true;
}
);
Event::assertNotDispatched('player.owner.updated');
// change owner successfully
Event::fake();
/** @var User */
$user = factory(User::class)->create();
$this->putJson(
route('admin.players.owner', ['player' => $player->pid]),
['uid' => $user->uid]
)->assertJson([
'code' => 0,
'message' => trans(
'admin.players.owner.success',
['player' => $player->name, 'user' => $user->nickname]
),
]);
Event::assertDispatched(
'player.owner.updating',
function ($eventName, $payload) use ($player, $user) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals($user->uid, $payload[1]);
return true;
}
);
Event::assertDispatched(
'player.owner.updated',
function ($eventName, $payload) use ($player, $user) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals($user->uid, $payload[1]->uid);
return true;
}
);
}
public function testTexture()
{
Event::fake();
/** @var Player */
$player = factory(Player::class)->create();
// missing `tid` field
$this->putJson(
route('admin.players.texture', ['player' => $player->pid])
)->assertJsonValidationErrors(['tid']);
// change texture with a non-integer value
$this->putJson(
route('admin.players.texture', ['player' => $player->pid]),
['tid' => 's']
)->assertJsonValidationErrors(['tid']);
// missing `type` field
$this->putJson(
route('admin.players.texture', ['player' => $player->pid]),
['tid' => -1]
)->assertJsonValidationErrors(['type']);
// invalid type
$this->putJson(
route('admin.players.texture', ['player' => $player->pid]),
['tid' => -1, 'type' => 'elytra']
)->assertJsonValidationErrors(['type']);
// invalid texture
$this->putJson(
route('admin.players.texture', ['player' => $player->pid]),
['tid' => -1, 'type' => 'skin']
)->assertJson([
'code' => 1,
'message' => trans('admin.players.textures.non-existent', ['tid' => -1]),
]);
Event::assertDispatched(
'player.texture.updating',
function ($eventName, $payload) use ($player) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals('skin', $payload[1]);
$this->assertEquals(-1, $payload[2]);
return true;
}
);
Event::assertNotDispatched('player.texture.updated');
/** @var Texture */
$skin = factory(Texture::class)->create();
/** @var Texture */
$cape = factory(Texture::class)->states('cape')->create();
// skin
Event::fake();
$this->putJson(
route('admin.players.texture', ['player' => $player->pid]),
['tid' => $skin->tid, 'type' => 'skin']
)->assertJson([
'code' => 0,
'message' => trans('admin.players.textures.success', ['player' => $player->name]),
]);
$previousTid = $player->tid_skin;
$player->refresh();
$this->assertEquals($skin->tid, $player->tid_skin);
Event::assertDispatched(
'player.texture.updating',
function ($eventName, $payload) use ($player, $skin) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals('skin', $payload[1]);
$this->assertEquals($skin->tid, $payload[2]);
return true;
}
);
Event::assertDispatched(
'player.texture.updated',
function ($eventName, $payload) use ($player, $previousTid) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals('skin', $payload[1]);
$this->assertEquals($previousTid, $payload[2]);
return true;
}
);
// cape
Event::fake();
$this->putJson(
route('admin.players.texture', ['player' => $player->pid]),
['tid' => $cape->tid, 'type' => 'cape']
)->assertJson([
'code' => 0,
'message' => trans('admin.players.textures.success', ['player' => $player->name]),
]);
$previousTid = $player->tid_cape;
$player->refresh();
$this->assertEquals($cape->tid, $player->tid_cape);
Event::assertDispatched(
'player.texture.updating',
function ($eventName, $payload) use ($player, $cape) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals('cape', $payload[1]);
$this->assertEquals($cape->tid, $payload[2]);
return true;
}
);
Event::assertDispatched(
'player.texture.updated',
function ($eventName, $payload) use ($player, $previousTid) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals('cape', $payload[1]);
$this->assertEquals($previousTid, $payload[2]);
return true;
}
);
// reset texture
Event::fake();
$this->putJson(
route('admin.players.texture', ['player' => $player->pid]),
['tid' => 0, 'type' => 'skin']
)->assertJson([
'code' => 0,
'message' => trans('admin.players.textures.success', ['player' => $player->name]),
]);
$previousTid = $player->tid_skin;
$player->refresh();
$this->assertEquals(0, $player->tid_skin);
$this->assertNotEquals(0, $player->tid_cape);
Event::assertDispatched(
'player.texture.updating',
function ($eventName, $payload) use ($player) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals('skin', $payload[1]);
$this->assertEquals(0, $payload[2]);
return true;
}
);
Event::assertDispatched(
'player.texture.updated',
function ($eventName, $payload) use ($player, $previousTid) {
$this->assertEquals($player->pid, $payload[0]->pid);
$this->assertEquals('skin', $payload[1]);
$this->assertEquals($previousTid, $payload[2]);
return true;
}
);
$this->putJson(
route('admin.players.texture', ['player' => $player->pid]),
['tid' => 0, 'type' => 'cape']
)->assertJson([
'code' => 0,
'message' => trans('admin.players.textures.success', ['player' => $player->name]),
]);
$player->refresh();
$this->assertEquals(0, $player->tid_cape);
}
public function testDelete()
{
Event::fake();
/** @var Player */
$player = factory(Player::class)->create();
$this->deleteJson(route('admin.players.delete', ['player' => $player->pid]))
->assertJson([
'code' => 0,
'message' => trans('admin.players.delete.success'),
]);
Event::assertDispatched('player.deleting', function ($eventName, $payload) use ($player) {
$this->assertEquals($player->pid, $payload[0]->pid);
return true;
});
Event::assertDispatched('player.deleted', function ($eventName, $payload) use ($player) {
$this->assertEquals($player->pid, $payload[0]->pid);
return true;
});
$this->assertNull(Player::find($player->pid));
}
}