Add ability to set custom field to video form

pull/3105/head
Chocobozzz 2020-08-20 16:18:16 +02:00 committed by Chocobozzz
parent f95628636b
commit 7294aab0c8
30 changed files with 249 additions and 76 deletions

View File

@ -7,38 +7,7 @@
<form *ngIf="hasRegisteredSettings()" role="form" (ngSubmit)="formValidated()" [formGroup]="form">
<div class="form-group" *ngFor="let setting of registeredSettings">
<label *ngIf="setting.type !== 'input-checkbox'" [attr.for]="setting.name" [innerHTML]="setting.label"></label>
<input *ngIf="setting.type === 'input'" type="text" [id]="setting.name" [formControlName]="setting.name" />
<textarea *ngIf="setting.type === 'input-textarea'" type="text" [id]="setting.name" [formControlName]="setting.name"></textarea>
<my-help *ngIf="setting.type === 'markdown-text'" helpType="markdownText"></my-help>
<my-help *ngIf="setting.type === 'markdown-enhanced'" helpType="markdownEnhanced"></my-help>
<my-markdown-textarea
*ngIf="setting.type === 'markdown-text'"
markdownType="text" [id]="setting.name" [formControlName]="setting.name" textareaWidth="500px"
[classes]="{ 'input-error': formErrors['settings.name'] }"
></my-markdown-textarea>
<my-markdown-textarea
*ngIf="setting.type === 'markdown-enhanced'"
markdownType="enhanced" [id]="setting.name" [formControlName]="setting.name" textareaWidth="500px"
[classes]="{ 'input-error': formErrors['settings.name'] }"
></my-markdown-textarea>
<my-peertube-checkbox
*ngIf="setting.type === 'input-checkbox'"
[id]="setting.name"
[formControlName]="setting.name"
[labelInnerHTML]="setting.label"
></my-peertube-checkbox>
<div *ngIf="formErrors[setting.name]" class="form-error">
{{ formErrors[setting.name] }}
</div>
<my-dynamic-form-field [form]="form" [setting]="setting" [formErrors]="formErrors"></my-dynamic-form-field>
</div>
<input type="submit" i18n value="Update plugin settings" [disabled]="!form.valid">

View File

