From 3c065fe3b3e1385d59ad1980251d14b712648155 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 22 Dec 2021 18:02:36 +0100 Subject: [PATCH] Enhance plugin video fields Add video form tab selection Add ability to display an error --- client/e2e/src/po/admin-plugin.po.ts | 31 ++++++++ client/e2e/src/po/anonymous-settings.po.ts | 2 +- client/e2e/src/po/my-account.po.ts | 2 +- client/e2e/src/po/video-upload.po.ts | 21 +++-- .../e2e/src/suites-local/plugins.e2e-spec.ts | 79 +++++++++++++++++++ client/e2e/src/utils/elements.ts | 2 +- .../shared/video-edit.component.html | 13 ++- .../shared/video-edit.component.ts | 57 ++++++++++--- .../video-upload.component.ts | 4 +- .../app/shared/shared-forms/form-reactive.ts | 19 +++-- .../shared-forms/form-validator.service.ts | 10 ++- .../register-client-form-field.model.ts | 7 ++ support/doc/plugins/guide.md | 17 +++- 13 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 client/e2e/src/po/admin-plugin.po.ts create mode 100644 client/e2e/src/suites-local/plugins.e2e-spec.ts diff --git a/client/e2e/src/po/admin-plugin.po.ts b/client/e2e/src/po/admin-plugin.po.ts new file mode 100644 index 000000000..6a3f3cf28 --- /dev/null +++ b/client/e2e/src/po/admin-plugin.po.ts @@ -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() + } +} diff --git a/client/e2e/src/po/anonymous-settings.po.ts b/client/e2e/src/po/anonymous-settings.po.ts index 180d371fa..21216a8f2 100644 --- a/client/e2e/src/po/anonymous-settings.po.ts +++ b/client/e2e/src/po/anonymous-settings.po.ts @@ -13,7 +13,7 @@ export class AnonymousSettingsPage { } async clickOnP2PCheckbox () { - const p2p = getCheckbox('p2pEnabled') + const p2p = await getCheckbox('p2pEnabled') await p2p.waitForClickable() await p2p.click() diff --git a/client/e2e/src/po/my-account.po.ts b/client/e2e/src/po/my-account.po.ts index 13a764e87..20dafbf06 100644 --- a/client/e2e/src/po/my-account.po.ts +++ b/client/e2e/src/po/my-account.po.ts @@ -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 diff --git a/client/e2e/src/po/video-upload.po.ts b/client/e2e/src/po/video-upload.po.ts index 2206b56c3..38395ea2f 100644 --- a/client/e2e/src/po/video-upload.po.ts +++ b/client/e2e/src/po/video-upload.po.ts @@ -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 index 000000000..14802c1ca --- /dev/null +++ b/client/e2e/src/suites-local/plugins.e2e-spec.ts @@ -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 }) + }) +}) diff --git a/client/e2e/src/utils/elements.ts b/client/e2e/src/utils/elements.ts index 315718879..3ffa5defd 100644 --- a/client/e2e/src/utils/elements.ts +++ b/client/e2e/src/utils/elements.ts @@ -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) { diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html index aa88d6c4c..f65cd8883 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html @@ -146,6 +146,13 @@ + + +
+ +
+ +
@@ -339,15 +346,15 @@ - + Plugin settings
-
- +
+
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts index 2bec933e9..a03005bcb 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts @@ -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.form.addControl('pluginData', this.pluginDataFormGroup) + 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() } diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts index 76f154249..fa5800897 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts @@ -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 } diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts index f2ce82360..30b59c141 100644 --- a/client/src/app/shared/shared-forms/form-reactive.ts +++ b/client/src/app/shared/shared-forms/form-reactive.ts @@ -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') } } } diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts index c0664de5f..055fbb2d9 100644 --- a/client/src/app/shared/shared-forms/form-validator.service.ts +++ b/client/src/app/shared/shared-forms/form-validator.service.ts @@ -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[]) + ) } } diff --git a/shared/models/plugins/client/register-client-form-field.model.ts b/shared/models/plugins/client/register-client-form-field.model.ts index 2df071337..30fd63266 100644 --- a/shared/models/plugins/client/register-client-form-field.model.ts +++ b/shared/models/plugins/client/register-client-form-field.model.ts @@ -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' } diff --git a/support/doc/plugins/guide.md b/support/doc/plugins/guide.md index 26fcb8987..5c1f6a2af 100644 --- a/support/doc/plugins/guide.md +++ b/support/doc/plugins/guide.md @@ -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 }) } } ```