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
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 lit/lit#2489 (comment))
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
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 lit/lit#2489 (comment))
I'm definitely not expert neither in playwright nor lit-elements, but i don't see why the
form.subscribefunction would be called on mount by this piece of codeAny idea?
Cheers