Skip to content

Commit

Permalink
fix(components): prefer mjml enum type over CSSProperties (#85)
Browse files Browse the repository at this point in the history
In the previous version enum was being pre-empted by CSSProperties. This resulted in some values not passing type checking when using validationLevel: "strict". Fixes #81
  • Loading branch information
IanEdington authored Feb 2, 2023
1 parent d422621 commit e8befa5
Show file tree
Hide file tree
Showing 15 changed files with 191 additions and 69 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
export const ATTRIBUTES_TO_USE_CSSProperties_WITH = new Set([
"color",
"textDecoration",
"textTransform",

"border",
"borderRadius",
"borderColor",
"borderStyle",

"backgroundColor",
"backgroundPosition",
"backgroundSize",
]);

/**
* Converts an mjml type definition into a typescript type definition
* Handles boolean, integer, enum, and
* This is used to generate the types on the React <> mjml binding component.
*/
export function getPropTypeFromMjmlAttributeType(
attribute: string,
mjmlAttributeType: string
): string {
if (mjmlAttributeType === "boolean") {
return "boolean";
}
if (mjmlAttributeType === "integer") {
return "number";
}
// e.g. "vertical-align": "enum(top,bottom,middle)"
if (mjmlAttributeType.startsWith("enum(")) {
return transformEnumType(mjmlAttributeType);
}
if (ATTRIBUTES_TO_USE_CSSProperties_WITH.has(attribute)) {
// When possible prefer using the CSSProperties definitions over the
// less helpful "string" or "string | number" type definitions.
return `React.CSSProperties["${attribute}"]`;
}
if (
mjmlAttributeType.startsWith("unit") &&
mjmlAttributeType.includes("px")
) {
return "string | number";
}
return "string";
}

/**
* Converts an mjml enum type definition into a typescript string literal type.
* Strings like `"enum(a,b,c)"` become `"a" | "b" | "c"`.
* This is used to generate the types on the React <> mjml binding component.
*/
function transformEnumType(mjmlAttributeType: string): string {
return (
mjmlAttributeType
.match(/\(.*\)/)?.[0]
?.slice(1, -1)
.split(",")
.map((str) => '"' + str + '"')
.join(" | ") ?? "unknown"
);
}
60 changes: 5 additions & 55 deletions scripts/generate-mjml-react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import camelCase from "lodash.camelcase";
import upperFirst from "lodash.upperfirst";
import * as path from "path";

import { getPropTypeFromMjmlAttributeType } from "./generate-mjml-react-utils/getPropTypeFromMjmlAttributeType";

const MJML_REACT_DIR = "src";

const UTILS_FILE = "utils";
Expand All @@ -23,7 +25,7 @@ const GENERATED_HEADER_TSX = `
*/
`;

interface IMjmlComponent {
export interface IMjmlComponent {
componentName: string;
allowedAttributes?: Record<string, string>;
defaultAttributes?: Record<string, string>;
Expand Down Expand Up @@ -52,30 +54,14 @@ const MJML_COMPONENT_NAMES = MJML_COMPONENTS_TO_GENERATE.map(
(component) => component.componentName
);

const ATTRIBUTES_TO_USE_CSSProperties_WITH = new Set([
"color",
"textAlign",
"verticalAlign",
"textDecoration",
"textTransform",

"border",
"borderRadius",
"borderColor",
"borderStyle",

"backgroundColor",
"backgroundPosition",
"backgroundRepeat",
"backgroundSize",
]);

const TYPE_OVERRIDE: { [componentName: string]: { [prop: string]: string } } = {
mjml: { owa: "string", lang: "string" },
"mj-style": { inline: "boolean" },
"mj-class": { name: "string" },
"mj-table": { cellspacing: "string", cellpadding: "string" },
"mj-selector": { path: "string" },
"mj-section": { fullWidth: "boolean" },
"mj-wrapper": { fullWidth: "boolean" },
"mj-html-attribute": { name: "string" },
"mj-include": { path: "string" },
"mj-breakpoint": { width: "string" },
Expand Down Expand Up @@ -117,42 +103,6 @@ const ALLOW_ANY_PROPERTY = new Set(
)
);

function getPropTypeFromMjmlAttributeType(
attribute: string,
mjmlAttributeType: string
): string {
if (attribute === "fullWidth") {
return "boolean";
}
if (ATTRIBUTES_TO_USE_CSSProperties_WITH.has(attribute)) {
return `React.CSSProperties["${attribute}"]`;
}
if (
mjmlAttributeType.startsWith("unit") &&
mjmlAttributeType.includes("px")
) {
return "string | number";
}
if (mjmlAttributeType === "boolean") {
return "boolean";
}
if (mjmlAttributeType === "integer") {
return "number";
}
// e.g. "vertical-align": "enum(top,bottom,middle)"
if (mjmlAttributeType.startsWith("enum")) {
return (
mjmlAttributeType
.match(/\(.*\)/)?.[0]
?.slice(1, -1)
.split(",")
.map((str) => "'" + str + "'")
.join(" | ") ?? "unknown"
);
}
return "string";
}

function buildTypesForComponent(mjmlComponent: IMjmlComponent): string {
const {
componentName,
Expand Down
4 changes: 2 additions & 2 deletions src/mjml/MjmlButton.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/mjml/MjmlColumn.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/mjml/MjmlGroup.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/mjml/MjmlHero.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/mjml/MjmlSection.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/mjml/MjmlSocial.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/mjml/MjmlSocialElement.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/mjml/MjmlTable.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/mjml/MjmlText.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/mjml/MjmlWrapper.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions test/__mockData__/mockMjmlReactTestData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ export const mockMjmlReactTestData: MockComponentData = {
),
expectedMjml: `<mj-section full-width="full-width" padding-top="10px" css-class="first-section">Content</mj-section>`,
},
{
// @ts-expect-error invalid textAlign prop for test purposes
mjmlReact: <MjmlSection textAlign="start">Content</MjmlSection>,
expectedMjml: '<mj-section text-align="start">Content</mj-section>',
},
],
MjmlColumn: [
{
Expand Down
104 changes: 104 additions & 0 deletions test/generate-mjml-react/getPropTypeFromMjmlAttributeType.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// import camelCase from "lodash.camelcase";
import _ from "lodash";

import { IMjmlComponent } from "../../scripts/generate-mjml-react";
import {
ATTRIBUTES_TO_USE_CSSProperties_WITH,
getPropTypeFromMjmlAttributeType,
} from "../../scripts/generate-mjml-react-utils/getPropTypeFromMjmlAttributeType";

describe("getPropTypeFromMjmlAttributeType", () => {
test.each`
mjmlAttributeType | reactType
${"boolean"} | ${"boolean"}
${"color"} | ${"string"}
${"integer"} | ${"number"}
${"string"} | ${"string"}
${"unit(px)"} | ${"string | number"}
${"unit(px,%)"} | ${"string | number"}
${"unit(px,%){1,4}"} | ${"string | number"}
${"unit(px,%,)"} | ${"string | number"}
${"unit(px,auto)"} | ${"string | number"}
${"unitWithNegative(px,em)"} | ${"string | number"}
${"enum(file-start)"} | ${'"file-start"'}
${"enum(auto,fixed)"} | ${'"auto" | "fixed"'}
${"enum(auto,fixed,initial,inherit)"} | ${'"auto" | "fixed" | "initial" | "inherit"'}
${"enum(full-width,false,)"} | ${'"full-width" | "false" | ""'}
`(
"transforms mjmlType: $mjmlAttributeType into React type: $reactType",
({ mjmlAttributeType, reactType }) => {
expect(getPropTypeFromMjmlAttributeType("n/a", mjmlAttributeType)).toBe(
reactType
);
}
);

describe("use CSSProperties for useful mjml types", () => {
const presetCoreComponents: Array<IMjmlComponent> =
require("mjml-preset-core").components;

const allMjmlTypesGroupedByAttribute = presetCoreComponents.reduce(
(map, component) => {
if (component.allowedAttributes !== undefined) {
for (const [key, value] of Object.entries(
component.allowedAttributes
)) {
if (map.get(key) === undefined) {
map.set(key, new Set());
}
map.get(key)?.add(value);
}
}
return map;
},
new Map<string, Set<string>>()
);

const val = _.flatten(
Array.from(ATTRIBUTES_TO_USE_CSSProperties_WITH).map((attribute) => {
const allMjmlTypes = allMjmlTypesGroupedByAttribute.get(
_.kebabCase(attribute)
);
if (allMjmlTypes === undefined) {
// place a debug statement here to view the full allMjmlTypesGroupedByAttribute
throw Error(`allMjmlTypes must be defined for ${attribute}`);
}
return Array.from(allMjmlTypes).map(
(mjmlType: string) =>
({
mjmlType,
attribute,
} as { mjmlType: string; attribute: string })
);
})
);

test.each(val)(
"mjml attribute $attribute with type $mjmlType becomes a CSSProperty",
({ mjmlType, attribute }) => {
expect(getPropTypeFromMjmlAttributeType(attribute, mjmlType)).toContain(
"CSSProperties"
);
}
);

const cssAttribute = Array.from(ATTRIBUTES_TO_USE_CSSProperties_WITH)[0]!;

test("CSSProperties preempt 'unit'", () => {
expect(
getPropTypeFromMjmlAttributeType(cssAttribute, "unit(px)")
).toContain("CSSProperties");
});
test("CSSProperties does not preempt 'boolean', 'integer', or 'enum'", () => {
expect(
getPropTypeFromMjmlAttributeType(cssAttribute, "boolean")
).toContain("boolean");
expect(
getPropTypeFromMjmlAttributeType(cssAttribute, "integer")
).toContain("number");
expect(
getPropTypeFromMjmlAttributeType(cssAttribute, "enum(x,y,z)")
).toContain('"x"');
});
});
});
2 changes: 1 addition & 1 deletion test/mjml-props.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";

import * as mjmlComponents from "../src";
import * as mjmlComponents from "../src/mjml";
import { renderToMjml } from "../src/utils/renderToMjml";

/**
Expand Down

0 comments on commit e8befa5

Please sign in to comment.