Adding some LitElements including DynamicTableFormElement

This commit is contained in:
Mehdi Benadel 2024-05-13 02:14:22 +02:00
parent 2638e137b3
commit 0fe9fb4dca
8 changed files with 490 additions and 53 deletions

View File

@ -0,0 +1,42 @@
import { html, LitElement } from 'lit'
import { repeat } from 'lit-html/directives/repeat.js'
import { customElement, property } from 'lit/decorators.js'
@customElement('channel-configuration')
export class ChannelConfigurationElement extends LitElement {
@property()
public list: string[] = ["foo", "bar", "baz"]
@property()
public newEl: string = 'change_me'
createRenderRoot = () => {
return this
}
render() {
return html`
<ul>
${repeat(this.list, (el: string, index) => html`<li>${el}<button @click=${this._removeFromList(index)}>remove</button></li>`
)}
<li><input .value=${this.newEl}/><button @click=${this._addToList(this.newEl)}>add</button></li>
</ul>
`
}
private _addToList(newEl: string) {
return () => {
this.list.push(newEl)
this.requestUpdate('list')
}
}
private _removeFromList(index: number) {
return () => {
this.list.splice(index, 1)
this.requestUpdate('list')
}
}
}

View File

@ -0,0 +1,313 @@
import { html, LitElement, TemplateResult } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import { customElement, property, state } from 'lit/decorators.js'
type DynamicTableAcceptedTypes = number | string | boolean | Date
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 }
inputType?: DynamicTableAcceptedInputTypes
default?: DynamicTableAcceptedTypes
}
@customElement('dynamic-table-form')
export class DynamicTableFormElement extends LitElement {
@property({ attribute: false })
public header: { [key : string]: TemplateResult<1> } = {}
@property({ attribute: false })
public schema: { [key : string]: CellDataSchema } = {}
@property({ reflect: true })
public rows: { _id: number; [key : string]: DynamicTableAcceptedTypes }[] = []
@property({ attribute: false })
public formName: string = ''
@state()
private _lastRowId = 1
createRenderRoot = () => {
return this
}
private _getDefaultRow = () => {
return Object.fromEntries([...Object.entries(this.schema).map((entry) => [entry[0], entry[1].default ?? '']), ['_id', this._lastRowId++]])
}
private _addRow = () => {
this.rows.push(this._getDefaultRow())
this.requestUpdate('rows')
}
private _removeRow = (rowId: number) => {
this.rows = this.rows.filter((x) => x._id != rowId)
this.requestUpdate('rows')
}
render = () => {
const inputId = `peertube-livechat-${this.formName.replaceAll('_','-')}-table`
return html`
<div class="row mt-5">
<div class="col-12 col-lg-4 col-xl-3">
<h2>Bot command #1</h2>
<p>You can configure the bot to respond to commands. A command is a message starting with a "!", like for example "!help" that calls the "help" command. For more information about how to configure this feature, please refer to the documentation by clicking on the help button.</p>
<a href="https://livingston.frama.io/peertube-plugin-livechat/documentation/user/streamers/bot/commands/" target="_blank" title="Online help" class="orange-button peertube-button-link">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 4.233 4.233">
<path style="display:inline;opacity:.998;fill:none;fill-opacity:1;stroke:currentColor;stroke-width:.529167;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="M1.48 1.583V.86c0-.171.085-.31.19-.31h.893c.106 0 .19.139.19.31v.838c0 .171-.107.219-.19.284l-.404.314c-.136.106-.219.234-.221.489l-.003.247"></path>
<path style="display:inline;fill:currentColor;stroke-width:.235169" d="M1.67 3.429h.529v.597H1.67z"></path>
</svg>
</a>
</div>
<div class="col-12 col-lg-8 col-xl-9">
<table class="table table-striped table-hover table-sm" id=${inputId}>
${this._renderHeader()}
<tbody>
${repeat(this.rows, this._renderDataRow)}
</tbody>
<tfoot>
<tr><td><button @click=${this._addRow}>Add Row</button></td></tr>
</tfoot>
</table>
</div>
</div>
${JSON.stringify(this.rows)}
`
}
private _renderHeader = () => {
return html`<thead><tr><th scope="col">#</th>${Object.values(this.header).map(this._renderHeaderCell)}<th scope="col">Remove Row</th></tr></thead>`
}
private _renderHeaderCell = (headerCellData: TemplateResult<1> | any) => {
return html`<th scope="col">${headerCellData}</th>`
}
private _renderDataRow = (rowData: { _id: number; [key : string]: DynamicTableAcceptedTypes }) => {
if (!rowData._id) {
rowData._id = this._lastRowId++
}
const inputId = `peertube-livechat-${this.formName.replaceAll('_','-')}-row-${rowData._id}`
return html`<tr id=${inputId}><td class="form-group">${rowData._id}</td>${repeat(Object.entries(rowData).filter(([k,v]) => k != '_id'), (data) => this.renderDataCell(data, rowData._id))}<td class="form-group"><button @click=${() => this._removeRow(rowData._id)}>Remove</button></td></tr>`
}
renderDataCell = (property: [string, DynamicTableAcceptedTypes], rowId: number) => {
const [propertyName, propertyValue] = property
const propertySchema = this.schema[propertyName] ?? {}
let formElement
const inputName = `${this.formName.replaceAll('-','_')}_${propertyName.toString().replaceAll('-','_')}_${rowId}`
const inputId = `peertube-livechat-${this.formName.replaceAll('_','-')}-${propertyName.toString().replaceAll('_','-')}-${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 = html`<input
type=${propertySchema.inputType}
name=${inputName}
class="form-control"
id=${inputId}
min=${propertySchema?.min}
max=${propertySchema?.max}
minlength=${propertySchema?.minlength}
maxlength=${propertySchema?.maxlength}
@oninput=${(event: InputEvent) => this._updatePropertyFromValue(event, propertyName, rowId)}
.value=${propertyValue}
/>`
break
case 'textarea':
formElement = html`<textarea
name=${inputName}
class="form-control"
id=${inputId}
min=${propertySchema?.min}
max=${propertySchema?.max}
minlength=${propertySchema?.minlength}
maxlength=${propertySchema?.maxlength}
@oninput=${(event: InputEvent) => this._updatePropertyFromValue(event, propertyName, rowId)}
.value=${propertyValue}
></textarea>`
break
case 'select':
formElement = html`<select class="form-select" aria-label="Default select example">
<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>`
break
}
break
case Date:
switch (propertySchema.inputType) {
case undefined:
propertySchema.inputType = 'datetime'
case 'date':
case 'datetime':
case 'datetime-local':
case 'time':
formElement = html`<input
type=${propertySchema.inputType}
name=${inputName}
class="form-control"
id=${inputId}
min=${propertySchema?.min}
max=${propertySchema?.max}
minlength=${propertySchema?.minlength}
maxlength=${propertySchema?.maxlength}
@oninput=${(event: InputEvent) => this._updatePropertyFromValue(event, propertyName, rowId)}
.value=${propertyValue}
/>`
break
}
break
case Number:
switch (propertySchema.inputType) {
case undefined:
propertySchema.inputType = 'number'
case 'number':
case 'range':
formElement = html`<input
type=${propertySchema.inputType}
name=${inputName}
class="form-control"
id=${inputId}
min=${propertySchema?.min}
max=${propertySchema?.max}
minlength=${propertySchema?.minlength}
maxlength=${propertySchema?.maxlength}
@oninput=${(event: InputEvent) => this._updatePropertyFromValue(event, propertyName, rowId)}
.value=${propertyValue}
/>`
break
}
break
case Boolean:
switch (propertySchema.inputType) {
case undefined:
propertySchema.inputType = 'checkbox'
case 'checkbox':
formElement = html`<input
type="checkbox"
name=${inputName}
class="form-check-input"
id=${inputId}
@oninput=${(event: InputEvent) => this._updatePropertyFromValue(event, propertyName, rowId)}
value=""
?checked=${propertyValue}
/>`
break
}
break
}
if (!formElement) {
console.warn(`value type '${propertyValue.constructor}' is incompatible with field type '${propertySchema.inputType}' for form entry '${propertyName.toString()}'.`)
}
console.log
return html`<td class="form-group">${formElement}</td>`
}
_updatePropertyFromValue(event: InputEvent, propertyName: string, rowId : number) {
let target = event?.target as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)
if(target?.value) {
for(let row of this.rows) {
if(row._id === rowId) {
row[propertyName] = target?.value
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`)
}
}
}

