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 Jul 1, 2022
1 parent c1e8ddc commit 47ea8e9
Show file tree
Hide file tree
Showing 8 changed files with 538 additions and 17 deletions.
41 changes: 27 additions & 14 deletions README.md
Expand Up @@ -48,6 +48,18 @@ npm install eslint-plugin-storybook --save-dev
yarn add eslint-plugin-storybook --dev
```

And finally, add this to your .eslintignore file:

```
// Inside your .eslintignore file
!.storybook
!.storybook/*
```

This allows for this plugin to also lint your configuration files inside the .storybook folder, so that you always have a correct configuration and don't face any issues regarding mistyped addon names, for instance.

For more info, check this [ESLint documentation](https://eslint.org/docs/latest/user-guide/configuring/ignoring-code#:~:text=In%20addition%20to,contents%2C%20are%20ignored).

## Usage

Use `.eslintrc.*` file to configure rules. See also: https://eslint.org/docs/user-guide/configuring
Expand Down Expand Up @@ -96,20 +108,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
80 changes: 80 additions & 0 deletions docs/rules/no-uninstalled-addons.md
@@ -0,0 +1,80 @@
# 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.

Another very important side note: your ESLint config must allow the linting of the .storybook folder. By default, ESLint ignores all dot-files so this folder will be ignored. In order to allow this rule to lint the .storybook/main.js file, it's important to configure ESLint to lint this file. This can be achieved by writing something like:

```
// Inside your .eslintignore file
!.storybook
.storybook/*
!.storybook/main.js
```

For more info, check this [ESLint documentation](https://eslint.org/docs/latest/user-guide/configuring/ignoring-code#:~:text=In%20addition%20to,contents%2C%20are%20ignored).

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
## 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
6 changes: 6 additions & 0 deletions lib/configs/addon-interactions.ts
Expand Up @@ -16,5 +16,11 @@ export = {
'storybook/use-storybook-testing-library': 'error',
},
},
{
files: ['main.@(js|cjs|mjs|ts)'],
rules: {
'storybook/no-uninstalled-addons': 'error',
},
},
],
}
6 changes: 6 additions & 0 deletions lib/configs/csf.ts
Expand Up @@ -17,5 +17,11 @@ export = {
'storybook/story-exports': 'error',
},
},
{
files: ['main.@(js|cjs|mjs|ts)'],
rules: {
'storybook/no-uninstalled-addons': 'error',
},
},
],
}
6 changes: 6 additions & 0 deletions lib/configs/recommended.ts
Expand Up @@ -21,5 +21,11 @@ export = {
'storybook/use-storybook-testing-library': 'error',
},
},
{
files: ['main.@(js|cjs|mjs|ts)'],
rules: {
'storybook/no-uninstalled-addons': 'error',
},
},
],
}
174 changes: 174 additions & 0 deletions lib/rules/no-uninstalled-addons.ts
@@ -0,0 +1,174 @@
/**
* @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'
import { BigIntLiteral, BooleanLiteral, NullLiteral, StringLiteral } from 'typescript'
import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'

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

export = createStorybookRule({
name: 'no-uninstalled-addons',
defaultOptions: [],
meta: {
type: 'problem',
docs: {
description:
'This rule identifies storybook addons that are invalid because they are either not installed or contain a typo in their name.',
categories: [CategoryId.RECOMMENDED],
recommended: 'error', // or 'error'
},
messages: {
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
//----------------------------------------------------------------------

// this will not only exclude the nullables but it will also exclude the type undefined from them, so that TS does not complain
function excludeNullable<T>(item: T | undefined): item is T {
return !!item
}

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
}

const extractAllAddonsFromTheStorybookConfig = (addonsProperty: Property | undefined) => {
if (addonsProperty && isArrayExpression(addonsProperty.value)) {
// extract all nodes taht are a string inside the addons array
const nodesWithAddons = addonsProperty.value.elements
.map((elem) => (isLiteral(elem) ? { value: elem.value, node: elem } : undefined))
.filter(excludeNullable)

const listOfAddonsInString = nodesWithAddons.map((elem) => elem.value) as string[]

// extract all nodes that are an object inside the addons array
const nodesWithAddonsInObj = addonsProperty.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)
? { value: property.value.value, node: property.value }
: undefined
})
.filter(excludeNullable)

const listOfAddonsInObj = nodesWithAddonsInObj.map((elem) => elem.value) as string[]

const listOfAddons = [...listOfAddonsInString, ...listOfAddonsInObj]
const listOfAddonElements = [...nodesWithAddons, ...nodesWithAddonsInObj]
return { listOfAddons, listOfAddonElements }
}

return { listOfAddons: [], listOfAddonElements: [] }
}

//----------------------------------------------------------------------
// 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(), '../../')
: './'

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

const { listOfAddons, listOfAddonElements } =
extractAllAddonsFromTheStorybookConfig(addonsProp)

const result = areThereAddonsNotInstalled(listOfAddons, depsAndDevDeps)

if (result) {
const elemsWithErrors = listOfAddonElements.filter(
(elem) => !!result.find((addon) => addon.name === elem.value)
)

elemsWithErrors.forEach((elem) => {
context.report({
node: elem.node,
messageId: 'addonIsNotInstalled',
data: { addonName: elem.value },
})
})
}
}
},
}
},
})

0 comments on commit 47ea8e9

Please sign in to comment.