]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Allow user to search through their watch history (#3576)
authorRigel Kent <sendmemail@rigelk.eu>
Wed, 13 Jan 2021 08:16:15 +0000 (09:16 +0100)
committerGitHub <noreply@github.com>
Wed, 13 Jan 2021 08:16:15 +0000 (09:16 +0100)
* allow user to search through their watch history

* add tests for search in watch history

* Update client/src/app/shared/shared-main/users/user-history.service.ts

12 files changed:
client/src/app/+my-library/my-history/my-history.component.html
client/src/app/+my-library/my-history/my-history.component.scss
client/src/app/+my-library/my-history/my-history.component.ts
client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html
client/src/app/shared/shared-main/users/user-history.service.ts
server/controllers/api/users/my-history.ts
server/middlewares/validators/user-history.ts
server/models/account/user-video-history.ts
server/models/video/video.ts
server/tests/api/videos/videos-history.ts
shared/extra-utils/videos/video-history.ts
support/doc/api/openapi.yaml

index 58b874ebfce2ad78e301918aa991cf8f921282ad..c180161e73644b30cebddd0f3d67f2ff8be5efc6 100644 (file)
@@ -1,12 +1,23 @@
 <h1>
   <my-global-icon iconName="history" aria-hidden="true"></my-global-icon>
-  <ng-container i18n>My history</ng-container>
+  <ng-container i18n>My watch history</ng-container> <span class="badge badge-secondary">{{ pagination.totalItems }}</span>
 </h1>
 
 <div class="top-buttons">
-  <div class="history-switch">
+  <div>
+    <div class="input-group has-feedback has-clear">
+      <input
+        type="text" name="history-search" id="history-search" i18n-placeholder placeholder="Search your history"
+        (keyup)="onSearch($event)"
+      >
+      <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
+      <span class="sr-only" i18n>Clear filters</span>
+    </div>
+  </div>
+
+  <div class="history-switch ml-auto mr-3">
     <my-input-switch [(ngModel)]="videosHistoryEnabled" (ngModelChange)="onVideosHistoryChange()"></my-input-switch>
-    <label i18n>Video history</label>
+    <label i18n>Track watch history</label>
   </div>
 
   <button class="delete-history" (click)="deleteHistory()" i18n>
@@ -16,7 +27,7 @@
 </div>
 
 
-<div class="no-history" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">You don't have any video history yet.</div>
+<div class="no-history" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">You don't have any video in your watch history yet.</div>
 
 <div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" class="videos">
   <div class="video" *ngFor="let video of videos">
index 9eeeaf310b612aa9fbeb50225a27e0733a7c3ce4..928a8a3da8ff1a9e000e9f720a9933265dc39f84 100644 (file)
 }
 
 .top-buttons {
-  margin-bottom: 20px;
+  margin-bottom: 30px;
   display: flex;
   align-items: center;
   flex-wrap: wrap;
 
+  #history-search {
+    @include peertube-input-text(250px);
+  }
+
   .history-switch {
     display: flex;
-    flex-grow: 1;
 
     label {
       margin: 0 0 0 5px;
+      color: var(--greyForegroundColor);
+      font-size: 15px;
+      font-weight: $font-semibold;
     }
   }
 
