Allow user to search through their watch history (#3576)

* 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
pull/3592/head
Rigel Kent 2021-01-13 09:16:15 +01:00 committed by GitHub
parent 22078471fb
commit d8b34ee55b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 97 additions and 16 deletions

View File

@ -1,12 +1,23 @@
<h1> <h1>
<my-global-icon iconName="history" aria-hidden="true"></my-global-icon> <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> </h1>
<div class="top-buttons"> <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> <my-input-switch [(ngModel)]="videosHistoryEnabled" (ngModelChange)="onVideosHistoryChange()"></my-input-switch>
<label i18n>Video history</label> <label i18n>Track watch history</label>
</div> </div>
<button class="delete-history" (click)="deleteHistory()" i18n> <button class="delete-history" (click)="deleteHistory()" i18n>
@ -16,7 +27,7 @@
</div> </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 myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" class="videos">
<div class="video" *ngFor="let video of videos"> <div class="video" *ngFor="let video of videos">

View File

@ -10,17 +10,23 @@
} }
.top-buttons { .top-buttons {
margin-bottom: 20px; margin-bottom: 30px;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
#history-search {
@include peertube-input-text(250px);
}
.history-switch { .history-switch {
display: flex; display: flex;
flex-grow: 1;
label { label {
margin: 0 0 0 5px; margin: 0 0 0 5px;
color: var(--greyForegroundColor);
font-size: 15px;
font-weight: $font-semibold;
} }
} }

View File

@ -13,6 +13,8 @@ import {
import { immutableAssign } from '@app/helpers' import { immutableAssign } from '@app/helpers'
import { UserHistoryService } from '@app/shared/shared-main' import { UserHistoryService } from '@app/shared/shared-main'
import { AbstractVideoList } from '@app/shared/shared-video-miniature' import { AbstractVideoList } from '@app/shared/shared-video-miniature'
import { Subject } from 'rxjs'
import { debounceTime, tap, distinctUntilChanged } from 'rxjs/operators'
@Component({ @Component({
templateUrl: './my-history.component.html', templateUrl: './my-history.component.html',
@ -26,6 +28,9 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD
totalItems: null totalItems: null
} }
videosHistoryEnabled: boolean videosHistoryEnabled: boolean
search: string
protected searchStream: Subject<string>
constructor ( constructor (
protected router: Router, protected router: Router,
@ -41,7 +46,7 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD
) { ) {
super() super()
this.titlePage = $localize`My videos history` this.titlePage = $localize`My watch history`
} }
ngOnInit () { ngOnInit () {
@ -52,6 +57,28 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD
this.videosHistoryEnabled = this.authService.getUser().videosHistoryEnabled 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 () { ngOnDestroy () {
@ -61,7 +88,10 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD
getVideosObservable (page: number) { getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page }) 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 () { generateSyndicationList () {

View File

@ -15,7 +15,7 @@
</div> </div>
</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 class="video-channels" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let videoChannel of videoChannels" class="video-channel"> <div *ngFor="let videoChannel of videoChannels" class="video-channel">

View File

@ -18,11 +18,12 @@ export class UserHistoryService {
private videoService: VideoService private videoService: VideoService
) {} ) {}
getUserVideosHistory (historyPagination: ComponentPaginationLight) { getUserVideosHistory (historyPagination: ComponentPaginationLight, search?: string) {
const pagination = this.restService.componentPaginationToRestPagination(historyPagination) const pagination = this.restService.componentPaginationToRestPagination(historyPagination)
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination) params = this.restService.addRestGetParams(params, pagination)
params = params.append('search', search)
return this.authHttp return this.authHttp
.get<ResultList<Video>>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params }) .get<ResultList<Video>>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params })

View File

@ -5,6 +5,7 @@ import {
authenticate, authenticate,
paginationValidator, paginationValidator,
setDefaultPagination, setDefaultPagination,
userHistoryListValidator,
userHistoryRemoveValidator userHistoryRemoveValidator
} from '../../../middlewares' } from '../../../middlewares'
import { getFormattedObjects } from '../../../helpers/utils' import { getFormattedObjects } from '../../../helpers/utils'
@ -18,6 +19,7 @@ myVideosHistoryRouter.get('/me/history/videos',
authenticate, authenticate,
paginationValidator, paginationValidator,
setDefaultPagination, setDefaultPagination,
userHistoryListValidator,
asyncMiddleware(listMyVideosHistory) asyncMiddleware(listMyVideosHistory)
) )
@ -38,7 +40,7 @@ export {
async function listMyVideosHistory (req: express.Request, res: express.Response) { async function listMyVideosHistory (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User 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)) return res.json(getFormattedObjects(resultList.data, resultList.total))
} }

View File

@ -1,8 +1,22 @@
import * as express from 'express' import * as express from 'express'
import { body } from 'express-validator' import { body, query } from 'express-validator'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { areValidationErrors } from './utils' 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 = [ const userHistoryRemoveValidator = [
body('beforeDate') body('beforeDate')
@ -21,5 +35,6 @@ const userHistoryRemoveValidator = [
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
userHistoryListValidator,
userHistoryRemoveValidator userHistoryRemoveValidator
} }

View File

@ -55,10 +55,11 @@ export class UserVideoHistoryModel extends Model {
}) })
User: UserModel User: UserModel
static listForApi (user: MUserAccountId, start: number, count: number) { static listForApi (user: MUserAccountId, start: number, count: number, search?: string) {
return VideoModel.listForApi({ return VideoModel.listForApi({
start, start,
count, count,
search,
sort: '-"userVideoHistory"."updatedAt"', sort: '-"userVideoHistory"."updatedAt"',
nsfw: null, // All nsfw: null, // All
includeLocalVideos: true, includeLocalVideos: true,

View File

@ -1087,6 +1087,7 @@ export class VideoModel extends Model {
user?: MUserAccountId user?: MUserAccountId
historyOfUser?: MUserId historyOfUser?: MUserId
countVideos?: boolean countVideos?: boolean
search?: string
}) { }) {
if ((options.filter === 'all-local' || options.filter === 'all') && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { 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') 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, includeLocalVideos: options.includeLocalVideos,
user: options.user, user: options.user,
historyOfUser: options.historyOfUser, historyOfUser: options.historyOfUser,
trendingDays trendingDays,
search: options.search
} }
return VideoModel.getAvailableForApi(queryOptions, options.countVideos) return VideoModel.getAvailableForApi(queryOptions, options.countVideos)

View File

@ -152,6 +152,15 @@ describe('Test videos history', function () {
expect(res.body.data).to.have.lengthOf(0) 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 () { it('Should clear my history', async function () {
await removeMyVideosHistory(server.url, server.accessToken, video3WatchedDate.toISOString()) await removeMyVideosHistory(server.url, server.accessToken, video3WatchedDate.toISOString())
}) })

View File

@ -14,13 +14,16 @@ function userWatchVideo (
return makePutBodyRequest({ url, path, token, fields, statusCodeExpected }) 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' const path = '/api/v1/users/me/history/videos'
return makeGetRequest({ return makeGetRequest({
url, url,
path, path,
token, token,
query: {
search
},
statusCodeExpected: HttpStatusCode.OK_200 statusCodeExpected: HttpStatusCode.OK_200
}) })
} }

View File

@ -933,6 +933,7 @@ paths:
parameters: parameters:
- $ref: '#/components/parameters/start' - $ref: '#/components/parameters/start'
- $ref: '#/components/parameters/count' - $ref: '#/components/parameters/count'
- $ref: '#/components/parameters/search'
responses: responses:
'200': '200':
description: successful operation description: successful operation