Fixes #20354 ## Problem Front component form events currently expose serialized form state through a sandbox-specific event shape, such as `event.detail.value` and `event.detail.checked`. That works for examples that explicitly read `event.detail`, but it is surprising for app authors writing standard React form handlers: ```tsx onChange={(event) => { setValue(event.target.value); }} Internal app code already has to defend against multiple possible shapes: // Values may live on e.detail.value, e.value, or e.target.value. This suggests the sandbox event shape is leaking into userland. Solution This change keeps the existing event.detail behavior, but also syncs serialized event target properties back onto the remote element before dispatching the event. That means both styles work: // Existing sandbox-specific style event.detail.value; // Standard React style event.target.value; The same applies to checked, files, scroll/media target properties, and similar serialized target state. What Changed Added a shared helper to apply serialized event target properties onto the remote element. Updated generated remote element event configs to dispatch serialized events through a custom event config. Updated the remote-dom element generator so regenerated files preserve this behavior. Updated Storybook form-event examples to use standard React event target reads. Added/updated Storybook coverage for input, checkbox, textarea, select, submit, and caret preservation flows. Validation Ran git diff --check Ran a targeted TypeScript error scan for the changed front component renderer files Manually verified the Storybook FrontComponent/EventForwarding form event story locally: text input updates state checkbox updates state submit reflects the updated JSON Note: local Storybook verification on Windows required temporary local build/cache fixes that are not included in this PR, to keep this change focused on front component event behavior. --------- Co-authored-by: Charles Bochet <charles@twenty.com>
447 lines
13 KiB
TypeScript
447 lines
13 KiB
TypeScript
import {
|
|
type CodeBlockWriter,
|
|
type Project,
|
|
type SourceFile,
|
|
VariableDeclarationKind,
|
|
} from 'ts-morph';
|
|
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import {
|
|
CUSTOM_ELEMENT_NAMES,
|
|
INTERNAL_ELEMENT_CLASSES,
|
|
TYPE_NAMES,
|
|
} from './constants';
|
|
import { type ComponentSchema, type PropertySchema } from './schemas';
|
|
import { schemaTypeToConstructor } from './utils';
|
|
|
|
const schemaTypeToTs = (type: PropertySchema['type']): string => type;
|
|
|
|
const generatePropertyEntries = (
|
|
properties: Record<string, PropertySchema>,
|
|
): string[] =>
|
|
Object.entries(properties).map(([name, schema]) => {
|
|
const optional = schema.optional ? '?' : '';
|
|
return `'${name}'${optional}: ${schemaTypeToTs(schema.type)}`;
|
|
});
|
|
|
|
const writePropertyEntries = (
|
|
writer: CodeBlockWriter,
|
|
properties: Record<string, PropertySchema>,
|
|
): void => {
|
|
for (const [name, schema] of Object.entries(properties)) {
|
|
writer.writeLine(
|
|
`'${name}': { type: ${schemaTypeToConstructor(schema.type)} },`,
|
|
);
|
|
}
|
|
};
|
|
|
|
const getSpecificProperties = (
|
|
component: ComponentSchema,
|
|
commonPropertyNames: Set<string>,
|
|
): Record<string, PropertySchema> => {
|
|
const specific: Record<string, PropertySchema> = {};
|
|
for (const [name, schema] of Object.entries(component.properties)) {
|
|
if (!commonPropertyNames.has(name)) {
|
|
specific[name] = schema;
|
|
}
|
|
}
|
|
return specific;
|
|
};
|
|
|
|
const generateCommonPropertiesType = (
|
|
sourceFile: SourceFile,
|
|
commonProperties: Record<string, PropertySchema>,
|
|
): void => {
|
|
const entries = generatePropertyEntries(commonProperties);
|
|
|
|
sourceFile.addTypeAlias({
|
|
isExported: true,
|
|
name: TYPE_NAMES.COMMON_PROPERTIES,
|
|
type: (writer) => {
|
|
writer.block(() => {
|
|
for (const entry of entries) {
|
|
writer.writeLine(`${entry};`);
|
|
}
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
const generateCommonEventsType = (
|
|
sourceFile: SourceFile,
|
|
events: readonly string[],
|
|
): void => {
|
|
if (events.length === 0) {
|
|
return;
|
|
}
|
|
|
|
sourceFile.addTypeAlias({
|
|
isExported: true,
|
|
name: TYPE_NAMES.COMMON_EVENTS,
|
|
type: (writer) => {
|
|
writer.block(() => {
|
|
for (const event of events) {
|
|
writer.writeLine(
|
|
`${event}(event: RemoteEvent<SerializedEventData>): void;`,
|
|
);
|
|
}
|
|
});
|
|
},
|
|
});
|
|
|
|
sourceFile.addVariableStatement({
|
|
declarationKind: VariableDeclarationKind.Const,
|
|
declarations: [
|
|
{
|
|
name: TYPE_NAMES.COMMON_EVENTS_ARRAY,
|
|
initializer: (writer) => {
|
|
writer.write('[');
|
|
writer.newLine();
|
|
writer.indent(() => {
|
|
for (const event of events) {
|
|
writer.writeLine(`'${event}',`);
|
|
}
|
|
});
|
|
writer.write('] as const');
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
sourceFile.addVariableStatement({
|
|
declarationKind: VariableDeclarationKind.Const,
|
|
declarations: [
|
|
{
|
|
name: 'createSerializedEventConfig',
|
|
initializer: (writer) => {
|
|
writer.write(
|
|
'(eventType: string): RemoteElementEventListenerDefinition => (',
|
|
);
|
|
writer.block(() => {
|
|
writer.writeLine(
|
|
'dispatchEvent(this: Element, eventData: SerializedEventData) {',
|
|
);
|
|
writer.indent(() => {
|
|
writer.writeLine('applySerializedEventTargetProperties(');
|
|
writer.indent(() => {
|
|
writer.writeLine('this as unknown as Record<string, unknown>,');
|
|
writer.writeLine('eventData,');
|
|
});
|
|
writer.writeLine(');');
|
|
writer.blankLine();
|
|
writer.writeLine('return new CustomEvent(eventType, {');
|
|
writer.indent(() => {
|
|
writer.writeLine('detail: eventData,');
|
|
});
|
|
writer.writeLine('}) as RemoteEvent<SerializedEventData>;');
|
|
});
|
|
writer.writeLine('},');
|
|
});
|
|
writer.write(')');
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
sourceFile.addVariableStatement({
|
|
declarationKind: VariableDeclarationKind.Const,
|
|
declarations: [
|
|
{
|
|
name: 'HTML_COMMON_EVENTS_CONFIG',
|
|
initializer: (writer) => {
|
|
writer.writeLine('Object.fromEntries(');
|
|
writer.indent(() => {
|
|
writer.writeLine(
|
|
`${TYPE_NAMES.COMMON_EVENTS_ARRAY}.map((eventType) => [`,
|
|
);
|
|
writer.indent(() => {
|
|
writer.writeLine('eventType,');
|
|
writer.writeLine('createSerializedEventConfig(eventType),');
|
|
});
|
|
writer.writeLine(']),');
|
|
});
|
|
writer.write(
|
|
`) as RemoteElementEventListenersDefinition<${TYPE_NAMES.COMMON_EVENTS}>`,
|
|
);
|
|
},
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
const generateCommonPropertiesConfig = (
|
|
sourceFile: SourceFile,
|
|
commonProperties: Record<string, PropertySchema>,
|
|
): void => {
|
|
sourceFile.addVariableStatement({
|
|
declarationKind: VariableDeclarationKind.Const,
|
|
declarations: [
|
|
{
|
|
name: TYPE_NAMES.COMMON_PROPERTIES_CONFIG,
|
|
initializer: (writer) => {
|
|
writer.block(() => {
|
|
for (const [name, schema] of Object.entries(commonProperties)) {
|
|
writer.writeLine(
|
|
`'${name}': { type: ${schemaTypeToConstructor(schema.type)} },`,
|
|
);
|
|
}
|
|
});
|
|
},
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
const generateElementPropertyType = (
|
|
sourceFile: SourceFile,
|
|
component: ComponentSchema,
|
|
specificProperties: Record<string, PropertySchema>,
|
|
): void => {
|
|
const hasSpecificProps = Object.keys(specificProperties).length > 0;
|
|
|
|
if (hasSpecificProps) {
|
|
const entries = generatePropertyEntries(specificProperties);
|
|
sourceFile.addTypeAlias({
|
|
isExported: true,
|
|
name: `${component.name}Properties`,
|
|
type: (writer) => {
|
|
if (isDefined(component.htmlTag)) {
|
|
writer.write(`${TYPE_NAMES.COMMON_PROPERTIES} & `);
|
|
}
|
|
writer.block(() => {
|
|
for (const entry of entries) {
|
|
writer.writeLine(`${entry};`);
|
|
}
|
|
});
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const generateElementDefinition = (
|
|
sourceFile: SourceFile,
|
|
component: ComponentSchema,
|
|
specificProperties: Record<string, PropertySchema>,
|
|
commonEventNames: Set<string>,
|
|
shouldUseCommonHtmlPropertiesConfig: boolean,
|
|
): void => {
|
|
const isHtml = isDefined(component.htmlTag);
|
|
const hasCommonHtmlEvents = commonEventNames.size > 0 && isHtml;
|
|
const customEvents = component.events.filter(
|
|
(event) => !hasCommonHtmlEvents || !commonEventNames.has(event),
|
|
);
|
|
const hasEvents = hasCommonHtmlEvents || customEvents.length > 0;
|
|
const hasSpecificProps = Object.keys(specificProperties).length > 0;
|
|
const hasProps = Object.keys(component.properties).length > 0;
|
|
|
|
const propsType = hasSpecificProps
|
|
? `${component.name}Properties`
|
|
: hasProps && isHtml
|
|
? TYPE_NAMES.COMMON_PROPERTIES
|
|
: TYPE_NAMES.EMPTY_RECORD;
|
|
|
|
const customEventsInline = customEvents
|
|
.map((event) => `${event}(event: RemoteEvent<SerializedEventData>): void`)
|
|
.join('; ');
|
|
|
|
let eventsType: string = TYPE_NAMES.EMPTY_RECORD;
|
|
|
|
if (hasCommonHtmlEvents && customEvents.length > 0) {
|
|
eventsType = `${TYPE_NAMES.COMMON_EVENTS} & { ${customEventsInline} }`;
|
|
} else if (hasCommonHtmlEvents) {
|
|
eventsType = TYPE_NAMES.COMMON_EVENTS;
|
|
} else if (customEvents.length > 0) {
|
|
eventsType = `{ ${customEventsInline} }`;
|
|
}
|
|
|
|
sourceFile.addVariableStatement({
|
|
isExported: true,
|
|
declarationKind: VariableDeclarationKind.Const,
|
|
declarations: [
|
|
{
|
|
name: `${component.name}Element`,
|
|
initializer: (writer) => {
|
|
writer.write('createRemoteElement<');
|
|
writer.newLine();
|
|
writer.indent(() => {
|
|
writer.writeLine(`${propsType},`);
|
|
writer.writeLine('Record<string, never>,');
|
|
writer.writeLine(`${TYPE_NAMES.EMPTY_RECORD},`);
|
|
writer.write(eventsType);
|
|
});
|
|
writer.newLine();
|
|
writer.write('>');
|
|
|
|
const hasConfig = hasProps || hasEvents;
|
|
if (!hasConfig) {
|
|
writer.write('({})');
|
|
return;
|
|
}
|
|
|
|
writer.write('(');
|
|
writer.block(() => {
|
|
if (hasProps) {
|
|
if (hasSpecificProps && isHtml) {
|
|
writer.write('properties: ');
|
|
writer.block(() => {
|
|
writer.writeLine(
|
|
`...${TYPE_NAMES.COMMON_PROPERTIES_CONFIG},`,
|
|
);
|
|
writePropertyEntries(writer, specificProperties);
|
|
});
|
|
writer.write(',');
|
|
writer.newLine();
|
|
} else if (shouldUseCommonHtmlPropertiesConfig && isHtml) {
|
|
writer.write(
|
|
`properties: ${TYPE_NAMES.COMMON_PROPERTIES_CONFIG},`,
|
|
);
|
|
writer.newLine();
|
|
} else {
|
|
writer.write('properties: ');
|
|
writer.block(() => {
|
|
writePropertyEntries(writer, specificProperties);
|
|
});
|
|
writer.write(',');
|
|
writer.newLine();
|
|
}
|
|
}
|
|
if (hasEvents) {
|
|
writer.write('events: ');
|
|
writer.block(() => {
|
|
if (hasCommonHtmlEvents) {
|
|
writer.writeLine('...HTML_COMMON_EVENTS_CONFIG,');
|
|
}
|
|
|
|
for (const event of customEvents) {
|
|
writer.writeLine(
|
|
`'${event}': createSerializedEventConfig('${event}'),`,
|
|
);
|
|
}
|
|
});
|
|
writer.write(',');
|
|
writer.newLine();
|
|
}
|
|
});
|
|
writer.write(')');
|
|
},
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
const generateCustomElementRegistrations = (
|
|
sourceFile: SourceFile,
|
|
components: ComponentSchema[],
|
|
): void => {
|
|
for (const component of components) {
|
|
sourceFile.addStatements(
|
|
`customElements.define('${component.customElementName}', ${component.name}Element);`,
|
|
);
|
|
}
|
|
sourceFile.addStatements(
|
|
`customElements.define('${CUSTOM_ELEMENT_NAMES.ROOT}', ${INTERNAL_ELEMENT_CLASSES.ROOT});`,
|
|
);
|
|
sourceFile.addStatements(
|
|
`customElements.define('${CUSTOM_ELEMENT_NAMES.FRAGMENT}', ${INTERNAL_ELEMENT_CLASSES.FRAGMENT});`,
|
|
);
|
|
};
|
|
|
|
const generateTagNameMapDeclaration = (
|
|
sourceFile: SourceFile,
|
|
components: ComponentSchema[],
|
|
): void => {
|
|
sourceFile.addStatements((writer) => {
|
|
writer.writeLine('declare global {');
|
|
writer.indent(() => {
|
|
writer.writeLine('interface HTMLElementTagNameMap {');
|
|
writer.indent(() => {
|
|
for (const component of components) {
|
|
writer.writeLine(
|
|
`'${component.customElementName}': InstanceType<typeof ${component.name}Element>;`,
|
|
);
|
|
}
|
|
writer.writeLine(
|
|
`'${CUSTOM_ELEMENT_NAMES.ROOT}': InstanceType<typeof ${INTERNAL_ELEMENT_CLASSES.ROOT}>;`,
|
|
);
|
|
writer.writeLine(
|
|
`'${CUSTOM_ELEMENT_NAMES.FRAGMENT}': InstanceType<typeof ${INTERNAL_ELEMENT_CLASSES.FRAGMENT}>;`,
|
|
);
|
|
});
|
|
writer.writeLine('}');
|
|
});
|
|
writer.writeLine('}');
|
|
});
|
|
};
|
|
|
|
export const generateRemoteElements = (
|
|
project: Project,
|
|
components: ComponentSchema[],
|
|
commonProperties: Record<string, PropertySchema>,
|
|
commonEvents: readonly string[] = [],
|
|
): SourceFile => {
|
|
const sourceFile = project.createSourceFile('remote-elements.ts', '', {
|
|
overwrite: true,
|
|
});
|
|
|
|
const commonEventNames = new Set(commonEvents);
|
|
const shouldUseCommonHtmlPropertiesConfig =
|
|
Object.keys(commonProperties).length > 0;
|
|
|
|
sourceFile.addImportDeclaration({
|
|
moduleSpecifier: '@remote-dom/core/elements',
|
|
namedImports: [
|
|
'createRemoteElement',
|
|
INTERNAL_ELEMENT_CLASSES.ROOT,
|
|
INTERNAL_ELEMENT_CLASSES.FRAGMENT,
|
|
{ name: 'RemoteEvent', isTypeOnly: true },
|
|
{ name: 'RemoteElementEventListenerDefinition', isTypeOnly: true },
|
|
{ name: 'RemoteElementEventListenersDefinition', isTypeOnly: true },
|
|
],
|
|
});
|
|
|
|
sourceFile.addImportDeclaration({
|
|
moduleSpecifier: '@/constants/SerializedEventData',
|
|
namedImports: [
|
|
'applySerializedEventTargetProperties',
|
|
{ name: 'SerializedEventData', isTypeOnly: true },
|
|
],
|
|
});
|
|
|
|
const commonPropertyNames = new Set(Object.keys(commonProperties));
|
|
|
|
generateCommonPropertiesType(sourceFile, commonProperties);
|
|
|
|
if (commonEventNames.size > 0) {
|
|
generateCommonEventsType(sourceFile, commonEvents);
|
|
}
|
|
|
|
if (shouldUseCommonHtmlPropertiesConfig) {
|
|
generateCommonPropertiesConfig(sourceFile, commonProperties);
|
|
}
|
|
|
|
for (const component of components) {
|
|
const specificProperties = isDefined(component.htmlTag)
|
|
? getSpecificProperties(component, commonPropertyNames)
|
|
: component.properties;
|
|
|
|
generateElementPropertyType(sourceFile, component, specificProperties);
|
|
generateElementDefinition(
|
|
sourceFile,
|
|
component,
|
|
specificProperties,
|
|
commonEventNames,
|
|
shouldUseCommonHtmlPropertiesConfig,
|
|
);
|
|
}
|
|
|
|
generateCustomElementRegistrations(sourceFile, components);
|
|
|
|
sourceFile.addStatements(
|
|
`export { ${INTERNAL_ELEMENT_CLASSES.ROOT}, ${INTERNAL_ELEMENT_CLASSES.FRAGMENT} };`,
|
|
);
|
|
|
|
generateTagNameMapDeclaration(sourceFile, components);
|
|
|
|
return sourceFile;
|
|
};
|