rewrite reports management page with React
This commit is contained in:
parent
c0d9d18efc
commit
291efe730f
@ -9,6 +9,7 @@ use Blessing\Filter;
|
||||
use Blessing\Rejection;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ReportController extends Controller
|
||||
@ -65,46 +66,35 @@ class ReportController extends Controller
|
||||
|
||||
public function manage(Request $request)
|
||||
{
|
||||
$search = $request->input('search', '');
|
||||
$sortField = $request->input('sortField', 'report_at');
|
||||
$sortType = $request->input('sortType', 'desc');
|
||||
$page = $request->input('page', 1);
|
||||
$perPage = $request->input('perPage', 10);
|
||||
$q = $request->input('q');
|
||||
|
||||
$reports = Report::where('tid', 'like', '%'.$search.'%')
|
||||
->orWhere('reporter', 'like', '%'.$search.'%')
|
||||
->orWhere('reason', 'like', '%'.$search.'%')
|
||||
->orderBy($sortField, $sortType)
|
||||
->offset(($page - 1) * $perPage)
|
||||
->limit($perPage)
|
||||
->get()
|
||||
->makeHidden(['informer'])
|
||||
->map(function ($report) {
|
||||
$uploader = User::find($report->uploader);
|
||||
if ($uploader) {
|
||||
$report->uploaderName = $uploader->nickname;
|
||||
}
|
||||
if ($report->informer) {
|
||||
$report->reporterName = $report->informer->nickname;
|
||||
}
|
||||
$pagination = Report::usingSearchString($q)->paginate(9);
|
||||
$collection = $pagination->getCollection()->map(function ($report) {
|
||||
$uploader = User::find($report->uploader);
|
||||
if ($uploader) {
|
||||
$report->uploaderName = $uploader->nickname;
|
||||
}
|
||||
if ($report->informer) {
|
||||
$report->reporterName = $report->informer->nickname;
|
||||
}
|
||||
$report->getAttribute('texture');
|
||||
|
||||
return $report;
|
||||
});
|
||||
return $report;
|
||||
});
|
||||
$pagination->setCollection($collection);
|
||||
|
||||
return [
|
||||
'totalRecords' => Report::count(),
|
||||
'data' => $reports,
|
||||
];
|
||||
return $pagination;
|
||||
}
|
||||
|
||||
public function review(Request $request, Dispatcher $dispatcher)
|
||||
{
|
||||
$data = $this->validate($request, [
|
||||
'id' => 'required|exists:reports',
|
||||
public function review(
|
||||
Report $report,
|
||||
Request $request,
|
||||
Dispatcher $dispatcher
|
||||
) {
|
||||
$data = $request->validate([
|
||||
'action' => ['required', Rule::in(['delete', 'ban', 'reject'])],
|
||||
]);
|
||||
$action = $data['action'];
|
||||
$report = Report::find($data['id']);
|
||||
|
||||
$dispatcher->dispatch('report.reviewing', [$report, $action]);
|
||||
|
||||
@ -127,8 +117,10 @@ class ReportController extends Controller
|
||||
|
||||
switch ($action) {
|
||||
case 'delete':
|
||||
/** @var Texture */
|
||||
$texture = $report->texture;
|
||||
if ($texture) {
|
||||
Storage::disk('textures')->delete($texture->hash);
|
||||
$texture->delete();
|
||||
$dispatcher->dispatch('texture.deleted', [$texture]);
|
||||
} else {
|
||||
|
@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Lorisleiva\LaravelSearchString\Concerns\SearchString;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@ -19,6 +20,8 @@ use Illuminate\Support\Carbon;
|
||||
*/
|
||||
class Report extends Model
|
||||
{
|
||||
use SearchString;
|
||||
|
||||
public const CREATED_AT = 'report_at';
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
@ -33,6 +36,12 @@ class Report extends Model
|
||||
'status' => 'integer',
|
||||
];
|
||||
|
||||
protected $searchStringColumns = [
|
||||
'id', 'tid', 'uploader', 'reporter',
|
||||
'reason', 'status',
|
||||
'report_at' => ['date' => true],
|
||||
];
|
||||
|
||||
public function texture()
|
||||
{
|
||||
return $this->belongsTo(Texture::class, 'tid', 'tid');
|
||||
|
@ -3,7 +3,6 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasPassword;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
@ -63,7 +63,7 @@ export default [
|
||||
},
|
||||
{
|
||||
path: 'admin/reports',
|
||||
component: () => import('../views/admin/Reports.vue'),
|
||||
react: () => import('../views/admin/ReportsManagement'),
|
||||
el: '.content > .container-fluid',
|
||||
},
|
||||
{
|
||||
|
@ -1,144 +0,0 @@
|
||||
<template>
|
||||
<div class="container-fluid">
|
||||
<vue-good-table
|
||||
mode="remote"
|
||||
:rows="reports"
|
||||
:total-rows="totalRecords || reports.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 === 'tid'">
|
||||
{{ props.formattedRow[props.column.field] }}
|
||||
<a :href="`${baseUrl}/skinlib/show/${props.row.tid}`">{{ $t('report.check') }}</a>
|
||||
<a href="#" @click="deleteTexture(props.row)">
|
||||
{{ $t('report.delete') }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-else-if="props.column.field === 'uploader'">
|
||||
{{ props.row.uploaderName }} (UID: {{ props.row.uploader }})
|
||||
<a href="#" @click="ban(props.row)">
|
||||
{{ $t('report.ban') }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-else-if="props.column.field === 'reporter'">
|
||||
{{ props.row.reporterName }} (UID: {{ props.row.reporter }})
|
||||
</span>
|
||||
<span v-else-if="props.column.field === 'status'">
|
||||
{{ $t(`report.status.${props.row.status}`) }}
|
||||
</span>
|
||||
<span v-else-if="props.column.field === 'ops'">
|
||||
<button class="btn btn-default" @click="reject(props.row)">
|
||||
{{ $t('report.reject') }}
|
||||
</button>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ props.formattedRow[props.column.field] }}
|
||||
</span>
|
||||
</template>
|
||||
</vue-good-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { VueGoodTable } from 'vue-good-table'
|
||||
import 'vue-good-table/dist/vue-good-table.min.css'
|
||||
import tableOptions from '../../components/mixins/tableOptions'
|
||||
import serverTable from '../../components/mixins/serverTable'
|
||||
import emitMounted from '../../components/mixins/emitMounted'
|
||||
import { toast } from '../../scripts/notify'
|
||||
|
||||
export default {
|
||||
name: 'ReportsManagement',
|
||||
components: {
|
||||
VueGoodTable,
|
||||
},
|
||||
mixins: [
|
||||
emitMounted,
|
||||
tableOptions,
|
||||
serverTable,
|
||||
],
|
||||
props: {
|
||||
baseUrl: {
|
||||
type: String,
|
||||
default: blessing.base_url,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
reports: [],
|
||||
columns: [
|
||||
{
|
||||
field: 'id', type: 'number', hidden: true,
|
||||
},
|
||||
{
|
||||
field: 'tid', label: this.$t('report.tid'), type: 'number',
|
||||
},
|
||||
{ field: 'uploader', label: this.$t('skinlib.show.uploader') },
|
||||
{ field: 'reporter', label: this.$t('report.reporter') },
|
||||
{
|
||||
field: 'reason',
|
||||
label: this.$t('report.reason'),
|
||||
sortable: false,
|
||||
width: '23%',
|
||||
},
|
||||
{ field: 'status', label: this.$t('report.status-title') },
|
||||
{
|
||||
field: 'report_at',
|
||||
label: this.$t('report.time'),
|
||||
globalSearchDisabled: true,
|
||||
},
|
||||
{
|
||||
field: 'ops',
|
||||
label: this.$t('admin.operationsTitle'),
|
||||
globalSearchDisabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.sortField = 'report_at'
|
||||
this.sortType = 'desc'
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
const { data, totalRecords } = await this.$http.get(
|
||||
'/admin/reports/list',
|
||||
this.serverParams,
|
||||
)
|
||||
this.totalRecords = totalRecords
|
||||
this.reports = data
|
||||
},
|
||||
deleteTexture(report) {
|
||||
this.resolve(report, 'delete')
|
||||
},
|
||||
ban(report) {
|
||||
this.resolve(report, 'ban')
|
||||
},
|
||||
reject(report) {
|
||||
this.resolve(report, 'reject')
|
||||
},
|
||||
async resolve(report, action) {
|
||||
const {
|
||||
code, message, data,
|
||||
} = await this.$http.post(
|
||||
'/admin/reports',
|
||||
{ id: report.id, action },
|
||||
)
|
||||
if (code === 0) {
|
||||
toast.success(message)
|
||||
report.status = data.status
|
||||
} else {
|
||||
toast.error(message)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
@ -0,0 +1,31 @@
|
||||
@use '../../../styles/utils';
|
||||
|
||||
.box {
|
||||
width: 240px;
|
||||
transition-property: box-shadow;
|
||||
transition-duration: 0.3s;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: unset;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
cursor: pointer;
|
||||
width: 170px;
|
||||
height: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex: 1 1 auto;
|
||||
|
||||
* {
|
||||
margin: 2.5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.reason {
|
||||
@include utils.truncate-text;
|
||||
}
|
116
resources/assets/src/views/admin/ReportsManagement/ImageBox.tsx
Normal file
116
resources/assets/src/views/admin/ReportsManagement/ImageBox.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import React from 'react'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import { Texture } from '@/scripts/types'
|
||||
import { Report, Status } from './types'
|
||||
import styles from './ImageBox.module.scss'
|
||||
|
||||
interface Props {
|
||||
report: Report
|
||||
onClick(texture: Texture | null): void
|
||||
onBan(): void
|
||||
onDelete(): void
|
||||
onReject(): void
|
||||
}
|
||||
|
||||
const ImageBox: React.FC<Props> = (props) => {
|
||||
const { report } = props
|
||||
|
||||
const handleImageClick = () => props.onClick(report.texture)
|
||||
|
||||
return (
|
||||
<div className={`card mr-3 mb-3 ${styles.box}`}>
|
||||
<div className="card-header">
|
||||
<b>
|
||||
{t('skinlib.show.uploader')}
|
||||
{': '}
|
||||
</b>
|
||||
<span className="mr-1">{report.uploaderName}</span>
|
||||
(UID: {report.uploader})
|
||||
</div>
|
||||
<div className={`card-body ${styles.body}`}>
|
||||
<img
|
||||
src={`${blessing.base_url}/preview/${report.tid}?height=150`}
|
||||
alt={report.tid.toString()}
|
||||
className="card-img-top"
|
||||
onClick={handleImageClick}
|
||||
/>
|
||||
</div>
|
||||
<div className={`card-footer ${styles.footer}`}>
|
||||
<div className="d-flex justify-content-between">
|
||||
<div>
|
||||
{report.status === Status.Pending ? (
|
||||
<span className="badge bg-warning">{t('report.status.0')}</span>
|
||||
) : report.status === Status.Resolved ? (
|
||||
<span className="badge bg-success">{t('report.status.1')}</span>
|
||||
) : (
|
||||
<span className="badge bg-danger">{t('report.status.2')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="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={`${blessing.base_url}/skinlib/show/${report.tid}`}
|
||||
className="dropdown-item"
|
||||
target="_blank"
|
||||
>
|
||||
<i className="fas fa-share-square mr-2"></i>
|
||||
{t('user.viewInSkinlib')}
|
||||
</a>
|
||||
<a href="#" className="dropdown-item" onClick={props.onBan}>
|
||||
<i className="fas fa-user-slash mr-2"></i>
|
||||
{t('report.ban')}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="dropdown-item dropdown-item-danger"
|
||||
onClick={props.onDelete}
|
||||
>
|
||||
<i className="fas fa-trash mr-2"></i>
|
||||
{t('skinlib.show.delete-texture')}
|
||||
</a>
|
||||
<a href="#" className="dropdown-item" onClick={props.onReject}>
|
||||
<i className="fas fa-thumbs-down mr-2"></i>
|
||||
{t('report.reject')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>
|
||||
{t('report.reporter')}
|
||||
{': '}
|
||||
</b>
|
||||
<span className="mr-1">{report.reporterName}</span>
|
||||
(UID: {report.reporter})
|
||||
</div>
|
||||
<details>
|
||||
<summary className={styles.reason}>
|
||||
<b>
|
||||
{t('report.reason')}
|
||||
{': '}
|
||||
</b>
|
||||
{report.reason}
|
||||
</summary>
|
||||
<div>{report.reason}</div>
|
||||
<div>
|
||||
<small>
|
||||
{t('report.time')}
|
||||
{': '}
|
||||
{report.report_at}
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageBox
|
155
resources/assets/src/views/admin/ReportsManagement/index.tsx
Normal file
155
resources/assets/src/views/admin/ReportsManagement/index.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
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 { Paginator, Texture, TextureType } from '@/scripts/types'
|
||||
import { toast, showModal } from '@/scripts/notify'
|
||||
import Loading from '@/components/Loading'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import ViewerSkeleton from '@/components/ViewerSkeleton'
|
||||
import { Report, Status } from './types'
|
||||
import ImageBox from './ImageBox'
|
||||
|
||||
const Previewer = React.lazy(() => import('@/components/Viewer'))
|
||||
|
||||
const ReportsManagement: React.FC = () => {
|
||||
const [reports, setReports] = useImmer<Report[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [query, setQuery] = useState('status:0 sort:-report_at')
|
||||
const [viewingTexture, setViewingTexture] = useState<Texture | null>(null)
|
||||
|
||||
const getReports = async () => {
|
||||
setIsLoading(true)
|
||||
const { data, last_page }: Paginator<Report> = await fetch.get(
|
||||
'/admin/reports/list',
|
||||
{
|
||||
q: query,
|
||||
page,
|
||||
},
|
||||
)
|
||||
setTotalPages(last_page)
|
||||
setReports(() => data)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getReports()
|
||||
}, [page])
|
||||
|
||||
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value)
|
||||
}
|
||||
|
||||
const handleSubmitQuery = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
getReports()
|
||||
}
|
||||
|
||||
const handleProceedReport = async (
|
||||
report: Report,
|
||||
index: number,
|
||||
action: 'ban' | 'delete' | 'reject',
|
||||
) => {
|
||||
type Ok = { code: 0; message: string; data: { status: Status } }
|
||||
type Err = { code: 1; message: string }
|
||||
const resp = await fetch.put<Ok | Err>(`/admin/reports/${report.id}`, {
|
||||
action,
|
||||
})
|
||||
|
||||
if (resp.code === 0) {
|
||||
toast.success(resp.message)
|
||||
setReports((reports) => {
|
||||
reports[index].status = resp.data.status
|
||||
})
|
||||
} else {
|
||||
toast.error(resp.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (report: Report, index: number) => {
|
||||
try {
|
||||
await showModal({
|
||||
text: t('skinlib.deleteNotice'),
|
||||
okButtonType: 'danger',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
handleProceedReport(report, index, 'delete')
|
||||
}
|
||||
|
||||
const textureUrl =
|
||||
viewingTexture && `${blessing.base_url}/textures/${viewingTexture.hash}`
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-lg-8">
|
||||
<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>
|
||||
{isLoading ? (
|
||||
<div className="card-body">
|
||||
<Loading />
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="card-body text-center">{t('general.noResult')}</div>
|
||||
) : (
|
||||
<div className="card-body d-flex flex-wrap">
|
||||
{reports.map((report, i) => (
|
||||
<ImageBox
|
||||
key={report.id}
|
||||
report={report}
|
||||
onClick={setViewingTexture}
|
||||
onBan={() => handleProceedReport(report, i, 'ban')}
|
||||
onDelete={() => handleDelete(report, i)}
|
||||
onReject={() => handleProceedReport(report, i, 'reject')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-footer">
|
||||
<div className="float-right">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-4">
|
||||
<React.Suspense fallback={<ViewerSkeleton />}>
|
||||
<Previewer
|
||||
{...{
|
||||
[viewingTexture?.type === TextureType.Cape
|
||||
? TextureType.Cape
|
||||
: 'skin']: textureUrl,
|
||||
}}
|
||||
isAlex={viewingTexture?.type === TextureType.Alex}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default hot(ReportsManagement)
|
20
resources/assets/src/views/admin/ReportsManagement/types.ts
Normal file
20
resources/assets/src/views/admin/ReportsManagement/types.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Texture } from '@/scripts/types'
|
||||
|
||||
export const enum Status {
|
||||
Pending = 0,
|
||||
Resolved = 1,
|
||||
Rejected = 2,
|
||||
}
|
||||
|
||||
export type Report = {
|
||||
id: number
|
||||
tid: number
|
||||
texture: Texture | null
|
||||
uploader: number
|
||||
uploaderName: string
|
||||
reporter: number
|
||||
reporterName: string
|
||||
reason: string
|
||||
status: Status
|
||||
report_at: string
|
||||
}
|
@ -1,102 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '../../utils'
|
||||
import { toast } from '@/scripts/notify'
|
||||
import Reports from '@/views/admin/Reports.vue'
|
||||
|
||||
jest.mock('@/scripts/notify')
|
||||
|
||||
test('basic render', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({
|
||||
data: [{
|
||||
id: 1,
|
||||
uploader: 1,
|
||||
uploaderName: 'a',
|
||||
reporter: 2,
|
||||
reporterName: 'b',
|
||||
reason: 'sth',
|
||||
status: 0,
|
||||
}],
|
||||
})
|
||||
const wrapper = mount(Reports)
|
||||
await flushPromises()
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('a (UID: 1)')
|
||||
expect(text).toContain('b (UID: 2)')
|
||||
expect(text).toContain('sth')
|
||||
expect(text).toContain('report.status.0')
|
||||
})
|
||||
|
||||
test('link to skin library', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({
|
||||
data: [{ id: 1, tid: 1 }],
|
||||
})
|
||||
const wrapper = mount(Reports)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('a').attributes('href')).toBe('/skinlib/show/1')
|
||||
})
|
||||
|
||||
test('delete texture', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: [{ id: 1, status: 0 }] })
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValueOnce({ code: 1, message: 'fail' })
|
||||
.mockResolvedValue({
|
||||
code: 0, message: 'ok', data: { status: 1 },
|
||||
})
|
||||
const wrapper = mount(Reports)
|
||||
await flushPromises()
|
||||
const button = wrapper.findAll('a').at(1)
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith(
|
||||
'/admin/reports',
|
||||
{ id: 1, action: 'delete' },
|
||||
)
|
||||
expect(toast.error).toBeCalledWith('fail')
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(toast.success).toBeCalledWith('ok')
|
||||
expect(wrapper.text()).toContain('report.status.1')
|
||||
})
|
||||
|
||||
test('ban uploader', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: [{ id: 1, status: 0 }] })
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValue({
|
||||
code: 0, message: 'ok', data: { status: 1 },
|
||||
})
|
||||
const wrapper = mount(Reports)
|
||||
await flushPromises()
|
||||
const button = wrapper.findAll('a').at(2)
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith(
|
||||
'/admin/reports',
|
||||
{ id: 1, action: 'ban' },
|
||||
)
|
||||
expect(toast.success).toBeCalledWith('ok')
|
||||
expect(wrapper.text()).toContain('report.status.1')
|
||||
})
|
||||
|
||||
test('reject', async () => {
|
||||
Vue.prototype.$http.get.mockResolvedValue({ data: [{ id: 1, status: 0 }] })
|
||||
Vue.prototype.$http.post
|
||||
.mockResolvedValue({
|
||||
code: 0, message: 'ok', data: { status: 2 },
|
||||
})
|
||||
const wrapper = mount(Reports)
|
||||
await flushPromises()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(Vue.prototype.$http.post).toBeCalledWith(
|
||||
'/admin/reports',
|
||||
{ id: 1, action: 'reject' },
|
||||
)
|
||||
expect(toast.success).toBeCalledWith('ok')
|
||||
expect(wrapper.text()).toContain('report.status.2')
|
||||
})
|
172
resources/assets/tests/views/admin/ReportsManagement.test.tsx
Normal file
172
resources/assets/tests/views/admin/ReportsManagement.test.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import React from 'react'
|
||||
import { render, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { createPaginator } from '../../utils'
|
||||
import { t } from '@/scripts/i18n'
|
||||
import * as fetch from '@/scripts/net'
|
||||
import { Texture, TextureType } from '@/scripts/types'
|
||||
import ReportsManagement from '@/views/admin/ReportsManagement'
|
||||
import { Report, Status } from '@/views/admin/ReportsManagement/types'
|
||||
|
||||
jest.mock('@/scripts/net')
|
||||
|
||||
const fixture: Readonly<Report> = Object.freeze<Report>({
|
||||
id: 1,
|
||||
tid: 1,
|
||||
texture: null,
|
||||
uploader: 1,
|
||||
uploaderName: 'xx',
|
||||
reporter: 2,
|
||||
reporterName: 'yy',
|
||||
reason: 'nsfw',
|
||||
status: Status.Pending,
|
||||
report_at: new Date().toString(),
|
||||
})
|
||||
|
||||
test('search reports', async () => {
|
||||
fetch.get.mockResolvedValue(createPaginator([]))
|
||||
|
||||
const { getByTitle, getByText } = render(<ReportsManagement />)
|
||||
await waitFor(() =>
|
||||
expect(fetch.get).toBeCalledWith('/admin/reports/list', {
|
||||
q: 'status:0 sort:-report_at',
|
||||
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/reports/list', {
|
||||
q: 's',
|
||||
page: 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('preview texture', async () => {
|
||||
const texture: Texture = {
|
||||
tid: fixture.tid,
|
||||
name: 'cape',
|
||||
type: TextureType.Cape,
|
||||
hash: 'def',
|
||||
size: 2,
|
||||
uploader: fixture.uploader,
|
||||
public: true,
|
||||
upload_at: new Date().toString(),
|
||||
likes: 1,
|
||||
}
|
||||
fetch.get.mockResolvedValue(createPaginator([{ ...fixture, texture }]))
|
||||
|
||||
const { getByAltText } = render(<ReportsManagement />)
|
||||
await waitFor(() => expect(fetch.get).toBeCalled())
|
||||
|
||||
fireEvent.click(getByAltText(fixture.tid.toString()))
|
||||
})
|
||||
|
||||
describe('proceed report', () => {
|
||||
beforeEach(() => {
|
||||
fetch.get.mockResolvedValue(createPaginator([fixture]))
|
||||
})
|
||||
|
||||
describe('ban uploader', () => {
|
||||
it('succeeded', async () => {
|
||||
fetch.put.mockResolvedValue({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: { status: Status.Resolved },
|
||||
})
|
||||
|
||||
const { getByText, getByRole, queryByText } = render(
|
||||
<ReportsManagement />,
|
||||
)
|
||||
await waitFor(() => expect(fetch.get).toBeCalled())
|
||||
|
||||
fireEvent.click(getByText(t('report.ban')))
|
||||
await waitFor(() =>
|
||||
expect(fetch.put).toBeCalledWith(`/admin/reports/${fixture.id}`, {
|
||||
action: 'ban',
|
||||
}),
|
||||
)
|
||||
expect(queryByText('ok')).toBeInTheDocument()
|
||||
expect(getByRole('status')).toBeInTheDocument()
|
||||
expect(queryByText(t('report.status.1'))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('failed', async () => {
|
||||
fetch.put.mockResolvedValue({ code: 1, message: 'failed' })
|
||||
|
||||
const { getByText, getByRole, queryByText } = render(
|
||||
<ReportsManagement />,
|
||||
)
|
||||
await waitFor(() => expect(fetch.get).toBeCalled())
|
||||
|
||||
fireEvent.click(getByText(t('report.ban')))
|
||||
await waitFor(() =>
|
||||
expect(fetch.put).toBeCalledWith(`/admin/reports/${fixture.id}`, {
|
||||
action: 'ban',
|
||||
}),
|
||||
)
|
||||
expect(queryByText('failed')).toBeInTheDocument()
|
||||
expect(getByRole('alert')).toBeInTheDocument()
|
||||
expect(queryByText(t('report.status.0'))).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete texture', () => {
|
||||
it('cancelled', async () => {
|
||||
const { getByText } = render(<ReportsManagement />)
|
||||
await waitFor(() => expect(fetch.get).toBeCalled())
|
||||
|
||||
fireEvent.click(getByText(t('skinlib.show.delete-texture')))
|
||||
fireEvent.click(getByText(t('general.cancel')))
|
||||
expect(fetch.put).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('succeeded', async () => {
|
||||
fetch.put.mockResolvedValue({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: { status: Status.Resolved },
|
||||
})
|
||||
|
||||
const { getByText, getByRole, queryByText } = render(
|
||||
<ReportsManagement />,
|
||||
)
|
||||
await waitFor(() => expect(fetch.get).toBeCalled())
|
||||
|
||||
fireEvent.click(getByText(t('skinlib.show.delete-texture')))
|
||||
fireEvent.click(getByText(t('general.confirm')))
|
||||
await waitFor(() =>
|
||||
expect(fetch.put).toBeCalledWith(`/admin/reports/${fixture.id}`, {
|
||||
action: 'delete',
|
||||
}),
|
||||
)
|
||||
expect(queryByText('ok')).toBeInTheDocument()
|
||||
expect(getByRole('status')).toBeInTheDocument()
|
||||
expect(queryByText(t('report.status.1'))).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('reject report', async () => {
|
||||
fetch.put.mockResolvedValue({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: { status: Status.Rejected },
|
||||
})
|
||||
|
||||
const { getByText, getByRole, queryByText } = render(<ReportsManagement />)
|
||||
await waitFor(() => expect(fetch.get).toBeCalled())
|
||||
|
||||
fireEvent.click(getByText(t('report.reject')))
|
||||
await waitFor(() =>
|
||||
expect(fetch.put).toBeCalledWith(`/admin/reports/${fixture.id}`, {
|
||||
action: 'reject',
|
||||
}),
|
||||
)
|
||||
expect(queryByText('ok')).toBeInTheDocument()
|
||||
expect(getByRole('status')).toBeInTheDocument()
|
||||
expect(queryByText(t('report.status.2'))).toBeInTheDocument()
|
||||
})
|
||||
})
|
@ -73,6 +73,7 @@
|
||||
- Fixed potential "Invalid Signature" issue.
|
||||
- Fixed that duplicated player name is not detected when updating player name in administration panel.
|
||||
- Fixed that normal administrator can set other user as administrator.
|
||||
- Fixed that texture file won't be deleted when deleting texture in reports management.
|
||||
|
||||
## Removed
|
||||
|
||||
|
@ -73,6 +73,7 @@
|
||||
- 修复可能的「Invalid Signature」问题
|
||||
- 修复在管理面板中修改角色名时不检测角色名是否重复的问题
|
||||
- 修复普通管理员可设置其他用户为管理员的问题
|
||||
- 修复处理举报中删除材质时不删除材质文件的问题
|
||||
|
||||
## 移除
|
||||
|
||||
|
@ -61,5 +61,10 @@ Route::prefix('admin')
|
||||
Route::delete('{uid}', 'ClosetManagementController@remove');
|
||||
});
|
||||
|
||||
Route::prefix('reports')->group(function () {
|
||||
Route::get('', 'ReportController@manage');
|
||||
Route::put('{report}', 'ReportController@review');
|
||||
});
|
||||
|
||||
Route::post('notifications', 'NotificationsController@send');
|
||||
});
|
||||
|
@ -159,8 +159,8 @@ Route::prefix('admin')
|
||||
|
||||
Route::prefix('reports')->group(function () {
|
||||
Route::view('', 'admin.reports');
|
||||
Route::post('', 'ReportController@review');
|
||||
Route::any('list', 'ReportController@manage');
|
||||
Route::put('{report}', 'ReportController@review');
|
||||
Route::get('list', 'ReportController@manage');
|
||||
});
|
||||
|
||||
Route::prefix('i18n')->group(function () {
|
||||
|
@ -9,6 +9,7 @@ use Blessing\Filter;
|
||||
use Blessing\Rejection;
|
||||
use Event;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ReportControllerTest extends TestCase
|
||||
{
|
||||
@ -22,20 +23,20 @@ class ReportControllerTest extends TestCase
|
||||
$user = factory(User::class)->create();
|
||||
$texture = factory(Texture::class)->create();
|
||||
|
||||
// Without `tid` field
|
||||
// without `tid` field
|
||||
$this->actingAs($user)
|
||||
->postJson('/skinlib/report')
|
||||
->assertJsonValidationErrors('tid');
|
||||
|
||||
// Invalid texture
|
||||
// invalid texture
|
||||
$this->postJson('/skinlib/report', ['tid' => $texture->tid - 1])
|
||||
->assertJsonValidationErrors('tid');
|
||||
|
||||
// Without `reason` field
|
||||
// without `reason` field
|
||||
$this->postJson('/skinlib/report', ['tid' => $texture->tid])
|
||||
->assertJsonValidationErrors('reason');
|
||||
|
||||
// Lack of score
|
||||
// lack of score
|
||||
$user->score = 0;
|
||||
$user->save();
|
||||
option(['reporter_score_modification' => -5]);
|
||||
@ -46,7 +47,7 @@ class ReportControllerTest extends TestCase
|
||||
]);
|
||||
option(['reporter_score_modification' => 5]);
|
||||
|
||||
// Rejection
|
||||
// rejection
|
||||
$filter->add(
|
||||
'user_can_report',
|
||||
function ($can, $tid, $reason, $reporter) use ($texture, $user) {
|
||||
@ -61,7 +62,7 @@ class ReportControllerTest extends TestCase
|
||||
->assertJson(['code' => 1, 'message' => 'rejected']);
|
||||
$filter->remove('user_can_report');
|
||||
|
||||
// Success
|
||||
// success
|
||||
$this->postJson('/skinlib/report', ['tid' => $texture->tid, 'reason' => 'reason'])
|
||||
->assertJson([
|
||||
'code' => 0,
|
||||
@ -93,7 +94,7 @@ class ReportControllerTest extends TestCase
|
||||
return true;
|
||||
});
|
||||
|
||||
// Prevent duplication
|
||||
// prevent duplication
|
||||
$this->postJson('/skinlib/report', ['tid' => $texture->tid, 'reason' => 'reason'])
|
||||
->assertJson([
|
||||
'code' => 1,
|
||||
@ -134,18 +135,7 @@ class ReportControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($reporter)
|
||||
->getJson('/admin/reports/list')
|
||||
->assertJson([
|
||||
'totalRecords' => 1,
|
||||
'data' => [[
|
||||
'tid' => $texture->tid,
|
||||
'uploader' => $uploader->uid,
|
||||
'reporter' => $reporter->uid,
|
||||
'reason' => 'test',
|
||||
'status' => Report::PENDING,
|
||||
'uploaderName' => $uploader->nickname,
|
||||
'reporterName' => $reporter->nickname,
|
||||
]],
|
||||
]);
|
||||
->assertJson(['data' => [$report->toArray()]]);
|
||||
}
|
||||
|
||||
public function testReview()
|
||||
@ -164,25 +154,17 @@ class ReportControllerTest extends TestCase
|
||||
$report->save();
|
||||
$report->refresh();
|
||||
|
||||
// Without `id` field
|
||||
$this->actingAs($admin)
|
||||
->postJson('/admin/reports')
|
||||
->assertJsonValidationErrors('id');
|
||||
|
||||
// Not existed
|
||||
$this->postJson('/admin/reports', ['id' => $report->id - 1])
|
||||
->assertJsonValidationErrors('id');
|
||||
|
||||
// Without `action` field
|
||||
$this->postJson('/admin/reports', ['id' => $report->id])
|
||||
$this->actingAs($admin)
|
||||
->putJson('/admin/reports/'.$report->id)
|
||||
->assertJsonValidationErrors('action');
|
||||
|
||||
// Invalid action
|
||||
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'a'])
|
||||
// invalid action
|
||||
$this->putJson('/admin/reports/'.$report->id, ['action' => 'a'])
|
||||
->assertJsonValidationErrors('action');
|
||||
|
||||
// Allow to process again
|
||||
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'reject'])
|
||||
// allow to process again
|
||||
$this->putJson('/admin/reports/'.$report->id, ['action' => 'reject'])
|
||||
->assertJson(['code' => 0]);
|
||||
$id = $report->id;
|
||||
Event::assertDispatched('report.reviewing', function ($event, $payload) use ($id) {
|
||||
@ -213,10 +195,10 @@ class ReportControllerTest extends TestCase
|
||||
$report->refresh();
|
||||
$id = $report->id;
|
||||
|
||||
// Should not cost score
|
||||
// should not cost score
|
||||
$score = $reporter->score;
|
||||
$this->actingAs($admin)
|
||||
->postJson('/admin/reports', ['id' => $report->id, 'action' => 'reject'])
|
||||
->putJson('/admin/reports/'.$report->id, ['action' => 'reject'])
|
||||
->assertJson([
|
||||
'code' => 0,
|
||||
'message' => trans('general.op-success'),
|
||||
@ -240,12 +222,12 @@ class ReportControllerTest extends TestCase
|
||||
return true;
|
||||
});
|
||||
|
||||
// Should cost score
|
||||
// should cost score
|
||||
$report->status = Report::PENDING;
|
||||
$report->save();
|
||||
option(['reporter_score_modification' => 5]);
|
||||
$score = $reporter->score;
|
||||
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'reject'])
|
||||
$this->putJson('/admin/reports/'.$report->id, ['action' => 'reject'])
|
||||
->assertJson(['code' => 0]);
|
||||
$reporter->refresh();
|
||||
$this->assertEquals($score - 5, $reporter->score);
|
||||
@ -254,11 +236,13 @@ class ReportControllerTest extends TestCase
|
||||
public function testReviewDelete()
|
||||
{
|
||||
Event::fake();
|
||||
$disk = Storage::fake('textures');
|
||||
|
||||
$uploader = factory(User::class)->create();
|
||||
$reporter = factory(User::class)->create();
|
||||
$admin = factory(User::class)->states('admin')->create();
|
||||
$texture = factory(Texture::class)->create(['uploader' => $uploader->uid]);
|
||||
$disk->put($texture->hash, '');
|
||||
|
||||
$report = new Report();
|
||||
$report->tid = $texture->tid;
|
||||
@ -278,7 +262,7 @@ class ReportControllerTest extends TestCase
|
||||
]);
|
||||
$score = $reporter->score;
|
||||
$this->actingAs($admin)
|
||||
->postJson('/admin/reports', ['id' => $report->id, 'action' => 'delete'])
|
||||
->putJson('/admin/reports/'.$report->id, ['action' => 'delete'])
|
||||
->assertJson([
|
||||
'code' => 0,
|
||||
'message' => trans('general.op-success'),
|
||||
@ -289,6 +273,7 @@ class ReportControllerTest extends TestCase
|
||||
$this->assertEquals(Report::RESOLVED, $report->status);
|
||||
$this->assertNull(Texture::find($texture->tid));
|
||||
$this->assertEquals($score + 7, $reporter->score);
|
||||
Storage::assertMissing($texture->hash);
|
||||
Event::assertDispatched('report.reviewing', function ($event, $payload) use ($id) {
|
||||
[$report, $action] = $payload;
|
||||
$this->assertEquals($id, $report->id);
|
||||
@ -337,7 +322,7 @@ class ReportControllerTest extends TestCase
|
||||
$score = $reporter->score;
|
||||
$texture->delete();
|
||||
$this->actingAs($admin)
|
||||
->postJson('/admin/reports', ['id' => $report->id, 'action' => 'delete'])
|
||||
->putJson('/admin/reports/'.$report->id, ['action' => 'delete'])
|
||||
->assertJson([
|
||||
'code' => 0,
|
||||
'message' => trans('general.texture-deleted'),
|
||||
@ -375,11 +360,11 @@ class ReportControllerTest extends TestCase
|
||||
$report->refresh();
|
||||
$id = $report->id;
|
||||
|
||||
// Uploader should be banned
|
||||
// uploader should be banned
|
||||
option(['reporter_reward_score' => 6]);
|
||||
$score = $reporter->score;
|
||||
$this->actingAs($admin)
|
||||
->postJson('/admin/reports', ['id' => $report->id, 'action' => 'ban'])
|
||||
->putJson('/admin/reports/'.$report->id, ['action' => 'ban'])
|
||||
->assertJson([
|
||||
'code' => 0,
|
||||
'message' => trans('general.op-success'),
|
||||
@ -391,14 +376,14 @@ class ReportControllerTest extends TestCase
|
||||
$this->assertEquals($score + 6, $reporter->score);
|
||||
option(['reporter_reward_score' => 0]);
|
||||
|
||||
// Should not ban admin uploader
|
||||
// should not ban admin uploader
|
||||
$report->refresh();
|
||||
$report->status = Report::PENDING;
|
||||
$report->save();
|
||||
$uploader->refresh();
|
||||
$uploader->permission = User::ADMIN;
|
||||
$uploader->save();
|
||||
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'ban'])
|
||||
$this->putJson('/admin/reports/'.$report->id, ['action' => 'ban'])
|
||||
->assertJson([
|
||||
'code' => 1,
|
||||
'message' => trans('admin.users.operations.no-permission'),
|
||||
@ -427,10 +412,10 @@ class ReportControllerTest extends TestCase
|
||||
return true;
|
||||
});
|
||||
|
||||
// Uploader has deleted its account
|
||||
// uploader has deleted its account
|
||||
$report->uploader = -1;
|
||||
$report->save();
|
||||
$this->postJson('/admin/reports', ['id' => $report->id, 'action' => 'ban'])
|
||||
$this->putJson('/admin/reports/'.$report->id, ['action' => 'ban'])
|
||||
->assertJson([
|
||||
'code' => 1,
|
||||
'message' => trans('admin.users.operations.non-existent'),
|
||||
|
Loading…
Reference in New Issue
Block a user