Feat/sidecar components (#1578)
* Added a new eslint plugin in TypeScript for Effect components * Fixed edge cases * Fixed lint * Fix eslint --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
1
packages/eslint-plugin-twenty-ts/.gitignore
vendored
Normal file
1
packages/eslint-plugin-twenty-ts/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
dist/
|
||||
5
packages/eslint-plugin-twenty-ts/index.ts
Normal file
5
packages/eslint-plugin-twenty-ts/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
"effect-components": require("./src/rules/effect-components"),
|
||||
},
|
||||
};
|
||||
7
packages/eslint-plugin-twenty-ts/jest.config.js
Normal file
7
packages/eslint-plugin-twenty-ts/jest.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
|
||||
"moduleDirectories": ["node_modules"]
|
||||
};
|
||||
35
packages/eslint-plugin-twenty-ts/package.json
Normal file
35
packages/eslint-plugin-twenty-ts/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "eslint-plugin-twenty-ts",
|
||||
"version": "1.0.1",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build": "rimraf ./dist && tsc --outDir ./dist"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||
"@typescript-eslint/parser": "^6.7.0",
|
||||
"@typescript-eslint/rule-tester": "^6.7.0",
|
||||
"@typescript-eslint/utils": "^6.7.0",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-standard-with-typescript": "^39.0.0",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^28.1.3",
|
||||
"prettier": "^3.0.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
||||
114
packages/eslint-plugin-twenty-ts/src/rules/effect-components.ts
Normal file
114
packages/eslint-plugin-twenty-ts/src/rules/effect-components.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { TSESTree, ESLintUtils } from "@typescript-eslint/utils";
|
||||
|
||||
const createRule = ESLintUtils.RuleCreator((name) => `https://docs.twenty.com`);
|
||||
|
||||
function checkIsPascalCase(input: string): boolean {
|
||||
const pascalCaseRegex = /^(?:\p{Uppercase_Letter}\p{Letter}*)+$/u;
|
||||
|
||||
return pascalCaseRegex.test(input);
|
||||
}
|
||||
|
||||
const effectComponentsRule = createRule({
|
||||
create(context) {
|
||||
const checkThatNodeIsEffectComponent = (node: TSESTree.FunctionDeclaration | TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression) => {
|
||||
const isPascalCase = checkIsPascalCase(node.id?.name ?? "");
|
||||
|
||||
if(!isPascalCase) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isReturningFragmentOrNull = (
|
||||
// Direct return of JSX fragment, e.g., () => <></>
|
||||
(node.body.type === 'JSXFragment' && node.body.children.length === 0) ||
|
||||
// Direct return of null, e.g., () => null
|
||||
(node.body.type === 'Literal' && node.body.value === null) ||
|
||||
// Return JSX fragment or null from block
|
||||
(node.body.type === 'BlockStatement' &&
|
||||
node.body.body.some(statement =>
|
||||
statement.type === 'ReturnStatement' &&
|
||||
(
|
||||
// Empty JSX fragment return, e.g., return <></>;
|
||||
(statement.argument?.type === 'JSXFragment' && statement.argument.children.length === 0) ||
|
||||
// Empty React.Fragment return, e.g., return <React.Fragment></React.Fragment>;
|
||||
(statement.argument?.type === 'JSXElement' &&
|
||||
statement.argument.openingElement.name.type === 'JSXIdentifier' &&
|
||||
statement.argument.openingElement.name.name === 'React.Fragment' &&
|
||||
statement.argument.children.length === 0) ||
|
||||
// Literal null return, e.g., return null;
|
||||
(statement.argument?.type === 'Literal' && statement.argument.value === null)
|
||||
)
|
||||
))
|
||||
);
|
||||
|
||||
const hasEffectSuffix = node.id?.name.endsWith("Effect");
|
||||
|
||||
const hasEffectSuffixButIsNotEffectComponent = hasEffectSuffix && !isReturningFragmentOrNull
|
||||
const isEffectComponentButDoesNotHaveEffectSuffix = !hasEffectSuffix && isReturningFragmentOrNull;
|
||||
|
||||
if(isEffectComponentButDoesNotHaveEffectSuffix) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "effectSuffix",
|
||||
data: {
|
||||
componentName: node.id?.name,
|
||||
},
|
||||
fix(fixer) {
|
||||
if (node.id) {
|
||||
return fixer.replaceText(
|
||||
node.id,
|
||||
node.id?.name + "Effect",
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
} else if(hasEffectSuffixButIsNotEffectComponent) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "noEffectSuffix",
|
||||
data: {
|
||||
componentName: node.id?.name,
|
||||
},
|
||||
fix(fixer) {
|
||||
if (node.id) {
|
||||
return fixer.replaceText(
|
||||
node.id,
|
||||
node.id?.name.replace("Effect", ""),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ArrowFunctionExpression: checkThatNodeIsEffectComponent,
|
||||
FunctionDeclaration: checkThatNodeIsEffectComponent,
|
||||
FunctionExpression: checkThatNodeIsEffectComponent,
|
||||
};
|
||||
},
|
||||
name: "effect-components",
|
||||
meta: {
|
||||
docs: {
|
||||
description:
|
||||
"Effect components should end with the Effect suffix. This rule checks only components that are in PascalCase and that return a JSX fragment or null. Any renderProps or camelCase components are ignored.",
|
||||
},
|
||||
messages: {
|
||||
effectSuffix:
|
||||
"Effect component {{ componentName }} should end with the Effect suffix.",
|
||||
noEffectSuffix:
|
||||
"Component {{ componentName }} shouldn't end with the Effect suffix because it doesn't return a JSX fragment or null.",
|
||||
},
|
||||
type: "suggestion",
|
||||
schema: [],
|
||||
fixable: "code",
|
||||
},
|
||||
defaultOptions: [],
|
||||
});
|
||||
|
||||
module.exports = effectComponentsRule;
|
||||
|
||||
export default effectComponentsRule;
|
||||
97
packages/eslint-plugin-twenty-ts/src/tests/all.spec.ts
Normal file
97
packages/eslint-plugin-twenty-ts/src/tests/all.spec.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { RuleTester } from "@typescript-eslint/rule-tester";
|
||||
|
||||
import effectComponentsRule from "../rules/effect-components";
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ruleTester.run("effect-components", effectComponentsRule, {
|
||||
valid: [
|
||||
{
|
||||
code: `function TestComponentEffect() {
|
||||
return <></>;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `function TestComponent() {
|
||||
return <div></div>;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `export function useUpdateEffect() {
|
||||
return null;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `export function useUpdateEffect() {
|
||||
return <></>;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `function TestComponent() {
|
||||
return <><div></div></>;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `function TestComponentEffect() {
|
||||
return null;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `function TestComponentEffect() {
|
||||
useEffect(() => {}, []);
|
||||
|
||||
return null;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `function TestComponentEffect() {
|
||||
useEffect(() => {}, []);
|
||||
|
||||
return <></>;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `const TestComponentEffect = () => {
|
||||
useEffect(() => {}, []);
|
||||
|
||||
return <></>;
|
||||
}`,
|
||||
},
|
||||
{
|
||||
code: `const TestComponentEffect = () => {
|
||||
useEffect(() => {}, []);
|
||||
|
||||
return null;
|
||||
}`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "function TestComponent() { return <></>; }",
|
||||
output: 'function TestComponentEffect() { return <></>; }',
|
||||
errors: [
|
||||
{
|
||||
messageId: "effectSuffix",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "function TestComponentEffect() { return <><div></div></>; }",
|
||||
output: 'function TestComponent() { return <><div></div></>; }',
|
||||
errors: [
|
||||
{
|
||||
messageId: "noEffectSuffix",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
1
packages/eslint-plugin-twenty-ts/src/tests/file.ts
Normal file
1
packages/eslint-plugin-twenty-ts/src/tests/file.ts
Normal file
@ -0,0 +1 @@
|
||||
// Required by typescript-eslint https://typescript-eslint.io/packages/rule-tester#type-aware-testing
|
||||
1
packages/eslint-plugin-twenty-ts/src/tests/react.tsx
Normal file
1
packages/eslint-plugin-twenty-ts/src/tests/react.tsx
Normal file
@ -0,0 +1 @@
|
||||
// Required by typescript-eslint https://typescript-eslint.io/packages/rule-tester#type-aware-testing
|
||||
6
packages/eslint-plugin-twenty-ts/src/tests/tsconfig.json
Normal file
6
packages/eslint-plugin-twenty-ts/src/tests/tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
},
|
||||
"include": ["./file.ts", "./react.tsx"]
|
||||
}
|
||||
14
packages/eslint-plugin-twenty-ts/tsconfig.json
Normal file
14
packages/eslint-plugin-twenty-ts/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and in clude compatible library declarations. */
|
||||
"module": "Node16", /* Specify what module code is generated. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
|
||||
"moduleResolution": "Node16",
|
||||
}
|
||||
}
|
||||
3815
packages/eslint-plugin-twenty-ts/yarn.lock
Normal file
3815
packages/eslint-plugin-twenty-ts/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user