mirror of https://github.com/Chocobozzz/PeerTube
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.tspull/3592/head
parent
22078471fb
commit
d8b34ee55b
|
@ -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">
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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 })
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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())
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue