Skip to content

Commit

Permalink
feat(no-uninstalled-addons): add uninstalled plugin rule
Browse files Browse the repository at this point in the history
  • Loading branch information
andrelas1 committed Jun 30, 2022
1 parent c1e8ddc commit 5ec27e8
Show file tree
Hide file tree
Showing 8 changed files with 508 additions and 18 deletions.
29 changes: 15 additions & 14 deletions README.md
Expand Up @@ -96,20 +96,21 @@ This plugin does not support MDX files.

**Configurations**: csf, csf-strict, addon-interactions, recommended

| Name | Description | 🔧 | Included in configurations |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | --- | -------------------------------------------------------- |
| [`storybook/await-interactions`](./docs/rules/await-interactions.md) | Interactions should be awaited | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |
| [`storybook/context-in-play-function`](./docs/rules/context-in-play-function.md) | Pass a context when invoking play function of another story | | <ul><li>recommended</li><li>addon-interactions</li></ul> |
| [`storybook/csf-component`](./docs/rules/csf-component.md) | The component property should be set | | <ul><li>csf</li></ul> |
| [`storybook/default-exports`](./docs/rules/default-exports.md) | Story files should have a default export | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
| [`storybook/hierarchy-separator`](./docs/rules/hierarchy-separator.md) | Deprecated hierarchy separator in title property | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
| [`storybook/no-redundant-story-name`](./docs/rules/no-redundant-story-name.md) | A story should not have a redundant name property | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
| [`storybook/no-stories-of`](./docs/rules/no-stories-of.md) | storiesOf is deprecated and should not be used | | <ul><li>csf-strict</li></ul> |
| [`storybook/no-title-property-in-meta`](./docs/rules/no-title-property-in-meta.md) | Do not define a title in meta | 🔧 | <ul><li>csf-strict</li></ul> |
| [`storybook/prefer-pascal-case`](./docs/rules/prefer-pascal-case.md) | Stories should use PascalCase | 🔧 | <ul><li>recommended</li></ul> |
| [`storybook/story-exports`](./docs/rules/story-exports.md) | A story file must contain at least one story export | | <ul><li>recommended</li><li>csf</li></ul> |
| [`storybook/use-storybook-expect`](./docs/rules/use-storybook-expect.md) | Use expect from `@storybook/jest` | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |
| [`storybook/use-storybook-testing-library`](./docs/rules/use-storybook-testing-library.md) | Do not use testing-library directly on stories | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |
| Name | Description | 🔧 | Included in configurations |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | --- | -------------------------------------------------------- |
| [`storybook/await-interactions`](./docs/rules/await-interactions.md) | Interactions should be awaited | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |
| [`storybook/context-in-play-function`](./docs/rules/context-in-play-function.md) | Pass a context when invoking play function of another story | | <ul><li>recommended</li><li>addon-interactions</li></ul> |
| [`storybook/csf-component`](./docs/rules/csf-component.md) | The component property should be set | | <ul><li>csf</li></ul> |
| [`storybook/default-exports`](./docs/rules/default-exports.md) | Story files should have a default export | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
| [`storybook/hierarchy-separator`](./docs/rules/hierarchy-separator.md) | Deprecated hierarchy separator in title property | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
| [`storybook/no-redundant-story-name`](./docs/rules/no-redundant-story-name.md) | A story should not have a redundant name property | 🔧 | <ul><li>csf</li><li>recommended</li></ul> |
| [`storybook/no-stories-of`](./docs/rules/no-stories-of.md) | storiesOf is deprecated and should not be used | | <ul><li>csf-strict</li></ul> |
| [`storybook/no-title-property-in-meta`](./docs/rules/no-title-property-in-meta.md) | Do not define a title in meta | 🔧 | <ul><li>csf-strict</li></ul> |
| [`storybook/no-uninstalled-addons`](./docs/rules/no-uninstalled-addons.md) | This rule identifies storybook addons that are invalid because they are either not installed or contain a typo in their name. | | <ul><li>recommended</li></ul> |
| [`storybook/prefer-pascal-case`](./docs/rules/prefer-pascal-case.md) | Stories should use PascalCase | 🔧 | <ul><li>recommended</li></ul> |
| [`storybook/story-exports`](./docs/rules/story-exports.md) | A story file must contain at least one story export | | <ul><li>recommended</li><li>csf</li></ul> |
| [`storybook/use-storybook-expect`](./docs/rules/use-storybook-expect.md) | Use expect from `@storybook/jest` | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |
| [`storybook/use-storybook-testing-library`](./docs/rules/use-storybook-testing-library.md) | Do not use testing-library directly on stories | 🔧 | <ul><li>addon-interactions</li><li>recommended</li></ul> |

<!-- RULES-LIST:END -->