@ -5,22 +5,6 @@ h2 {
margin-bottom: 20px;
}
input:not([type=submit]) {
@include peertube-input-text(340px);
display: block;
}
textarea {
@include peertube-textarea(340px, 200px);
display: block;
}
.peertube-select-container {
@include peertube-select-container(340px);
}
input[type=submit], button {
@include peertube-button;
@include orange-button;

View File

@ -265,6 +265,21 @@
</ng-template>
</ng-container>
<ng-container ngbNavItem *ngIf="pluginFields.length !== 0">
<a ngbNavLink i18n>Plugin settings</a>
<ng-template ngbNavContent>
<div class="row plugin-settings">
<div class="col-md-12 col-xl-8">
<div *ngFor="let pluginSetting of pluginFields" class="form-group">
<my-dynamic-form-field [form]="pluginDataFormGroup" [formErrors]="formErrors" [setting]="pluginSetting.commonOptions"></my-dynamic-form-field>
</div>
</div>
</div>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="nav"></div>

View File

@ -7,7 +7,8 @@
@import 'variables';
@import 'mixins';
label {
label,
my-dynamic-form-field ::ng-deep label {
font-weight: $font-regular;
font-size: 100%;
}

View File

@ -1,8 +1,8 @@
import { forkJoin } from 'rxjs'
import { map } from 'rxjs/operators'
import { Component, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'
import { ServerService } from '@app/core'
import { HooksService, PluginService, ServerService } from '@app/core'
import { removeElementFromArray } from '@app/helpers'
import {
VIDEO_CATEGORY_VALIDATOR,
@ -21,6 +21,7 @@ import { FormReactiveValidationMessages, FormValidatorService, SelectChannelItem
import { InstanceService } from '@app/shared/shared-instance'
import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
import { RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions } from '@shared/models/plugins/register-client-form-field.model'
import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
@ -39,9 +40,12 @@ export class VideoEditComponent implements OnInit, OnDestroy {
@Input() schedulePublicationPossible = true
@Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = []
@Input() waitTranscodingEnabled = true
@Input() type: 'import-url' | 'import-torrent' | 'upload' | 'update'
@ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent
@Output() pluginFieldsAdded = new EventEmitter<void>()
// So that it can be accessed in the template
readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
@ -53,6 +57,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
tagValidators: ValidatorFn[]
tagValidatorsMessages: { [ name: string ]: string }
pluginDataFormGroup: FormGroup
schedulePublicationEnabled = false
calendarLocale: any = {}
@ -64,6 +70,11 @@ export class VideoEditComponent implements OnInit, OnDestroy {
serverConfig: ServerConfig
pluginFields: {
commonOptions: RegisterClientFormFieldOptions
videoFormOptions: RegisterClientVideoFieldOptions
}[] = []
private schedulerInterval: any
private firstPatchDone = false
private initialVideoCaptions: string[] = []
@ -72,9 +83,11 @@ export class VideoEditComponent implements OnInit, OnDestroy {
private formValidatorService: FormValidatorService,
private videoService: VideoService,
private serverService: ServerService,
private pluginService: PluginService,
private instanceService: InstanceService,
private i18nPrimengCalendarService: I18nPrimengCalendarService,
private ngZone: NgZone
private ngZone: NgZone,
private hooks: HooksService
) {
this.calendarLocale = this.i18nPrimengCalendarService.getCalendarLocale()
this.calendarTimezone = this.i18nPrimengCalendarService.getTimezone()
@ -136,19 +149,26 @@ export class VideoEditComponent implements OnInit, OnDestroy {
ngOnInit () {
this.updateForm()
this.pluginService.ensurePluginsAreLoaded('video-edit')
.then(() => this.updatePluginFields())
this.serverService.getVideoCategories()
.subscribe(res => this.videoCategories = res)
this.serverService.getVideoLicences()
.subscribe(res => this.videoLicences = res)
forkJoin([
this.instanceService.getAbout(),
this.serverService.getVideoLanguages()
]).pipe(map(([ about, languages ]) => ({ about, languages })))
.subscribe(res => {
this.videoLanguages = res.languages
.map(l => res.about.instance.languages.includes(l.id)
? { ...l, group: $localize`Instance languages`, groupOrder: 0 }
: { ...l, group: $localize`All languages`, groupOrder: 1 })
.map(l => {
return res.about.instance.languages.includes(l.id)
? { ...l, group: $localize`Instance languages`, groupOrder: 0 }
: { ...l, group: $localize`All languages`, groupOrder: 1 }
})
.sort((a, b) => a.groupOrder - b.groupOrder)
})
@ -173,6 +193,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
this.ngZone.runOutsideAngular(() => {
this.schedulerInterval = setInterval(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute
})
this.hooks.runAction('action:video-edit.init', 'video-edit', { type: this.type })
}
ngOnDestroy () {
@ -223,6 +245,23 @@ export class VideoEditComponent implements OnInit, OnDestroy {
})
}
private updatePluginFields () {
this.pluginFields = this.pluginService.getRegisteredVideoFormFields(this.type)
if (this.pluginFields.length === 0) return
const obj: any = {}
for (const setting of this.pluginFields) {
obj[setting.commonOptions.name] = new FormControl(setting.commonOptions.default)
}
this.pluginDataFormGroup = new FormGroup(obj)
this.form.addControl('pluginData', this.pluginDataFormGroup)
this.pluginFieldsAdded.emit()
}
private trackPrivacyChange () {
// We will update the schedule input and the wait transcoding checkbox validators
this.form.controls[ 'privacy' ]

View File

@ -58,6 +58,7 @@
<my-video-edit
[form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
type="import-torrent"
></my-video-edit>
<div class="submit-container">

View File

@ -54,6 +54,7 @@
<my-video-edit
[form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
type="import-url"
></my-video-edit>
<div class="submit-container">

View File

@ -69,6 +69,7 @@
[form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
[waitTranscodingEnabled]="waitTranscodingEnabled"
type="upload"
></my-video-edit>
<div class="submit-container">

View File

@ -10,11 +10,12 @@
[form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible"
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
[videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled"
type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()"
></my-video-edit>
<div class="submit-container">
<my-button className="orange-button" i18n-label label="Update" icon="circle-tick"
(click)="update()" (keydown.enter)="update()"
(click)="update()" (keydown.enter)="update()"
[disabled]="!form.valid || isUpdatingVideo === true"
></my-button>
</div>

View File

@ -126,6 +126,14 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
)
}
hydratePluginFieldsFromVideo () {
if (!this.video.pluginData) return
this.form.patchValue({
pluginData: this.video.pluginData
})
}
private hydrateFormFromVideo () {
this.form.patchValue(this.video.toFormPatch())

View File

@ -9,7 +9,7 @@ import { RestExtractor } from '@app/core/rest'
import { ServerService } from '@app/core/server/server.service'
import { getDevLocale, isOnDevLocale } from '@app/helpers'
import { CustomModalComponent } from '@app/modal/custom-modal.component'
import { Hooks, loadPlugin, PluginInfo, runHook } from '@root-helpers/plugins'
import { FormFields, Hooks, loadPlugin, PluginInfo, runHook } from '@root-helpers/plugins'
import { getCompleteLocale, isDefaultLocale, peertubeTranslate } from '@shared/core-utils/i18n'
import {
ClientHook,
@ -36,6 +36,7 @@ export class PluginService implements ClientHook {
'video-watch': new ReplaySubject<boolean>(1),
signup: new ReplaySubject<boolean>(1),
login: new ReplaySubject<boolean>(1),
'video-edit': new ReplaySubject<boolean>(1),
embed: new ReplaySubject<boolean>(1)
}
@ -50,6 +51,9 @@ export class PluginService implements ClientHook {
private loadingScopes: { [id in PluginClientScope]?: boolean } = {}
private hooks: Hooks = {}
private formFields: FormFields = {
video: []
}
constructor (
private authService: AuthService,
@ -188,9 +192,18 @@ export class PluginService implements ClientHook {
: PluginType.THEME
}
getRegisteredVideoFormFields (type: 'import-url' | 'import-torrent' | 'upload' | 'update') {
return this.formFields.video.filter(f => f.videoFormOptions.type === type)
}
private loadPlugin (pluginInfo: PluginInfo) {
return this.zone.runOutsideAngular(() => {
return loadPlugin(this.hooks, pluginInfo, pluginInfo => this.buildPeerTubeHelpers(pluginInfo))
return loadPlugin({
hooks: this.hooks,
formFields: this.formFields,
pluginInfo,
peertubeHelpersFactory: pluginInfo => this.buildPeerTubeHelpers(pluginInfo)
})
})
}

View File

@ -0,0 +1,35 @@
<div [formGroup]="form">
<label *ngIf="setting.type !== 'input-checkbox'" [attr.for]="setting.name" [innerHTML]="setting.label"></label>
<input *ngIf="setting.type === 'input'" type="text" [id]="setting.name" [formControlName]="setting.name" />
<textarea *ngIf="setting.type === 'input-textarea'" type="text" [id]="setting.name" [formControlName]="setting.name"></textarea>
<my-help *ngIf="setting.type === 'markdown-text'" helpType="markdownText"></my-help>
<my-help *ngIf="setting.type === 'markdown-enhanced'" helpType="markdownEnhanced"></my-help>
<my-markdown-textarea
*ngIf="setting.type === 'markdown-text'"
markdownType="text" [id]="setting.name" [formControlName]="setting.name" textareaWidth="500px"
[classes]="{ 'input-error': formErrors['settings.name'] }"
></my-markdown-textarea>
<my-markdown-textarea
*ngIf="setting.type === 'markdown-enhanced'"
markdownType="enhanced" [id]="setting.name" [formControlName]="setting.name" textareaWidth="500px"
[classes]="{ 'input-error': formErrors['settings.name'] }"
></my-markdown-textarea>
<my-peertube-checkbox
*ngIf="setting.type === 'input-checkbox'"
[id]="setting.name"
[formControlName]="setting.name"
[labelInnerHTML]="setting.label"
></my-peertube-checkbox>
<div *ngIf="formErrors[setting.name]" class="form-error">
{{ formErrors[setting.name] }}
</div>
</div>

View File

@ -0,0 +1,18 @@
@import '_variables';
@import '_mixins';
input:not([type=submit]) {
@include peertube-input-text(340px);
display: block;
}
textarea {
@include peertube-textarea(340px, 200px);
display: block;
}
.peertube-select-container {
@include peertube-select-container(340px);
}

View File

@ -0,0 +1,15 @@
import { Component, Input } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { RegisterClientFormFieldOptions } from '@shared/models'
@Component({
selector: 'my-dynamic-form-field',
templateUrl: './dynamic-form-field.component.html',
styleUrls: [ './dynamic-form-field.component.scss' ]
})
export class DynamicFormFieldComponent {
@Input() form: FormGroup
@Input() formErrors: any
@Input() setting: RegisterClientFormFieldOptions
}

View File

@ -15,6 +15,7 @@ import { ReactiveFileComponent } from './reactive-file.component'
import { SelectChannelComponent, SelectCheckboxComponent, SelectOptionsComponent, SelectTagsComponent } from './select'
import { TextareaAutoResizeDirective } from './textarea-autoresize.directive'
import { TimestampInputComponent } from './timestamp-input.component'
import { DynamicFormFieldComponent } from './dynamic-form-field.component'
@NgModule({
imports: [
@ -41,7 +42,9 @@ import { TimestampInputComponent } from './timestamp-input.component'
SelectChannelComponent,
SelectOptionsComponent,
SelectTagsComponent,
SelectCheckboxComponent
SelectCheckboxComponent,
DynamicFormFieldComponent
],
exports: [
@ -63,7 +66,9 @@ import { TimestampInputComponent } from './timestamp-input.component'
SelectChannelComponent,
SelectOptionsComponent,
SelectTagsComponent,
SelectCheckboxComponent
SelectCheckboxComponent,
DynamicFormFieldComponent
],
providers: [

View File

@ -25,6 +25,8 @@ export class VideoEdit implements VideoUpdate {
scheduleUpdate?: VideoScheduleUpdate
originallyPublishedAt?: Date | string
pluginData?: any
constructor (
video?: Video & {
tags: string[],
@ -55,10 +57,12 @@ export class VideoEdit implements VideoUpdate {
this.scheduleUpdate = video.scheduledUpdate
this.originallyPublishedAt = video.originallyPublishedAt ? new Date(video.originallyPublishedAt) : null
this.pluginData = video.pluginData
}
}
patch (values: { [ id: string ]: string }) {
patch (values: { [ id: string ]: any }) {
Object.keys(values).forEach((key) => {
this[ key ] = values[ key ]
})

View File

@ -84,6 +84,8 @@ export class Video implements VideoServerModel {
currentTime: number
}
pluginData?: any
static buildClientUrl (videoUUID: string) {
return '/videos/watch/' + videoUUID
}
@ -152,6 +154,8 @@ export class Video implements VideoServerModel {
this.originInstanceHost = this.account.host
this.originInstanceUrl = 'https://' + this.originInstanceHost
this.pluginData = hash.pluginData
}
isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {

View File

@ -96,6 +96,7 @@ export class VideoService implements VideosProvider {
downloadEnabled: video.downloadEnabled,
thumbnailfile: video.thumbnailfile,
previewfile: video.previewfile,
pluginData: video.pluginData,
scheduleUpdate,
originallyPublishedAt
}

View File

@ -1,6 +1,14 @@
import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
import { ClientHookName, ClientScript, RegisterClientHookOptions, ServerConfigPlugin, PluginType, clientHookObject } from '../../../shared/models'
import { RegisterClientHelpers } from 'src/types/register-client-option.model'
import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
import { RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions } from '@shared/models/plugins/register-client-form-field.model'
import {
ClientHookName,
clientHookObject,
ClientScript,
PluginType,
RegisterClientHookOptions,
ServerConfigPlugin
} from '../../../shared/models'
import { ClientScript as ClientScriptModule } from '../types/client-script.model'
import { importModule } from './utils'
@ -18,6 +26,13 @@ type PluginInfo = {
isTheme: boolean
}
type FormFields = {
video: {
commonOptions: RegisterClientFormFieldOptions
videoFormOptions: RegisterClientVideoFieldOptions
}[]
}
async function runHook<T> (hooks: Hooks, hookName: ClientHookName, result?: T, params?: any) {
if (!hooks[hookName]) return result
@ -34,7 +49,13 @@ async function runHook<T> (hooks: Hooks, hookName: ClientHookName, result?: T, p
return result
}
function loadPlugin (hooks: Hooks, pluginInfo: PluginInfo, peertubeHelpersFactory: (pluginInfo: PluginInfo) => RegisterClientHelpers) {
function loadPlugin (options: {
hooks: Hooks
pluginInfo: PluginInfo
peertubeHelpersFactory: (pluginInfo: PluginInfo) => RegisterClientHelpers
formFields?: FormFields
}) {
const { hooks, pluginInfo, peertubeHelpersFactory, formFields } = options
const { plugin, clientScript } = pluginInfo
const registerHook = (options: RegisterClientHookOptions) => {
@ -54,12 +75,23 @@ function loadPlugin (hooks: Hooks, pluginInfo: PluginInfo, peertubeHelpersFactor
})
}
const registerVideoField = (commonOptions: RegisterClientFormFieldOptions, videoFormOptions: RegisterClientVideoFieldOptions) => {
if (!formFields) {
throw new Error('Video field registration is not supported')
}
formFields.video.push({
commonOptions,
videoFormOptions
})
}
const peertubeHelpers = peertubeHelpersFactory(pluginInfo)
console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name)
return importModule(clientScript.script)
.then((script: ClientScriptModule) => script.register({ registerHook, peertubeHelpers }))
.then((script: ClientScriptModule) => script.register({ registerHook, registerVideoField, peertubeHelpers }))
.then(() => sortHooksByPriority(hooks))
.catch(err => console.error('Cannot import or register plugin %s.', pluginInfo.plugin.name, err))
}
@ -68,6 +100,7 @@ export {
HookStructValue,
Hooks,
PluginInfo,
FormFields,
loadPlugin,
runHook
}

View File

@ -750,7 +750,11 @@ export class PeerTubeEmbed {
isTheme: false
}
await loadPlugin(this.peertubeHooks, pluginInfo, _ => this.buildPeerTubeHelpers(translations))
await loadPlugin({
hooks: this.peertubeHooks,
pluginInfo,
peertubeHelpersFactory: _ => this.buildPeerTubeHelpers(translations)
})
}
}
}

View File

@ -1,8 +1,11 @@
import { RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions } from '@shared/models/plugins/register-client-form-field.model'
import { RegisterClientHookOptions } from '@shared/models/plugins/register-client-hook.model'
export type RegisterClientOptions = {
registerHook: (options: RegisterClientHookOptions) => void
registerVideoField: (commonOptions: RegisterClientFormFieldOptions, videoFormOptions: RegisterClientVideoFieldOptions) => void
peertubeHelpers: RegisterClientHelpers
}

View File

@ -414,7 +414,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
Notifier.Instance.notifyOnNewVideoIfNeeded(videoInstanceUpdated)
}
Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated })
Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body })
} catch (err) {
// Force fields we want to update
// If the transaction is retried, sequelize will think the object has not changed

View File

@ -78,7 +78,10 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFor
userHistory: userHistory ? {
currentTime: userHistory.currentTime
} : undefined
} : undefined,
// Can be added by external plugins
pluginData: (video as any).pluginData
}
if (options) {

View File

@ -70,6 +70,9 @@ export const clientActionHookObject = {
// Fired when a user click on 'View x replies' and they're loaded
'action:video-watch.video-thread-replies.loaded': true,
// Fired when the video edit page (upload, URL/torrent import, update) is being initialized
'action:video-edit.init': true,
// Fired when the login page is being initialized
'action:login.init': true,

View File

@ -19,6 +19,7 @@ export * from './plugin-video-privacy-manager.model'
export * from './plugin.type'
export * from './public-server.setting'
export * from './register-client-hook.model'
export * from './register-client-form-field.model'
export * from './register-server-hook.model'
export * from './register-server-setting.model'
export * from './server-hook.model'

View File

@ -1 +1 @@
export type PluginClientScope = 'common' | 'video-watch' | 'search' | 'signup' | 'login' | 'embed'
export type PluginClientScope = 'common' | 'video-watch' | 'search' | 'signup' | 'login' | 'embed' | 'video-edit'

View File

@ -0,0 +1,12 @@
export interface RegisterClientFormFieldOptions {
name: string
label: string
type: 'input' | 'input-checkbox' | 'input-textarea' | 'markdown-text' | 'markdown-enhanced'
// Default setting value
default?: string | boolean
}
export interface RegisterClientVideoFieldOptions {
type: 'import-url' | 'import-torrent' | 'update' | 'upload'
}

View File

@ -1,15 +1,10 @@
export interface RegisterServerSettingOptions {
name: string
label: string
type: 'input' | 'input-checkbox' | 'input-textarea' | 'markdown-text' | 'markdown-enhanced'
import { RegisterClientFormFieldOptions } from './register-client-form-field.model'
export interface RegisterServerSettingOptions extends RegisterClientFormFieldOptions {
// If the setting is not private, anyone can view its value (client code included)
// If the setting is private, only server-side hooks can access it
// Mainly used by the PeerTube client to get admin config
private: boolean
// Default setting value
default?: string | boolean
}
export interface RegisteredServerSettings {

View File

@ -19,4 +19,6 @@ export interface VideoUpdate {
previewfile?: Blob
scheduleUpdate?: VideoScheduleUpdate
originallyPublishedAt?: Date | string
pluginData?: any
}

View File

@ -53,6 +53,8 @@ export interface Video {
userHistory?: {
currentTime: number
}
pluginData?: any
}
export interface VideoDetails extends Video {