View File

@ -74,18 +74,18 @@
<table class="col-12 col-lg-4 col-xl-3 forbidden_words_table">
<thead>
<tr>
<th scope="col">{{forbiddenWords}} <span class="form-group-description">{{forbiddenWordsDesc2}}</th>
<th scope="col">{{forbiddenWords}} <span class="form-group-description">{{forbiddenWordsDesc2}}</span></th>
<th scope="col">{{forbiddenWordsRegexp}} <span class="form-group-description">{{forbiddenWordsRegexpDesc}}</span></th>
<th scope="col">{{forbiddenWordsApplyToModerators}} <span class="form-group-description">{{forbiddenWordsApplyToModeratorsDesc}}</span></th>
<th scope="col">{{forbiddenWordsLabel}} <span class="form-group-description">{{forbiddenWordsLabelDesc}}</th>
<th scope="col">{{forbiddenWordsReason}} <span class="form-group-description">{{forbiddenWordsReasonDesc}}</th>
<th scope="col">{{forbiddenWordsComments}} <span class="form-group-description">{{forbiddenWordsCommentsDesc}}</th>
<th scope="col">Remove <span class="form-group-description">Remove Row</th>
<th scope="col">{{forbiddenWordsLabel}} <span class="form-group-description">{{forbiddenWordsLabelDesc}}</span></th>
<th scope="col">{{forbiddenWordsReason}} <span class="form-group-description">{{forbiddenWordsReasonDesc}}</span></th>
<th scope="col">{{forbiddenWordsComments}} <span class="form-group-description">{{forbiddenWordsCommentsDesc}}</span></th>
<th scope="col">Remove <span class="form-group-description">Remove Row</span></th>
</tr>
</thead>
{{#forbiddenWordsArray}}{{! iterating on forbiddenWordsArray to display N fields }}
<tbody>
<tr class="button.peertube-livechat-forbidden-words-row-{{fieldNumber}}">
<tr class="peertube-livechat-forbidden-words-row-{{fieldNumber}}">
<td>
{{! warning: don't add extra line break in textarea! }}
<textarea

View File

@ -5,11 +5,13 @@
import type { RegisterClientOptions } from '@peertube/peertube-types/client'
import { localizedHelpUrl } from '../../../utils/help'
import { helpButtonSVG } from '../../../videowatch/buttons'
import { vivifyConfigurationChannel, getConfigurationChannelViewData } from './logic/channel'
import { getConfigurationChannelViewData } from './logic/channel'
import { TemplateResult, html } from 'lit'
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
// Must use require for mustache, import seems buggy.
const Mustache = require('mustache')
import './DynamicTableFormElement'
import './ChannelConfigurationElement'
/**
* Renders the configuration settings page for a given channel,
@ -25,13 +27,92 @@ async function renderConfigurationChannel (
rootEl: HTMLElement
): Promise<TemplateResult> {
try {
const view = await getConfigurationChannelViewData(registerClientOptions, channelId)
const view : {[key: string] : any} = await getConfigurationChannelViewData(registerClientOptions, channelId)
await fillViewHelpButtons(registerClientOptions, view)
await fillLabels(registerClientOptions, view)
return html`${unsafeHTML(Mustache.render(MUSTACHE_CONFIGURATION_CHANNEL, view))}`
//await vivifyConfigurationChannel(registerClientOptions, rootEl, channelId)
await vivifyConfigurationChannel(registerClientOptions, rootEl, channelId)
let tableHeader = {
words: html`${view.forbiddenWords}<div data-toggle="tooltip" data-placement="bottom" data-html="true" title=${view.forbiddenWordsDesc}></div>`,
regex: html`${view.forbiddenWordsRegexp}<div data-toggle="tooltip" data-placement="bottom" data-html="true" title=${view.forbiddenWordsRegexpDesc}></div>`,
applyToModerators: html`${view.forbiddenWordsApplyToModerators}<div data-toggle="tooltip" data-placement="bottom" data-html="true" title=${view.forbiddenWordsApplyToModeratorsDesc}></div>`,
label: html`${view.forbiddenWordsLabel}<div data-toggle="tooltip" data-placement="bottom" data-html="true" title=${view.forbiddenWordsLabelDesc}></div>`,
reason: html`${view.forbiddenWordsReason}<div data-toggle="tooltip" data-placement="bottom" data-html="true" title=${view.forbiddenWordsReasonDesc}></div>`,
comments: html`${view.forbiddenWordsComments}<div data-toggle="tooltip" data-placement="bottom" data-html="true" title=${view.forbiddenWordsCommentsDesc}></div>`,
}
let tableSchema = {
words: {
inputType: 'text',
default: 'helloqwesad'
},
regex: {
inputType: 'text',
default: 'helloaxzca'
},
applyToModerators: {
inputType: 'checkbox',
default: true
},
label: {
inputType: 'text',
default: 'helloasx'
},
reason: {
inputType: 'select',
default: 'transphobia',
label: 'choose your poison',
options: {'racism': 'Racism', 'sexism': 'Sexism', 'transphobia': 'Transphobia', 'bigotry': 'Bigotry'}
},
comments: {
inputType: 'textarea',
default: `Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
culpa qui officia deserunt mollit anim id est laborum.`
},
}
let tableRows = [
{
words: 'teweqwst',
regex: 'tesdgst',
applyToModerators: false,
label: 'teswet',
reason: 'sexism',
comments: 'tsdaswest',
},
{
words: 'tedsadst',
regex: 'tezxccst',
applyToModerators: true,
label: 'tewest',
reason: 'racism',
comments: 'tesxzct',
},
{
words: 'tesadsdxst',
regex: 'dsfsdf',
applyToModerators: false,
label: 'tesdadst',
reason: 'bigotry',
comments: 'tsadest',
},
]
return html`${unsafeHTML(Mustache.render(MUSTACHE_CONFIGURATION_CHANNEL, view))}
<div class="container">
<channel-configuration></channel-configuration>
<dynamic-table-form
.header=${tableHeader}
.schema=${tableSchema}
.rows=${tableRows}
.formName=${'dynamic-table-form'}
>
</dynamic-table-form>
</div>${JSON.stringify(tableRows)}`
} catch (err: any) {
registerClientOptions.peertubeHelpers.notifier.error(err.toString())
return html``
@ -40,7 +121,7 @@ async function renderConfigurationChannel (
async function fillViewHelpButtons (
registerClientOptions: RegisterClientOptions,
view: any
view: {[key: string]: string}
): Promise<void> {
const title = await registerClientOptions.peertubeHelpers.translate(LOC_ONLINE_HELP)
@ -67,7 +148,7 @@ async function fillViewHelpButtons (
async function fillLabels (
registerClientOptions: RegisterClientOptions,
view: any
view: {[key: string] : string}
): Promise<void> {
const { peertubeHelpers } = registerClientOptions
view.title = await peertubeHelpers.translate(LOC_LIVECHAT_CONFIGURATION_CHANNEL_TITLE)

View File

@ -14,7 +14,7 @@ import { getBaseRoute } from '../../../../utils/uri'
async function getConfigurationChannelViewData (
registerClientOptions: RegisterClientOptions,
channelId: string
): Promise<Object> {
): Promise<{[key: string] : any}> {
if (!channelId || !/^\d+$/.test(channelId)) {
throw new Error('Missing or invalid channel id.')
}

View File

@ -1,29 +1,30 @@
{
"compilerOptions": {
"module": "es6",
"moduleResolution": "node",
"target": "es5",
"compilerOptions": {
"experimentalDecorators": true,
"module": "es2022",
"moduleResolution": "node",
"target": "es2022",
"allowJs": true,
"sourceMap": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"alwaysStrict": true, // should already be true because of strict:true
"noImplicitAny": true, // should already be true because of strict:true
"noImplicitThis": true, // should already be true because of strict:true
"noImplicitReturns": true,
"strictBindCallApply": true, // should already be true because of strict:true
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true, // Seems necessary for peertube types to work
"isolatedModules": true, // Needed by esbuild https://esbuild.github.io/content-types/#isolated-modules
"esModuleInterop": true, // Needed by esbuild https://esbuild.github.io/content-types/#es-module-interop
"sourceMap": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"alwaysStrict": true, // should already be true because of strict:true
"noImplicitAny": true, // should already be true because of strict:true
"noImplicitThis": true, // should already be true because of strict:true
"noImplicitReturns": true,
"strictBindCallApply": true, // should already be true because of strict:true
"noUnusedLocals": false,
"allowSyntheticDefaultImports": true, // Seems necessary for peertube types to work
"isolatedModules": true, // Needed by esbuild https://esbuild.github.io/content-types/#isolated-modules
"esModuleInterop": true, // Needed by esbuild https://esbuild.github.io/content-types/#es-module-interop
"outDir": "../dist/client",
"paths": {
"shared/*": ["../shared/*"]
}
},
"include": [
"./**/*",
"../shared/**/*"
],
"exclude": []
"paths": {
"shared/*": ["../shared/*"]
}
},
"include": [
"./**/*",
"../shared/**/*"
],
"exclude": []
}

28
package-lock.json generated
View File

@ -53,7 +53,7 @@
"stylelint-config-recommended-scss": "^5.0.1",
"stylelint-config-standard-scss": "^2.0.1",
"svgo": "^2.8.0",
"typescript": "^4.3.5",
"typescript": "^5.0.0",
"yaml": "^2.2.1"
},
"engines": {
@ -5448,9 +5448,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001596",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz",
"integrity": "sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==",
"version": "1.0.30001617",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001617.tgz",
"integrity": "sha512-mLyjzNI9I+Pix8zwcrpxEbGlfqOkF9kM3ptzmKNw5tizSyYwMe+nGLTqMK9cO+0E+Bh6TsBxNAaHWEM8xwSsmA==",
"funding": [
{
"type": "opencollective",
@ -12007,16 +12007,16 @@
"dev": true
},
"node_modules/typescript": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
"integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==",
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
"node": ">=14.17"
}
},
"node_modules/uid-safe": {
@ -16575,9 +16575,9 @@
}
},
"caniuse-lite": {
"version": "1.0.30001596",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz",
"integrity": "sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ=="
"version": "1.0.30001617",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001617.tgz",
"integrity": "sha512-mLyjzNI9I+Pix8zwcrpxEbGlfqOkF9kM3ptzmKNw5tizSyYwMe+nGLTqMK9cO+0E+Bh6TsBxNAaHWEM8xwSsmA=="
},
"chalk": {
"version": "2.4.2",
@ -21410,9 +21410,9 @@
"dev": true
},
"typescript": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
"integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==",
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true
},
"uid-safe": {

View File

@ -77,7 +77,7 @@
"stylelint-config-recommended-scss": "^5.0.1",
"stylelint-config-standard-scss": "^2.0.1",
"svgo": "^2.8.0",
"typescript": "^4.3.5",
"typescript": "^5.0.0",
"yaml": "^2.2.1"
},
"engine": {