Allow users/visitors to search through an account's videos (#3589)

* WIP: account search

* add search to account view

* add tests for account search
This commit is contained in:
Rigel Kent 2021-01-19 13:43:33 +01:00 committed by GitHub
parent 2264c1ceed
commit 370240824e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 292 additions and 23 deletions

View File

@ -0,0 +1,104 @@
import { Subscription } from 'rxjs'
import { first, tap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
import { immutableAssign } from '@app/helpers'
import { Account, AccountService, VideoService } from '@app/shared/shared-main'
import { AbstractVideoList } from '@app/shared/shared-video-miniature'
import { VideoFilter } from '@shared/models'
@Component({
selector: 'my-account-search',
templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
styleUrls: [
'../../shared/shared-video-miniature/abstract-video-list.scss'
]
})
export class AccountSearchComponent extends AbstractVideoList implements OnInit, OnDestroy {
titlePage: string
loadOnInit = false
search = ''
filter: VideoFilter = null
private account: Account
private accountSub: Subscription
constructor (
protected router: Router,
protected serverService: ServerService,
protected route: ActivatedRoute,
protected authService: AuthService,
protected userService: UserService,
protected notifier: Notifier,
protected confirmService: ConfirmService,
protected screenService: ScreenService,
protected storageService: LocalStorageService,
private accountService: AccountService,
private videoService: VideoService
) {
super()
}
ngOnInit () {
super.ngOnInit()
this.enableAllFilterIfPossible()
// Parent get the account for us
this.accountSub = this.accountService.accountLoaded
.pipe(first())
.subscribe(account => {
this.account = account
this.reloadVideos()
this.generateSyndicationList()
})
}
ngOnDestroy () {
if (this.accountSub) this.accountSub.unsubscribe()
super.ngOnDestroy()
}
updateSearch (value: string) {
if (value === '') this.router.navigate(['../videos'], { relativeTo: this.route })
this.search = value
this.reloadVideos()
}
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
const options = {
account: this.account,
videoPagination: newPagination,
sort: this.sort,
nsfwPolicy: this.nsfwPolicy,
videoFilter: this.filter,
search: this.search
}
return this.videoService
.getAccountVideos(options)
.pipe(
tap(({ total }) => {
this.titlePage = this.search
? $localize`Published ${total} videos matching "${this.search}"`
: $localize`Published ${total} videos`
})
)
}
toggleModerationDisplay () {
this.filter = this.buildLocalFilter(this.filter, null)
this.reloadVideos()
}
generateSyndicationList () {
/* disable syndication */
}
}

View File

@ -5,6 +5,7 @@ import { AccountsComponent } from './accounts.component'
import { AccountVideosComponent } from './account-videos/account-videos.component' import { AccountVideosComponent } from './account-videos/account-videos.component'
import { AccountAboutComponent } from './account-about/account-about.component' import { AccountAboutComponent } from './account-about/account-about.component'
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component' import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
import { AccountSearchComponent } from './account-search/account-search.component'
const accountsRoutes: Routes = [ const accountsRoutes: Routes = [
{ {
@ -21,19 +22,6 @@ const accountsRoutes: Routes = [
redirectTo: 'video-channels', redirectTo: 'video-channels',
pathMatch: 'full' pathMatch: 'full'
}, },
{
path: 'videos',
component: AccountVideosComponent,
data: {
meta: {
title: $localize`Account videos`
},
reuse: {
enabled: true,
key: 'account-videos-list'
}
}
},
{ {
path: 'video-channels', path: 'video-channels',
component: AccountVideoChannelsComponent, component: AccountVideoChannelsComponent,
@ -51,6 +39,28 @@ const accountsRoutes: Routes = [
title: $localize`About account` title: $localize`About account`
} }
} }
},
{
path: 'videos',
component: AccountVideosComponent,
data: {
meta: {
title: $localize`Account videos`
},
reuse: {
enabled: true,
key: 'account-videos-list'
}
}
},
{
path: 'search',
component: AccountSearchComponent,
data: {
meta: {
title: $localize`Search videos within account`
}
}
} }
] ]
} }

View File

@ -44,11 +44,13 @@
</ng-template> </ng-template>
<list-overflow [items]="links" [itemTemplate]="linkTemplate"></list-overflow> <list-overflow [items]="links" [itemTemplate]="linkTemplate"></list-overflow>
<simple-search-input (searchChanged)="searchChanged($event)" name="search-videos" i18n-placeholder placeholder="Search videos"></simple-search-input>
</div> </div>
</div> </div>
<div class="margin-content"> <div class="margin-content">
<router-outlet></router-outlet> <router-outlet (activate)="onOutletLoaded($event)"></router-outlet>
</div> </div>
</div> </div>

View File

