test: cover current behavior
This commit is contained in:
parent
8af38226b4
commit
ec2033fe26
7 changed files with 464 additions and 1 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { WebComponent } from '../src/WebComponent.js'
|
import { WebComponent } from '../src/WebComponent.js'
|
||||||
|
import { html } from '../src/html.js'
|
||||||
|
|
||||||
let componentUnderTest
|
let componentUnderTest
|
||||||
|
|
||||||
|
|
@ -27,3 +28,251 @@ describe('WebComponent', () => {
|
||||||
expect(() => assignToReadonly()).toThrowError()
|
expect(() => assignToReadonly()).toThrowError()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a one-off custom element for `Class` under a unique tag,
|
||||||
|
* appends an instance to the document (triggering connectedCallback),
|
||||||
|
* and returns it. Remove it with `el.remove()` to trigger disconnect.
|
||||||
|
*/
|
||||||
|
let tagSeq = 0
|
||||||
|
function mount(Class, attributes = {}) {
|
||||||
|
const tag = `wc-test-${tagSeq++}`
|
||||||
|
window.customElements.define(tag, Class)
|
||||||
|
const el = document.createElement(tag)
|
||||||
|
for (const [name, value] of Object.entries(attributes)) {
|
||||||
|
el.setAttribute(name, value)
|
||||||
|
}
|
||||||
|
document.body.appendChild(el)
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('observedAttributes', () => {
|
||||||
|
it('is derived from static props as kebab-case names', () => {
|
||||||
|
class Observed extends WebComponent {
|
||||||
|
static props = { myName: 'World', isActive: false }
|
||||||
|
}
|
||||||
|
expect(Observed.observedAttributes).toEqual(['my-name', 'is-active'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is empty when no static props are declared', () => {
|
||||||
|
class Bare extends WebComponent {}
|
||||||
|
expect(Bare.observedAttributes).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('life-cycle hooks', () => {
|
||||||
|
it('calls onInit when connected, then afterViewInit after first render', () => {
|
||||||
|
const order = []
|
||||||
|
class Hooked extends WebComponent {
|
||||||
|
onInit() {
|
||||||
|
order.push('onInit')
|
||||||
|
}
|
||||||
|
afterViewInit() {
|
||||||
|
order.push('afterViewInit')
|
||||||
|
}
|
||||||
|
get template() {
|
||||||
|
return `<span>hi</span>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mount(Hooked)
|
||||||
|
expect(order).toEqual(['onInit', 'afterViewInit'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the template into the DOM on connect', () => {
|
||||||
|
class Rendered extends WebComponent {
|
||||||
|
get template() {
|
||||||
|
return `<h1>Hello</h1>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(Rendered)
|
||||||
|
expect(el.querySelector('h1')?.textContent).toBe('Hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onDestroy when disconnected', () => {
|
||||||
|
const onDestroy = vi.fn()
|
||||||
|
class Destroyed extends WebComponent {
|
||||||
|
onDestroy() {
|
||||||
|
onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(Destroyed)
|
||||||
|
el.remove()
|
||||||
|
expect(onDestroy).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('render()', () => {
|
||||||
|
it('renders a string template into innerHTML', () => {
|
||||||
|
class StringTemplate extends WebComponent {
|
||||||
|
get template() {
|
||||||
|
return `<p class="from-string">yo</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(StringTemplate)
|
||||||
|
expect(el.querySelector('p.from-string')?.textContent).toBe('yo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders an object (html) template into real DOM nodes', () => {
|
||||||
|
class ObjectTemplate extends WebComponent {
|
||||||
|
get template() {
|
||||||
|
return html`<p class="from-html">yo</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(ObjectTemplate)
|
||||||
|
const p = el.querySelector('p.from-html')
|
||||||
|
expect(p).not.toBeNull()
|
||||||
|
expect(p.textContent).toBe('yo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be called manually to re-render unobserved state', () => {
|
||||||
|
class Manual extends WebComponent {
|
||||||
|
_count = 0
|
||||||
|
get template() {
|
||||||
|
return `<span>${this._count}</span>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(Manual)
|
||||||
|
expect(el.querySelector('span').textContent).toBe('0')
|
||||||
|
el._count = 5
|
||||||
|
el.render()
|
||||||
|
expect(el.querySelector('span').textContent).toBe('5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be overridden to use a custom rendering approach', () => {
|
||||||
|
class Custom extends WebComponent {
|
||||||
|
render() {
|
||||||
|
this.innerHTML = '<em>custom</em>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(Custom)
|
||||||
|
expect(el.querySelector('em')?.textContent).toBe('custom')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('styling', () => {
|
||||||
|
it('applies a style object to an element via the html template', () => {
|
||||||
|
class StyleObject extends WebComponent {
|
||||||
|
get template() {
|
||||||
|
return html`<div style=${{ color: 'red', padding: '1em' }}>x</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(StyleObject)
|
||||||
|
const div = el.querySelector('div')
|
||||||
|
expect(div.style.color).toBe('red')
|
||||||
|
expect(div.style.padding).toBe('1em')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adopts a static styles stylesheet in the shadow root', () => {
|
||||||
|
class ShadowStyled extends WebComponent {
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
static styles = `p { color: red; }`
|
||||||
|
get template() {
|
||||||
|
return html`<p>hi</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(ShadowStyled)
|
||||||
|
expect(el.shadowRoot.adoptedStyleSheets).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shadow DOM', () => {
|
||||||
|
it('renders into a shadow root when shadowRootInit is set', () => {
|
||||||
|
class Shadowed extends WebComponent {
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
get template() {
|
||||||
|
return html`<p>in shadow</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(Shadowed)
|
||||||
|
expect(el.shadowRoot).not.toBeNull()
|
||||||
|
expect(el.shadowRoot.querySelector('p')?.textContent).toBe('in shadow')
|
||||||
|
// light DOM stays empty
|
||||||
|
expect(el.querySelector('p')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders into the light DOM by default', () => {
|
||||||
|
class Light extends WebComponent {
|
||||||
|
get template() {
|
||||||
|
return html`<p>in light</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(Light)
|
||||||
|
expect(el.shadowRoot).toBeNull()
|
||||||
|
expect(el.querySelector('p')?.textContent).toBe('in light')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive-prop contract as documented on the docs site
|
||||||
|
* (guides/usage, guides/prop-access, guides/life-cycle-hooks).
|
||||||
|
*
|
||||||
|
* These are marked `it.fails` because the current implementation cannot
|
||||||
|
* construct any component that declares `static props`: the constructor
|
||||||
|
* reflects each default with `setAttribute`, which synchronously fires
|
||||||
|
* `attributeChangedCallback` -> `#handleUpdateProp` before the `#props`
|
||||||
|
* proxy is assigned, throwing a TypeError. This is the Phase-0 correctness
|
||||||
|
* work called out at the top of the README (no attribute writes in the
|
||||||
|
* constructor; empty-string / removed-attribute handling). When those
|
||||||
|
* fixes land, flip these back to `it` — vitest will flag each one that
|
||||||
|
* starts passing.
|
||||||
|
*/
|
||||||
|
describe('reactive props (documented contract, pending Phase 0 fixes)', () => {
|
||||||
|
class HelloWorld extends WebComponent {
|
||||||
|
static props = { myName: 'World', count: 0, active: false, user: { a: 1 } }
|
||||||
|
get template() {
|
||||||
|
return `<h1>Hello ${this.props.myName}</h1>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it.fails('reflects static props defaults onto attributes', () => {
|
||||||
|
const el = mount(HelloWorld)
|
||||||
|
expect(el.getAttribute('my-name')).toBe('World')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.fails('exposes camelCase props with their declared types', () => {
|
||||||
|
const el = mount(HelloWorld)
|
||||||
|
expect(el.props.myName).toBe('World')
|
||||||
|
expect(el.props.count).toBe(0)
|
||||||
|
expect(el.props.active).toBe(false)
|
||||||
|
expect(el.props.user).toEqual({ a: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.fails('re-renders and updates props when an attribute changes', () => {
|
||||||
|
const el = mount(HelloWorld)
|
||||||
|
el.setAttribute('my-name', 'Ayo')
|
||||||
|
expect(el.props.myName).toBe('Ayo')
|
||||||
|
expect(el.querySelector('h1').textContent).toBe('Hello Ayo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.fails('reflects prop writes back to the attribute (props === setAttribute)', () => {
|
||||||
|
const el = mount(HelloWorld)
|
||||||
|
el.props.myName = 'Ayo'
|
||||||
|
expect(el.getAttribute('my-name')).toBe('Ayo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.fails('preserves the declared type when updating a number prop', () => {
|
||||||
|
const el = mount(HelloWorld)
|
||||||
|
el.setAttribute('count', '5')
|
||||||
|
expect(el.props.count).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.fails('fires onChanges with property/previousValue/currentValue', () => {
|
||||||
|
const changes = []
|
||||||
|
class WithChanges extends WebComponent {
|
||||||
|
static props = { myName: 'World' }
|
||||||
|
onChanges(change) {
|
||||||
|
changes.push(change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(WithChanges)
|
||||||
|
el.setAttribute('my-name', 'Ayo')
|
||||||
|
expect(changes.at(-1)).toMatchObject({
|
||||||
|
property: 'my-name',
|
||||||
|
currentValue: 'Ayo',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
28
test/exports.test.mjs
Normal file
28
test/exports.test.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import * as main from '../src/index.js'
|
||||||
|
import * as utils from '../src/utils/index.js'
|
||||||
|
|
||||||
|
describe('main exports', () => {
|
||||||
|
it('exposes WebComponent and html', () => {
|
||||||
|
expect(typeof main.WebComponent).toBe('function')
|
||||||
|
expect(typeof main.html).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('WebComponent extends HTMLElement', () => {
|
||||||
|
expect(main.WebComponent.prototype).toBeInstanceOf(HTMLElement)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('utils exports', () => {
|
||||||
|
it('exposes every documented utility', () => {
|
||||||
|
for (const name of [
|
||||||
|
'serialize',
|
||||||
|
'deserialize',
|
||||||
|
'getCamelCase',
|
||||||
|
'getKebabCase',
|
||||||
|
'createElement',
|
||||||
|
]) {
|
||||||
|
expect(typeof utils[name], name).toBe('function')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
49
test/html.test.mjs
Normal file
49
test/html.test.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { html } from '../src/html.js'
|
||||||
|
|
||||||
|
describe('html tagged template', () => {
|
||||||
|
it('produces a {type, props, children} vnode', () => {
|
||||||
|
const vnode = html`<p>hi</p>`
|
||||||
|
expect(vnode).toMatchObject({ type: 'p', children: ['hi'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads static attributes into props', () => {
|
||||||
|
const vnode = html`<a href="/x">y</a>`
|
||||||
|
expect(vnode.props).toEqual({ href: '/x' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads interpolated attribute values into props', () => {
|
||||||
|
const href = '/dynamic'
|
||||||
|
const vnode = html`<a href=${href}>y</a>`
|
||||||
|
expect(vnode.props.href).toBe('/dynamic')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats bare attributes as boolean true', () => {
|
||||||
|
const vnode = html`<input disabled />`
|
||||||
|
expect(vnode.type).toBe('input')
|
||||||
|
expect(vnode.props).toEqual({ disabled: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports spread props', () => {
|
||||||
|
const vnode = html`<a ...${{ href: '/x', title: 't' }}>y</a>`
|
||||||
|
expect(vnode.props).toMatchObject({ href: '/x', title: 't' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests child vnodes', () => {
|
||||||
|
const vnode = html`<ul><li>a</li></ul>`
|
||||||
|
expect(vnode.type).toBe('ul')
|
||||||
|
expect(vnode.children[0]).toMatchObject({ type: 'li', children: ['a'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interleaves text and interpolated values as children', () => {
|
||||||
|
const vnode = html`<p>Hello ${'World'}</p>`
|
||||||
|
expect(vnode.type).toBe('p')
|
||||||
|
expect(vnode.children.join('')).toBe('Hello World')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an array for multiple root nodes', () => {
|
||||||
|
const vnodes = html`<p>a</p><p>b</p>`
|
||||||
|
expect(Array.isArray(vnodes)).toBe(true)
|
||||||
|
expect(vnodes).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
35
test/utils/case-conversion.test.mjs
Normal file
35
test/utils/case-conversion.test.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { getCamelCase } from '../../src/utils/get-camel-case.mjs'
|
||||||
|
import { getKebabCase } from '../../src/utils/get-kebab-case.mjs'
|
||||||
|
|
||||||
|
describe('getKebabCase', () => {
|
||||||
|
test('converts camelCase to kebab-case', () => {
|
||||||
|
expect(getKebabCase('myName')).toBe('my-name')
|
||||||
|
expect(getKebabCase('isActive')).toBe('is-active')
|
||||||
|
expect(getKebabCase('dataFooBar')).toBe('data-foo-bar')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves single-word names unchanged', () => {
|
||||||
|
expect(getKebabCase('emotion')).toBe('emotion')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getCamelCase', () => {
|
||||||
|
test('converts kebab-case to camelCase', () => {
|
||||||
|
expect(getCamelCase('my-name')).toBe('myName')
|
||||||
|
expect(getCamelCase('is-active')).toBe('isActive')
|
||||||
|
expect(getCamelCase('data-foo-bar')).toBe('dataFooBar')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves single-word names unchanged', () => {
|
||||||
|
expect(getCamelCase('emotion')).toBe('emotion')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('case conversion round-trip', () => {
|
||||||
|
test('camel -> kebab -> camel is stable', () => {
|
||||||
|
for (const name of ['myName', 'isActive', 'dataFooBar', 'emotion']) {
|
||||||
|
expect(getCamelCase(getKebabCase(name))).toBe(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
61
test/utils/create-element.test.mjs
Normal file
61
test/utils/create-element.test.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createElement } from '../../src/utils/create-element.mjs'
|
||||||
|
import { html } from '../../src/html.js'
|
||||||
|
|
||||||
|
describe('createElement', () => {
|
||||||
|
it('turns a string leaf into a text node', () => {
|
||||||
|
const node = createElement('hello')
|
||||||
|
expect(node.nodeType).toBe(Node.TEXT_NODE)
|
||||||
|
expect(node.textContent).toBe('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds an element from a vnode', () => {
|
||||||
|
const el = createElement({ type: 'p', props: null, children: ['hi'] })
|
||||||
|
expect(el.tagName).toBe('P')
|
||||||
|
expect(el.textContent).toBe('hi')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('assigns known DOM properties directly', () => {
|
||||||
|
const el = createElement({ type: 'div', props: { id: 'foo' }, children: [] })
|
||||||
|
expect(el.id).toBe('foo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to setAttribute for unknown props', () => {
|
||||||
|
const el = createElement({ type: 'div', props: { 'data-x': 'y' }, children: [] })
|
||||||
|
expect(el.getAttribute('data-x')).toBe('y')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies style objects', () => {
|
||||||
|
const el = createElement(html`<div style=${{ color: 'red', padding: '1em' }}>x</div>`)
|
||||||
|
expect(el.style.color).toBe('red')
|
||||||
|
expect(el.style.padding).toBe('1em')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches event handlers passed as props', () => {
|
||||||
|
const onClick = vi.fn()
|
||||||
|
const btn = createElement(html`<button onClick=${onClick}>hey</button>`)
|
||||||
|
btn.click()
|
||||||
|
expect(onClick).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends nested element and text children', () => {
|
||||||
|
const el = createElement(html`<ul><li>a</li><li>b</li></ul>`)
|
||||||
|
expect(el.tagName).toBe('UL')
|
||||||
|
expect(el.querySelectorAll('li')).toHaveLength(2)
|
||||||
|
expect(el.textContent).toBe('ab')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps an array of vnodes in a document fragment', () => {
|
||||||
|
const frag = createElement([
|
||||||
|
{ type: 'p', props: null, children: ['a'] },
|
||||||
|
{ type: 'p', props: null, children: ['b'] },
|
||||||
|
])
|
||||||
|
expect(frag.nodeType).toBe(Node.DOCUMENT_FRAGMENT_NODE)
|
||||||
|
expect(frag.childNodes).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a multi-root html template as a fragment', () => {
|
||||||
|
const frag = createElement(html`<p>a</p><p>b</p>`)
|
||||||
|
expect(frag.querySelectorAll('p')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
39
test/utils/deserialize.test.mjs
Normal file
39
test/utils/deserialize.test.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { serialize } from '../../src/utils/serialize.mjs'
|
||||||
|
import { deserialize } from '../../src/utils/deserialize.mjs'
|
||||||
|
|
||||||
|
describe('deserialize', () => {
|
||||||
|
test('parses number strings', () => {
|
||||||
|
expect(deserialize('3', 'number')).toBe(3)
|
||||||
|
expect(deserialize('-4.5', 'number')).toBe(-4.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parses boolean strings', () => {
|
||||||
|
expect(deserialize('true', 'boolean')).toBe(true)
|
||||||
|
expect(deserialize('false', 'boolean')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parses object strings', () => {
|
||||||
|
expect(deserialize('{"hello":"world"}', 'object')).toEqual({ hello: 'world' })
|
||||||
|
expect(deserialize('[1,2,3]', 'object')).toEqual([1, 2, 3])
|
||||||
|
expect(deserialize('null', 'object')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes strings through untouched', () => {
|
||||||
|
expect(deserialize('World', 'string')).toBe('World')
|
||||||
|
// an unknown/default type is treated as a plain string
|
||||||
|
expect(deserialize('World', 'symbol')).toBe('World')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('round-trips values through serialize', () => {
|
||||||
|
const cases = [
|
||||||
|
[3, 'number'],
|
||||||
|
[false, 'boolean'],
|
||||||
|
[{ a: 1, b: [2, 3] }, 'object'],
|
||||||
|
['hello', 'string'],
|
||||||
|
]
|
||||||
|
for (const [value, type] of cases) {
|
||||||
|
expect(deserialize(serialize(value), type)).toEqual(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -13,6 +13,8 @@ export default defineConfig({
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['html', 'text'],
|
reporter: ['html', 'text'],
|
||||||
include: ['src'],
|
include: ['src'],
|
||||||
|
// barrel files only re-export; they carry no logic to cover
|
||||||
|
exclude: ['**/index.js'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue