From 35d96635599fe39fa76c749df97bfb53205b6268 Mon Sep 17 00:00:00 2001 From: Mehdi Benadel Date: Sun, 26 May 2024 05:06:28 +0200 Subject: [PATCH] Channel configuration validation + tags input --- assets/styles/configuration.scss | 86 +++++++ client/@types/global.d.ts | 3 + .../elements/channel-configuration.ts | 98 +++++-- .../configuration/services/channel-details.ts | 72 +++++- .../common/lib/elements/dynamic-table-form.ts | 239 ++++++++++++++---- client/common/lib/elements/index.js | 1 + client/common/lib/elements/tags-input.ts | 193 ++++++++++++++ client/common/lib/models/validation.ts | 13 + client/tsconfig.json | 2 +- languages/en.yml | 3 + shared/lib/types.ts | 38 +-- 11 files changed, 656 insertions(+), 92 deletions(-) create mode 100644 client/common/lib/elements/tags-input.ts create mode 100644 client/common/lib/models/validation.ts diff --git a/assets/styles/configuration.scss b/assets/styles/configuration.scss index 3b523a0f..5988260a 100644 --- a/assets/styles/configuration.scss +++ b/assets/styles/configuration.scss @@ -247,3 +247,89 @@ livechat-dynamic-table-form { background-color: var(--bs-orange); } } + +livechat-tags-input { + --tag-padding-vertical: 3px; + --tag-padding-horizontal: 6px; + + display: flex; + align-items: flex-start; + flex-wrap: wrap; + + input { + flex: 1; + border: none; + padding: var(--tag-padding-vertical) 0 0; + color: inherit; + background-color: inherit; + + &:focus { + outline: transparent; + } + } + + #tags { + display: flex; + flex-wrap: wrap; + padding: 0; + margin: var(--tag-padding-vertical) 0 0; + } + + .tag { + width: auto; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + padding: 0 var(--tag-padding-horizontal); + font-size: 14px; + list-style: none; + border-radius: 3px; + margin: 0 3px 3px 0; + background: var(--bs-orange); + + &, + &:active, + &:focus { + color: #fff; + background-color: var(--mainColor); + } + + &:hover { + color: #fff; + background-color: var(--mainHoverColor); + } + + &[disabled], + &.disabled { + cursor: default; + color: #fff; + background-color: var(--inputBorderColor); + } + + .tag-name { + margin-top: 3px; + } + + .tag-close { + display: block; + width: 12px; + height: 12px; + line-height: 10px; + text-align: center; + font-size: 14px; + margin-left: var(--tag-padding-horizontal); + color: var(--bs-orange); + border-radius: 50%; + background: #fff; + cursor: pointer; + } + } + + @media screen and (max-width: 567px) { + .tags-input { + width: calc(100vw - 32px); + } + } +} diff --git a/client/@types/global.d.ts b/client/@types/global.d.ts index c5a0f177..a9e9dd58 100644 --- a/client/@types/global.d.ts +++ b/client/@types/global.d.ts @@ -82,6 +82,9 @@ declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_BOT_NICKNAME: string declare const LOC_LIVECHAT_CONFIGURATION_CHANNEL_FOR_MORE_INFO: string declare const LOC_INVALID_VALUE: string +declare const LOC_INVALID_VALUE_WRONG_TYPE: string +declare const LOC_INVALID_VALUE_WRONG_FORMAT: string +declare const LOC_INVALID_VALUE_NOT_IN_RANGE: string declare const LOC_CHATROOM_NOT_ACCESSIBLE: string declare const LOC_PROMOTE: string diff --git a/client/common/configuration/elements/channel-configuration.ts b/client/common/configuration/elements/channel-configuration.ts index 81d5ca1d..23aecf9d 100644 --- a/client/common/configuration/elements/channel-configuration.ts +++ b/client/common/configuration/elements/channel-configuration.ts @@ -4,7 +4,7 @@ import type { RegisterClientOptions } from '@peertube/peertube-types/client' import type { ChannelConfiguration } from 'shared/lib/types' -import { html } from 'lit' +import { TemplateResult, html, nothing } from 'lit' import { customElement, property, state } from 'lit/decorators.js' import { ptTr } from '../../lib/directives/translation' import { Task } from '@lit/task' @@ -13,6 +13,8 @@ import { provide } from '@lit/context' import { channelConfigurationContext, channelDetailsServiceContext } from '../contexts/channel' import { registerClientOptionsContext } from '../../lib/contexts/peertube' import { LivechatElement } from '../../lib/elements/livechat' +import { ValidationError, ValidationErrorType } from '../../lib/models/validation' +import { classMap } from 'lit/directives/class-map.js' @customElement('livechat-channel-configuration') export class ChannelConfigurationElement extends LivechatElement { @@ -31,7 +33,7 @@ export class ChannelConfigurationElement extends LivechatElement { private _channelDetailsService?: ChannelDetailsService @state() - public _formStatus: boolean | any = undefined + public _validationError?: ValidationError private readonly _asyncTaskRender = new Task(this, { task: async ([registerClientOptions]) => { @@ -49,21 +51,51 @@ export class ChannelConfigurationElement extends LivechatElement { this._channelDetailsService.saveOptions(this._channelConfiguration.channel.id, this._channelConfiguration.configuration) .then(() => { - this._formStatus = { success: true } + this._validationError = undefined this.registerClientOptions ?.peertubeHelpers.notifier.info('Livechat configuration has been properly updated.') - this.requestUpdate('_formStatus') + this.requestUpdate('_validationError') }) - .catch((error: Error) => { - console.error(`An error occurred. ${error.name}: ${error.message}`) + .catch((error: ValidationError) => { + this._validationError = error + console.warn(`A validation error occurred in saving configuration. ${error.name}: ${error.message}`) this.registerClientOptions ?.peertubeHelpers.notifier.error( - `An error occurred. ${(error.name && error.message) ? `${error.name}: ${error.message}` : ''}`) - this.requestUpdate('_formStatus') + `An error occurred. ${(error.message) ? `${error.message}` : ''}`) + this.requestUpdate('_validationError') }) } } + private readonly _getInputValidationClass = (propertyName: string): { [key: string]: boolean } => { + const validationErrorTypes: ValidationErrorType[] | undefined = + this._validationError?.properties[`${propertyName}`] + return validationErrorTypes ? (validationErrorTypes.length ? { 'is-invalid': true } : { 'is-valid': true }) : {} + } + + private readonly _renderFeedback = (feedbackId: string, + propertyName: string): TemplateResult | typeof nothing => { + const errorMessages: TemplateResult[] = [] + const validationErrorTypes: ValidationErrorType[] | undefined = + this._validationError?.properties[`${propertyName}`] ?? undefined + + if (validationErrorTypes && validationErrorTypes.length !== 0) { + if (validationErrorTypes.includes(ValidationErrorType.WrongType)) { + errorMessages.push(html`${ptTr(LOC_INVALID_VALUE_WRONG_TYPE)}`) + } + if (validationErrorTypes.includes(ValidationErrorType.WrongFormat)) { + errorMessages.push(html`${ptTr(LOC_INVALID_VALUE_WRONG_FORMAT)}`) + } + if (validationErrorTypes.includes(ValidationErrorType.NotInRange)) { + errorMessages.push(html`${ptTr(LOC_INVALID_VALUE_NOT_IN_RANGE)}`) + } + + return html`
${errorMessages}
` + } else { + return nothing + } + } + protected override render = (): unknown => { const tableHeaderList = { forbiddenWords: { @@ -116,8 +148,8 @@ export class ChannelConfigurationElement extends LivechatElement { const tableSchema = { forbiddenWords: { entries: { - inputType: 'textarea', - default: [''], + inputType: 'tags', + default: [], separator: '\n' }, regex: { @@ -144,9 +176,9 @@ export class ChannelConfigurationElement extends LivechatElement { }, quotes: { messages: { - inputType: 'textarea', - default: [''], - separator: '\n' + inputType: 'tags', + default: [], + separators: ['\n', '\t', ';'] }, delay: { inputType: 'number', @@ -194,11 +226,12 @@ export class ChannelConfigurationElement extends LivechatElement { + ${this._renderFeedback('peertube-livechat-slowmode-duration-feedback', 'slowMode.duration')} @@ -244,14 +278,15 @@ export class ChannelConfigurationElement extends LivechatElement { ${this._channelConfiguration?.configuration.bot.enabled ? html`
- + { if (event?.target && this._channelConfiguration) { this._channelConfiguration.configuration.bot.nickname = @@ -262,6 +297,7 @@ export class ChannelConfigurationElement extends LivechatElement { } value="${this._channelConfiguration?.configuration.bot.nickname}" /> + ${this._renderFeedback('peertube-livechat-bot-nickname-feedback', 'bot.nickname')}
` : '' } @@ -278,18 +314,20 @@ export class ChannelConfigurationElement extends LivechatElement {
{ - if (this._channelConfiguration) { - this._channelConfiguration.configuration.bot.forbiddenWords = e.detail - this.requestUpdate('_channelConfiguration') - } + .header=${tableHeaderList.forbiddenWords} + .schema=${tableSchema.forbiddenWords} + .validation=${this._validationError?.properties} + .validationPrefix=${'bot.forbiddenWords'} + .rows=${this._channelConfiguration?.configuration.bot.forbiddenWords} + @update=${(e: CustomEvent) => { + if (this._channelConfiguration) { + this._channelConfiguration.configuration.bot.forbiddenWords = e.detail + this.requestUpdate('_channelConfiguration') } } - .formName=${'forbidden-words'}> - + } + .formName=${'forbidden-words'}> +
@@ -304,6 +342,8 @@ export class ChannelConfigurationElement extends LivechatElement { { if (this._channelConfiguration) { @@ -328,6 +368,8 @@ export class ChannelConfigurationElement extends LivechatElement { { if (this._channelConfiguration) { diff --git a/client/common/configuration/services/channel-details.ts b/client/common/configuration/services/channel-details.ts index ad212d0e..91aaa773 100644 --- a/client/common/configuration/services/channel-details.ts +++ b/client/common/configuration/services/channel-details.ts @@ -3,7 +3,9 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { RegisterClientOptions } from '@peertube/peertube-types/client' -import { ChannelLiveChatInfos, ChannelConfiguration, ChannelConfigurationOptions } from 'shared/lib/types' +import type { ValidationError } from '../../lib/models/validation' +import type { ChannelLiveChatInfos, ChannelConfiguration, ChannelConfigurationOptions } from 'shared/lib/types' +import { ValidationErrorType } from '../../lib/models/validation' import { getBaseRoute } from '../../../utils/uri' export class ChannelDetailsService { @@ -19,7 +21,73 @@ export class ChannelDetailsService { } validateOptions = (channelConfigurationOptions: ChannelConfigurationOptions): boolean => { - return !!channelConfigurationOptions + let hasErrors = false + const validationError: ValidationError = { + name: 'ChannelConfigurationOptionsValidationError', + message: 'There was an error during validation', + properties: {} + } + const botConf = channelConfigurationOptions.bot + const slowModeDuration = channelConfigurationOptions.slowMode.duration + + validationError.properties['slowMode.duration'] = [] + + if ( + (typeof slowModeDuration !== 'number') || + isNaN(slowModeDuration)) { + validationError.properties['slowMode.duration'].push(ValidationErrorType.WrongType) + hasErrors = true + } else if ( + slowModeDuration < 0 || + slowModeDuration > 1000 + ) { + validationError.properties['slowMode.duration'].push(ValidationErrorType.NotInRange) + hasErrors = true + } + + // If !bot.enabled, we don't have to validate these fields: + // The backend will ignore those values. + if (botConf.enabled) { + validationError.properties['bot.nickname'] = [] + + if (/[^\p{L}\p{N}\p{Z}_-]/u.test(botConf.nickname ?? '')) { + validationError.properties['bot.nickname'].push(ValidationErrorType.WrongFormat) + hasErrors = true + } + + for (const [i, fw] of botConf.forbiddenWords.entries()) { + for (const v of fw.entries) { + validationError.properties[`bot.forbiddenWords.${i}.entries`] = [] + if (fw.regexp) { + if (v.trim() !== '') { + try { + const test = new RegExp(v) + test.test(v) + } catch (_) { + validationError.properties[`bot.forbiddenWords.${i}.entries`] + .push(ValidationErrorType.WrongFormat) + hasErrors = true + } + } + } + } + } + + for (const [i, cd] of botConf.commands.entries()) { + validationError.properties[`bot.commands.${i}.command`] = [] + + if (/\s+/.test(cd.command)) { + validationError.properties[`bot.commands.${i}.command`].push(ValidationErrorType.WrongFormat) + hasErrors = true + } + } + } + + if (hasErrors) { + throw validationError + } + + return true } saveOptions = async (channelId: number, diff --git a/client/common/lib/elements/dynamic-table-form.ts b/client/common/lib/elements/dynamic-table-form.ts index 10fc5880..d4ea9cda 100644 --- a/client/common/lib/elements/dynamic-table-form.ts +++ b/client/common/lib/elements/dynamic-table-form.ts @@ -4,12 +4,16 @@ /* eslint no-fallthrough: "off" */ +import type { TagsInputElement } from './tags-input' +import { ValidationErrorType } from '../models/validation' import { html, nothing, TemplateResult } from 'lit' import { repeat } from 'lit/directives/repeat.js' import { customElement, property, state } from 'lit/decorators.js' import { ifDefined } from 'lit/directives/if-defined.js' import { unsafeHTML } from 'lit/directives/unsafe-html.js' +import { classMap } from 'lit/directives/class-map.js' import { LivechatElement } from './livechat' +import { ptTr } from '../directives/translation' // This content comes from the file assets/images/plus-square.svg, from the Feather icons set https://feathericons.com/ const AddSVG: string = @@ -50,6 +54,7 @@ type DynamicTableAcceptedInputTypes = 'textarea' | 'time' | 'url' | 'week' +| 'tags' interface CellDataSchema { min?: number @@ -57,14 +62,20 @@ interface CellDataSchema { minlength?: number maxlength?: number size?: number - label?: string + label?: TemplateResult | string options?: { [key: string]: string } datalist?: DynamicTableAcceptedTypes[] - separator?: string + separators?: string[] inputType?: DynamicTableAcceptedInputTypes default?: DynamicTableAcceptedTypes } +interface DynamicTableRowData { + _id: number + _originalIndex: number + row: { [key: string]: DynamicTableAcceptedTypes } +} + @customElement('livechat-dynamic-table-form') export class DynamicTableFormElement extends LivechatElement { @property({ attribute: false }) @@ -73,11 +84,17 @@ export class DynamicTableFormElement extends LivechatElement { @property({ attribute: false }) public schema: { [key: string]: CellDataSchema } = {} + @property() + public validation?: {[key: string]: ValidationErrorType[] } + + @property({ attribute: false }) + public validationPrefix: string = '' + @property({ attribute: false }) public rows: Array<{ [key: string]: DynamicTableAcceptedTypes }> = [] @state() - public _rowsById: Array<{ _id: number, row: { [key: string]: DynamicTableAcceptedTypes } }> = [] + public _rowsById: DynamicTableRowData[] = [] @property({ attribute: false }) public formName: string = '' @@ -102,7 +119,8 @@ export class DynamicTableFormElement extends LivechatElement { private readonly _addRow = (): void => { const newRow = this._getDefaultRow() - this._rowsById.push({ _id: this._lastRowId++, row: newRow }) + // Create row and assign id and original index + this._rowsById.push({ _id: this._lastRowId++, _originalIndex: this.rows.length, row: newRow }) this.rows.push(newRow) this.requestUpdate('rows') this.requestUpdate('_rowsById') @@ -123,11 +141,17 @@ export class DynamicTableFormElement extends LivechatElement { this._updateLastRowId() + // Filter removed rows this._rowsById.filter(rowById => this.rows.includes(rowById.row)) - for (const row of this.rows) { - if (this._rowsById.filter(rowById => rowById.row === row).length === 0) { - this._rowsById.push({ _id: this._lastRowId++, row }) + for (let i = 0; i < this.rows.length; i++) { + if (this._rowsById.filter(rowById => rowById.row === this.rows[i]).length === 0) { + // Add row and assign id + this._rowsById.push({ _id: this._lastRowId++, _originalIndex: i, row: this.rows[i] }) + } else { + // Update index in case it changed + this._rowsById.filter(rowById => rowById.row === this.rows[i]) + .forEach((value) => { value._originalIndex = i }) } } @@ -169,14 +193,16 @@ export class DynamicTableFormElement extends LivechatElement { ` } - private readonly _renderDataRow = (rowData: { _id: number - row: {[key: string]: DynamicTableAcceptedTypes} }): TemplateResult => { + private readonly _renderDataRow = (rowData: DynamicTableRowData): TemplateResult => { const inputId = `peertube-livechat-${this.formName.replace(/_/g, '-')}-row-${rowData._id}` return html` ${Object.keys(this.header) .sort((k1, k2) => this.columnOrder.indexOf(k1) - this.columnOrder.indexOf(k2)) - .map(k => this.renderDataCell([k, rowData.row[k] ?? this.schema[k].default], rowData._id))} + .map(key => this.renderDataCell(key, + rowData.row[key] ?? this.schema[key].default, + rowData._id, + rowData._originalIndex))}