// SPDX-FileCopyrightText: 2024 Mehdi Benadel // // SPDX-License-Identifier: AGPL-3.0-only /* eslint no-fallthrough: "off" */ 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 { LivechatElement } from './livechat' // This content comes from the file assets/images/plus-square.svg, from the Feather icons set https://feathericons.com/ const AddSVG: string = ` ` // This content comes from the file assets/images/x-square.svg, from the Feather icons set https://feathericons.com/ const RemoveSVG: string = ` ` type DynamicTableAcceptedTypes = number | string | boolean | Date | Array type DynamicTableAcceptedInputTypes = 'textarea' | 'select' | 'checkbox' | 'range' | 'color' | 'date' | 'datetime' | 'datetime-local' | 'email' | 'file' | 'image' | 'month' | 'number' | 'password' | 'tel' | 'text' | 'time' | 'url' | 'week' interface CellDataSchema { min?: number max?: number minlength?: number maxlength?: number size?: number label?: string options?: { [key: string]: string } datalist?: DynamicTableAcceptedTypes[] separator?: string inputType?: DynamicTableAcceptedInputTypes default?: DynamicTableAcceptedTypes } @customElement('livechat-dynamic-table-form') export class DynamicTableFormElement extends LivechatElement { @property({ attribute: false }) public header: { [key: string]: { colName: TemplateResult, description: TemplateResult } } = {} @property({ attribute: false }) public schema: { [key: string]: CellDataSchema } = {} @property({ attribute: false }) public rows: Array<{ [key: string]: DynamicTableAcceptedTypes }> = [] @state() public _rowsById: Array<{ _id: number, row: { [key: string]: DynamicTableAcceptedTypes } }> = [] @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 readonly _addRow = (): void => { const newRow = this._getDefaultRow() this._rowsById.push({ _id: this._lastRowId++, row: newRow }) this.rows.push(newRow) this.requestUpdate('rows') this.requestUpdate('_rowsById') this.dispatchEvent(new CustomEvent('update', { detail: this.rows })) } private readonly _removeRow = (rowId: number): void => { 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() 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 }) } } 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`
${this._renderHeader()} ${repeat(this._rowsById, (rowById) => rowById._id, this._renderDataRow)} ${this._renderFooter()}
` } private readonly _renderHeader = (): TemplateResult => { return html` ${Object.entries(this.header) .sort(([k1, _1], [k2, _2]) => this.columnOrder.indexOf(k1) - this.columnOrder.indexOf(k2)) .map(([_, v]) => this._renderHeaderCell(v))} ` } private readonly _renderHeaderCell = (headerCellData: { colName: TemplateResult description: TemplateResult }): TemplateResult => { return html`
${headerCellData.colName}
` } private readonly _renderDataRow = (rowData: { _id: number row: {[key: string]: DynamicTableAcceptedTypes} }): 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))} ` } private readonly _renderFooter = (): TemplateResult => { return html` ${Object.values(this.header).map(() => html``)} ` } renderDataCell = (property: [string, DynamicTableAcceptedTypes], rowId: number): TemplateResult => { let [propertyName, propertyValue] = property 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}` switch (propertySchema.default?.constructor) { case String: switch (propertySchema.inputType) { case undefined: propertySchema.inputType = 'text' 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 = this._renderInput(rowId, inputId, inputName, propertyName, propertySchema, propertyValue as string) break case 'textarea': formElement = this._renderTextarea(rowId, inputId, inputName, propertyName, propertySchema, propertyValue as string) break case 'select': formElement = this._renderSelect(rowId, inputId, inputName, propertyName, propertySchema, propertyValue as string) break } break case Date: switch (propertySchema.inputType) { case undefined: propertySchema.inputType = 'datetime' case 'date': case 'datetime': case 'datetime-local': case 'time': formElement = this._renderInput(rowId, inputId, inputName, propertyName, propertySchema, (propertyValue as Date).toISOString()) break } break case Number: switch (propertySchema.inputType) { case undefined: propertySchema.inputType = 'number' case 'number': case 'range': formElement = this._renderInput(rowId, inputId, inputName, propertyName, propertySchema, propertyValue as string) break } break case Boolean: switch (propertySchema.inputType) { case undefined: propertySchema.inputType = 'checkbox' case 'checkbox': formElement = this._renderCheckbox(rowId, inputId, inputName, propertyName, propertySchema, propertyValue as boolean) break } break case Array: switch (propertySchema.inputType) { case undefined: propertySchema.inputType = 'text' 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 = this._renderInput(rowId, inputId, inputName, propertyName, propertySchema, (propertyValue)?.join(propertySchema.separator ?? ',') ?? propertyValue ?? propertySchema.default ?? '') break case 'textarea': if (propertyValue.constructor !== Array) { propertyValue = (propertyValue) ? [propertyValue as (number | string)] : [] } formElement = this._renderTextarea(rowId, inputId, inputName, propertyName, propertySchema, (propertyValue)?.join(propertySchema.separator ?? ',') ?? propertyValue ?? propertySchema.default ?? '') break } } if (!formElement) { console.warn(`value type '${(propertyValue.constructor.toString())}' is incompatible` + `with field type '${propertySchema.inputType as string}' for form entry '${propertyName.toString()}'.`) } return html`${formElement}` } _renderInput = (rowId: number, inputId: string, inputName: string, propertyName: string, propertySchema: CellDataSchema, propertyValue: string): TemplateResult => { return html` this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)} .value=${propertyValue} /> ${(propertySchema.datalist) ? html` ${(propertySchema.datalist ?? []).map((value) => html`` : nothing} ` } _renderTextarea = (rowId: number, inputId: string, inputName: string, propertyName: string, propertySchema: CellDataSchema, propertyValue: string): TemplateResult => { return html`` } _renderCheckbox = (rowId: number, inputId: string, inputName: string, propertyName: string, propertySchema: CellDataSchema, propertyValue: boolean): TemplateResult => { return html` this._updatePropertyFromValue(event, propertyName, propertySchema, rowId)} .value=${propertyValue} ?checked=${propertyValue} />` } _renderSelect = (rowId: number, inputId: string, inputName: string, propertyName: string, propertySchema: CellDataSchema, propertyValue: string): TemplateResult => { return html`` } _updatePropertyFromValue = (event: Event, propertyName: string, propertySchema: CellDataSchema, rowId: number): undefined => { const target = event.target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) const value = (target) ? (target instanceof HTMLInputElement && target.type === 'checkbox') ? !!(target.checked) : target.value : undefined if (value !== undefined) { for (const rowById of this._rowsById) { if (rowById._id === rowId) { switch (propertySchema.default?.constructor) { case Array: 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 })) return } } console.warn(`Could not update property : Did not find a property named '${propertyName}' in row '${rowId}'`) } else { console.warn('Could not update property : Target or value was undefined') } } }