index 4ba95124d80f98c5f26791cfa4b5406577e163e1..0c8e4b83f51e624b06045af94041241cd853aaee 100644 (file)
@@ -13,6 +13,8 @@ import {
 import { immutableAssign } from '@app/helpers'
 import { UserHistoryService } from '@app/shared/shared-main'
 import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { Subject } from 'rxjs'
+import { debounceTime, tap, distinctUntilChanged } from 'rxjs/operators'
 
 @Component({
   templateUrl: './my-history.component.html',
@@ -26,6 +28,9 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD
     totalItems: null
   }
   videosHistoryEnabled: boolean
+  search: string
+
+  protected searchStream: Subject<string>
 
   constructor (
     protected router: Router,
@@ -41,7 +46,7 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD
   ) {
     super()
 
-    this.titlePage = $localize`My videos history`
+    this.titlePage = $localize`My watch history`
   }
 
   ngOnInit () {
@@ -52,6 +57,28 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD
         this.videosHistoryEnabled = this.authService.getUser().videosHistoryEnabled
       })
 
+    this.searchStream = new Subject()
+
+    this.searchStream
+      .pipe(
+        debounceTime(400),
+        distinctUntilChanged()
+      )
+      .subscribe(search => {
+        this.search = search
+        this.reloadVideos()
+      })
+  }
+
+  onSearch (event: Event) {
+    const target = event.target as HTMLInputElement
+    this.searchStream.next(target.value)
+  }
+
+  resetSearch () {
+    const searchInput = document.getElementById('history-search') as HTMLInputElement
+    searchInput.value = ''
+    this.searchStream.next('')
   }
 
   ngOnDestroy () {
@@ -61,7 +88,10 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD
   getVideosObservable (page: number) {
     const newPagination = immutableAssign(this.pagination, { currentPage: page })
 
-    return this.userHistoryService.getUserVideosHistory(newPagination)
+    return this.userHistoryService.getUserVideosHistory(newPagination, this.search)
+      .pipe(
+        tap(res => this.pagination.totalItems = res.total)
+      )
   }
 
   generateSyndicationList () {
index 6ab3826bafb66fe1b7c0879165d4456639540af8..510b400c0da5c9607be3e9a1f5f5c45395652499 100644 (file)
@@ -15,7 +15,7 @@
   </div>
 </div>
 
-<div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscriptions yet.</div>
+<div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscription yet.</div>
 
 <div class="video-channels" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
   <div *ngFor="let videoChannel of videoChannels" class="video-channel">
index 43970dc5bd4764f13618142fd8e1c9428de91130..bb87dcba872d3a1d1d7498bb11ea9abbab0255c0 100644 (file)
@@ -18,11 +18,12 @@ export class UserHistoryService {
     private videoService: VideoService
   ) {}
 
-  getUserVideosHistory (historyPagination: ComponentPaginationLight) {
+  getUserVideosHistory (historyPagination: ComponentPaginationLight, search?: string) {
     const pagination = this.restService.componentPaginationToRestPagination(historyPagination)
 
     let params = new HttpParams()
     params = this.restService.addRestGetParams(params, pagination)
+    params = params.append('search', search)
 
     return this.authHttp
                .get<ResultList<Video>>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params })
index 80d4dc748c5e0b4e38211c74034bea0bd3d96360..72c7da3739945a9cbe4e627281f88d91edcce064 100644 (file)
@@ -5,6 +5,7 @@ import {
   authenticate,
   paginationValidator,
   setDefaultPagination,
+  userHistoryListValidator,
   userHistoryRemoveValidator
 } from '../../../middlewares'
 import { getFormattedObjects } from '../../../helpers/utils'
@@ -18,6 +19,7 @@ myVideosHistoryRouter.get('/me/history/videos',
   authenticate,
   paginationValidator,
   setDefaultPagination,
+  userHistoryListValidator,
   asyncMiddleware(listMyVideosHistory)
 )
 
@@ -38,7 +40,7 @@ export {
 async function listMyVideosHistory (req: express.Request, res: express.Response) {
   const user = res.locals.oauth.token.User
 
-  const resultList = await UserVideoHistoryModel.listForApi(user, req.query.start, req.query.count)
+  const resultList = await UserVideoHistoryModel.listForApi(user, req.query.start, req.query.count, req.query.search)
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
 }
index 2f1d3cc4134ecb1ec2e424fa6fa366aac034ebd0..058bf77583a9c27c6ceb5ba096bc039b68332474 100644 (file)
@@ -1,8 +1,22 @@
 import * as express from 'express'
-import { body } from 'express-validator'
+import { body, query } from 'express-validator'
 import { logger } from '../../helpers/logger'
 import { areValidationErrors } from './utils'
-import { isDateValid } from '../../helpers/custom-validators/misc'
+import { exists, isDateValid } from '../../helpers/custom-validators/misc'
+
+const userHistoryListValidator = [
+  query('search')
+    .optional()
+    .custom(exists).withMessage('Should have a valid search'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking userHistoryListValidator parameters', { parameters: req.query })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
 
 const userHistoryRemoveValidator = [
   body('beforeDate')
@@ -21,5 +35,6 @@ const userHistoryRemoveValidator = [
 // ---------------------------------------------------------------------------
 
 export {
+  userHistoryListValidator,
   userHistoryRemoveValidator
 }
index 45171fc6063c0e2ce65806385d870b6625d511f8..6be1d65ea19e76d2e29d0f0b86960f102a02dea3 100644 (file)
@@ -55,10 +55,11 @@ export class UserVideoHistoryModel extends Model {
   })
   User: UserModel
 
-  static listForApi (user: MUserAccountId, start: number, count: number) {
+  static listForApi (user: MUserAccountId, start: number, count: number, search?: string) {
     return VideoModel.listForApi({
       start,
       count,
+      search,
       sort: '-"userVideoHistory"."updatedAt"',
       nsfw: null, // All
       includeLocalVideos: true,
index abf823d4b62adfce9e730ba343342ca1b842faf7..5027e980d94fde0fcd2d823b0712d349e7fb56f0 100644 (file)
@@ -1087,6 +1087,7 @@ export class VideoModel extends Model {
     user?: MUserAccountId
     historyOfUser?: MUserId
     countVideos?: boolean
+    search?: string
   }) {
     if ((options.filter === 'all-local' || options.filter === 'all') && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
       throw new Error('Try to filter all-local but no user has not the see all videos right')
@@ -1123,7 +1124,8 @@ export class VideoModel extends Model {
       includeLocalVideos: options.includeLocalVideos,
       user: options.user,
       historyOfUser: options.historyOfUser,
-      trendingDays
+      trendingDays,
+      search: options.search
     }
 
     return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
index 661d603cb9a369cc2765d44d597669bce1ecd553..b25cff879603fc97e60ee4468507521448a6a6ef 100644 (file)
@@ -152,6 +152,15 @@ describe('Test videos history', function () {
     expect(res.body.data).to.have.lengthOf(0)
   })
 
+  it('Should be able to search through videos in my history', async function () {
+    const res = await listMyVideosHistory(server.url, server.accessToken, '2')
+
+    expect(res.body.total).to.equal(1)
+
+    const videos: Video[] = res.body.data
+    expect(videos[0].name).to.equal('video 2')
+  })
+
   it('Should clear my history', async function () {
     await removeMyVideosHistory(server.url, server.accessToken, video3WatchedDate.toISOString())
   })
index 0dd3afb248d205f1a9a2d79ca51e9de3c21844c1..b989e14dcaa38d0fbb2ed00efb58820098b4bec3 100644 (file)
@@ -14,13 +14,16 @@ function userWatchVideo (
   return makePutBodyRequest({ url, path, token, fields, statusCodeExpected })
 }
 
-function listMyVideosHistory (url: string, token: string) {
+function listMyVideosHistory (url: string, token: string, search?: string) {
   const path = '/api/v1/users/me/history/videos'
 
   return makeGetRequest({
     url,
     path,
     token,
+    query: {
+      search
+    },
     statusCodeExpected: HttpStatusCode.OK_200
   })
 }
index fe4552ff77a777a6882521437dc2de3e8ec24907..8ad98a9a9a13933a510467e8ef552ef356490f32 100644 (file)
@@ -933,6 +933,7 @@ paths:
       parameters:
         - $ref: '#/components/parameters/start'
         - $ref: '#/components/parameters/count'
+        - $ref: '#/components/parameters/search'
       responses:
         '200':
           description: successful operation