// SPDX-FileCopyrightText: 2024 Mehdi Benadel // // SPDX-License-Identifier: AGPL-3.0-only import { css, html, LitElement, 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' // 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') // FIXME: use LivechatElement instead of LitElement? If so, be carefull about createRenderRoot export class DynamicTableFormElement extends LitElement { @property({ attribute: false }) public header: { [key: string]: { colName: TemplateResult, description: TemplateResult } } = {} @property({ attribute: false }) public schema: { [key: string]: CellDataSchema } = {} @property({ attribute: false }) public rows: { [key: string]: DynamicTableAcceptedTypes }[] = [] @state() public _rowsById: { _id: number; row: { [key: string]: DynamicTableAcceptedTypes } }[] = [] @property({ attribute: false }) public formName: string = '' @state() private _lastRowId = 1 @property({ attribute: false }) private _colOrder: string[] = [] static styles = css` table { table-layout: fixed; text-align: center; } table td, table th { word-wrap:break-word; vertical-align: top; padding: 5px 7px; } table tbody > :nth-child(odd) { background-color: var(--greySecondaryBackgroundColor); } button { padding: 2px; } .dynamic-table-add-row { background-color: var(--bs-green); } .dynamic-table-remove-row { background-color: var(--bs-orange); } `; protected createRenderRoot = () => { if (document.head.querySelector(`style[data-tagname="${this.tagName}"]`)) { return this; } const style = document.createElement("style"); style.innerHTML = DynamicTableFormElement.styles.toString(); style.setAttribute("data-tagname", this.tagName); document.head.append(style); return this } // fixes situations when list has been reinitialized or changed outside of CustomElement private _updateLastRowId = () => { for (let rowById of this._rowsById) { this._lastRowId = Math.max(this._lastRowId, rowById._id + 1); } } private _getDefaultRow = () : { [key: string]: DynamicTableAcceptedTypes } => { this._updateLastRowId() return Object.fromEntries([...Object.entries(this.schema).map((entry) => [entry[0], entry[1].default ?? ''])]) } private _addRow = () => { let newRow = this._getDefaultRow() this._rowsById.push({_id:this._lastRowId++, row: newRow}) this.rows.push(newRow) this.requestUpdate('rows') this.dispatchEvent(new CustomEvent('update', { detail: this.rows })) } private _removeRow = (rowId: number) => { let 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.dispatchEvent(new CustomEvent('update', { detail: this.rows })) } render = () => { const inputId = `peertube-livechat-${this.formName.replace(/_/g, '-')}-table` this._updateLastRowId() this._rowsById.filter(rowById => this.rows.includes(rowById.row)) for (let row of this.rows) { if (this._rowsById.filter(rowById => rowById.row === row).length == 0) { this._rowsById.push({_id: this._lastRowId++, row }) } } return html` ${this._renderHeader()} ${repeat(this._rowsById, (rowById) => rowById._id, this._renderDataRow)} ${this._renderFooter()}
` } private _renderHeader = () => { if (this._colOrder.length !== Object.keys(this.header).length) { this._colOrder = this._colOrder.filter(key => Object.keys(this.header).includes(key)) this._colOrder.push(...Object.keys(this.header).filter(key => !this._colOrder.includes(key))) } return html` ${Object.entries(this.header).sort(([k1,_1], [k2,_2]) => this._colOrder.indexOf(k1) - this._colOrder.indexOf(k2)) .map(([k,v]) => this._renderHeaderCell(v))} ` } private _renderHeaderCell = (headerCellData: { colName: TemplateResult, description: TemplateResult }) => { return html`
${headerCellData.colName}
` } private _renderDataRow = (rowData: { _id: number; row: {[key: string]: DynamicTableAcceptedTypes} }) => { const inputId = `peertube-livechat-${this.formName.replace(/_/g, '-')}-row-${rowData._id}` return html` ${Object.entries(rowData.row).filter(([k, v]) => k != '_id') .sort(([k1,_1], [k2,_2]) => this._colOrder.indexOf(k1) - this._colOrder.indexOf(k2)) .map((data) => this.renderDataCell(data, rowData._id))} ` } private _renderFooter = () => { return html` ${Object.values(this.header).map(() => html``)} ` } renderDataCell = (property: [string, DynamicTableAcceptedTypes], rowId: number) => { const [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 (propertyValue.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': formElement = this._renderInput(rowId, inputId, inputName, propertyName, propertySchema, (propertyValue as Array).join(propertySchema.separator ?? ',')) break case 'textarea': formElement = this._renderTextarea(rowId, inputId, inputName, propertyName, propertySchema, (propertyValue as Array).join(propertySchema.separator ?? ',')) break } } if (!formElement) { console.warn(`value type '${propertyValue.constructor}' is incompatible` + `with field type '${propertySchema.inputType}' for form entry '${propertyName.toString()}'.`) } return html`${formElement}` } _renderInput = (rowId: number, inputId: string, inputName: string, propertyName: string, propertySchema: CellDataSchema, propertyValue: string) => { 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) => { return html`` } _renderCheckbox = (rowId: number, inputId: string, inputName: string, propertyName: string, propertySchema: CellDataSchema, propertyValue: boolean) => { 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) => { return html`` } _updatePropertyFromValue = (event: Event, propertyName: string, propertySchema: CellDataSchema, rowId: number) => { let target = event?.target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) let value = (target && target instanceof HTMLInputElement && target.type == "checkbox") ? !!(target?.checked) : target?.value if (value !== undefined) { for (let rowById of this._rowsById) { if (rowById._id === rowId) { switch (rowById.row[propertyName].constructor) { case Array: rowById.row[propertyName] = (value as string).split(propertySchema.separator ?? ',') default: rowById.row[propertyName] = value } 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`) } } }