Skip to content

Commit 88ddf02

Browse files
authored
feat(eslint-plugin-internal): add no-empty-story rule [AR-54518] (#311)
1 parent e57dbdd commit 88ddf02

7 files changed

Lines changed: 336 additions & 19 deletions

File tree

packages/design-system/src/components/ds-spinner/ds-spinner.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const meta: Meta<typeof DsSpinner> = {
2929
export default meta;
3030
type Story = StoryObj<typeof DsSpinner>;
3131

32+
// eslint-disable-next-line @drivenets/ds-internal/no-empty-story
3233
export const Default: Story = {
3334
args: {},
3435
};

packages/design-system/src/components/ds-table/stories/ds-table.stories.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ const meta: Meta<typeof DsTable<Person, unknown>> = {
1313
},
1414
args: {
1515
columns,
16-
data: defaultData,
1716
stickyHeader: true,
1817
bordered: true,
1918
fullWidth: true,
@@ -27,7 +26,11 @@ const meta: Meta<typeof DsTable<Person, unknown>> = {
2726
export default meta;
2827
type Story = StoryObj<typeof DsTable<Person, unknown>>;
2928

30-
export const Default: Story = {};
29+
export const Default: Story = {
30+
args: {
31+
data: defaultData,
32+
},
33+
};
3134

3235
export const EmptyState: Story = {
3336
args: {
@@ -37,6 +40,7 @@ export const EmptyState: Story = {
3740

3841
export const NoBorder: Story = {
3942
args: {
43+
data: defaultData,
4044
bordered: false,
4145
},
4246
};

packages/eslint-plugin-internal/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { consistentDeprecatedStories } from './rules/consistent-deprecated-stori
55
import { consistentStoryTitles } from './rules/consistent-story-titles';
66
import { noAutodocsTag } from './rules/no-autodocs-tag';
77
import { noCrossComponentInternalImport } from './rules/no-cross-component-internal-import';
8+
import { noEmptyStory } from './rules/no-empty-story';
89
import { noUselessTsxExtension } from './rules/no-useless-tsx-extension';
910
import { noVitestBrowserReact } from './rules/no-vitest-browser-react';
1011
import { noVitestInStories } from './rules/no-vitest-in-stories';
@@ -21,6 +22,7 @@ const plugin = {
2122
'consistent-story-titles': consistentStoryTitles,
2223
'no-autodocs-tag': noAutodocsTag,
2324
'no-cross-component-internal-import': noCrossComponentInternalImport,
25+
'no-empty-story': noEmptyStory,
2426
'no-useless-tsx-extension': noUselessTsxExtension,
2527
'no-vitest-browser-react': noVitestBrowserReact,
2628
'no-vitest-in-stories': noVitestInStories,
@@ -56,6 +58,7 @@ Object.assign(plugin.configs, {
5658
'@drivenets/ds-internal/consistent-deprecated-stories': 'error',
5759
'@drivenets/ds-internal/consistent-story-titles': 'error',
5860
'@drivenets/ds-internal/no-autodocs-tag': 'error',
61+
'@drivenets/ds-internal/no-empty-story': 'error',
5962
'@drivenets/ds-internal/no-vitest-in-stories': 'error',
6063
'@drivenets/ds-internal/require-story-params': 'error',
6164
},
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { RuleTester } from '@typescript-eslint/rule-tester';
2+
3+
import { noEmptyStory } from '../no-empty-story';
4+
5+
const ruleTester = new RuleTester();
6+
7+
ruleTester.run('no-empty-story', noEmptyStory, {
8+
valid: [
9+
{
10+
name: 'non-empty args',
11+
code: `export const Primary = { args: { label: 'Hello' } };`,
12+
},
13+
14+
{
15+
name: 'has render function',
16+
code: `export const Primary = { render: () => {} };`,
17+
},
18+
19+
{
20+
name: 'has render and empty args',
21+
code: `export const Primary = { args: {}, render: () => {} };`,
22+
},
23+
24+
{
25+
name: 'has play function',
26+
code: `export const Primary = { play: () => {} };`,
27+
},
28+
29+
{
30+
name: 'has play and empty args',
31+
code: `export const Primary = { args: {}, play: () => {} };`,
32+
},
33+
34+
{
35+
name: 'args referencing a variable',
36+
code: `
37+
const someArgs = { label: 'Hello' };
38+
export const Primary = { args: someArgs };
39+
`,
40+
},
41+
42+
{
43+
name: 'default export with empty object',
44+
code: `export default {};`,
45+
},
46+
47+
{
48+
name: 'named export of a non-object value',
49+
code: `export const name = 'Button';`,
50+
},
51+
],
52+
53+
invalid: [
54+
{
55+
name: 'empty object',
56+
code: `export const Primary = {};`,
57+
errors: [
58+
{
59+
messageId: 'noEmptyStory',
60+
line: 1,
61+
endLine: 1,
62+
column: 24,
63+
endColumn: 26,
64+
},
65+
],
66+
},
67+
68+
{
69+
name: 'empty object with type annotation',
70+
code: `export const Primary: Story = {};`,
71+
errors: [
72+
{
73+
messageId: 'noEmptyStory',
74+
line: 1,
75+
endLine: 1,
76+
column: 31,
77+
endColumn: 33,
78+
},
79+
],
80+
},
81+
82+
{
83+
name: 'empty object with satisfies',
84+
code: `export const Primary = {} satisfies Story;`,
85+
errors: [
86+
{
87+
messageId: 'noEmptyStory',
88+
line: 1,
89+
endLine: 1,
90+
column: 24,
91+
endColumn: 26,
92+
},
93+
],
94+
},
95+
96+
{
97+
name: 'empty object with as assertion',
98+
code: `export const Primary = {} as Story;`,
99+
errors: [
100+
{
101+
messageId: 'noEmptyStory',
102+
line: 1,
103+
endLine: 1,
104+
column: 24,
105+
endColumn: 26,
106+
},
107+
],
108+
},
109+
110+
{
111+
name: 'empty args',
112+
code: `export const Primary = { args: {} };`,
113+
errors: [
114+
{
115+
messageId: 'noEmptyStory',
116+
line: 1,
117+
endLine: 1,
118+
column: 24,
119+
endColumn: 36,
120+
},
121+
],
122+
},
123+
124+
{
125+
name: 'variable reference to empty object',
126+
code: `
127+
const empty = {};
128+
export const Primary = empty;
129+
`,
130+
errors: [
131+
{
132+
messageId: 'noEmptyStory',
133+
line: 2,
134+
endLine: 2,
135+
column: 19,
136+
endColumn: 21,
137+
},
138+
],
139+
},
140+
141+
{
142+
name: 'variable reference chain',
143+
code: `
144+
const a = {};
145+
const b = a;
146+
export const Primary = b;
147+
`,
148+
errors: [
149+
{
150+
messageId: 'noEmptyStory',
151+
line: 2,
152+
endLine: 2,
153+
column: 15,
154+
endColumn: 17,
155+
},
156+
],
157+
},
158+
159+
{
160+
name: 'multiple empty exports',
161+
code: `
162+
export const Primary = {};
163+
export const Secondary = {};
164+
`,
165+
errors: [
166+
{
167+
messageId: 'noEmptyStory',
168+
line: 2,
169+
endLine: 2,
170+
column: 28,
171+
endColumn: 30,
172+
},
173+
{
174+
messageId: 'noEmptyStory',
175+
line: 3,
176+
endLine: 3,
177+
column: 30,
178+
endColumn: 32,
179+
},
180+
],
181+
},
182+
183+
{
184+
name: 'shared empty variable',
185+
code: `
186+
const empty = {};
187+
export const A = empty;
188+
export const B = empty;
189+
`,
190+
errors: [
191+
{
192+
messageId: 'noEmptyStory',
193+
line: 2,
194+
endLine: 2,
195+
column: 19,
196+
endColumn: 21,
197+
},
198+
],
199+
},
200+
],
201+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { findVariable } from '@typescript-eslint/utils/ast-utils';
2+
import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
3+
import { getObjectProperty } from './utils/get-object-property';
4+
import { unwrapExpression } from './utils/unwrap-expression';
5+
import { createRule } from '../create-rule';
6+
7+
type MessageId = 'noEmptyStory';
8+
9+
export const noEmptyStory = createRule<[], MessageId>({
10+
name: 'no-empty-story',
11+
meta: {
12+
type: 'problem',
13+
docs: {
14+
description: 'Disallow empty story definitions.',
15+
},
16+
messages: {
17+
noEmptyStory:
18+
'Empty stories are not allowed. Add non-empty `args` object, `render` function or `play` function.',
19+
},
20+
schema: [],
21+
},
22+
defaultOptions: [],
23+
create(context) {
24+
const reported = new Set<TSESTree.Node>();
25+
26+
function checkNode(node: TSESTree.Node) {
27+
node = unwrapExpression(node);
28+
29+
// When 2 stories reference the same variable, we don't want to report it twice.
30+
if (reported.has(node)) {
31+
return;
32+
}
33+
34+
switch (node.type) {
35+
// Node is an object, we just need to check if it's empty.
36+
case AST_NODE_TYPES.ObjectExpression:
37+
if (isEmptyStory(node)) {
38+
context.report({ node, messageId: 'noEmptyStory' });
39+
reported.add(node);
40+
}
41+
break;
42+
43+
// Node is an identifier, probably a reference to a variable declaration, so we're following
44+
// the references chain (a story can reference a variable that references another variable),
45+
// and checking recursively.
46+
case AST_NODE_TYPES.Identifier: {
47+
const scope = context.sourceCode.getScope(node);
48+
const variable = findVariable(scope, node.name);
49+
50+
variable?.defs.forEach((def) => {
51+
if (def.node.type === AST_NODE_TYPES.VariableDeclarator && def.node.init) {
52+
checkNode(def.node.init);
53+
}
54+
});
55+
56+
break;
57+
}
58+
59+
default:
60+
break;
61+
}
62+
}
63+
64+
return {
65+
ExportNamedDeclaration(node) {
66+
if (node.declaration?.type !== AST_NODE_TYPES.VariableDeclaration) {
67+
return;
68+
}
69+
70+
node.declaration.declarations.forEach((declaration) => {
71+
if (declaration.id.type === AST_NODE_TYPES.Identifier && declaration.init) {
72+
checkNode(declaration.init);
73+
}
74+
});
75+
},
76+
};
77+
},
78+
});
79+
80+
function isEmptyStory(node: TSESTree.ObjectExpression) {
81+
if (isEmptyObject(node)) {
82+
return true;
83+
}
84+
85+
const renderProp = getObjectProperty({ obj: node, name: 'render' });
86+
const playProp = getObjectProperty({ obj: node, name: 'play' });
87+
88+
// We'll trust TypeScript here to ensure that as long as we have
89+
// `render` or `play` properties they should be valid.
90+
if (renderProp || playProp) {
91+
return false;
92+
}
93+
94+
const argsProp = getObjectProperty({
95+
obj: node,
96+
name: 'args',
97+
// The `args` prop can be a plain object or reference to a variable.
98+
// We're not following the variable references when checking the `args` prop, because it's overkill
99+
// and we could assume that if the `args` prop is a reference to a variable, it should be non-empty.
100+
predicate: (v) => v.type === AST_NODE_TYPES.ObjectExpression || v.type === AST_NODE_TYPES.Identifier,
101+
});
102+
103+
return !argsProp || isEmptyObject(argsProp.value);
104+
}
105+
106+
function isEmptyObject(node: TSESTree.Expression): boolean {
107+
return node.type === AST_NODE_TYPES.ObjectExpression && node.properties.length === 0;
108+
}

0 commit comments

Comments
 (0)