eslint-plugin-storybook
Advanced tools
| /** | ||
| * @fileoverview Interactions should be awaited | ||
| * @author Yann Braga | ||
| */ | ||
| 'use strict' | ||
| const { docsUrl, isPlayFunction } = require('../utils') | ||
| const { CATEGORY_ID } = require('../utils/constants') | ||
| const { | ||
| isCallExpression, | ||
| isMemberExpression, | ||
| isIdentifier, | ||
| isExpressionStatement, | ||
| isVariableDeclaration, | ||
| isVariableDeclarator, | ||
| isAwaitExpression, | ||
| } = require('../utils/ast') | ||
| //------------------------------------------------------------------------------ | ||
| // Rule Definition | ||
| //------------------------------------------------------------------------------ | ||
| /** | ||
| * @type {import('eslint').Rule.RuleModule} | ||
| */ | ||
| module.exports = { | ||
| meta: { | ||
| type: null, // `problem`, `suggestion`, or `layout` | ||
| docs: { | ||
| description: 'Interactions should be awaited', | ||
| category: CATEGORY_ID.ADDON_INTERACTIONS, | ||
| recommended: true, | ||
| recommendedConfig: 'error', // or 'warn' | ||
| url: docsUrl('await-interactions'), // URL to the documentation page for this rule | ||
| }, | ||
| messages: { | ||
| interactionShouldBeAwaited: 'Interaction should be awaited: {{method}}', | ||
| fixSuggestion: 'Add `await` to method', | ||
| }, | ||
| // fixable: 'code', // Or `code` or `whitespace` | ||
| }, | ||
| create(context) { | ||
| // variables should be defined here | ||
| //---------------------------------------------------------------------- | ||
| // Helpers | ||
| //---------------------------------------------------------------------- | ||
| // any helper functions should go here or else delete this section | ||
| const FUNCTIONS_TO_BE_AWAITED = [ | ||
| 'expect', | ||
| 'waitFor', | ||
| 'waitForElementToBeRemoved', | ||
| 'wait', | ||
| 'waitForElement', | ||
| 'waitForDomChange', | ||
| 'userEvent', | ||
| ] | ||
| /** | ||
| * @param {import('eslint').Rule.Node[]} body | ||
| */ | ||
| const getNonAwaitedCallExpressions = (body = []) => { | ||
| return body | ||
| .filter((b) => { | ||
| return isExpressionStatement(b) && isCallExpression(b.expression) | ||
| }) | ||
| .map((d) => d.expression) | ||
| } | ||
| /** | ||
| * @param {import('eslint').Rule.Node[]} body | ||
| */ | ||
| const getNonAwaitedInitializations = (body = []) => { | ||
| const initializations = body | ||
| .flatMap((b) => { | ||
| return ( | ||
| isVariableDeclaration(b) && | ||
| b.declarations | ||
| .filter((d) => isVariableDeclarator(d) && !isAwaitExpression(d.init)) | ||
| .map((d) => d.init) | ||
| ) | ||
| }) | ||
| .filter(Boolean) | ||
| return initializations | ||
| } | ||
| const getMethodThatShouldBeAwaited = (expression = {}) => { | ||
| const shouldAwait = (name) => { | ||
| console.log('should I await..', name) | ||
| return FUNCTIONS_TO_BE_AWAITED.includes(name) || name.startsWith('findBy') | ||
| } | ||
| if ( | ||
| isCallExpression(expression) && | ||
| isMemberExpression(expression.callee) && | ||
| isIdentifier(expression.callee.object) && | ||
| shouldAwait(expression.callee.object.name) | ||
| ) { | ||
| return expression.callee.object | ||
| } | ||
| if ( | ||
| isCallExpression(expression) && | ||
| isMemberExpression(expression.callee) && | ||
| isIdentifier(expression.callee.property) && | ||
| shouldAwait(expression.callee.property.name) | ||
| ) { | ||
| return expression.callee.property | ||
| } | ||
| return null | ||
| } | ||
| //---------------------------------------------------------------------- | ||
| // Public | ||
| //---------------------------------------------------------------------- | ||
| /** | ||
| * @param {import('eslint').Rule.Node} node | ||
| */ | ||
| let invocationsThatShouldBeAwaited = [] | ||
| return { | ||
| AssignmentExpression(node) { | ||
| if (!isExpressionStatement(node.parent)) { | ||
| return null | ||
| } | ||
| if (isPlayFunction(node)) { | ||
| const { right } = node | ||
| const expressionBody = (right.body && right.body.body) || [] | ||
| const callExpressions = [ | ||
| ...getNonAwaitedCallExpressions(expressionBody), | ||
| ...getNonAwaitedInitializations(expressionBody), | ||
| ] | ||
| callExpressions.forEach((expression) => { | ||
| const method = getMethodThatShouldBeAwaited(expression) | ||
| if (method) { | ||
| invocationsThatShouldBeAwaited.push(method) | ||
| } | ||
| }) | ||
| } | ||
| }, | ||
| 'Program:exit': function () { | ||
| if (invocationsThatShouldBeAwaited.length) { | ||
| invocationsThatShouldBeAwaited.forEach((node) => { | ||
| context.report({ | ||
| node, | ||
| messageId: 'interactionShouldBeAwaited', | ||
| data: { | ||
| method: node.name, | ||
| }, | ||
| // @TODO: make this auto-fixable. Currently it's pretty dumb so something like this can happen: | ||
| // canvas.findByText => canvas.await findByText | ||
| // instead of the correct: await canvas.findByText | ||
| // fix: function (fixer) { | ||
| // return fixer.insertTextBefore(node, 'await ') | ||
| // }, | ||
| // suggest: [ | ||
| // { | ||
| // messageId: 'fixSuggestion', | ||
| // fix: function (fixer) { | ||
| // return fixer.insertTextBefore(node, 'await ') | ||
| // }, | ||
| // }, | ||
| // ], | ||
| }) | ||
| }) | ||
| } | ||
| }, | ||
| } | ||
| }, | ||
| } |
| const { AST_NODE_TYPES, ASTUtils } = require('@typescript-eslint/experimental-utils') | ||
| const isNodeOfType = (nodeType) => (node) => node && node.type === nodeType | ||
| const isArrayExpression = isNodeOfType(AST_NODE_TYPES.ArrayExpression) | ||
| const isArrowFunctionExpression = isNodeOfType(AST_NODE_TYPES.ArrowFunctionExpression) | ||
| const isBlockStatement = isNodeOfType(AST_NODE_TYPES.BlockStatement) | ||
| const isCallExpression = isNodeOfType(AST_NODE_TYPES.CallExpression) | ||
| const isExpressionStatement = isNodeOfType(AST_NODE_TYPES.ExpressionStatement) | ||
| const isVariableDeclaration = isNodeOfType(AST_NODE_TYPES.VariableDeclaration) | ||
| const isAssignmentExpression = isNodeOfType(AST_NODE_TYPES.AssignmentExpression) | ||
| const isSequenceExpression = isNodeOfType(AST_NODE_TYPES.SequenceExpression) | ||
| const isImportDeclaration = isNodeOfType(AST_NODE_TYPES.ImportDeclaration) | ||
| const isImportDefaultSpecifier = isNodeOfType(AST_NODE_TYPES.ImportDefaultSpecifier) | ||
| const isImportNamespaceSpecifier = isNodeOfType(AST_NODE_TYPES.ImportNamespaceSpecifier) | ||
| const isImportSpecifier = isNodeOfType(AST_NODE_TYPES.ImportSpecifier) | ||
| const isJSXAttribute = isNodeOfType(AST_NODE_TYPES.JSXAttribute) | ||
| const isLiteral = isNodeOfType(AST_NODE_TYPES.Literal) | ||
| const isMemberExpression = isNodeOfType(AST_NODE_TYPES.MemberExpression) | ||
| const isNewExpression = isNodeOfType(AST_NODE_TYPES.NewExpression) | ||
| const isObjectExpression = isNodeOfType(AST_NODE_TYPES.ObjectExpression) | ||
| const isObjectPattern = isNodeOfType(AST_NODE_TYPES.ObjectPattern) | ||
| const isProperty = isNodeOfType(AST_NODE_TYPES.Property) | ||
| const isReturnStatement = isNodeOfType(AST_NODE_TYPES.ReturnStatement) | ||
| const isFunctionExpression = isNodeOfType(AST_NODE_TYPES.FunctionExpression) | ||
| module.exports = Object.assign( | ||
| { | ||
| isArrayExpression, | ||
| isArrowFunctionExpression, | ||
| isBlockStatement, | ||
| isCallExpression, | ||
| isExpressionStatement, | ||
| isVariableDeclaration, | ||
| isAssignmentExpression, | ||
| isSequenceExpression, | ||
| isImportDeclaration, | ||
| isImportDefaultSpecifier, | ||
| isImportNamespaceSpecifier, | ||
| isImportSpecifier, | ||
| isJSXAttribute, | ||
| isLiteral, | ||
| isMemberExpression, | ||
| isNewExpression, | ||
| isObjectExpression, | ||
| isObjectPattern, | ||
| isProperty, | ||
| isReturnStatement, | ||
| isFunctionExpression, | ||
| }, | ||
| ASTUtils | ||
| ) |
@@ -10,2 +10,3 @@ /* | ||
| 'import/no-anonymous-default-export': 'off', | ||
| 'storybook/await-interactions': 'error', | ||
| 'storybook/use-storybook-expect': 'error', | ||
@@ -12,0 +13,0 @@ 'storybook/use-storybook-testing-library': 'error', |
@@ -13,10 +13,4 @@ /* | ||
| 'storybook/hierarchy-separator': 'warn', | ||
| 'storybook/meta-inline-properties': [ | ||
| 'error', | ||
| { | ||
| csfVersion: 3, | ||
| }, | ||
| ], | ||
| 'storybook/no-redundant-story-name': 'warn', | ||
| }, | ||
| } |
@@ -10,10 +10,5 @@ /* | ||
| 'import/no-anonymous-default-export': 'off', | ||
| 'storybook/await-interactions': 'error', | ||
| 'storybook/default-exports': 'error', | ||
| 'storybook/hierarchy-separator': 'warn', | ||
| 'storybook/meta-inline-properties': [ | ||
| 'error', | ||
| { | ||
| csfVersion: 3, | ||
| }, | ||
| ], | ||
| 'storybook/no-redundant-story-name': 'warn', | ||
@@ -20,0 +15,0 @@ 'storybook/prefer-pascal-case': 'warn', |
@@ -25,3 +25,3 @@ /** | ||
| messages: { | ||
| description: 'Missing component property.', | ||
| missingComponentProperty: 'Missing component property.', | ||
| }, | ||
@@ -61,3 +61,3 @@ fixable: null, // Or `code` or `whitespace` | ||
| node, | ||
| messageId: 'description', | ||
| messageId: 'missingComponentProperty', | ||
| }) | ||
@@ -64,0 +64,0 @@ } |
@@ -25,3 +25,3 @@ /** | ||
| messages: { | ||
| description: 'The file should have a default export.', | ||
| shouldHaveDefaultExport: 'The file should have a default export.', | ||
| }, | ||
@@ -57,3 +57,3 @@ }, | ||
| loc: { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } }, | ||
| messageId: 'description', | ||
| messageId: 'shouldHaveDefaultExport', | ||
| }) | ||
@@ -60,0 +60,0 @@ } |
@@ -26,3 +26,3 @@ /** | ||
| messages: { | ||
| fixSuggestion: 'Use correct separators', | ||
| useCorrectSeparators: 'Use correct separators', | ||
| deprecatedHierarchySeparator: | ||
@@ -46,7 +46,7 @@ 'Deprecated hierachy separator in title property: {{metaTitle}}.', | ||
| if (!titleNode) { | ||
| if (!titleNode || !titleNode.value.type === 'Literal') { | ||
| return | ||
| } | ||
| const metaTitle = titleNode.value.raw | ||
| const metaTitle = titleNode.value.raw || '' | ||
@@ -64,3 +64,3 @@ if (metaTitle.includes('|') || metaTitle.includes('.')) { | ||
| { | ||
| messageId: 'fixSuggestion', | ||
| messageId: 'useCorrectSeparators', | ||
| fix: function (fixer) { | ||
@@ -67,0 +67,0 @@ return fixer.replaceTextRange( |
@@ -21,2 +21,3 @@ /** | ||
| recommended: true, | ||
| excludeFromConfig: true, | ||
| recommendedConfig: ['error', { csfVersion: 3 }], | ||
@@ -26,3 +27,3 @@ url: docsUrl('meta-inline-properties'), // URL to the documentation page for this rule | ||
| messages: { | ||
| description: 'Meta should only have inline properties: {{properties}}', | ||
| metaShouldHaveInlineProperties: 'Meta should only have inline properties: {{property}}', | ||
| }, | ||
@@ -52,3 +53,9 @@ schema: [ | ||
| //---------------------------------------------------------------------- | ||
| const isInline = (node) => { | ||
| return ( | ||
| node.value.type === 'ObjectExpression' || | ||
| node.value.type === 'Literal' || | ||
| node.value.type === 'ArrayExpression' | ||
| ) | ||
| } | ||
| // any helper functions should go here or else delete this section | ||
@@ -77,9 +84,4 @@ | ||
| metaNodes.forEach((metaNode) => { | ||
| const isDynamic = | ||
| metaNode.shorthand || | ||
| metaNode.value.type === 'BinaryExpression' || | ||
| (metaNode.value.type === 'TemplateLiteral' && metaNode.value.expressions.length > 0) | ||
| if (isDynamic) { | ||
| dynamicProperties.push(metaNode.key.name) | ||
| if (!isInline(metaNode)) { | ||
| dynamicProperties.push(metaNode) | ||
| } | ||
@@ -89,9 +91,10 @@ }) | ||
| if (dynamicProperties.length > 0) { | ||
| context.report({ | ||
| node, | ||
| loc: node.loc, | ||
| messageId: 'description', | ||
| data: { | ||
| properties: dynamicProperties.join(', '), | ||
| }, | ||
| dynamicProperties.forEach((propertyNode) => { | ||
| context.report({ | ||
| node: propertyNode, | ||
| messageId: 'metaShouldHaveInlineProperties', | ||
| data: { | ||
| property: propertyNode.key.name, | ||
| }, | ||
| }) | ||
| }) | ||
@@ -98,0 +101,0 @@ } |
@@ -26,4 +26,4 @@ /** | ||
| messages: { | ||
| fixSuggestion: 'Remove redundant name', | ||
| description: | ||
| removeRedundantName: 'Remove redundant name', | ||
| storyNameIsRedundant: | ||
| 'Named exports should not use the name annotation if it is redundant to the name that would be generated by the export name', | ||
@@ -87,6 +87,6 @@ }, | ||
| node: storyNameNode, | ||
| messageId: 'description', | ||
| messageId: 'storyNameIsRedundant', | ||
| suggest: [ | ||
| { | ||
| messageId: 'fixSuggestion', | ||
| messageId: 'removeRedundantName', | ||
| fix: function (fixer) { | ||
@@ -93,0 +93,0 @@ return fixer.remove(storyNameNode) |
@@ -25,3 +25,3 @@ /** | ||
| messages: { | ||
| description: 'storiesOf is deprecated and should not be used', | ||
| doNotUseStoriesOf: 'storiesOf is deprecated and should not be used', | ||
| }, | ||
@@ -48,3 +48,3 @@ }, | ||
| node, | ||
| messageId: 'description', | ||
| messageId: 'doNotUseStoriesOf', | ||
| }) | ||
@@ -51,0 +51,0 @@ } |
@@ -27,4 +27,4 @@ /** | ||
| messages: { | ||
| fixSuggestion: 'Do not define a title in meta', | ||
| description: `CSF3 does not need a title in meta`, | ||
| removeTitleInMeta: 'Do not define a title in meta', | ||
| noTitleInMeta: `CSF3 does not need a title in meta`, | ||
| }, | ||
@@ -49,3 +49,3 @@ }, | ||
| node, | ||
| messageId: 'description', | ||
| messageId: 'noTitleInMeta', | ||
| // In case we want this to be auto fixed by --fix | ||
@@ -59,3 +59,3 @@ // fix: function (fixer) { | ||
| { | ||
| messageId: 'fixSuggestion', | ||
| messageId: 'removeTitleInMeta', | ||
| fix: function (fixer) { | ||
@@ -62,0 +62,0 @@ // @TODO this suggestion keeps the comma and might result in error: |
@@ -24,4 +24,4 @@ /** | ||
| messages: { | ||
| fixSuggestion: 'Use pascal case', | ||
| description: 'The story should use PascalCase notation: {{name}}', | ||
| convertToPascalCase: 'Use pascal case', | ||
| usePascalCase: 'The story should use PascalCase notation: {{name}}', | ||
| }, | ||
@@ -75,7 +75,7 @@ }, | ||
| node: identifier, | ||
| messageId: 'description', | ||
| messageId: 'usePascalCase', | ||
| data: { name }, | ||
| suggest: [ | ||
| { | ||
| messageId: 'fixSuggestion', | ||
| messageId: 'convertToPascalCase', | ||
| fix: function (fixer) { | ||
@@ -82,0 +82,0 @@ return fixer.replaceTextRange(identifier.range, toPascalCase(name)) |
@@ -9,2 +9,8 @@ /** | ||
| const { CATEGORY_ID } = require('../utils/constants') | ||
| const { | ||
| isExpressionStatement, | ||
| isCallExpression, | ||
| isMemberExpression, | ||
| isIdentifier, | ||
| } = require('../utils/ast') | ||
@@ -28,4 +34,4 @@ //------------------------------------------------------------------------------ | ||
| messages: { | ||
| fixSuggestion: 'Update imports', | ||
| description: | ||
| updateImports: 'Update imports', | ||
| useExpectFromStorybook: | ||
| 'Do not use expect from jest directly in the story. You should use from `@storybook/jest` instead.', | ||
@@ -43,7 +49,13 @@ }, | ||
| const getExpressionStatements = (body = []) => { | ||
| return body.filter((b) => b.type === 'ExpressionStatement') | ||
| return body.filter((b) => isExpressionStatement(b)) | ||
| } | ||
| const isExpect = (expression = {}) => { | ||
| return expression.callee.object.callee.name === 'expect' | ||
| return ( | ||
| isCallExpression(expression) && | ||
| isMemberExpression(expression.callee) && | ||
| isCallExpression(expression.callee.object) && | ||
| isIdentifier(expression.callee.object.callee) && | ||
| expression.callee.object.callee.name === 'expect' | ||
| ) | ||
| } | ||
@@ -71,2 +83,6 @@ | ||
| AssignmentExpression(node) { | ||
| if (!isExpressionStatement(node.parent)) { | ||
| return null | ||
| } | ||
| if (isPlayFunction(node)) { | ||
@@ -88,3 +104,3 @@ const { right } = node | ||
| node, | ||
| messageId: 'description', | ||
| messageId: 'useExpectFromStorybook', | ||
| fix: function (fixer) { | ||
@@ -98,3 +114,3 @@ return fixer.insertTextAfterRange( | ||
| { | ||
| messageId: 'fixSuggestion', | ||
| messageId: 'updateImports', | ||
| fix: function (fixer) { | ||
@@ -101,0 +117,0 @@ return fixer.insertTextAfterRange( |
@@ -27,4 +27,4 @@ /** | ||
| messages: { | ||
| fixSuggestion: 'Update imports', | ||
| description: | ||
| updateImports: 'Update imports', | ||
| dontUseTestingLibraryDirectly: | ||
| 'Do not use `{{library}}` directly in the story. You should import the functions from `@storybook/testing-library` instead.', | ||
@@ -57,3 +57,3 @@ }, | ||
| node, | ||
| messageId: 'description', | ||
| messageId: 'dontUseTestingLibraryDirectly', | ||
| data: { | ||
@@ -70,3 +70,3 @@ library: node.source.value, | ||
| { | ||
| messageId: 'fixSuggestion', | ||
| messageId: 'updateImports', | ||
| fix: function (fixer) { | ||
@@ -73,0 +73,0 @@ return fixer.replaceTextRange( |
+9
-2
| { | ||
| "name": "eslint-plugin-storybook", | ||
| "version": "0.0.1-alpha.0", | ||
| "version": "0.0.1-alpha.1", | ||
| "description": "Best practice rules for Storybook", | ||
@@ -12,2 +12,8 @@ "keywords": [ | ||
| "author": "yannbf@gmail.com", | ||
| "contributors": [ | ||
| { | ||
| "name": "Rafael Rozon", | ||
| "email": "rafaelrozon.developer@gmail.com" | ||
| } | ||
| ], | ||
| "main": "lib/index.js", | ||
@@ -38,3 +44,4 @@ "files": [ | ||
| "dependencies": { | ||
| "requireindex": "^1.1.0" | ||
| "requireindex": "^1.1.0", | ||
| "@typescript-eslint/experimental-utils": "^5.3.0" | ||
| }, | ||
@@ -41,0 +48,0 @@ "devDependencies": { |
+1
-1
@@ -108,6 +108,6 @@ <p align="center"> | ||
| | ------------------------------------------------------------------------------------------ | ------------------------------------------------- | --- | ------------------------------- | | ||
| | [`storybook/await-interactions`](./docs/rules/await-interactions.md) | Interactions should be awaited | | addon-interactions, recommended | | ||
| | [`storybook/csf-component`](./docs/rules/csf-component.md) | The component property should be set | | csf | | ||
| | [`storybook/default-exports`](./docs/rules/default-exports.md) | Story files should have a default export | | csf, recommended | | ||
| | [`storybook/hierarchy-separator`](./docs/rules/hierarchy-separator.md) | Deprecated hierachy separator in title property | 🔧 | csf, recommended | | ||
| | [`storybook/meta-inline-properties`](./docs/rules/meta-inline-properties.md) | Meta should only have inline properties | | csf, recommended | | ||
| | [`storybook/no-redundant-story-name`](./docs/rules/no-redundant-story-name.md) | A story should not have a redundant name property | 🔧 | csf, recommended | | ||
@@ -114,0 +114,0 @@ | [`storybook/no-stories-of`](./docs/rules/no-stories-of.md) | storiesOf is deprecated and should not be used | | csf-strict | |
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
43952
23.38%21
10.53%1032
26.78%0
-100%3
50%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added