Skip to content

Commit 384df0c

Browse files
authored
feat(eslint-plugin-react-components): add prefer-fluentui-v9 rule (#33449)
1 parent 7487d70 commit 384df0c

9 files changed

+251
-12
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "feat: add prefer-fluentui-v9 rule",
4+
"packageName": "@fluentui/eslint-plugin-react-components",
5+
"email": "[email protected]",
6+
"dependentChangeType": "none"
7+
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
{
22
"extends": ["plugin:@fluentui/eslint-plugin/node", "plugin:eslint-plugin/recommended"],
33
"plugins": ["eslint-plugin"],
4-
"root": true
4+
"root": true,
5+
"overrides": [
6+
{
7+
"files": ["src/rules/*.ts"],
8+
"rules": {
9+
"@typescript-eslint/naming-convention": "off"
10+
}
11+
}
12+
]
513
}

packages/react-components/eslint-plugin-react-components/README.md

+25-4
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,42 @@ module.exports = {
4040
};
4141
```
4242

43-
1. Or configure individual rules manually:
43+
2. Or configure individual rules manually:
4444

4545
```js
4646
module.exports = {
4747
plugins: ['@fluentui/react-components'],
4848
rules: {
49-
'@fluentui/react-components/rule-name-1': 'error',
50-
'@fluentui/react-components/rule-name-2': 'warn',
49+
'@fluentui/react-components/prefer-fluentui-v9': 'warn',
5150
},
5251
};
5352
```
5453

5554
## Available Rules
5655

57-
TBD
56+
### prefer-fluentui-v9
57+
58+
This rule ensures the use of Fluent UI v9 counterparts for Fluent UI v8 components.
59+
60+
#### Examples
61+
62+
**✅ Do**
63+
64+
```js
65+
// Import and use components that have been already migrated to Fluent UI v9
66+
import { Button } from '@fluentui/react-components';
67+
68+
const Component = () => <Button>...</Button>;
69+
```
70+
71+
**❌ Don't**
72+
73+
```js
74+
// Avoid importing and using Fluent UI V8 components that have already been migrated to Fluent UI V9.
75+
import { DefaultButton } from '@fluentui/react';
76+
77+
const Component = () => <DefaultButton>...</DefaultButton>;
78+
```
5879

5980
## License
6081

packages/react-components/eslint-plugin-react-components/etc/eslint-plugin-react-components.api.md

+7-3
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
55
```ts
66

7+
import { RuleListener } from '@typescript-eslint/utils/dist/ts-eslint';
8+
import { RuleModule } from '@typescript-eslint/utils/dist/ts-eslint';
9+
710
// @public (undocumented)
8-
const plugin: {
11+
export const plugin: {
912
meta: {
1013
name: string;
1114
version: string;
@@ -16,9 +19,10 @@ const plugin: {
1619
rules: {};
1720
};
1821
};
19-
rules: {};
22+
rules: {
23+
"prefer-fluentui-v9": RuleModule<"replaceFluent8With9" | "replaceIconWithJsx" | "replaceStackWithFlex" | "replaceFocusZoneWithTabster", {}[], unknown, RuleListener>;
24+
};
2025
};
21-
export default plugin;
2226

2327
// (No @packageDocumentation comment for this package)
2428

packages/react-components/eslint-plugin-react-components/src/index.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { name, version } from '../package.json';
2+
import { RULE_NAME as preferFluentUIV9Name, rule as preferFluentUIV9 } from './rules/prefer-fluentui-v9';
23

34
const allRules = {
4-
// add all rules here
5+
[preferFluentUIV9Name]: preferFluentUIV9,
56
};
67

78
const configs = {
@@ -14,7 +15,7 @@ const configs = {
1415
};
1516

1617
// Plugin definition
17-
const plugin = {
18+
export const plugin = {
1819
meta: {
1920
name,
2021
version,
@@ -33,4 +34,4 @@ Object.assign(configs, {
3334
},
3435
});
3536

36-
export default plugin;
37+
module.exports = plugin;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { RuleTester } from '@typescript-eslint/rule-tester';
2+
import { RULE_NAME, rule } from './prefer-fluentui-v9';
3+
4+
const ruleTester = new RuleTester();
5+
6+
ruleTester.run(RULE_NAME, rule, {
7+
valid: [
8+
{
9+
code: `import type { IDropdownOption } from '@fluentui/react';`,
10+
},
11+
{
12+
code: `import type { ITheme } from '@fluentui/react';`,
13+
},
14+
{
15+
code: `import { ThemeProvider } from '@fluentui/react';`,
16+
},
17+
{
18+
code: `import { Button } from '@fluentui/react-components';`,
19+
},
20+
],
21+
invalid: [
22+
{
23+
code: `import { Dropdown, Icon } from '@fluentui/react';`,
24+
errors: [{ messageId: 'replaceFluent8With9' }, { messageId: 'replaceIconWithJsx' }],
25+
},
26+
{
27+
code: `import { Stack } from '@fluentui/react';`,
28+
errors: [{ messageId: 'replaceStackWithFlex' }],
29+
},
30+
{
31+
code: `import { DatePicker } from '@fluentui/react';`,
32+
errors: [
33+
{
34+
messageId: 'replaceFluent8With9',
35+
data: { fluent8: 'DatePicker', fluent9: 'DatePicker', package: '@fluentui/react-datepicker-compat' },
36+
},
37+
],
38+
},
39+
],
40+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2+
3+
import { createRule } from './utils/create-rule';
4+
5+
export const RULE_NAME = 'prefer-fluentui-v9';
6+
7+
type Options = Array<{}>;
8+
9+
type MessageIds = 'replaceFluent8With9' | 'replaceIconWithJsx' | 'replaceStackWithFlex' | 'replaceFocusZoneWithTabster';
10+
11+
export const rule = createRule<Options, MessageIds>({
12+
name: RULE_NAME,
13+
meta: {
14+
type: 'problem',
15+
docs: {
16+
description: 'This rule ensures the use of Fluent UI v9 counterparts for Fluent UI v8 components.',
17+
},
18+
schema: [],
19+
messages: {
20+
replaceFluent8With9: `Avoid importing {{ fluent8 }} from '@fluentui/react', as this package has started migration to Fluent UI 9. Import {{ fluent9 }} from '{{ package }}' instead.`,
21+
replaceIconWithJsx: `Avoid using Icon from '@fluentui/react', as this package has already migrated to Fluent UI 9. Use a JSX SVG icon from '@fluentui/react-icons' instead.`,
22+
replaceStackWithFlex: `Avoid using Stack from '@fluentui/react', as this package has already migrated to Fluent UI 9. Use native CSS flexbox instead. More details are available at https://react.fluentui.dev/?path=/docs/concepts-migration-from-v8-components-flex-stack--docs`,
23+
replaceFocusZoneWithTabster: `Avoid using {{ fluent8 }} from '@fluentui/react', as this package has already migrated to Fluent UI 9. Use the equivalent [Tabster](https://tabster.io/) hook instead.`,
24+
},
25+
},
26+
defaultOptions: [],
27+
create(context) {
28+
return {
29+
ImportDeclaration(node) {
30+
if (node.source.value !== '@fluentui/react') {
31+
return;
32+
}
33+
34+
for (const specifier of node.specifiers) {
35+
if (
36+
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
37+
specifier.imported.type === AST_NODE_TYPES.Identifier
38+
) {
39+
const name = specifier.imported.name;
40+
41+
switch (name) {
42+
case 'Icon':
43+
context.report({ node, messageId: 'replaceIconWithJsx' });
44+
break;
45+
case 'Stack':
46+
context.report({ node, messageId: 'replaceStackWithFlex' });
47+
break;
48+
case 'FocusTrapZone':
49+
case 'FocusZone':
50+
context.report({ node, messageId: 'replaceFocusZoneWithTabster', data: { fluent8: name } });
51+
break;
52+
default:
53+
if (isMigration(name)) {
54+
const migration = MIGRATIONS[name];
55+
56+
context.report({
57+
node,
58+
messageId: 'replaceFluent8With9',
59+
data: {
60+
fluent8: name,
61+
fluent9: migration.import,
62+
package: migration.package,
63+
},
64+
});
65+
}
66+
}
67+
}
68+
}
69+
},
70+
};
71+
},
72+
});
73+
74+
/**
75+
* Migrations from Fluent 8 components to Fluent 9 components.
76+
* @see https://react.fluentui.dev/?path=/docs/concepts-migration-from-v8-component-mapping--docs
77+
*/
78+
const MIGRATIONS = {
79+
makeStyles: { import: 'makeStyles', package: '@fluentui/react-components' },
80+
ActionButton: { import: 'Button', package: '@fluentui/react-components' },
81+
Announced: { import: 'useAnnounce', package: '@fluentui/react-components' },
82+
Breadcrumb: { import: 'Breadcrumb', package: '@fluentui/react-components' },
83+
Button: { import: 'Button', package: '@fluentui/react-components' },
84+
Callout: { import: 'Popover', package: '@fluentui/react-components' },
85+
Calendar: { import: 'Calendar', package: '@fluentui/react-calendar-compat' },
86+
CommandBar: { import: 'Toolbar', package: '@fluentui/react-components' },
87+
CommandBarButton: { import: 'Toolbar', package: '@fluentui/react-components' },
88+
CommandButton: { import: 'MenuButton', package: '@fluentui/react-components' },
89+
CompoundButton: { import: 'CompoundButton', package: '@fluentui/react-components' },
90+
Checkbox: { import: 'Checkbox', package: '@fluentui/react-components' },
91+
ChoiceGroup: { import: 'RadioGroup', package: '@fluentui/react-components' },
92+
Coachmark: { import: 'TeachingPopover', package: '@fluentui/react-components' },
93+
ComboBox: { import: 'Combobox', package: '@fluentui/react-components' },
94+
ContextualMenu: { import: 'Menu', package: '@fluentui/react-components' },
95+
DefaultButton: { import: 'Button', package: '@fluentui/react-components' },
96+
DatePicker: { import: 'DatePicker', package: '@fluentui/react-datepicker-compat' },
97+
DetailsList: { import: 'DataGrid', package: '@fluentui/react-components' },
98+
Dialog: { import: 'Dialog', package: '@fluentui/react-components' },
99+
DocumentCard: { import: 'Card', package: '@fluentui/react-components' },
100+
Dropdown: { import: 'Dropdown', package: '@fluentui/react-components' },
101+
Fabric: { import: 'FluentProvider', package: '@fluentui/react-components' },
102+
Facepile: { import: 'AvatarGroup', package: '@fluentui/react-components' },
103+
FocusTrapZone: { import: 'Tabster', package: '@fluentui/react-components' },
104+
FocusZone: { import: 'Tabster', package: '@fluentui/react-components' },
105+
GroupedList: { import: 'Tree', package: '@fluentui/react-components' },
106+
HoverCard: { import: 'Popover', package: '@fluentui/react-components' }, // Not a direct equivalent; but could be used with custom behavior.
107+
IconButton: { import: 'Button', package: '@fluentui/react-components' },
108+
Image: { import: 'Image', package: '@fluentui/react-components' },
109+
Keytips: { import: 'Keytips', package: '@fluentui-contrib/react-keytips' },
110+
Label: { import: 'Label', package: '@fluentui/react-components' },
111+
Layer: { import: 'Portal', package: '@fluentui/react-components' },
112+
Link: { import: 'Link', package: '@fluentui/react-components' },
113+
MessageBar: { import: 'MessageBar', package: '@fluentui/react-components' },
114+
Modal: { import: 'Dialog', package: '@fluentui/react-components' },
115+
OverflowSet: { import: 'Overflow', package: '@fluentui/react-components' },
116+
Overlay: { import: 'Portal', package: '@fluentui/react-components' },
117+
Panel: { import: 'Drawer', package: '@fluentui/react-components' },
118+
PeoplePicker: { import: 'TagPicker', package: '@fluentui/react-components' },
119+
Persona: { import: 'Persona', package: '@fluentui/react-components' },
120+
Pivot: { import: 'TabList', package: '@fluentui/react-components' },
121+
PivotItem: { import: 'Tab', package: '@fluentui/react-components' },
122+
ProgressIndicator: { import: 'ProgressBar', package: '@fluentui/react-components' },
123+
Rating: { import: 'Rating', package: '@fluentui/react-components' },
124+
SearchBox: { import: 'SearchBox', package: '@fluentui/react-components' },
125+
Separator: { import: 'Divider', package: '@fluentui/react-components' },
126+
Shimmer: { import: 'Skeleton', package: '@fluentui/react-components' },
127+
Slider: { import: 'Slider', package: '@fluentui/react-components' },
128+
SplitButton: { import: 'SplitButton', package: '@fluentui/react-components' },
129+
SpinButton: { import: 'SpinButton', package: '@fluentui/react-components' },
130+
Spinner: { import: 'Spinner', package: '@fluentui/react-components' },
131+
Stack: { import: 'StackShim', package: '@fluentui/react-components' },
132+
SwatchColorPicker: { import: 'SwatchPicker', package: '@fluentui/react-components' },
133+
TagPicker: { import: 'TagPicker', package: '@fluentui/react-components' },
134+
TeachingBubble: { import: 'TeachingPopover', package: '@fluentui/react-components' },
135+
Text: { import: 'Text', package: '@fluentui/react-components' },
136+
TextField: { import: 'Input', package: '@fluentui/react-components' },
137+
TimePicker: { import: 'TimePicker', package: '@fluentui/react-timepicker-compat' },
138+
ToggleButton: { import: 'ToggleButton', package: '@fluentui/react-components' },
139+
Toggle: { import: 'Switch', package: '@fluentui/react-components' },
140+
Tooltip: { import: 'Tooltip', package: '@fluentui/react-components' },
141+
};
142+
143+
/**
144+
* Checks if a component name is in the MIGRATIONS list.
145+
* @param name - The name of the component.
146+
* @returns True if the component is in the MIGRATIONS list, false otherwise.
147+
*/
148+
const isMigration = (name: string): name is keyof typeof MIGRATIONS => name in MIGRATIONS;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ESLintUtils } from '@typescript-eslint/utils';
2+
3+
/**
4+
* Creates an ESLint rule with a pre-configured URL pointing to the rule's documentation.
5+
*/
6+
export const createRule = ESLintUtils.RuleCreator(
7+
name =>
8+
`https://github.com/microsoft/fluentui/blob/master/packages/react-components/eslint-plugin-react-components/README.md#${name}`,
9+
);

packages/react-components/eslint-plugin-react-components/tsconfig.spec.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"extends": "./tsconfig.json",
33
"compilerOptions": {
4-
"module": "CommonJS",
4+
"module": "NodeNext",
5+
"moduleResolution": "NodeNext",
56
"outDir": "dist",
67
"types": ["jest", "node"]
78
},

0 commit comments

Comments
 (0)