Switching to a named filters/single input on video-abuse

This commit is contained in:
Rigel Kent 2020-05-02 22:38:18 +02:00 committed by Rigel Kent
parent c2a89b70ce
commit 0d3a2982a9
11 changed files with 175 additions and 31 deletions

View File

@ -12,6 +12,7 @@
input { input {
@include peertube-input-text(250px); @include peertube-input-text(250px);
flex-grow: 1;
} }
} }

View File

@ -7,10 +7,32 @@
<ng-template pTemplate="caption"> <ng-template pTemplate="caption">
<div class="caption"> <div class="caption">
<div class="ml-auto"> <div class="ml-auto">
<input <div class="input-group">
type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
(keyup)="onSearch($event)" <div class="input-group-text" ngbDropdownToggle>
> <span class="caret" aria-haspopup="menu" role="button"></span>
</div>
<div role="menu" ngbDropdownMenu>
<h6 class="dropdown-header" i18n>Filter reports</h6>
<!-- TODO:
<div class="dropdown-item" i18n>Reports opened by admins</div>
<div class="dropdown-item" i18n>Reports on videos with multiple reports</div>
<div class="dropdown-item" i18n>Unassigned reports</div>
-->
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a>
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a>
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a>
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'is:blocked' }" class="dropdown-item" i18n>Reports with blocked videos</a>
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'is:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
</div>
</div>
<input
type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
(keyup)="onSearch($event)"
>
</div>
</div> </div>
</div> </div>
</ng-template> </ng-template>
@ -100,7 +122,7 @@
<td class="action-cell"> <td class="action-cell">
<my-action-dropdown <my-action-dropdown
[ngClass]="{ 'show': expanded }" placement="bottom-right auto" container="body" [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
i18n-label label="Actions" [actions]="videoAbuseActions" [entry]="videoAbuse" i18n-label label="Actions" [actions]="videoAbuseActions" [entry]="videoAbuse"
></my-action-dropdown> ></my-action-dropdown>
</td> </td>
@ -118,7 +140,7 @@
<div class="d-flex"> <div class="d-flex">
<span class="col-3 moderation-expanded-label" i18n>Reporter</span> <span class="col-3 moderation-expanded-label" i18n>Reporter</span>
<span class="col-9 moderation-expanded-text"> <span class="col-9 moderation-expanded-text">
<div class="chip"> <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="chip">
<img <img
class="avatar" class="avatar"
[src]="videoAbuse.reporterAccount.avatar.path" [src]="videoAbuse.reporterAccount.avatar.path"
@ -128,8 +150,8 @@
<div> <div>
<span class="text-muted">{{ createByString(videoAbuse.reporterAccount) }}</span> <span class="text-muted">{{ createByString(videoAbuse.reporterAccount) }}</span>
</div> </div>
</div> </a>
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': videoAbuse.reporterAccount.displayName }" class="ml-auto text-muted video-details-links" i18n> <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
{videoAbuse.countReportsForReporter, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span> {videoAbuse.countReportsForReporter, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
</a> </a>
</span> </span>
@ -149,7 +171,7 @@
<span class="text-muted">{{ videoAbuse.video.channel.ownerAccount ? createByString(videoAbuse.video.channel.ownerAccount) : '' }}</span> <span class="text-muted">{{ videoAbuse.video.channel.ownerAccount ? createByString(videoAbuse.video.channel.ownerAccount) : '' }}</span>
</div> </div>
</div> </div>
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': videoAbuse.video.channel.ownerAccount.displayName }" class="ml-auto text-muted video-details-links" i18n> <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +videoAbuse.video.channel.ownerAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
{videoAbuse.countReportsForReportee, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span> {videoAbuse.countReportsForReportee, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
</a> </a>
</span> </span>

View File

@ -13,3 +13,11 @@
.video-abuse-states .glyphicon-comment { .video-abuse-states .glyphicon-comment {
margin-left: 0.5rem; margin-left: 0.5rem;
} }
.input-group {
@include peertube-input-group(300px);
.dropdown-toggle::after {
margin-left: 0;
}
}

View File

@ -16,7 +16,3 @@ input[type=submit] {
margin-top: 10px; margin-top: 10px;
} }
.input-group-append {
height: 30px;
}

View File

@ -19,10 +19,6 @@ my-actor-avatar-info {
@include peertube-input-group(fit-content); @include peertube-input-group(fit-content);
} }
.input-group-append {
height: 30px;
}
input { input {
&[type=text] { &[type=text] {
@include peertube-input-text(340px); @include peertube-input-text(340px);

View File

@ -58,10 +58,6 @@
@include peertube-input-group(400px); @include peertube-input-group(400px);
} }
.input-group-append {
height: 30px;
}
input:not([type=submit]) { input:not([type=submit]) {
@include peertube-input-text(400px); @include peertube-input-text(400px);

View File

@ -88,10 +88,6 @@
} }
} }
.dropdown-header {
padding-left: 1rem;
}
::ng-deep form { ::ng-deep form {
padding: 0.25rem 1rem; padding: 0.25rem 1rem;
} }

View File

@ -41,6 +41,10 @@ $icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
font-size: 15px; font-size: 15px;
.dropdown-header {
padding-left: 1rem;
}
.dropdown-item { .dropdown-item {
padding: 3px 15px; padding: 3px 15px;
@ -262,6 +266,18 @@ ngb-tooltip-window {
} }
} }
.input-group > .form-control { .input-group {
flex: initial; & > .form-control {
flex: initial;
}
.input-group-prepend,
.input-group-append {
height: 30px;
}
.input-group-prepend + input {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
} }

View File

@ -32,6 +32,10 @@ p-table {
height: 40px; height: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
.input-group-text {
background-color: transparent;
}
} }
} }