Expand Down
71 changes: 71 additions & 0 deletions docs/rules/no-uninstalled-addons.md
@@ -0,0 +1,71 @@
# no-uninstalled-addons

<!-- RULE-CATEGORIES:START -->

**Included in these configurations**: <ul><li>recommended</li></ul>

<!-- RULE-CATEGORIES:END -->

## Rule Details

This rule checks if all addons in the storybook main.js file are properly listed in the root package.json of the npm project.

For instance, if the `@storybook/addon-links` is in the `.storybook/main.js` but is not listed in the `package.json` of the project, this rule will notify the user to add the addon to the package.json and install it.

As an important side note, this rule will check for the package.json in the same level of the .storybook folder.

Examples of **incorrect** code for this rule:

```js
// in .storybook/main.js
module.exports = {
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
}

// package.json
{
"devDependencies": {
"@storybook/addon-links": "0.0.1",
"@storybook/addon-essentials": "0.0.1",
'
}
}
```
Examples of **correct** code for this rule:
```js
// in .storybook/main.js
module.exports = {
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
}
// package.json
{
"devDependencies": {
"@storybook/addon-links": "0.0.1",
"@storybook/addon-essentials": "0.0.1",
"@storybook/addon-interactions": "0.0.1"
}
}
```
### Options
TODO: check if the package.json location should be an option.
## When Not To Use It
This rule is very handy to be used because if the user tries to start storybook but has forgotten to install the plugin, storybook will throw very weird errors that will give no clue to the user to what's going wrong. To prevent that, this rule should be always on.

## Further Reading

