Switching to a named filters/single input on video-abuse
authorRigel Kent <sendmemail@rigelk.eu>
Sat, 2 May 2020 20:38:18 +0000 (22:38 +0200)
committerRigel Kent <par@rigelk.eu>
Mon, 4 May 2020 13:01:44 +0000 (15:01 +0200)
client/src/app/+admin/moderation/moderation.component.scss
client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html
client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss
client/src/app/+admin/users/user-edit/user-password.component.scss
client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.scss
client/src/app/+signup/+register/register.component.scss
client/src/app/shared/user-subscription/subscribe-button.component.scss
client/src/sass/bootstrap.scss
client/src/sass/primeng-custom.scss
server/models/utils.ts
server/models/video/video-abuse.ts

index cf06401cf04255aac6f62e21296505c8f54a3203..26c2a30d4269b49efb9932d466e050b0a643fe5c 100644 (file)
@@ -12,6 +12,7 @@
 
   input {
     @include peertube-input-text(250px);
+    flex-grow: 1;
   }
 }
 
index 2f6e12d1c808213baee9eb74f28f3294ddfa4403..b55b18333deda6cdcf5193610160341f3a99d120 100644 (file)
@@ -7,10 +7,32 @@
   <ng-template pTemplate="caption">
     <div class="caption">
       <div class="ml-auto">
-        <input
-          type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
-          (keyup)="onSearch($event)"
-        >
+        <div class="input-group">
+          <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body">
+            <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>
   </ng-template>
 
       <td class="action-cell">
         <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"
         ></my-action-dropdown>
       </td>
               <div class="d-flex">
                 <span class="col-3 moderation-expanded-label" i18n>Reporter</span>
                 <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
                       class="avatar"
                       [src]="videoAbuse.reporterAccount.avatar.path"
                     <div>
                       <span class="text-muted">{{ createByString(videoAbuse.reporterAccount) }}</span>
                     </div>
-                  </div>
-                  <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': videoAbuse.reporterAccount.displayName }" class="ml-auto text-muted video-details-links" i18n>
+                  </a>
+                  <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>
                   </a>
                 </span>
                       <span class="text-muted">{{ videoAbuse.video.channel.ownerAccount ? createByString(videoAbuse.video.channel.ownerAccount) : '' }}</span>
                     </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>
                   </a>
                 </span>
index d6bc34935c07251c7cc64389e79ff833e6a33881..8eee15b64fc16aad60c3ad9cd634ed77fdffdb45 100644 (file)
 .video-abuse-states .glyphicon-comment {
   margin-left: 0.5rem;
 }
+
+.input-group {
+  @include peertube-input-group(300px);
+
+  .dropdown-toggle::after {
+    margin-left: 0;
+  }
+}
index 217d585afcc144a00894f16a9e7e72a9b55833f0..5cd93f6af140fe2b43e37aca49759343507ece40 100644 (file)
@@ -16,7 +16,3 @@ input[type=submit] {
 
   margin-top: 10px;
 }
-
-.input-group-append {
-  height: 30px;
-}
index 8f8af655cc7804fb6378739a9c1ec2acd936c933..ba27ee7ff1e33c56cabffe8e0f207113fb1576de 100644 (file)
@@ -19,10 +19,6 @@ my-actor-avatar-info {
   @include peertube-input-group(fit-content);
 }
 
-.input-group-append {
-  height: 30px;
-}
-
 input {
   &[type=text] {
     @include peertube-input-text(340px);
index e135b5cb4312488b91a7f4bb3845051f01d5c9bb..cc60ef524785571dce799812feb7b88bd89dd12a 100644 (file)
   @include peertube-input-group(400px);
 }
 
-.input-group-append {
-  height: 30px;
-}
-
 input:not([type=submit]) {
   @include peertube-input-text(400px);
 
index 5283a6cc3775ab8ea6ca08415b348f80236677aa..b739c5ae29a9d07109f59628a7015d8fb2643980 100644 (file)
     }
   }
 
-  .dropdown-header {
-    padding-left: 1rem;
-  }
-
   ::ng-deep form {
     padding: 0.25rem 1rem;
   }
index 377c85070bc6ec4036d32442c811e5b0c4c14ad0..50f1dafedd7497f2a21df2685a1b97fc458ba54e 100644 (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);
   font-size: 15px;
 
+  .dropdown-header {
+    padding-left: 1rem;
+  }
+
   .dropdown-item {
     padding: 3px 15px;
 
@@ -262,6 +266,18 @@ ngb-tooltip-window {
   }
 }
 
-.input-group > .form-control {
-  flex: initial;
+.input-group {
+  & > .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;
+  }
 }
index b3cd7cf511f1d4daa9d5caa7ce297d06c7fa76b6..eab2b2dfd3e3c733f3208ee938d53d068d42a7a3 100644 (file)
@@ -32,6 +32,10 @@ p-table {
       height: 40px;
       display: flex;
       align-items: center;
+
+      .input-group-text {
+        background-color: transparent;
+      }
     }
   }
 
index bdf2291f0ceb5b34ed3c089de5e71044ec7304b8..3e3825b3267fd7ecc63947bd2a6fcb666f10ef15 100644 (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 {
@@ -241,7 +289,8 @@ export {
   getFollowsSort,
   buildDirectionAndField,
   createSafeIn,
-  searchAttribute
+  searchAttribute,
+  parseQueryStringFilter
 }
 
 // ---------------------------------------------------------------------------
index 628f1caa6f6db4e3d82c8de46bc212a53f25bfc7..b1f8fed90e8545dddaccaa075075642520cb81a2 100644 (file)
@@ -9,7 +9,7 @@ import {
   isVideoAbuseStateValid
 } from '../../helpers/custom-validators/video-abuses'
 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 { VideoAbuseState, VideoDetails } from '../../../shared'
 import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
@@ -26,10 +26,17 @@ export enum ScopeNames {
 
 @Scopes(() => ({
   [ScopeNames.FOR_API]: (options: {
+    // search
     search?: string
     searchReporter?: string
+    searchReportee?: string
     searchVideo?: string
     searchVideoChannel?: string
+    // filters
+    id?: number
+    state?: VideoAbuseState
+    is?: any
+    // accountIds
     serverAccountId: 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 {
       attributes: {
         include: [
@@ -167,7 +192,13 @@ export enum ScopeNames {
             },
             {
               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' ],
@@ -280,7 +311,36 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
     }
 
     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,
       userAccountId
     }