peertube-plugin-livechat/client/common/lib/elements/dynamic-table-form.ts
John Livingston 90afdafbd9
Authentication token generation WIP (#98)
You can now generate links to join chatrooms with your current user. This can be used to create Docks in OBS for example. This could also be used to generate authentication token to join the chat from 3rd party tools.
2024-06-17 11:43:59 +02:00

741 lines
24 KiB
TypeScript

// SPDX-FileCopyrightText: 2024 Mehdi Benadel <https://mehdibenadel.com>
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import type { TagsInputElement } from './tags-input'
import type { DirectiveResult } from 'lit/directive'
import { ValidationErrorType } from '../models/validation'
import { maxSize, inputFileAccept } from 'shared/lib/emojis'
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'
import { AddSVG, RemoveSVG } from '../buttons'
type DynamicTableAcceptedTypes = number | string | boolean | Date | Array<number | string>
type DynamicTableAcceptedInputTypes = 'textarea'
| 'select'
| 'checkbox'
| 'range'
| 'color'
| 'date'
| 'datetime'
| 'datetime-local'
| 'email'
| 'file'
| 'image'
| 'month'
| 'number'
| 'password'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week'
| 'tags'
| 'image-file'
interface CellDataSchema {
min?: number
max?: number
minlength?: number
maxlength?: number
size?: number
label?: TemplateResult | string
options?: { [key: string]: string }
datalist?: DynamicTableAcceptedTypes[]
separator?: string
inputType?: DynamicTableAcceptedInputTypes
default?: DynamicTableAcceptedTypes
colClassList?: string[] // CSS classes to add to the <td> element.
}
interface DynamicTableRowData {
_id: number
_originalIndex: number
row: { [key: string]: DynamicTableAcceptedTypes }
}
interface DynamicFormHeaderCellData {
colName: TemplateResult | DirectiveResult
description: TemplateResult | DirectiveResult
headerClassList?: string[]
}
export interface DynamicFormHeader {
[key: string]: DynamicFormHeaderCellData
}
export interface DynamicFormSchema { [key: string]: CellDataSchema }
@customElement('livechat-dynamic-table-form')
export class DynamicTableFormElement extends LivechatElement {
@property({ attribute: false })
public header: DynamicFormHeader = {}
@property({ attribute: false })
public schema: DynamicFormSchema = {}
@property({ attribute: false })
public maxLines?: number = undefined
@property()
public validation?: {[key: string]: ValidationErrorType[] }
@property({ attribute: false })
public validationPrefix: string = ''
@property({ attribute: false })
public rows: Array<{ [key: string]: DynamicTableAcceptedTypes }> = []
@state()
public _rowsById: DynamicTableRowData[] = []
@property({ attribute: false })
public formName: string = ''
@state()
private _lastRowId = 1
@property({ attribute: false })
private columnOrder: string[] = []
// fixes situations when list has been reinitialized or changed outside of CustomElement
private readonly _updateLastRowId = (): void => {
for (const rowById of this._rowsById) {
this._lastRowId = Math.max(this._lastRowId, rowById._id + 1)
}
}
private readonly _getDefaultRow = (): { [key: string]: DynamicTableAcceptedTypes } => {
this._updateLastRowId()
return Object.fromEntries([...Object.entries(this.schema).map((entry) => [entry[0], entry[1].default ?? ''])])
}
private async _addRow (): Promise<void> {
const newRow = this._getDefaultRow()
// 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')
this.dispatchEvent(new CustomEvent('update', { detail: this.rows }))
// Once the update is completed, we give focus to the first input field of the new row.
await this.updateComplete
// Note: we make multiple querySelector, to be sure to not get a nested table.
// We want the top level table associated tr.
const input = this.querySelector('table')?.querySelector(
'&>tbody>tr:last-child>td input:not([type=hidden]),' +
'&>tbody>tr:last-child>td livechat-tags-input,' +
'&>tbody>tr:last-child>td textarea'
)
if (input) {
(input as HTMLElement).focus()
}
}
private async _removeRow (rowId: number): Promise<void> {
const confirmMsg = await this.ptTranslate(LOC_ACTION_REMOVE_ENTRY_CONFIRM)
await new Promise<void>((resolve, reject) => {
this.ptOptions.peertubeHelpers.showModal({
title: confirmMsg,
content: '',
close: true,
cancel: {
value: 'cancel',
action: reject
},
confirm: {
value: 'confirm',
action: resolve
}
})
})
const rowToRemove = this._rowsById.filter(rowById => rowById._id === rowId).map(rowById => rowById.row)[0]
this._rowsById = this._rowsById.filter(rowById => rowById._id !== rowId)
this.rows = this.rows.filter((row) => row !== rowToRemove)
this.requestUpdate('rows')
this.requestUpdate('_rowsById')
this.dispatchEvent(new CustomEvent('update', { detail: this.rows }))
}
protected override render = (): unknown => {
const inputId = `peertube-livechat-${this.formName.replace(/_/g, '-')}-table`
this._updateLastRowId()
// Filter removed rows
// FIXME: is this really necessary?
this._rowsById = this._rowsById.filter(rowById => this.rows.includes(rowById.row))
for (let i = 0; i < this.rows.length; i++) {
if (!this._rowsById.find(rowById => rowById.row === this.rows[i])) {
// 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 })
}
}
if (this.columnOrder.length !== Object.keys(this.header).length) {
this.columnOrder = this.columnOrder.filter(key => Object.keys(this.header).includes(key))
this.columnOrder.push(...Object.keys(this.header).filter(key => !this.columnOrder.includes(key)))
}
return html`
<div class="table-responsive">
<table class="table" id=${inputId}>
${this._renderHeader()}
<tbody>
${repeat(this._rowsById, (rowById) => rowById._id, this._renderDataRow)}
</tbody>
${this._renderFooter()}
</table>
</div>
`
}
private readonly _renderHeader = (): TemplateResult => {
const columns = Object.entries(this.header)
.sort(([k1, _1], [k2, _2]) => this.columnOrder.indexOf(k1) - this.columnOrder.indexOf(k2))
return html`<thead>
<tr>
${columns.map(([_, v]) => this._renderHeaderCell(v))}
<th scope="col"></th>
</tr>
<tr>
${columns.map(([_, v]) => this._renderHeaderDescriptionCell(v))}
<th scope="col"></th>
</tr>
</thead>`
}
private readonly _renderHeaderCell = (headerCellData: DynamicFormHeaderCellData): TemplateResult => {
return html`<th scope="col" class=${headerCellData.headerClassList?.join(' ') ?? ''}>
<div
data-toggle="tooltip"
data-placement="bottom"
data-html="true"
>
${headerCellData.colName}
</div>
</th>`
}
private _renderHeaderDescriptionCell (headerCellData: DynamicFormHeaderCellData): TemplateResult {
const classList = ['livechat-dynamic-table-form-description-header']
if (headerCellData.headerClassList) {
classList.push(...headerCellData.headerClassList)
}
return html`<th scope="col" class=${classList.join(' ')}>
${headerCellData.description}
</th>`
}
private readonly _renderDataRow = (rowData: DynamicTableRowData): TemplateResult => {
const inputId = `peertube-livechat-${this.formName.replace(/_/g, '-')}-row-${rowData._id}`
return html`<tr id=${inputId}>
${Object.keys(this.header)
.sort((k1, k2) => this.columnOrder.indexOf(k1) - this.columnOrder.indexOf(k2))
.map(key => this.renderDataCell(key,
rowData.row[key] ?? this.schema[key].default,
rowData._id,
rowData._originalIndex))}
<td class="form-group">
<button type="button"
class="dynamic-table-remove-row"
.title=${ptTr(LOC_ACTION_REMOVE_ENTRY) as any}
@click=${async () => this._removeRow(rowData._id)}
>
${unsafeHTML(RemoveSVG)}
</button>
</td>
</tr>`
}
private readonly _renderFooter = (): TemplateResult => {
if (this.maxLines && this._rowsById.length >= this.maxLines) {
return html``
}
// Note: the addRow button is in first column, so it won't be hidden if screen not wide enough.
return html`<tfoot>
<tr>
<td class="dynamic-table-add-row-cell">
<button type="button"
class="dynamic-table-add-row"
.title=${ptTr(LOC_ACTION_ADD_ENTRY) as any}
@click=${this._addRow}
>
${unsafeHTML(AddSVG)}
</button>
</td>
${Object.values(this.header).map(() => html`<td></td>`)}
</tr>
</tfoot>`
}
renderDataCell = (propertyName: string,
propertyValue: DynamicTableAcceptedTypes,
rowId: number,
originalIndex: number): TemplateResult => {
const propertySchema = this.schema[propertyName] ?? {}
let formElement
const inputName = `${this.formName.replace(/-/g, '_')}_${propertyName.toString().replace(/-/g, '_')}_${rowId}`
const inputId =
`peertube-livechat-${this.formName.replace(/_/g, '-')}-${propertyName.toString().replace(/_/g, '-')}-${rowId}`
const feedback = this._renderFeedback(inputId, propertyName, originalIndex)
switch (propertySchema.default?.constructor) {
case String:
propertySchema.inputType ??= 'text'
switch (propertySchema.inputType) {
case 'text':
case 'color':
case 'date':
case 'datetime':
case 'datetime-local':
case 'email':
case 'file':
case 'image':
case 'month':
case 'number':
case 'password':
case 'range':
case 'tel':
case 'time':
case 'url':
case 'week':
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
propertyName,
propertySchema,
propertyValue as string,
originalIndex)}
${feedback}
`
break
case 'textarea':
formElement = html`${this._renderTextarea(rowId,
inputId,
inputName,
propertyName,
propertySchema,
propertyValue as string,
originalIndex)}
${feedback}
`
break
case 'select':
formElement = html`${this._renderSelect(rowId,
inputId,
inputName,
propertyName,
propertySchema,
propertyValue as string,
originalIndex)}
${feedback}
`
break
case 'image-file':
formElement = html`${this._renderImageFileInput(rowId,
inputId,
inputName,
propertyName,
propertySchema,
propertyValue?.toString(),
originalIndex)}
${feedback}
`
break
}
break
case Date:
propertySchema.inputType ??= 'datetime'
switch (propertySchema.inputType) {
case 'date':
case 'datetime':
case 'datetime-local':
case 'time':
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
propertyName,
propertySchema,
(propertyValue as Date).toISOString(),
originalIndex)}
${feedback}
`
break
}
break
case Number:
propertySchema.inputType ??= 'number'
switch (propertySchema.inputType) {
case 'number':
case 'range':
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
propertyName,
propertySchema,
propertyValue as string,
originalIndex)}
${feedback}
`
break
}
break
case Boolean:
propertySchema.inputType ??= 'checkbox'
switch (propertySchema.inputType) {
case 'checkbox':
formElement = html`${this._renderCheckbox(rowId,
inputId,
inputName,
propertyName,
propertySchema,
propertyValue as boolean,
originalIndex)}
${feedback}
`
break
}
break
case Array:
propertySchema.inputType ??= 'text'
switch (propertySchema.inputType) {
case 'text':
case 'color':
case 'date':
case 'datetime':
case 'datetime-local':
case 'email':
case 'file':
case 'image':
case 'month':
case 'number':
case 'password':
case 'range':
case 'tel':
case 'time':
case 'url':
case 'week':
if (propertyValue.constructor !== Array) {
propertyValue = (propertyValue) ? [propertyValue as (number | string)] : []
}
formElement = html`${this._renderInput(rowId,
inputId,
inputName,
propertyName,
propertySchema,
(propertyValue)?.join(propertySchema.separator ?? ',') ??
propertyValue ?? propertySchema.default ?? '',
originalIndex)}
${feedback}
`
break
case 'textarea':
if (propertyValue.constructor !== Array) {
propertyValue = (propertyValue) ? [propertyValue as (number | string)] : []
}
formElement = html`${this._renderTextarea(rowId,
inputId,
inputName,
propertyName,
propertySchema,
(propertyValue)?.join(propertySchema.separator ?? ',') ??
propertyValue ?? propertySchema.default ?? '',
originalIndex)}
${feedback}
`
break
case 'tags':
if (propertyValue.constructor !== Array) {
propertyValue = (propertyValue) ? [propertyValue as (number | string)] : []
}
formElement = html`${this._renderTagsInput(rowId,
inputId,
inputName,
propertyName,
propertySchema,
propertyValue,
originalIndex)}
${feedback}
`
break
}
}
if (!formElement) {
this.logger.warn(`value type '${(propertyValue.constructor.toString())}' is incompatible` +
`with field type '${propertySchema.inputType as string}' for form entry '${propertyName.toString()}'.`)
}
const classList = ['form-group']
if (propertySchema.colClassList) {
classList.push(...propertySchema.colClassList)
}
return html`<td class=${classList.join(' ')}>${formElement}</td>`
}
_renderInput = (rowId: number,
inputId: string,
inputName: string,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: string,
originalIndex: number): TemplateResult => {
return html`<input
type=${propertySchema.inputType as any}
name=${inputName}
class=${classMap(
Object.assign(
{ 'form-control': true },
this._getInputValidationClass(propertyName, originalIndex)
)
)}
id=${inputId}
aria-describedby="${inputId}-feedback"
list=${ifDefined(propertySchema.datalist ? inputId + '-datalist' : undefined)}
min=${ifDefined(propertySchema.min)}
max=${ifDefined(propertySchema.max)}
minlength=${ifDefined(propertySchema.minlength)}
maxlength=${ifDefined(propertySchema.maxlength)}
@change=${(event: Event) => this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)}
.value=${propertyValue}
/>
${(propertySchema.datalist)
? html`<datalist id=${inputId + '-datalist'}>
${(propertySchema.datalist ?? []).map((value) => html`<option value=${value.toString()}>`)}
</datalist>`
: nothing}`
}
_renderTagsInput = (rowId: number,
inputId: string,
inputName: string,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: Array<string | number>,
originalIndex: number): TemplateResult => {
return html`<livechat-tags-input
.name=${inputName}
class=${classMap(
Object.assign(
{ 'form-control': true },
this._getInputValidationClass(propertyName, originalIndex)
)
)}
id=${inputId}
.inputPlaceholder=${propertySchema.label as any}
aria-describedby="${inputId}-feedback"
.min=${propertySchema.min}
.max=${propertySchema.max}
.minlength=${propertySchema.minlength}
.maxlength=${propertySchema.maxlength}
.datalist=${propertySchema.datalist as any}
.separator=${propertySchema.separator ?? '\n'}
@change=${(event: Event) => this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)}
.value=${propertyValue as any}
></livechat-tags-input>`
}
_renderTextarea = (rowId: number,
inputId: string,
inputName: string,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: string,
originalIndex: number): TemplateResult => {
return html`<textarea
name=${inputName}
class=${classMap(
Object.assign(
{ 'form-control': true },
this._getInputValidationClass(propertyName, originalIndex)
)
)}
id=${inputId}
aria-describedby="${inputId}-feedback"
min=${ifDefined(propertySchema.min)}
max=${ifDefined(propertySchema.max)}
minlength=${ifDefined(propertySchema.minlength)}
maxlength=${ifDefined(propertySchema.maxlength)}
@change=${(event: Event) => this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)}
.value=${propertyValue}></textarea>`
}
_renderCheckbox = (rowId: number,
inputId: string,
inputName: string,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: boolean,
originalIndex: number): TemplateResult => {
return html`<input
type="checkbox"
name=${inputName}
class=${classMap(
Object.assign(
{ 'form-check-input': true },
this._getInputValidationClass(propertyName, originalIndex)
)
)}
id=${inputId}
aria-describedby="${inputId}-feedback"
@change=${(event: Event) => this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)}
value="1"
?checked=${propertyValue} />`
}
_renderSelect = (rowId: number,
inputId: string,
inputName: string,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: string,
originalIndex: number): TemplateResult => {
return html`<select
class=${classMap(
Object.assign(
{ 'form-select': true },
this._getInputValidationClass(propertyName, originalIndex)
)
)}
id=${inputId}
aria-describedby="${inputId}-feedback"
aria-label=${inputName}
@change=${(event: Event) => this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)}
>
<option ?selected=${!propertyValue}>${propertySchema.label ?? 'Choose your option'}</option>
${Object.entries(propertySchema.options ?? {})
?.map(([value, name]) =>
html`<option ?selected=${propertyValue === value} value=${value}>${name}</option>`
)}
</select>`
}
_renderImageFileInput = (rowId: number,
inputId: string,
inputName: string,
propertyName: string,
propertySchema: CellDataSchema,
propertyValue: string,
originalIndex: number
): TemplateResult => {
return html`<livechat-image-file-input
.name=${inputName}
class=${classMap(this._getInputValidationClass(propertyName, originalIndex))}
id=${inputId}
aria-describedby="${inputId}-feedback"
@change=${(event: Event) => this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)}
.value=${propertyValue}
.maxSize=${maxSize}
.accept=${inputFileAccept}
></livechat-image-file-input>`
}
_getInputValidationClass = (propertyName: string,
originalIndex: number): { [key: string]: boolean } => {
const validationErrorTypes: ValidationErrorType[] | undefined =
this.validation?.[`${this.validationPrefix}.${originalIndex}.${propertyName}`]
return validationErrorTypes !== undefined
? (validationErrorTypes.length ? { 'is-invalid': true } : { 'is-valid': true })
: {}
}
_renderFeedback = (inputId: string,
propertyName: string,
originalIndex: number): TemplateResult | typeof nothing => {
const errorMessages: TemplateResult[] = []
const validationErrorTypes: ValidationErrorType[] | undefined =
this.validation?.[`${this.validationPrefix}.${originalIndex}.${propertyName}`]
if (validationErrorTypes !== undefined && validationErrorTypes.length !== 0) {
if (validationErrorTypes.includes(ValidationErrorType.Missing)) {
errorMessages.push(html`${ptTr(LOC_INVALID_VALUE_MISSING)}`)
}
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)}`)
}
if (validationErrorTypes.includes(ValidationErrorType.Duplicate)) {
errorMessages.push(html`${ptTr(LOC_INVALID_VALUE_DUPLICATE)}`)
}
return html`<div id="${inputId}-feedback" class="invalid-feedback">${errorMessages}</div>`
} else {
return nothing
}
}
_updatePropertyFromValue = (event: Event,
propertyName: string,
propertySchema: CellDataSchema,
rowId: number): void => {
const target = event.target as (TagsInputElement | HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)
const value = (target)
? (target instanceof HTMLInputElement && target.type === 'checkbox')
? !!(target.checked)
: target.value
: undefined
if (value === undefined) {
this.logger.warn('Could not update property : Target or value was undefined')
return
}
const rowById = this._rowsById.find(rowById => rowById._id === rowId)
if (!rowById) {
this.logger.warn(`Could not update property : Did not find a property named '${propertyName}' in row '${rowId}'`)
return
}
switch (propertySchema.default?.constructor) {
case Array:
if (value.constructor === Array || !propertySchema.separator) {
rowById.row[propertyName] = value
} else {
rowById.row[propertyName] = (value as string)
.split(propertySchema.separator)
}
break
default:
rowById.row[propertyName] = value
break
}
this.rows = this._rowsById.map(rowById => rowById.row)
this.requestUpdate('rows')
this.requestUpdate('_rowsById')
this.dispatchEvent(new CustomEvent('update', { detail: this.rows }))
}
}