Check the issue in GitHub: https://github.com/storybookjs/eslint-plugin-storybook/issues/95
7 changes: 6 additions & 1 deletion lib/configs/addon-interactions.ts
Expand Up @@ -7,7 +7,12 @@ export = {
plugins: ['storybook'],
overrides: [
{
files: ['*.stories.@(ts|tsx|js|jsx|mjs|cjs)', '*.story.@(ts|tsx|js|jsx|mjs|cjs)'],
files: [
'*.stories.@(ts|tsx|js|jsx|mjs|cjs)',
'*.story.@(ts|tsx|js|jsx|mjs|cjs)',
'main.js',
'main.js',
],
rules: {
'import/no-anonymous-default-export': 'off',
'storybook/await-interactions': 'error',
Expand Down
7 changes: 6 additions & 1 deletion lib/configs/csf.ts
Expand Up @@ -7,7 +7,12 @@ export = {
plugins: ['storybook'],
overrides: [
{
files: ['*.stories.@(ts|tsx|js|jsx|mjs|cjs)', '*.story.@(ts|tsx|js|jsx|mjs|cjs)'],
files: [
'*.stories.@(ts|tsx|js|jsx|mjs|cjs)',
'*.story.@(ts|tsx|js|jsx|mjs|cjs)',
'main.js',
'main.js',
],
rules: {
'import/no-anonymous-default-export': 'off',
'storybook/csf-component': 'warn',
Expand Down
8 changes: 7 additions & 1 deletion lib/configs/recommended.ts
Expand Up @@ -7,14 +7,20 @@ export = {
plugins: ['storybook'],
overrides: [
{
files: ['*.stories.@(ts|tsx|js|jsx|mjs|cjs)', '*.story.@(ts|tsx|js|jsx|mjs|cjs)'],
files: [
'*.stories.@(ts|tsx|js|jsx|mjs|cjs)',
'*.story.@(ts|tsx|js|jsx|mjs|cjs)',
'main.js',
'main.js',
],
rules: {
'import/no-anonymous-default-export': 'off',
'storybook/await-interactions': 'error',
'storybook/context-in-play-function': 'error',
'storybook/default-exports': 'error',
'storybook/hierarchy-separator': 'warn',
'storybook/no-redundant-story-name': 'warn',
'storybook/no-uninstalled-addons': 'error',
'storybook/prefer-pascal-case': 'warn',
'storybook/story-exports': 'error',
'storybook/use-storybook-expect': 'error',
Expand Down
185 changes: 185 additions & 0 deletions lib/rules/no-uninstalled-addons.ts
@@ -0,0 +1,185 @@
/**
* @fileoverview This rule identifies storybook addons that are invalid because they are either not installed or contain a typo in their name.
* @author Andre Santos
*/

import { readFileSync } from 'fs'
import { resolve } from 'path'

import { createStorybookRule } from '../utils/create-storybook-rule'
import { CategoryId } from '../utils/constants'
import {
isObjectExpression,
isProperty,
isIdentifier,
isArrayExpression,
isLiteral,
} from '../utils/ast'
import { Property } from '@typescript-eslint/types/dist/ast-spec'

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

export = createStorybookRule({
name: 'no-uninstalled-addons',
defaultOptions: [],
meta: {
type: 'problem', // `problem`, `suggestion`, or `layout`
docs: {
description:
'This rule identifies storybook addons that are invalid because they are either not installed or contain a typo in their name.',
// Add the categories that suit this rule.
categories: [CategoryId.RECOMMENDED],
recommended: 'error', // or 'error'
},
messages: {
// find out how to make this message dynamic
addonIsNotInstalled: `The {{ addonName }} is not installed. Did you forget to install it?`,
},

schema: [], // Add a schema if the rule has options. Otherwise remove this
},

create(context) {
// variables should be defined here

//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------

type MergeDepsWithDevDeps = (packageJson: Record<string, string>) => string[]
const mergeDepsWithDevDeps: MergeDepsWithDevDeps = (packageJson) => {
const deps = Object.keys(packageJson.dependencies || {})
const devDeps = Object.keys(packageJson.devDependencies || {})
return [...deps, ...devDeps]
}

type IsAddonInstalled = (addon: string, installedAddons: string[]) => boolean
const isAddonInstalled: IsAddonInstalled = (addon, installedAddons) => {
return installedAddons.includes(addon)
}

type AreThereAddonsNotInstalled = (
addons: string[],
installedSbAddons: string[]
) => false | { name: string }[]
const areThereAddonsNotInstalled: AreThereAddonsNotInstalled = (addons, installedSbAddons) => {
const result = addons
.filter((addon) => !isAddonInstalled(addon, installedSbAddons))
.map((addon) => ({ name: addon }))
return result.length ? result : false
}

type GetPackageJson = (path: string) => Record<string, any>

const getPackageJson: GetPackageJson = (path) => {
const packageJson = {
devDependencies: {},
dependencies: {},
}
try {
const file = readFileSync(path, 'utf8')
const parsedFile = JSON.parse(file)
packageJson.dependencies = parsedFile.dependencies || {}
packageJson.devDependencies = parsedFile.devDependencies || {}
} catch (e) {
console.error(
'Could not fetch package.json - it is probably not in the same directory as the .storybook folder'
)
}

return packageJson
}

//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------

return {
AssignmentExpression: function (node) {
// when this is running for .storybook/main.js, we get the path to the folder which contains the package.json of the
// project. This will be handy for monorepos that may be running ESLint in a node process in another folder.
const projectRoot = context.getPhysicalFilename
? resolve(context.getPhysicalFilename(), '../../')
: './'

try {
} catch (e) {}
const packageJsonObject = getPackageJson(`${projectRoot}/package.json`)
const depsAndDevDeps = mergeDepsWithDevDeps(packageJsonObject)

if (isObjectExpression(node.right)) {
const addonsProp = node.right.properties.find(
(prop) => isProperty(prop) && isIdentifier(prop.key) && prop.key.name === 'addons'
) as Property | undefined

if (addonsProp) {
if (isArrayExpression(addonsProp.value)) {
// extract all nodes taht are a string inside the addons array
const listOfAddonsInString: string[] = addonsProp.value.elements
.map((elem) => (isLiteral(elem) ? elem.value : undefined))
.filter((elem) => !!elem) as string[]

// extract all nodes that are an object inside the addons array
const listOfAddonsInObj = addonsProp.value.elements
.map((elem) => (isObjectExpression(elem) ? elem : { properties: [] }))
.map((elem) => {
const property: Property = elem.properties.find(
(prop) => isProperty(prop) && isIdentifier(prop.key) && prop.key.name === 'name'
) as Property
return isLiteral(property?.value) ? property.value.value : ''
})
.filter((elem) => !!elem) as string[]

const listOfAddons = [...listOfAddonsInString, ...listOfAddonsInObj]

const result = areThereAddonsNotInstalled(listOfAddons, depsAndDevDeps)

result
? context.report({
node,
messageId: 'addonIsNotInstalled',
data: { addonName: result[0].name },
})
: null
}
}
}
},
}
},
})

/**
* Notes about this new feature
*
*
* The issues that this rule is trying to solve are:
* 1 - Addon is listed in the main.js file of Storybook, but is not installed
* 2 - Addon is listed in the main.js file of Storybook, but it contains a typo in its name
*
* Obs:
*
* addons: [
* // usual way to register addons
* '@storybook/addon-actions',
* {
*
* // alternative way to register addons
* name: '@storybook/addon-actions',
* options: {
* docs: false
* }
* ]
*
* Not every addon is starts with @storybook/addon or storybook-addon. But most of them do and this is a recommended way to register them.
*
* The solution:
*
* - When the addon is listed but not installed or there is a typo but it is not prefixed in the recommended way:
* The addon ${addonName} is not installed. Did you forget to install it?
*
* - When the addon is listed but has a typo and it is prefixed in the recommended way:
* The addon ${addonName} is not installed. Did you mean CORRECT_NAME instead of NAME_WITH_TYPO?
*/

0 comments on commit 5ec27e8

Please sign in to comment.