Skip to content

Commit

Permalink
feat(eslint-plugin-react-components): add prefer-fluentui-v9 rule (#3…
Browse files Browse the repository at this point in the history
  • Loading branch information
dmytrokirpa authored Jan 7, 2025
1 parent 7487d70 commit 384df0c
Show file tree
Hide file tree
Showing 9 changed files with 251 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat: add prefer-fluentui-v9 rule",
"packageName": "@fluentui/eslint-plugin-react-components",
"email": "dmytrokirpa@microsoft.com",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
{
"extends": ["plugin:@fluentui/eslint-plugin/node", "plugin:eslint-plugin/recommended"],
"plugins": ["eslint-plugin"],
"root": true
"root": true,
"overrides": [
{
"files": ["src/rules/*.ts"],
"rules": {
"@typescript-eslint/naming-convention": "off"
}
}
]
}
29 changes: 25 additions & 4 deletions packages/react-components/eslint-plugin-react-components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,42 @@ module.exports = {
};
```

1. Or configure individual rules manually:
2. Or configure individual rules manually:

```js
module.exports = {
plugins: ['@fluentui/react-components'],
rules: {
'@fluentui/react-components/rule-name-1': 'error',
'@fluentui/react-components/rule-name-2': 'warn',
'@fluentui/react-components/prefer-fluentui-v9': 'warn',
},
};
```

## Available Rules

TBD
### prefer-fluentui-v9

This rule ensures the use of Fluent UI v9 counterparts for Fluent UI v8 components.

#### Examples

**✅ Do**

```js
// Import and use components that have been already migrated to Fluent UI v9
import { Button } from '@fluentui/react-components';

const Component = () => <Button>...</Button>;
```

**❌ Don't**

```js
// Avoid importing and using Fluent UI V8 components that have already been migrated to Fluent UI V9.
import { DefaultButton } from '@fluentui/react';

const Component = () => <DefaultButton>...</DefaultButton>;
```

## License

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
```ts

import { RuleListener } from '@typescript-eslint/utils/dist/ts-eslint';
import { RuleModule } from '@typescript-eslint/utils/dist/ts-eslint';

// @public (undocumented)
const plugin: {
export const plugin: {
meta: {
name: string;
version: string;
Expand All @@ -16,9 +19,10 @@ const plugin: {
rules: {};
};
};
rules: {};
rules: {
"prefer-fluentui-v9": RuleModule<"replaceFluent8With9" | "replaceIconWithJsx" | "replaceStackWithFlex" | "replaceFocusZoneWithTabster", {}[], unknown, RuleListener>;
};
};
export default plugin;

// (No @packageDocumentation comment for this package)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { name, version } from '../package.json';
import { RULE_NAME as preferFluentUIV9Name, rule as preferFluentUIV9 } from './rules/prefer-fluentui-v9';

const allRules = {
// add all rules here
[preferFluentUIV9Name]: preferFluentUIV9,
};

const configs = {
Expand All @@ -14,7 +15,7 @@ const configs = {
};

// Plugin definition
const plugin = {
export const plugin = {
meta: {
name,
version,
Expand All @@ -33,4 +34,4 @@ Object.assign(configs, {
},
});

export default plugin;
module.exports = plugin;
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { RULE_NAME, rule } from './prefer-fluentui-v9';

const ruleTester = new RuleTester();

ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: `import type { IDropdownOption } from '@fluentui/react';`,
},
{
code: `import type { ITheme } from '@fluentui/react';`,
},
{
code: `import { ThemeProvider } from '@fluentui/react';`,
},
{
code: `import { Button } from '@fluentui/react-components';`,
},
],
invalid: [
{
code: `import { Dropdown, Icon } from '@fluentui/react';`,
errors: [{ messageId: 'replaceFluent8With9' }, { messageId: 'replaceIconWithJsx' }],
},
{
code: `import { Stack } from '@fluentui/react';`,
errors: [{ messageId: 'replaceStackWithFlex' }],
},
{
code: `import { DatePicker } from '@fluentui/react';`,
errors: [
{
messageId: 'replaceFluent8With9',
data: { fluent8: 'DatePicker', fluent9: 'DatePicker', package: '@fluentui/react-datepicker-compat' },
},
],
},
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { AST_NODE_TYPES } from '@typescript-eslint/utils';

import { createRule } from './utils/create-rule';

export const RULE_NAME = 'prefer-fluentui-v9';

type Options = Array<{}>;

type MessageIds = 'replaceFluent8With9' | 'replaceIconWithJsx' | 'replaceStackWithFlex' | 'replaceFocusZoneWithTabster';

export const rule = createRule<Options, MessageIds>({
name: RULE_NAME,
meta: {
type: 'problem',
docs: {
description: 'This rule ensures the use of Fluent UI v9 counterparts for Fluent UI v8 components.',
},
schema: [],
messages: {
replaceFluent8With9: `Avoid importing {{ fluent8 }} from '@fluentui/react', as this package has started migration to Fluent UI 9. Import {{ fluent9 }} from '{{ package }}' instead.`,
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.`,
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`,
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.`,
},
},
defaultOptions: [],
create(context) {
return {
ImportDeclaration(node) {
if (node.source.value !== '@fluentui/react') {
return;
}

for (const specifier of node.specifiers) {
if (
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
specifier.imported.type === AST_NODE_TYPES.Identifier
) {
const name = specifier.imported.name;

switch (name) {
case 'Icon':
context.report({ node, messageId: 'replaceIconWithJsx' });
break;
case 'Stack':
context.report({ node, messageId: 'replaceStackWithFlex' });
break;
case 'FocusTrapZone':
case 'FocusZone':
context.report({ node, messageId: 'replaceFocusZoneWithTabster', data: { fluent8: name } });
break;
default:
if (isMigration(name)) {
const migration = MIGRATIONS[name];

context.report({
node,
messageId: 'replaceFluent8With9',
data: {
fluent8: name,
fluent9: migration.import,
package: migration.package,
},
});
}
}
}
}
},
};
},
});

/**
* Migrations from Fluent 8 components to Fluent 9 components.
* @see https://react.fluentui.dev/?path=/docs/concepts-migration-from-v8-component-mapping--docs
*/
const MIGRATIONS = {
makeStyles: { import: 'makeStyles', package: '@fluentui/react-components' },
ActionButton: { import: 'Button', package: '@fluentui/react-components' },
Announced: { import: 'useAnnounce', package: '@fluentui/react-components' },
Breadcrumb: { import: 'Breadcrumb', package: '@fluentui/react-components' },
Button: { import: 'Button', package: '@fluentui/react-components' },
Callout: { import: 'Popover', package: '@fluentui/react-components' },
Calendar: { import: 'Calendar', package: '@fluentui/react-calendar-compat' },
CommandBar: { import: 'Toolbar', package: '@fluentui/react-components' },
CommandBarButton: { import: 'Toolbar', package: '@fluentui/react-components' },
CommandButton: { import: 'MenuButton', package: '@fluentui/react-components' },
CompoundButton: { import: 'CompoundButton', package: '@fluentui/react-components' },
Checkbox: { import: 'Checkbox', package: '@fluentui/react-components' },
ChoiceGroup: { import: 'RadioGroup', package: '@fluentui/react-components' },
Coachmark: { import: 'TeachingPopover', package: '@fluentui/react-components' },
ComboBox: { import: 'Combobox', package: '@fluentui/react-components' },
ContextualMenu: { import: 'Menu', package: '@fluentui/react-components' },
DefaultButton: { import: 'Button', package: '@fluentui/react-components' },
DatePicker: { import: 'DatePicker', package: '@fluentui/react-datepicker-compat' },
DetailsList: { import: 'DataGrid', package: '@fluentui/react-components' },
Dialog: { import: 'Dialog', package: '@fluentui/react-components' },
DocumentCard: { import: 'Card', package: '@fluentui/react-components' },
Dropdown: { import: 'Dropdown', package: '@fluentui/react-components' },
Fabric: { import: 'FluentProvider', package: '@fluentui/react-components' },
Facepile: { import: 'AvatarGroup', package: '@fluentui/react-components' },
FocusTrapZone: { import: 'Tabster', package: '@fluentui/react-components' },
FocusZone: { import: 'Tabster', package: '@fluentui/react-components' },
GroupedList: { import: 'Tree', package: '@fluentui/react-components' },
HoverCard: { import: 'Popover', package: '@fluentui/react-components' }, // Not a direct equivalent; but could be used with custom behavior.
IconButton: { import: 'Button', package: '@fluentui/react-components' },
Image: { import: 'Image', package: '@fluentui/react-components' },
Keytips: { import: 'Keytips', package: '@fluentui-contrib/react-keytips' },
Label: { import: 'Label', package: '@fluentui/react-components' },
Layer: { import: 'Portal', package: '@fluentui/react-components' },
Link: { import: 'Link', package: '@fluentui/react-components' },
MessageBar: { import: 'MessageBar', package: '@fluentui/react-components' },
Modal: { import: 'Dialog', package: '@fluentui/react-components' },
OverflowSet: { import: 'Overflow', package: '@fluentui/react-components' },
Overlay: { import: 'Portal', package: '@fluentui/react-components' },
Panel: { import: 'Drawer', package: '@fluentui/react-components' },
PeoplePicker: { import: 'TagPicker', package: '@fluentui/react-components' },
Persona: { import: 'Persona', package: '@fluentui/react-components' },
Pivot: { import: 'TabList', package: '@fluentui/react-components' },
PivotItem: { import: 'Tab', package: '@fluentui/react-components' },
ProgressIndicator: { import: 'ProgressBar', package: '@fluentui/react-components' },
Rating: { import: 'Rating', package: '@fluentui/react-components' },
SearchBox: { import: 'SearchBox', package: '@fluentui/react-components' },
Separator: { import: 'Divider', package: '@fluentui/react-components' },
Shimmer: { import: 'Skeleton', package: '@fluentui/react-components' },
Slider: { import: 'Slider', package: '@fluentui/react-components' },
SplitButton: { import: 'SplitButton', package: '@fluentui/react-components' },
SpinButton: { import: 'SpinButton', package: '@fluentui/react-components' },
Spinner: { import: 'Spinner', package: '@fluentui/react-components' },
Stack: { import: 'StackShim', package: '@fluentui/react-components' },
SwatchColorPicker: { import: 'SwatchPicker', package: '@fluentui/react-components' },
TagPicker: { import: 'TagPicker', package: '@fluentui/react-components' },
TeachingBubble: { import: 'TeachingPopover', package: '@fluentui/react-components' },
Text: { import: 'Text', package: '@fluentui/react-components' },
TextField: { import: 'Input', package: '@fluentui/react-components' },
TimePicker: { import: 'TimePicker', package: '@fluentui/react-timepicker-compat' },
ToggleButton: { import: 'ToggleButton', package: '@fluentui/react-components' },
Toggle: { import: 'Switch', package: '@fluentui/react-components' },
Tooltip: { import: 'Tooltip', package: '@fluentui/react-components' },
};

/**
* Checks if a component name is in the MIGRATIONS list.
* @param name - The name of the component.
* @returns True if the component is in the MIGRATIONS list, false otherwise.
*/
const isMigration = (name: string): name is keyof typeof MIGRATIONS => name in MIGRATIONS;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ESLintUtils } from '@typescript-eslint/utils';

/**
* Creates an ESLint rule with a pre-configured URL pointing to the rule's documentation.
*/
export const createRule = ESLintUtils.RuleCreator(
name =>
`https://github.com/microsoft/fluentui/blob/master/packages/react-components/eslint-plugin-react-components/README.md#${name}`,
);
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"types": ["jest", "node"]
},
Expand Down

0 comments on commit 384df0c

Please sign in to comment.