Open cdevos-purse opened 7 hours ago
Hi there !
I'm trying to component-test an input field that would be decorated by a connector for final-form.
However, it looks like a final-form function ( subscribe ) is call during mount for no reason.
subscribe
Here's the code for my test
import { test, expect } from '@sand4rt/experimental-ct-web'; import { ControlledInput } from './ControlledInput.element'; import { createForm } from './final-form/Form'; test.describe('Controlled input', () => { test('render props', async ({ mount }) => { const form = createForm({ initialValues: { value: '' }, onSubmit: () => {}, }); const component = await mount(ControlledInput, { props: { label: 'Label', descriptiveText: 'Descriptive text', form: form as any, name: 'value', }, }); await expect(component).toContainText('Label'); await expect(component).toContainText('Descriptive text'); }); });
That's the component itself
import {css, html, LitElement, PropertyValues} from 'lit'; import {customElement, property, state} from 'lit/decorators.js'; import { InputType, PaymentFormFieldValueFormatter, } from './types'; import {FieldState, FormApi} from 'final-form'; import {FinalFormController} from './final-form/RegisterFieldDecorator'; function showValidState(state: FieldState<Record<string, any>[string]>) { if (!state.dirty){ return null; } if(!state.valid){ return false; } if(state.valid){ return true; } return state.active; } @customElement('controlled-input') export class ControlledInput extends LitElement { @state() inhibateValidation = false; @property() // @ts-expect-error form!: FormApi; @property() shell = false; @property() label: string | undefined = undefined; @property() descriptiveText: string | undefined = undefined; @property() placeholder: string | undefined = undefined; @property() type: InputType = 'text'; @property() name?: string; @property({type: 'string', reflect: true}) validationMode: 'onblur' | 'onchange' = 'onchange'; @property() formatter: PaymentFormFieldValueFormatter = (v: string | null) => v; // @ts-expect-error #controller: FinalFormController<any>; protected override firstUpdated(_changedProperties: PropertyValues) { super.firstUpdated(_changedProperties); try { this.#controller = new FinalFormController( this, this.form, this.formatter ); } catch (e) { console.log(e); } } protected override updated(_changedProperties: PropertyValues) { super.updated(_changedProperties); if (_changedProperties.has('form')) { this.dispatchEvent( new CustomEvent('onregister', {detail: this.#controller.form}) ); } } static override get styles() { return css` [....] `; } override render() { console.log({controller : this.#controller}); if (!this.#controller) return html``; console.log({controller : this.#controller}); const {register, form} = this.#controller; const state = form.getFieldState(this.name ?? ''); const dataValid = showValidState(state ?? {} as any); const ariaInvalid = !state?.active && state?.dirty && !!state?.error; const afterText = (ariaInvalid && state?.error) || this.descriptiveText; const descriptiveText = html` <p class="${dataValid ? 'valid' : ''} ${state?.dirty && state?.error ? 'error' : ''}" > ${afterText} </p> `; if (this.shell) { return html` <div class="basic-container"> <input type="hidden" ${register(this.name ?? '')} /> <label for="${this.id}">${this.label}</label> <span id="${this.id}" class="${this.className} ${state?.active ? 'focused' : ''}" aria-invalid="${ariaInvalid}" data-valid="${dataValid}" > <slot></slot> </span> ${descriptiveText} </div> `; } if (this.type === 'checkbox') { return html` <div class="inline-container"> <input id="${this.id}" class="${this.className} ${state?.active ? 'focused' : ''}" placeholder="${this.placeholder}" type="checkbox" aria-invalid="${ariaInvalid}" data-valid="${dataValid}" ${register(this.name ?? '')} /> <label for="${this.id}"> ${afterText} </label> </div> `; } // return html` <div class="basic-container"> <label for="${this.id}">${this.label}</label> <input id="${this.id}" class="${this.className} ${state?.active ? 'focused' : ''}" placeholder="${this.placeholder}" type="${this.type}" aria-invalid="${ariaInvalid}" data-valid="${dataValid}" ${register(this.name ?? '')} /> ${afterText} </div> `; } }
and that's the decorator
import { noChange, nothing, ReactiveController, ReactiveControllerHost, } from 'lit'; import { Directive, directive, ElementPart, PartInfo, PartType, } from 'lit/directive.js'; import { FieldConfig, FormApi, FormSubscription, formSubscriptionItems, Unsubscribe, } from 'final-form'; import {PaymentFormFieldValueFormatter} from '../types'; export type {Config} from 'final-form'; const allFormSubscriptionItems = formSubscriptionItems.reduce( (acc, item) => ((acc[item as keyof FormSubscription] = true), acc), {} as FormSubscription ); export class FinalFormController<FormValues> implements ReactiveController { #host: ReactiveControllerHost; #unsubscribe: Unsubscribe | null = null; form: FormApi<FormValues>; formatter?: PaymentFormFieldValueFormatter; // https://final-form.org/docs/final-form/types/Config constructor( host: ReactiveControllerHost, formApi: FormApi<FormValues>, formatter?: PaymentFormFieldValueFormatter ) { this.form = formApi; this.formatter = formatter; (this.#host = host).addController(this); } hostConnected() { try { this.#unsubscribe = this.form.subscribe(() => { this.#host.requestUpdate(); }, allFormSubscriptionItems); } catch (e){ console.warn("Subscribe failed for some reason",e); } } hostUpdate() {} hostDisconnected() { this.#unsubscribe?.(); } // https://final-form.org/docs/final-form/types/FieldConfig register = <K extends keyof FormValues>( name: K, fieldConfig?: FieldConfig<FormValues[K]> ) => { console.log(`Registering ${name}`); try { return registerDirective(this.form, name, fieldConfig, this.formatter); } catch (e){ console.warn(e); throw e; } }; } class RegisterDirective extends Directive { #registered = false; constructor(partInfo: PartInfo) { super(partInfo); if (partInfo.type !== PartType.ELEMENT) { throw new Error( 'The `register` directive must be used in the `element` attribute' ); } } override update( part: ElementPart, [form, name, fieldConfig, formatter]: Parameters<this['render']> ) { if (!this.#registered) { form.registerField( name, (fieldState) => { const {blur, change, focus, value} = fieldState; const el = part.element as HTMLInputElement | HTMLSelectElement; el.name = String(name); if (!this.#registered) { el.addEventListener('blur', () => blur()); el.addEventListener('input', (event) => { if (el.type === 'checkbox') { change((event.target as HTMLInputElement).checked); } else { let newValue = (event.target as HTMLInputElement).value; if (!event.type.includes('deleteContent') && formatter) { newValue = formatter(newValue) ?? ''; } change(newValue); } }); el.addEventListener('focus', () => focus()); } // initial values sync if (el.type === 'checkbox') { (el as HTMLInputElement).checked = value === true; } else { el.value = value === undefined ? '' : value; } }, {value: true}, fieldConfig ); this.#registered = true; } return noChange; } // Can't get generics carried over from directive call render( _form: FormApi<any>, _name: PropertyKey, _fieldConfig?: FieldConfig<any>, _flormatter?: PaymentFormFieldValueFormatter ) { return nothing; } } const registerDirective = directive(RegisterDirective);
The decorator is working fine in a browser (largely inspired by https://github.com/lit/lit/discussions/2489#discussioncomment-6105401)
I'm definitely not expert neither in playwright nor lit-elements, but i don't see why the form.subscribe function would be called on mount by this piece of code
form.subscribe
Any idea? Cheers
Hi there !
I'm trying to component-test an input field that would be decorated by a connector for final-form.
However, it looks like a final-form function (
subscribe
) is call during mount for no reason.Here's the code for my test
That's the component itself
and that's the decorator
The decorator is working fine in a browser (largely inspired by https://github.com/lit/lit/discussions/2489#discussioncomment-6105401)
I'm definitely not expert neither in playwright nor lit-elements, but i don't see why the
form.subscribe
function would be called on mount by this piece of codeAny idea? Cheers