diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2021-01-13 09:16:15 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-01-13 09:16:15 +0100 |
commit | d8b34ee55b654912f86bb8b472d391ced8c28f64 (patch) | |
tree | efa2b8ac36c00fa6e9b5af3f13e54a47bc7a7701 | |
parent | 22078471fbe5a4dea6177bd1fa19da1cf887679e (diff) | |
download | PeerTube-d8b34ee55b654912f86bb8b472d391ced8c28f64.tar.gz PeerTube-d8b34ee55b654912f86bb8b472d391ced8c28f64.tar.zst PeerTube-d8b34ee55b654912f86bb8b472d391ced8c28f64.zip |
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
12 files changed, 97 insertions, 16 deletions
diff --git a/client/src/app/+my-library/my-history/my-history.component.html b/client/src/app/+my-library/my-history/my-history.component.html index 58b874ebf..c180161e7 100644 --- a/client/src/app/+my-library/my-history/my-history.component.html +++ b/client/src/app/+my-library/my-history/my-history.component.html | |||
@@ -1,12 +1,23 @@ | |||
1 | <h1> | 1 | <h1> |
2 | <my-global-icon iconName="history" aria-hidden="true"></my-global-icon> | 2 | <my-global-icon iconName="history" aria-hidden="true"></my-global-icon> |
3 | <ng-container i18n>My history</ng-container> | 3 | <ng-container i18n>My watch history</ng-container> <span class="badge badge-secondary">{{ pagination.totalItems }}</span> |
4 | </h1> | 4 | </h1> |
5 | 5 | ||
6 | <div class="top-buttons"> | 6 | <div class="top-buttons"> |
7 | <div class="history-switch"> | 7 | <div> |
8 | <div class="input-group has-feedback has-clear"> | ||
9 | <input | ||
10 | type="text" name="history-search" id="history-search" i18n-placeholder placeholder="Search your history" | ||
11 | (keyup)="onSearch($event)" | ||
12 | > | ||
13 | <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a> | ||
14 | <span class="sr-only" i18n>Clear filters</span> | ||
15 | </div> | ||
16 | </div> | ||
17 | |||
18 | <div class="history-switch ml-auto mr-3"> | ||
8 | <my-input-switch [(ngModel)]="videosHistoryEnabled" (ngModelChange)="onVideosHistoryChange()"></my-input-switch> | 19 | <my-input-switch [(ngModel)]="videosHistoryEnabled" (ngModelChange)="onVideosHistoryChange()"></my-input-switch> |
9 | <label i18n>Video history</label> | 20 | <label i18n>Track watch history</label> |
10 | </div> | 21 | </div> |
11 | 22 | ||
12 | <button class="delete-history" (click)="deleteHistory()" i18n> | 23 | <button class="delete-history" (click)="deleteHistory()" i18n> |
@@ -16,7 +27,7 @@ | |||
16 | </div> | 27 | </div> |
17 | 28 | ||
18 | 29 | ||
19 | <div class="no-history" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">You don't have any video history yet.</div> | 30 | <div class="no-history" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">You don't have any video in your watch history yet.</div> |
20 | 31 | ||
21 | <div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" class="videos"> | 32 | <div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" class="videos"> |
22 | <div class="video" *ngFor="let video of videos"> | 33 | <div class="video" *ngFor="let video of videos"> |
diff --git a/client/src/app/+my-library/my-history/my-history.component.scss b/client/src/app/+my-library/my-history/my-history.component.scss index 9eeeaf310..928a8a3da 100644 --- a/client/src/app/+my-library/my-history/my-history.component.scss +++ b/client/src/app/+my-library/my-history/my-history.component.scss | |||
@@ -10,17 +10,23 @@ | |||
10 | } | 10 | } |
11 | 11 | ||
12 | .top-buttons { | 12 | .top-buttons { |
13 | margin-bottom: 20px; | 13 | margin-bottom: 30px; |
14 | display: flex; | 14 | display: flex; |
15 | align-items: center; | 15 | align-items: center; |
16 | flex-wrap: wrap; | 16 | flex-wrap: wrap; |
17 | 17 | ||
18 | #history-search { | ||
19 | @include peertube-input-text(250px); | ||
20 | } | ||
21 | |||
18 | .history-switch { | 22 | .history-switch { |
19 | display: flex; | 23 | display: flex; |
20 | flex-grow: 1; | ||
21 | 24 | ||
22 | label { | 25 | label { |
23 | margin: 0 0 0 5px; | 26 | margin: 0 0 0 5px; |
27 | color: var(--greyForegroundColor); | ||
28 | font-size: 15px; | ||
29 | font-weight: $font-semibold; | ||
24 | } | 30 | } |
25 | } | 31 | } |
26 | 32 | ||
diff --git a/client/src/app/+my-library/my-history/my-history.component.ts b/client/src/app/+my-library/my-history/my-history.component.ts index 4ba95124d..0c8e4b83f 100644 --- a/client/src/app/+my-library/my-history/my-history.component.ts +++ b/client/src/app/+my-library/my-history/my-history.component.ts | |||
@@ -13,6 +13,8 @@ import { | |||
13 | import { immutableAssign } from '@app/helpers' | 13 | import { immutableAssign } from '@app/helpers' |
14 | import { UserHistoryService } from '@app/shared/shared-main' | 14 | import { UserHistoryService } from '@app/shared/shared-main' |
15 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' | 15 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' |
16 | import { Subject } from 'rxjs' | ||
17 | import { debounceTime, tap, distinctUntilChanged } from 'rxjs/operators' | ||
16 | 18 | ||
17 | @Component({ | 19 | @Component({ |
18 | templateUrl: './my-history.component.html', | 20 | templateUrl: './my-history.component.html', |
@@ -26,6 +28,9 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD | |||
26 | totalItems: null | 28 | totalItems: null |
27 | } | 29 | } |
28 | videosHistoryEnabled: boolean | 30 | videosHistoryEnabled: boolean |
31 | search: string | ||
32 | |||
33 | protected searchStream: Subject<string> | ||
29 | 34 | ||
30 | constructor ( | 35 | constructor ( |
31 | protected router: Router, | 36 | protected router: Router, |
@@ -41,7 +46,7 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD | |||
41 | ) { | 46 | ) { |
42 | super() | 47 | super() |
43 | 48 | ||
44 | this.titlePage = $localize`My videos history` | 49 | this.titlePage = $localize`My watch history` |
45 | } | 50 | } |
46 | 51 | ||
47 | ngOnInit () { | 52 | ngOnInit () { |
@@ -52,6 +57,28 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD | |||
52 | this.videosHistoryEnabled = this.authService.getUser().videosHistoryEnabled | 57 | this.videosHistoryEnabled = this.authService.getUser().videosHistoryEnabled |
53 | }) | 58 | }) |
54 | 59 | ||
60 | this.searchStream = new Subject() | ||
61 | |||
62 | this.searchStream | ||
63 | .pipe( | ||
64 | debounceTime(400), | ||
65 | distinctUntilChanged() | ||
66 | ) | ||
67 | .subscribe(search => { | ||
68 | this.search = search | ||
69 | this.reloadVideos() | ||
70 | }) | ||
71 | } | ||
72 | |||
73 | onSearch (event: Event) { | ||
74 | const target = event.target as HTMLInputElement | ||
75 | this.searchStream.next(target.value) | ||
76 | } | ||
77 | |||
78 | resetSearch () { | ||
79 | const searchInput = document.getElementById('history-search') as HTMLInputElement | ||
80 | searchInput.value = '' | ||
81 | this.searchStream.next('') | ||
55 | } | 82 | } |
56 | 83 | ||
57 | ngOnDestroy () { | 84 | ngOnDestroy () { |
@@ -61,7 +88,10 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD | |||
61 | getVideosObservable (page: number) { | 88 | getVideosObservable (page: number) { |
62 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | 89 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) |
63 | 90 | ||
64 | return this.userHistoryService.getUserVideosHistory(newPagination) | 91 | return this.userHistoryService.getUserVideosHistory(newPagination, this.search) |
92 | .pipe( | ||
93 | tap(res => this.pagination.totalItems = res.total) | ||
94 | ) | ||
65 | } | 95 | } |
66 | 96 | ||
67 | generateSyndicationList () { | 97 | generateSyndicationList () { |
diff --git a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html index 6ab3826ba..510b400c0 100644 --- a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html +++ b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html | |||
@@ -15,7 +15,7 @@ | |||
15 | </div> | 15 | </div> |
16 | </div> | 16 | </div> |
17 | 17 | ||
18 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscriptions yet.</div> | 18 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscription yet.</div> |
19 | 19 | ||
20 | <div class="video-channels" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> | 20 | <div class="video-channels" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> |
21 | <div *ngFor="let videoChannel of videoChannels" class="video-channel"> | 21 | <div *ngFor="let videoChannel of videoChannels" class="video-channel"> |
diff --git a/client/src/app/shared/shared-main/users/user-history.service.ts b/client/src/app/shared/shared-main/users/user-history.service.ts index 43970dc5b..bb87dcba8 100644 --- a/client/src/app/shared/shared-main/users/user-history.service.ts +++ b/client/src/app/shared/shared-main/users/user-history.service.ts | |||
@@ -18,11 +18,12 @@ export class UserHistoryService { | |||
18 | private videoService: VideoService | 18 | private videoService: VideoService |
19 | ) {} | 19 | ) {} |
20 | 20 | ||
21 | getUserVideosHistory (historyPagination: ComponentPaginationLight) { | 21 | getUserVideosHistory (historyPagination: ComponentPaginationLight, search?: string) { |
22 | const pagination = this.restService.componentPaginationToRestPagination(historyPagination) | 22 | const pagination = this.restService.componentPaginationToRestPagination(historyPagination) |
23 | 23 | ||
24 | let params = new HttpParams() | 24 | let params = new HttpParams() |
25 | params = this.restService.addRestGetParams(params, pagination) | 25 | params = this.restService.addRestGetParams(params, pagination) |
26 | params = params.append('search', search) | ||
26 | 27 | ||
27 | return this.authHttp | 28 | return this.authHttp |
28 | .get<ResultList<Video>>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params }) | 29 | .get<ResultList<Video>>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params }) |
diff --git a/server/controllers/api/users/my-history.ts b/server/controllers/api/users/my-history.ts index 80d4dc748..72c7da373 100644 --- a/server/controllers/api/users/my-history.ts +++ b/server/controllers/api/users/my-history.ts | |||
@@ -5,6 +5,7 @@ import { | |||
5 | authenticate, | 5 | authenticate, |
6 | paginationValidator, | 6 | paginationValidator, |
7 | setDefaultPagination, | 7 | setDefaultPagination, |
8 | userHistoryListValidator, | ||
8 | userHistoryRemoveValidator | 9 | userHistoryRemoveValidator |
9 | } from '../../../middlewares' | 10 | } from '../../../middlewares' |
10 | import { getFormattedObjects } from '../../../helpers/utils' | 11 | import { getFormattedObjects } from '../../../helpers/utils' |
@@ -18,6 +19,7 @@ myVideosHistoryRouter.get('/me/history/videos', | |||
18 | authenticate, | 19 | authenticate, |
19 | paginationValidator, | 20 | paginationValidator, |
20 | setDefaultPagination, | 21 | setDefaultPagination, |
22 | userHistoryListValidator, | ||
21 | asyncMiddleware(listMyVideosHistory) | 23 | asyncMiddleware(listMyVideosHistory) |
22 | ) | 24 | ) |
23 | 25 | ||
@@ -38,7 +40,7 @@ export { | |||
38 | async function listMyVideosHistory (req: express.Request, res: express.Response) { | 40 | async function listMyVideosHistory (req: express.Request, res: express.Response) { |
39 | const user = res.locals.oauth.token.User | 41 | const user = res.locals.oauth.token.User |
40 | 42 | ||
41 | const resultList = await UserVideoHistoryModel.listForApi(user, req.query.start, req.query.count) | 43 | const resultList = await UserVideoHistoryModel.listForApi(user, req.query.start, req.query.count, req.query.search) |
42 | 44 | ||
43 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | 45 | return res.json(getFormattedObjects(resultList.data, resultList.total)) |
44 | } | 46 | } |
diff --git a/server/middlewares/validators/user-history.ts b/server/middlewares/validators/user-history.ts index 2f1d3cc41..058bf7758 100644 --- a/server/middlewares/validators/user-history.ts +++ b/server/middlewares/validators/user-history.ts | |||
@@ -1,8 +1,22 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { body } from 'express-validator' | 2 | import { body, query } from 'express-validator' |
3 | import { logger } from '../../helpers/logger' | 3 | import { logger } from '../../helpers/logger' |
4 | import { areValidationErrors } from './utils' | 4 | import { areValidationErrors } from './utils' |
5 | import { isDateValid } from '../../helpers/custom-validators/misc' | 5 | import { exists, isDateValid } from '../../helpers/custom-validators/misc' |
6 | |||
7 | const userHistoryListValidator = [ | ||
8 | query('search') | ||
9 | .optional() | ||
10 | .custom(exists).withMessage('Should have a valid search'), | ||
11 | |||
12 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
13 | logger.debug('Checking userHistoryListValidator parameters', { parameters: req.query }) | ||
14 | |||
15 | if (areValidationErrors(req, res)) return | ||
16 | |||
17 | return next() | ||
18 | } | ||
19 | ] | ||
6 | 20 | ||
7 | const userHistoryRemoveValidator = [ | 21 | const userHistoryRemoveValidator = [ |
8 | body('beforeDate') | 22 | body('beforeDate') |
@@ -21,5 +35,6 @@ const userHistoryRemoveValidator = [ | |||
21 | // --------------------------------------------------------------------------- | 35 | // --------------------------------------------------------------------------- |
22 | 36 | ||
23 | export { | 37 | export { |
38 | userHistoryListValidator, | ||
24 | userHistoryRemoveValidator | 39 | userHistoryRemoveValidator |
25 | } | 40 | } |
diff --git a/server/models/account/user-video-history.ts b/server/models/account/user-video-history.ts index 45171fc60..6be1d65ea 100644 --- a/server/models/account/user-video-history.ts +++ b/server/models/account/user-video-history.ts | |||
@@ -55,10 +55,11 @@ export class UserVideoHistoryModel extends Model { | |||
55 | }) | 55 | }) |
56 | User: UserModel | 56 | User: UserModel |
57 | 57 | ||
58 | static listForApi (user: MUserAccountId, start: number, count: number) { | 58 | static listForApi (user: MUserAccountId, start: number, count: number, search?: string) { |
59 | return VideoModel.listForApi({ | 59 | return VideoModel.listForApi({ |
60 | start, | 60 | start, |
61 | count, | 61 | count, |
62 | search, | ||
62 | sort: '-"userVideoHistory"."updatedAt"', | 63 | sort: '-"userVideoHistory"."updatedAt"', |
63 | nsfw: null, // All | 64 | nsfw: null, // All |
64 | includeLocalVideos: true, | 65 | includeLocalVideos: true, |
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index abf823d4b..5027e980d 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -1087,6 +1087,7 @@ export class VideoModel extends Model { | |||
1087 | user?: MUserAccountId | 1087 | user?: MUserAccountId |
1088 | historyOfUser?: MUserId | 1088 | historyOfUser?: MUserId |
1089 | countVideos?: boolean | 1089 | countVideos?: boolean |
1090 | search?: string | ||
1090 | }) { | 1091 | }) { |
1091 | if ((options.filter === 'all-local' || options.filter === 'all') && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { | 1092 | if ((options.filter === 'all-local' || options.filter === 'all') && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) { |
1092 | throw new Error('Try to filter all-local but no user has not the see all videos right') | 1093 | 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 { | |||
1123 | includeLocalVideos: options.includeLocalVideos, | 1124 | includeLocalVideos: options.includeLocalVideos, |
1124 | user: options.user, | 1125 | user: options.user, |
1125 | historyOfUser: options.historyOfUser, | 1126 | historyOfUser: options.historyOfUser, |
1126 | trendingDays | 1127 | trendingDays, |
1128 | search: options.search | ||
1127 | } | 1129 | } |
1128 | 1130 | ||
1129 | return VideoModel.getAvailableForApi(queryOptions, options.countVideos) | 1131 | return VideoModel.getAvailableForApi(queryOptions, options.countVideos) |
diff --git a/server/tests/api/videos/videos-history.ts b/server/tests/api/videos/videos-history.ts index 661d603cb..b25cff879 100644 --- a/server/tests/api/videos/videos-history.ts +++ b/server/tests/api/videos/videos-history.ts | |||
@@ -152,6 +152,15 @@ describe('Test videos history', function () { | |||
152 | expect(res.body.data).to.have.lengthOf(0) | 152 | expect(res.body.data).to.have.lengthOf(0) |
153 | }) | 153 | }) |
154 | 154 | ||
155 | it('Should be able to search through videos in my history', async function () { | ||
156 | const res = await listMyVideosHistory(server.url, server.accessToken, '2') | ||
157 | |||
158 | expect(res.body.total).to.equal(1) | ||
159 | |||
160 | const videos: Video[] = res.body.data | ||
161 | expect(videos[0].name).to.equal('video 2') | ||
162 | }) | ||
163 | |||
155 | it('Should clear my history', async function () { | 164 | it('Should clear my history', async function () { |
156 | await removeMyVideosHistory(server.url, server.accessToken, video3WatchedDate.toISOString()) | 165 | await removeMyVideosHistory(server.url, server.accessToken, video3WatchedDate.toISOString()) |
157 | }) | 166 | }) |
diff --git a/shared/extra-utils/videos/video-history.ts b/shared/extra-utils/videos/video-history.ts index 0dd3afb24..b989e14dc 100644 --- a/shared/extra-utils/videos/video-history.ts +++ b/shared/extra-utils/videos/video-history.ts | |||
@@ -14,13 +14,16 @@ function userWatchVideo ( | |||
14 | return makePutBodyRequest({ url, path, token, fields, statusCodeExpected }) | 14 | return makePutBodyRequest({ url, path, token, fields, statusCodeExpected }) |
15 | } | 15 | } |
16 | 16 | ||
17 | function listMyVideosHistory (url: string, token: string) { | 17 | function listMyVideosHistory (url: string, token: string, search?: string) { |
18 | const path = '/api/v1/users/me/history/videos' | 18 | const path = '/api/v1/users/me/history/videos' |
19 | 19 | ||
20 | return makeGetRequest({ | 20 | return makeGetRequest({ |
21 | url, | 21 | url, |
22 | path, | 22 | path, |
23 | token, | 23 | token, |
24 | query: { | ||
25 | search | ||
26 | }, | ||
24 | statusCodeExpected: HttpStatusCode.OK_200 | 27 | statusCodeExpected: HttpStatusCode.OK_200 |
25 | }) | 28 | }) |
26 | } | 29 | } |
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index fe4552ff7..8ad98a9a9 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml | |||
@@ -933,6 +933,7 @@ paths: | |||
933 | parameters: | 933 | parameters: |
934 | - $ref: '#/components/parameters/start' | 934 | - $ref: '#/components/parameters/start' |
935 | - $ref: '#/components/parameters/count' | 935 | - $ref: '#/components/parameters/count' |
936 | - $ref: '#/components/parameters/search' | ||
936 | responses: | 937 | responses: |
937 | '200': | 938 | '200': |
938 | description: successful operation | 939 | description: successful operation |