]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Enhance plugin video fields
authorChocobozzz <me@florianbigard.com>
Wed, 22 Dec 2021 17:02:36 +0000 (18:02 +0100)
committerChocobozzz <me@florianbigard.com>
Wed, 29 Dec 2021 09:10:01 +0000 (10:10 +0100)
Add video form tab selection
Add ability to display an error

13 files changed:
client/e2e/src/po/admin-plugin.po.ts [new file with mode: 0644]
client/e2e/src/po/anonymous-settings.po.ts
client/e2e/src/po/my-account.po.ts
client/e2e/src/po/video-upload.po.ts
client/e2e/src/suites-local/plugins.e2e-spec.ts [new file with mode: 0644]
client/e2e/src/utils/elements.ts
client/src/app/+videos/+video-edit/shared/video-edit.component.html
client/src/app/+videos/+video-edit/shared/video-edit.component.ts
client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
client/src/app/shared/shared-forms/form-reactive.ts
client/src/app/shared/shared-forms/form-validator.service.ts
shared/models/plugins/client/register-client-form-field.model.ts
support/doc/plugins/guide.md

diff --git a/client/e2e/src/po/admin-plugin.po.ts b/client/e2e/src/po/admin-plugin.po.ts
new file mode 100644 (file)
index 0000000..6a3f3cf
--- /dev/null
@@ -0,0 +1,31 @@
+import { browserSleep, go } from '../utils'
+
+export class AdminPluginPage {
+
+  async navigateToSearch () {
+    await go('/admin/plugins/search')
+
+    await $('my-plugin-search').waitForDisplayed()
+  }
+
+  async search (name: string) {
+    const input = $('.search-bar input')
+    await input.waitForDisplayed()
+    await input.clearValue()
+    await input.setValue(name)
+
+    await browserSleep(1000)
+  }
+
+  async installHelloWorld () {
+    $('.plugin-name=hello-world').waitForDisplayed()
+
+    await $('.card-body my-button[icon=cloud-download]').click()
+
+    const submitModalButton = $('.modal-content input[type=submit]')
+    await submitModalButton.waitForClickable()
+    await submitModalButton.click()
+
+    await $('.card-body my-edit-button').waitForDisplayed()
+  }
+}
index 180d371fa0aa1b80048ca8270e95d767bf45b6af..21216a8f28d1e528e98eb40840eac2b6b47459ef 100644 (file)
@@ -13,7 +13,7 @@ export class AnonymousSettingsPage {
   }
 
   async clickOnP2PCheckbox () {
-    const p2p = getCheckbox('p2pEnabled')
+    const p2p = await getCheckbox('p2pEnabled')
     await p2p.waitForClickable()
 
     await p2p.click()
index 13a764e8782464c087a1975924deb907851e2f00..20dafbf0616eccb06ce1b3b58fd7b6471ea4a13c 100644 (file)
@@ -31,7 +31,7 @@ export class MyAccountPage {
   }
 
   async clickOnP2PCheckbox () {
-    const p2p = getCheckbox('p2pEnabled')
+    const p2p = await getCheckbox('p2pEnabled')
 
     await p2p.waitForClickable()
     await p2p.scrollIntoView(false) // Avoid issues with fixed header on firefox
index 2206b56c3faebdfcd8d96f3abc17838e76ab12c8..38395ea2f0b4ed81d44184a5aef24461f4ea924a 100644 (file)
@@ -3,7 +3,10 @@ import { getCheckbox, selectCustomSelect } from '../utils'
 
 export class VideoUploadPage {
   async navigateTo () {
-    await $('.header .publish-button').click()
+    const publishButton = await $('.header .publish-button')
+
+    await publishButton.waitForClickable()
+    await publishButton.click()
 
     await $('.upload-video-container').waitForDisplayed()
   }
@@ -24,15 +27,17 @@ export class VideoUploadPage {
 
     // Wait for the upload to finish
     await browser.waitUntil(async () => {
-      const actionButton = this.getSecondStepSubmitButton().$('.action-button')
+      const warning = await $('=Publish will be available when upload is finished').isDisplayed()
+      const progress = await $('.progress-bar=100%').isDisplayed()
 
-      const klass = await actionButton.getAttribute('class')
-      return !klass.includes('disabled')
+      return !warning && progress
     })
   }
 
-  setAsNSFW () {
-    return getCheckbox('nsfw').click()
+  async setAsNSFW () {
+    const checkbox = await getCheckbox('nsfw')
+
+    return checkbox.click()
   }
 
   async validSecondUploadStep (videoName: string) {
@@ -51,6 +56,10 @@ export class VideoUploadPage {
     return selectCustomSelect('privacy', 'Public')
   }
 
+  setAsPrivate () {
+    return selectCustomSelect('privacy', 'Private')
+  }
+
   private getSecondStepSubmitButton () {
     return $('.submit-container my-button')
   }
diff --git a/client/e2e/src/suites-local/plugins.e2e-spec.ts b/client/e2e/src/suites-local/plugins.e2e-spec.ts
new file mode 100644 (file)
index 0000000..14802c1
--- /dev/null
@@ -0,0 +1,79 @@
+import { AdminPluginPage } from '../po/admin-plugin.po'
+import { LoginPage } from '../po/login.po'
+import { VideoUploadPage } from '../po/video-upload.po'
+import { browserSleep, getCheckbox, waitServerUp } from '../utils'
+
+describe('Plugins', () => {
+  let videoUploadPage: VideoUploadPage
+  let loginPage: LoginPage
+  let adminPluginPage: AdminPluginPage
+
+  function getPluginCheckbox () {
+    return getCheckbox('hello-world-field-4')
+  }
+
+  async function expectSubmitState ({ disabled }: { disabled: boolean }) {
+    const disabledSubmit = await $('my-button .disabled')
+
+    if (disabled) expect(await disabledSubmit.isDisplayed()).toBeTruthy()
+    else expect(await disabledSubmit.isDisplayed()).toBeFalsy()
+  }
+
+  before(async () => {
+    await waitServerUp()
+  })
+
+  beforeEach(async () => {
+    loginPage = new LoginPage()
+    videoUploadPage = new VideoUploadPage()
+    adminPluginPage = new AdminPluginPage()
+
+    await browser.maximizeWindow()
+  })
+
+  it('Should install hello world plugin', async () => {
+    await loginPage.loginAsRootUser()
+
+    await adminPluginPage.navigateToSearch()
+    await adminPluginPage.search('hello-world')
+    await adminPluginPage.installHelloWorld()
+    await browser.refresh()
+  })
+
+  it('Should have checkbox in video edit page', async () => {
+    await videoUploadPage.navigateTo()
+    await videoUploadPage.uploadVideo()
+
+    await $('span=Super field 4 in main tab').waitForDisplayed()
+
+    const checkbox = await getPluginCheckbox()
+    expect(await checkbox.isDisplayed()).toBeTruthy()
+
+    await expectSubmitState({ disabled: true })
+  })
+
+  it('Should check the checkbox and be able to submit the video', async function () {
+    const checkbox = await getPluginCheckbox()
+    await checkbox.click()
+
+    await expectSubmitState({ disabled: false })
+  })
+
+  it('Should uncheck the checkbox and not be able to submit the video', async function () {
+    const checkbox = await getPluginCheckbox()
+    await checkbox.click()
+
+    await browserSleep(5000)
+
+    await expectSubmitState({ disabled: true })
+
+    const error = await $('.form-error*=Should be enabled')
+    expect(await error.isDisplayed()).toBeTruthy()
+  })
+
+  it('Should change the privacy and should hide the checkbox', async function () {
+    await videoUploadPage.setAsPrivate()
+
+    await expectSubmitState({ disabled: false })
+  })
+})
index 315718879978fe9b9cec76390b15dff8b5e64390..3ffa5defd38c8ef074d341ba39a0a68af6bdd3e3 100644 (file)
@@ -1,5 +1,5 @@
 function getCheckbox (name: string) {
-  return $(`my-peertube-checkbox[inputname=${name}] label`)
+  return $(`my-peertube-checkbox input[id=${name}]`).parentElement()
 }
 
 async function selectCustomSelect (id: string, valueLabel: string) {
index aa88d6c4c7deb16f4a66c01c1dd85d419c167039..f65cd8883944441f006d5fbe3894b9d91bff5365 100644 (file)
               </ng-template>
             </my-peertube-checkbox>
 
+            <ng-container ngbNavItem *ngIf="getPluginsFields('main').length !== 0">
+
+              <div *ngFor="let pluginSetting of getPluginsFields('main')" class="form-group" [hidden]="isPluginFieldHidden(pluginSetting)">
+                <my-dynamic-form-field [form]="pluginDataFormGroup" [formErrors]="formErrors['pluginData']" [setting]="pluginSetting.commonOptions"></my-dynamic-form-field>
+              </div>
+
+            </ng-container>
           </div>
         </div>
       </ng-template>
       </ng-template>
     </ng-container>
 
-    <ng-container ngbNavItem *ngIf="pluginFields.length !== 0">
+    <ng-container ngbNavItem *ngIf="getPluginsFields('plugin-settings').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" [hidden]="isPluginFieldHidden(pluginSetting)">
-              <my-dynamic-form-field [form]="pluginDataFormGroup" [formErrors]="formErrors" [setting]="pluginSetting.commonOptions"></my-dynamic-form-field>
+            <div *ngFor="let pluginSetting of getPluginsFields('plugin-settings')" class="form-group" [hidden]="isPluginFieldHidden(pluginSetting)">
+              <my-dynamic-form-field [form]="pluginDataFormGroup" [formErrors]="formErrors['pluginData']" [setting]="pluginSetting.commonOptions"></my-dynamic-form-field>
             </div>
           </div>
 
index 2bec933e9fc6fa1026aea3194f875a29b6a9a8d9..a03005bcbf578e20ecd5f824198f51e3447aaa9d 100644 (file)
@@ -1,10 +1,11 @@
 import { forkJoin } from 'rxjs'
 import { map } from 'rxjs/operators'
 import { SelectChannelItem } from 'src/types/select-options-item.model'
-import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
-import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms'
+import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
+import { AbstractControl, FormArray, FormControl, FormGroup, ValidationErrors, Validators } from '@angular/forms'
 import { HooksService, PluginService, ServerService } from '@app/core'
 import { removeElementFromArray } from '@app/helpers'
+import { BuildFormValidator } from '@app/shared/form-validators'
 import {
   VIDEO_CATEGORY_VALIDATOR,
   VIDEO_CHANNEL_VALIDATOR,
@@ -101,7 +102,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
     private instanceService: InstanceService,
     private i18nPrimengCalendarService: I18nPrimengCalendarService,
     private ngZone: NgZone,
-    private hooks: HooksService
+    private hooks: HooksService,
+    private cd: ChangeDetectorRef
   ) {
     this.calendarTimezone = this.i18nPrimengCalendarService.getTimezone()
     this.calendarDateFormat = this.i18nPrimengCalendarService.getDateFormat()
@@ -116,7 +118,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
       licence: this.serverConfig.defaults.publish.licence,
       tags: []
     }
-    const obj: any = {
+    const obj: { [ id: string ]: BuildFormValidator } = {
       name: VIDEO_NAME_VALIDATOR,
       privacy: VIDEO_PRIVACY_VALIDATOR,
       channelId: VIDEO_CHANNEL_VALIDATOR,
@@ -138,7 +140,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
       saveReplay: null
     }
 
-    this.formValidatorService.updateForm(
+    this.formValidatorService.updateFormGroup(
       this.form,
       this.formErrors,
       this.validationMessages,
@@ -275,6 +277,14 @@ export class VideoEditComponent implements OnInit, OnDestroy {
     })
   }
 
+  getPluginsFields (tab: 'main' | 'plugin-settings') {
+    return this.pluginFields.filter(p => {
+      const wanted = p.videoFormOptions.tab ?? 'plugin-settings'
+
+      return wanted === tab
+    })
+  }
+
   private sortVideoCaptions () {
     this.videoCaptions.sort((v1, v2) => {
       if (v1.language.label < v2.language.label) return -1
@@ -289,15 +299,44 @@ export class VideoEditComponent implements OnInit, OnDestroy {
 
     if (this.pluginFields.length === 0) return
 
-    const obj: any = {}
+    const pluginObj: { [ id: string ]: BuildFormValidator } = {}
+    const pluginValidationMessages: FormReactiveValidationMessages = {}
+    const pluginFormErrors: any = {}
+    const pluginDefaults: any = {}
 
     for (const setting of this.pluginFields) {
-      obj[setting.commonOptions.name] = new FormControl(setting.commonOptions.default)
+      const validator = (control: AbstractControl): ValidationErrors | null => {
+        if (!setting.commonOptions.error) return null
+
+        const error = setting.commonOptions.error({ formValues: this.form.value, value: control.value })
+
+        return error?.error ? { [setting.commonOptions.name]: error.text } : null
+      }
+
+      const name = setting.commonOptions.name
+
+      pluginObj[name] = {
+        VALIDATORS: [ validator ],
+        MESSAGES: {}
+      }
+
+      pluginDefaults[name] = setting.commonOptions.default
     }
 
-    this.pluginDataFormGroup = new FormGroup(obj)
+    this.pluginDataFormGroup = new FormGroup({})
+    this.formValidatorService.updateFormGroup(
+      this.pluginDataFormGroup,
+      pluginFormErrors,
+      pluginValidationMessages,
+      pluginObj,
+      pluginDefaults
+    )
+
     this.form.addControl('pluginData', this.pluginDataFormGroup)
+    this.formErrors['pluginData'] = pluginFormErrors
+    this.validationMessages['pluginData'] = pluginValidationMessages
 
+    this.cd.detectChanges()
     this.pluginFieldsAdded.emit()
   }
 
index 76f154249e3c65d5f4d5a2a35ea7a28977829df4..fa58008971dbfd82ea2411b78ceefc2bccba5fc2 100644 (file)
@@ -226,7 +226,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
   }
 
   isPublishingButtonDisabled () {
-    return !this.form.valid ||
+    return !this.checkForm() ||
       this.isUpdatingVideo === true ||
       this.videoUploaded !== true ||
       !this.videoUploadedIds.id
@@ -240,7 +240,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
   }
 
   updateSecondStep () {
-    if (this.isPublishingButtonDisabled() || !this.checkForm()) {
+    if (this.isPublishingButtonDisabled()) {
       return
     }
 
index f2ce8236006eff90c34b614ddae751a6a657e333..30b59c141d29f867a6d1f417f61f58993116b488 100644 (file)
@@ -56,13 +56,18 @@ export abstract class FormReactive {
 
       if (control.dirty) this.formChanged = true
 
-      // Don't care if dirty on force check
-      const isDirty = control.dirty || forceCheck === true
-      if (control && isDirty && control.enabled && !control.valid) {
-        const messages = validationMessages[field]
-        for (const key of Object.keys(control.errors)) {
-          formErrors[field] += messages[key] + ' '
-        }
+      if (forceCheck) control.updateValueAndValidity({ emitEvent: false })
+      if (!control || !control.dirty || !control.enabled || control.valid) continue
+
+      const staticMessages = validationMessages[field]
+      for (const key of Object.keys(control.errors)) {
+        const formErrorValue = control.errors[key]
+
+        // Try to find error message in static validation messages first
+        // Then check if the validator returns a string that is the error
+        if (typeof formErrorValue === 'boolean') formErrors[field] += staticMessages[key] + ' '
+        else if (typeof formErrorValue === 'string') formErrors[field] += control.errors[key]
+        else throw new Error('Form error value of ' + field + ' is invalid')
       }
     }
   }
index c0664de5f02bff696d78f66af8ed4ed4d426f5ad..055fbb2d956a0a64ebc24f51fbc5300ecaad1ff8 100644 (file)
@@ -40,7 +40,7 @@ export class FormValidatorService {
     return { form, formErrors, validationMessages }
   }
 
-  updateForm (
+  updateFormGroup (
     form: FormGroup,
     formErrors: FormReactiveErrors,
     validationMessages: FormReactiveValidationMessages,
@@ -52,7 +52,7 @@ export class FormValidatorService {
 
       const field = obj[name]
       if (this.isRecursiveField(field)) {
-        this.updateForm(
+        this.updateFormGroup(
           form[name],
           formErrors[name] as FormReactiveErrors,
           validationMessages[name] as FormReactiveValidationMessages,
@@ -66,8 +66,10 @@ export class FormValidatorService {
 
       const defaultValue = defaultValues[name] || ''
 
-      if (field?.VALIDATORS) form.addControl(name, new FormControl(defaultValue, field.VALIDATORS as ValidatorFn[]))
-      else form.addControl(name, new FormControl(defaultValue))
+      form.addControl(
+        name,
+        new FormControl(defaultValue, field?.VALIDATORS as ValidatorFn[])
+      )
     }
   }
 
index 2df071337a1c5ee347eb33cabb9e56c3eb604ec0..30fd632669894e3b52c63c8b7fd6281b31e655b3 100644 (file)
@@ -16,8 +16,15 @@ export type RegisterClientFormFieldOptions = {
 
   // Not supported by plugin setting registration, use registerSettingsScript instead
   hidden?: (options: any) => boolean
+
+  // Return undefined | null if there is no error or return a string with the detailed error
+  // Not supported by plugin setting registration
+  error?: (options: any) => { error: boolean, text?: string }
 }
 
 export interface RegisterClientVideoFieldOptions {
   type: 'update' | 'upload' | 'import-url' | 'import-torrent' | 'go-live'
+
+  // Default to 'plugin-settings'
+  tab?: 'main' | 'plugin-settings'
 }
index 26fcb8987793c826b988901991ed0646161c534b..5c1f6a2aff2ad1492dddbca07646dfda54c031a3 100644 (file)
@@ -692,16 +692,31 @@ async function register ({ registerVideoField, peertubeHelpers }) {
     type: 'input-textarea',
 
     default: '',
+
     // Optional, to hide a field depending on the current form state
     // liveVideo is in the options object when the user is creating/updating a live
     // videoToUpdate is in the options object when the user is updating a video
     hidden: ({ formValues, videoToUpdate, liveVideo }) => {
       return formValues.pluginData['other-field'] === 'toto'
+    },
+
+    // Optional, to display an error depending on the form state
+    error: ({ formValues, value }) => {
+      if (formValues['privacy'] !== 1 && formValues['privacy'] !== 2) return { error: false }
+      if (value === true) return { error: false }
+
+      return { error: true, text: 'Should be enabled' }
     }
   }
 
+  const videoFormOptions = {
+    // Optional, to choose to put your setting in a specific tab in video form
+    // type: 'main' | 'plugin-settings'
+    tab: 'main'
+  }
+
   for (const type of [ 'upload', 'import-url', 'import-torrent', 'update', 'go-live' ]) {
-    registerVideoField(commonOptions, { type })
+    registerVideoField(commonOptions, { type, ...videoFormOptions  })
   }
 }
 ```