View File

@ -219,6 +219,54 @@ function searchAttribute (sourceField, targetField) {
} }
} }
interface QueryStringFilterPrefixes {
[key: string]: string | { prefix: string, handler: Function, multiple?: boolean }
}
function parseQueryStringFilter (q: string, prefixes: QueryStringFilterPrefixes) {
const tokens = q // tokenize only if we have a querystring
? [].concat.apply([], q.split('"').map((v, i) => i % 2 ? v : v.split(' '))).filter(Boolean)
: []
// TODO: when Typescript supports Object.fromEntries, replace with the Object method
function fromEntries<T> (entries: [keyof T, T[keyof T]][]): T {
return entries.reduce(
(acc, [ key, value ]) => ({ ...acc, [key]: value }),
{} as T
)
}
const objectMap = (obj, fn) => fromEntries(
Object.entries(obj).map(
([ k, v ], i) => [ k, fn(v, k, i) ]
)
)
return {
// search is the querystring minus defined filters
search: tokens.filter(e => !Object.values(prefixes).some(p => {
if (typeof p === "string") {
return e.startsWith(p)
} else {
return e.startsWith(p.prefix)
}
})).join(' '),
// filters defined in prefixes are added under their own name
...objectMap(prefixes, v => {
if (typeof v === "string") {
return tokens.filter(e => e.startsWith(v)).map(e => e.slice(v.length))
} else {
const _tokens = tokens.filter(e => e.startsWith(v.prefix)).map(e => e.slice(v.prefix.length)).map(v.handler)
return !v.multiple
? _tokens.length > 0
? _tokens[0]
: ''
: _tokens
}
})
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
@ -241,7 +289,8 @@ export {
getFollowsSort, getFollowsSort,
buildDirectionAndField, buildDirectionAndField,
createSafeIn, createSafeIn,
searchAttribute searchAttribute,
parseQueryStringFilter
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -9,7 +9,7 @@ import {
isVideoAbuseStateValid isVideoAbuseStateValid
} from '../../helpers/custom-validators/video-abuses' } from '../../helpers/custom-validators/video-abuses'
import { AccountModel } from '../account/account' import { AccountModel } from '../account/account'
import { buildBlockedAccountSQL, getSort, throwIfNotValid, searchAttribute } from '../utils' import { buildBlockedAccountSQL, getSort, throwIfNotValid, searchAttribute, parseQueryStringFilter } from '../utils'
import { VideoModel } from './video' import { VideoModel } from './video'
import { VideoAbuseState, VideoDetails } from '../../../shared' import { VideoAbuseState, VideoDetails } from '../../../shared'
import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants' import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
@ -26,10 +26,17 @@ export enum ScopeNames {
@Scopes(() => ({ @Scopes(() => ({
[ScopeNames.FOR_API]: (options: { [ScopeNames.FOR_API]: (options: {
// search
search?: string search?: string
searchReporter?: string searchReporter?: string
searchReportee?: string
searchVideo?: string searchVideo?: string
searchVideoChannel?: string searchVideoChannel?: string
// filters
id?: number
state?: VideoAbuseState
is?: any
// accountIds
serverAccountId: number serverAccountId: number
userAccountId: number userAccountId: number
}) => { }) => {
@ -71,6 +78,24 @@ export enum ScopeNames {
}) })
} }
if (options.id) {
where = Object.assign(where, {
id: options.id
})
}
if (options.state) {
where = Object.assign(where, {
state: options.state
})
}
if (options.is) {
where = Object.assign(where, {
...options.is
})
}
return { return {
attributes: { attributes: {
include: [ include: [
@ -167,7 +192,13 @@ export enum ScopeNames {
}, },
{ {
model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }), model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
where: searchAttribute(options.searchVideoChannel, 'name') where: searchAttribute(options.searchVideoChannel, 'name'),
include: [
{
model: AccountModel,
where: searchAttribute(options.searchReportee, 'name')
}
]
}, },
{ {
attributes: [ 'id', 'reason', 'unfederated' ], attributes: [ 'id', 'reason', 'unfederated' ],
@ -280,7 +311,36 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
} }
const filters = { const filters = {
search, ...parseQueryStringFilter(search, {
id: {
prefix: '#',
handler: v => v
},
state: {
prefix: 'state:',
handler: v => {
if (v === "accepted") return VideoAbuseState.ACCEPTED
if (v === "pending") return VideoAbuseState.PENDING
if (v === "rejected") return VideoAbuseState.REJECTED
return undefined
}
},
is: {
prefix: 'is:',
handler: v => {
if (v === "deleted") return { deletedVideo: { [Op.not]: null } }
return undefined
}
},
searchReporter: {
prefix: 'reporter:',
handler: v => v
},
searchReportee: {
prefix: 'reportee:',
handler: v => v
}
}),
serverAccountId, serverAccountId,
userAccountId userAccountId
} }