@ -7,6 +7,7 @@ import { Account, AccountService, DropdownAction, ListOverflowItem, VideoChannel
import { AccountReportComponent } from '@app/shared/shared-moderation' import { AccountReportComponent } from '@app/shared/shared-moderation'
import { User, UserRight } from '@shared/models' import { User, UserRight } from '@shared/models'
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
import { AccountSearchComponent } from './account-search/account-search.component'
@Component({ @Component({
templateUrl: './accounts.component.html', templateUrl: './accounts.component.html',
@ -14,6 +15,7 @@ import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
}) })
export class AccountsComponent implements OnInit, OnDestroy { export class AccountsComponent implements OnInit, OnDestroy {
@ViewChild('accountReportModal') accountReportModal: AccountReportComponent @ViewChild('accountReportModal') accountReportModal: AccountReportComponent
accountSearch: AccountSearchComponent
account: Account account: Account
accountUser: User accountUser: User
@ -99,6 +101,18 @@ export class AccountsComponent implements OnInit, OnDestroy {
return $localize`${count} subscribers` return $localize`${count} subscribers`
} }
onOutletLoaded (component: Component) {
if (component instanceof AccountSearchComponent) {
this.accountSearch = component
} else {
this.accountSearch = undefined
}
}
searchChanged (search: string) {
if (this.accountSearch) this.accountSearch.updateSearch(search)
}
private onAccount (account: Account) { private onAccount (account: Account) {
this.prependModerationActions = undefined this.prependModerationActions = undefined

View File

@ -8,6 +8,7 @@ import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
import { AccountAboutComponent } from './account-about/account-about.component' import { AccountAboutComponent } from './account-about/account-about.component'
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component' import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
import { AccountVideosComponent } from './account-videos/account-videos.component' import { AccountVideosComponent } from './account-videos/account-videos.component'
import { AccountSearchComponent } from './account-search/account-search.component'
import { AccountsRoutingModule } from './accounts-routing.module' import { AccountsRoutingModule } from './accounts-routing.module'
import { AccountsComponent } from './accounts.component' import { AccountsComponent } from './accounts.component'
@ -27,7 +28,8 @@ import { AccountsComponent } from './accounts.component'
AccountsComponent, AccountsComponent,
AccountVideosComponent, AccountVideosComponent,
AccountVideoChannelsComponent, AccountVideoChannelsComponent,
AccountAboutComponent AccountAboutComponent,
AccountSearchComponent
], ],
exports: [ exports: [

View File

@ -1,3 +1,4 @@
export * from './help.component' export * from './help.component'
export * from './list-overflow.component' export * from './list-overflow.component'
export * from './top-menu-dropdown.component' export * from './top-menu-dropdown.component'
export * from './simple-search-input.component'

View File

@ -0,0 +1,14 @@
<span>
<my-global-icon iconName="search" aria-label="Search" role="button" (click)="showInput()"></my-global-icon>
<input
#ref
type="text"
[(ngModel)]="value"
(focusout)="focusLost()"
(keyup.enter)="searchChange()"
[hidden]="!shown"
[name]="name"
[placeholder]="placeholder"
>
</span>

View File

@ -0,0 +1,29 @@
@import '_variables';
@import '_mixins';
span {
opacity: .6;
&:focus-within {
opacity: 1;
}
}
my-global-icon {
height: 18px;
position: relative;
top: -2px;
}
input {
@include peertube-input-text(150px);
height: 22px; // maximum height for the account/video-channels links
padding-left: 10px;
background-color: transparent;
border: none;
&::placeholder {
font-size: 15px;
}
}

View File

@ -0,0 +1,54 @@
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { Subject } from 'rxjs'
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
@Component({
selector: 'simple-search-input',
templateUrl: './simple-search-input.component.html',
styleUrls: [ './simple-search-input.component.scss' ]
})
export class SimpleSearchInputComponent implements OnInit {
@ViewChild('ref') input: ElementRef
@Input() name = 'search'
@Input() placeholder = $localize`Search`
@Output() searchChanged = new EventEmitter<string>()
value = ''
shown: boolean
private searchSubject = new Subject<string>()
constructor (
private router: Router,
private route: ActivatedRoute
) {}
ngOnInit () {
this.searchSubject
.pipe(
debounceTime(400),
distinctUntilChanged()
)
.subscribe(value => this.searchChanged.emit(value))
this.searchSubject.next(this.value)
}
showInput () {
this.shown = true
setTimeout(() => this.input.nativeElement.focus())
}
focusLost () {
if (this.value !== '') return
this.shown = false
}
searchChange () {
this.router.navigate(['./search'], { relativeTo: this.route })
this.searchSubject.next(this.value)
}
}

View File

@ -30,7 +30,7 @@ import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditBu
import { DateToggleComponent } from './date' import { DateToggleComponent } from './date'
import { FeedComponent } from './feeds' import { FeedComponent } from './feeds'
import { LoaderComponent, SmallLoaderComponent } from './loaders' import { LoaderComponent, SmallLoaderComponent } from './loaders'
import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc' import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent, SimpleSearchInputComponent } from './misc'
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
import { VideoCaptionService } from './video-caption' import { VideoCaptionService } from './video-caption'
@ -88,6 +88,7 @@ import { VideoChannelService } from './video-channel'
HelpComponent, HelpComponent,
ListOverflowComponent, ListOverflowComponent,
TopMenuDropdownComponent, TopMenuDropdownComponent,
SimpleSearchInputComponent,
UserQuotaComponent, UserQuotaComponent,
UserNotificationsComponent UserNotificationsComponent
@ -140,6 +141,7 @@ import { VideoChannelService } from './video-channel'
HelpComponent, HelpComponent,
ListOverflowComponent, ListOverflowComponent,
TopMenuDropdownComponent, TopMenuDropdownComponent,
SimpleSearchInputComponent,
UserQuotaComponent, UserQuotaComponent,
UserNotificationsComponent UserNotificationsComponent

View File

@ -140,8 +140,9 @@ export class VideoService implements VideosProvider {
sort: VideoSortField sort: VideoSortField
nsfwPolicy?: NSFWPolicyType nsfwPolicy?: NSFWPolicyType
videoFilter?: VideoFilter videoFilter?: VideoFilter
search?: string
}): Observable<ResultList<Video>> { }): Observable<ResultList<Video>> {
const { account, videoPagination, sort, videoFilter, nsfwPolicy } = parameters const { account, videoPagination, sort, videoFilter, nsfwPolicy, search } = parameters
const pagination = this.restService.componentPaginationToRestPagination(videoPagination) const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
@ -156,6 +157,10 @@ export class VideoService implements VideosProvider {
params = params.set('filter', videoFilter) params = params.set('filter', videoFilter)
} }
if (search) {
params = params.set('search', search)
}
return this.authHttp return this.authHttp
.get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params }) .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
.pipe( .pipe(

View File

@ -665,6 +665,11 @@
font-size: 130%; font-size: 130%;
} }
} }
list-overflow {
display: inline-block;
width: max-content;
}
} }
} }

