From 384df0ccb4a5324da6daee8f18712ef5a81fa05a Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 7 Jan 2025 14:52:33 +0100 Subject: [PATCH] feat(eslint-plugin-react-components): add prefer-fluentui-v9 rule (#33449) --- ...-1c2bc691-c857-43cf-9806-ffe8d0d058c0.json | 7 + .../.eslintrc.json | 10 +- .../eslint-plugin-react-components/README.md | 29 +++- .../etc/eslint-plugin-react-components.api.md | 10 +- .../src/index.ts | 7 +- .../src/rules/prefer-fluentui-v9.spec.ts | 40 +++++ .../src/rules/prefer-fluentui-v9.ts | 148 ++++++++++++++++++ .../src/rules/utils/create-rule.ts | 9 ++ .../tsconfig.spec.json | 3 +- 9 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 change/@fluentui-eslint-plugin-react-components-1c2bc691-c857-43cf-9806-ffe8d0d058c0.json create mode 100644 packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.spec.ts create mode 100644 packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.ts create mode 100644 packages/react-components/eslint-plugin-react-components/src/rules/utils/create-rule.ts diff --git a/change/@fluentui-eslint-plugin-react-components-1c2bc691-c857-43cf-9806-ffe8d0d058c0.json b/change/@fluentui-eslint-plugin-react-components-1c2bc691-c857-43cf-9806-ffe8d0d058c0.json new file mode 100644 index 0000000000000..5459e40ce0c42 --- /dev/null +++ b/change/@fluentui-eslint-plugin-react-components-1c2bc691-c857-43cf-9806-ffe8d0d058c0.json @@ -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" +} diff --git a/packages/react-components/eslint-plugin-react-components/.eslintrc.json b/packages/react-components/eslint-plugin-react-components/.eslintrc.json index 63b4e8d8a8963..700ea69cb4542 100644 --- a/packages/react-components/eslint-plugin-react-components/.eslintrc.json +++ b/packages/react-components/eslint-plugin-react-components/.eslintrc.json @@ -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" + } + } + ] } diff --git a/packages/react-components/eslint-plugin-react-components/README.md b/packages/react-components/eslint-plugin-react-components/README.md index 5da83b63a8050..0b22544de6553 100644 --- a/packages/react-components/eslint-plugin-react-components/README.md +++ b/packages/react-components/eslint-plugin-react-components/README.md @@ -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 = () => ; +``` + +**❌ 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 = () => ...; +``` ## License diff --git a/packages/react-components/eslint-plugin-react-components/etc/eslint-plugin-react-components.api.md b/packages/react-components/eslint-plugin-react-components/etc/eslint-plugin-react-components.api.md index ebe584c246463..d4416ee6720ef 100644 --- a/packages/react-components/eslint-plugin-react-components/etc/eslint-plugin-react-components.api.md +++ b/packages/react-components/eslint-plugin-react-components/etc/eslint-plugin-react-components.api.md @@ -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; @@ -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) diff --git a/packages/react-components/eslint-plugin-react-components/src/index.ts b/packages/react-components/eslint-plugin-react-components/src/index.ts index 821e8d8738ab9..e3ed57c99b71f 100644 --- a/packages/react-components/eslint-plugin-react-components/src/index.ts +++ b/packages/react-components/eslint-plugin-react-components/src/index.ts @@ -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 = { @@ -14,7 +15,7 @@ const configs = { }; // Plugin definition -const plugin = { +export const plugin = { meta: { name, version, @@ -33,4 +34,4 @@ Object.assign(configs, { }, }); -export default plugin; +module.exports = plugin; diff --git a/packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.spec.ts b/packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.spec.ts new file mode 100644 index 0000000000000..22f79c07d8055 --- /dev/null +++ b/packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.spec.ts @@ -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' }, + }, + ], + }, + ], +}); diff --git a/packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.ts b/packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.ts new file mode 100644 index 0000000000000..bf43866a6e90d --- /dev/null +++ b/packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.ts @@ -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({ + 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; diff --git a/packages/react-components/eslint-plugin-react-components/src/rules/utils/create-rule.ts b/packages/react-components/eslint-plugin-react-components/src/rules/utils/create-rule.ts new file mode 100644 index 0000000000000..0365797e179f6 --- /dev/null +++ b/packages/react-components/eslint-plugin-react-components/src/rules/utils/create-rule.ts @@ -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}`, +); diff --git a/packages/react-components/eslint-plugin-react-components/tsconfig.spec.json b/packages/react-components/eslint-plugin-react-components/tsconfig.spec.json index 469fcba4d7ba7..18aba1c937537 100644 --- a/packages/react-components/eslint-plugin-react-components/tsconfig.spec.json +++ b/packages/react-components/eslint-plugin-react-components/tsconfig.spec.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "module": "CommonJS", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "dist", "types": ["jest", "node"] },