View File

@ -175,7 +175,8 @@ async function listAccountVideos (req: express.Request, res: express.Response) {
withFiles: false, withFiles: false,
accountId: account.id, accountId: account.id,
user: res.locals.oauth ? res.locals.oauth.token.User : undefined, user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
countVideos countVideos,
search: req.query.search
}, 'filter:api.accounts.videos.list.params') }, 'filter:api.accounts.videos.list.params')
const resultList = await Hooks.wrapPromiseFun( const resultList = await Hooks.wrapPromiseFun(

View File

@ -6,6 +6,7 @@ import { MVideoFullLight } from '@server/types/models'
import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model' import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
import { import {
exists,
isBooleanValid, isBooleanValid,
isDateValid, isDateValid,
isFileFieldValid, isFileFieldValid,
@ -444,6 +445,9 @@ const commonVideosFiltersValidator = [
.optional() .optional()
.customSanitizer(toBooleanOrNull) .customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid skip count boolean'), .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
query('search')
.optional()
.custom(exists).withMessage('Should have a valid search'),
(req: express.Request, res: express.Response, next: express.NextFunction) => { (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking commons video filters query', { parameters: req.query }) logger.debug('Checking commons video filters query', { parameters: req.query })

View File

@ -34,7 +34,7 @@ describe('Test users with multiple servers', function () {
let userAvatarFilename: string let userAvatarFilename: string
before(async function () { before(async function () {
this.timeout(120000) this.timeout(120_000)
servers = await flushAndRunMultipleServers(3) servers = await flushAndRunMultipleServers(3)
@ -92,7 +92,7 @@ describe('Test users with multiple servers', function () {
}) })
it('Should be able to update my description', async function () { it('Should be able to update my description', async function () {
this.timeout(10000) this.timeout(10_000)
await updateMyUser({ await updateMyUser({
url: servers[0].url, url: servers[0].url,
@ -109,7 +109,7 @@ describe('Test users with multiple servers', function () {
}) })
it('Should be able to update my avatar', async function () { it('Should be able to update my avatar', async function () {
this.timeout(10000) this.timeout(10_000)
const fixture = 'avatar2.png' const fixture = 'avatar2.png'
@ -164,8 +164,27 @@ describe('Test users with multiple servers', function () {
} }
}) })
it('Should search through account videos', async function () {
this.timeout(10_000)
const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'Kami no chikara' })
await waitJobs(servers)
for (const server of servers) {
const res = await getAccountVideos(server.url, server.accessToken, 'user1@localhost:' + servers[0].port, 0, 5, undefined, {
search: 'Kami'
})
expect(res.body.total).to.equal(1)
expect(res.body.data).to.be.an('array')
expect(res.body.data).to.have.lengthOf(1)
expect(res.body.data[0].uuid).to.equal(resVideo.body.video.uuid)
}
})
it('Should remove the user', async function () { it('Should remove the user', async function () {
this.timeout(10000) this.timeout(10_000)
for (const server of servers) { for (const server of servers) {
const resAccounts = await getAccountsList(server.url, '-createdAt') const resAccounts = await getAccountsList(server.url, '-createdAt')

View File

@ -194,7 +194,10 @@ function getAccountVideos (
start: number, start: number,
count: number, count: number,
sort?: string, sort?: string,
query: { nsfw?: boolean } = {} query: {
nsfw?: boolean
search?: string
} = {}
) { ) {
const path = '/api/v1/accounts/' + accountName + '/videos' const path = '/api/v1/accounts/' + accountName + '/videos'