diff --git a/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/DEVELOPER.md b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/DEVELOPER.md index e8fac6dd..2e22fb97 100644 --- a/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/DEVELOPER.md +++ b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/DEVELOPER.md @@ -66,10 +66,10 @@ autofixer-smarthr-ui-migrationルールに新しいバージョン(v[XX]→v[Y ## 参考にするファイル 必ず以下のファイルを読んで、実装パターンを踏襲してください(最新のversionディレクトリを参照): -- rules/autofixer-smarthr-ui-migration/versions/v93-to-v94/REFERENCE.md(実装パターンの詳細説明) -- rules/autofixer-smarthr-ui-migration/versions/v93-to-v94/index.js(実装例) -- rules/autofixer-smarthr-ui-migration/versions/v93-to-v94/README.md(ユーザー向け移行ガイド) -- rules/autofixer-smarthr-ui-migration/versions/v93-to-v94/test.js(テストケース) +- rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/REFERENCE.md(実装パターンの詳細説明) +- rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/index.js(実装例) +- rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/README.md(ユーザー向け移行ガイド) +- rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/test.js(テストケース) - test/autofixer-smarthr-ui-migration.js(メインテスト) - libs/common.js(rootPathの取得、tsconfig.jsonのpaths設定読み込み) @@ -588,7 +588,7 @@ https://github.com/kufu/smarthr-ui/releases 各versionディレクトリに`REFERENCE.md`があり、実装パターンや注意点が記載されています。 -**最新version:** [v93-to-v94/REFERENCE.md](./versions/v93-to-v94/REFERENCE.md) +**最新version:** [v94-to-v95/REFERENCE.md](./versions/v94-to-v95/REFERENCE.md) このドキュメントには以下が含まれます: - ファイル構造と各セクションの説明 diff --git a/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/README.md b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/README.md index bc9cae54..5658d54e 100644 --- a/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/README.md +++ b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/README.md @@ -195,6 +195,7 @@ export const ActionDialog = (props) =>
{props.children}
| `91` → `92` | [移行ガイド](./versions/v91-to-v92/README.md) | | `92` → `93` | [移行ガイド](./versions/v92-to-v93/README.md) | | `93` → `94` | [移行ガイド](./versions/v93-to-v94/README.md) | +| `94` → `95` | [移行ガイド](./versions/v94-to-v95/README.md) | ## 使用方法 diff --git a/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/index.js b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/index.js index 0dc4d47d..4777f538 100644 --- a/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/index.js +++ b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/index.js @@ -19,6 +19,7 @@ const v90ToV91 = require('./versions/v90-to-v91/index') const v91ToV92 = require('./versions/v91-to-v92/index') const v92ToV93 = require('./versions/v92-to-v93/index') const v93ToV94 = require('./versions/v93-to-v94/index') +const v94ToV95 = require('./versions/v94-to-v95/index') // サポートしているバージョン間の移行モジュール const VERSION_MODULES = { @@ -26,6 +27,7 @@ const VERSION_MODULES = { 'v91-v92': v91ToV92, 'v92-v93': v92ToV93, 'v93-v94': v93ToV94, + 'v94-v95': v94ToV95, } module.exports = { @@ -60,6 +62,8 @@ module.exports = { ...v90ToV91.messages, ...v91ToV92.messages, ...v92ToV93.messages, + ...v93ToV94.messages, + ...v94ToV95.messages, }, }, create(context) { diff --git a/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/README.md b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/README.md new file mode 100644 index 00000000..a9023b14 --- /dev/null +++ b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/README.md @@ -0,0 +1,309 @@ +# smarthr-ui v94 → v95 移行ガイド + +このドキュメントは、smarthr-ui v94からv95への移行に必要な変更をまとめたものです。 + +## 対応する破壊的変更 + +### 1. LanguageSwitcher: decorators属性の削除 + +v95では、`LanguageSwitcher`コンポーネントから`decorators`属性が削除されました。翻訳はsmarthr-ui内で自動的に行われます。 + +#### 変更内容 + +- `decorators` 属性削除 +- トリガーラベルは常に`'Language'`で固定 +- チェックアイコンのaltはsmarthr-uiの翻訳が自動適用(全9言語対応済み) + +#### 移行方法 + +**Before (v94):** +```tsx + 'Language' }} /> +``` + +**After (v95):** +```tsx + +``` + +#### 自動修正可能なパターン + +以下のパターンは、ESLintの`--fix`オプションで自動的に修正されます: + +```tsx +// decorators属性を削除 + '言語' }} /> +→ +``` + +### 1-2. AppLauncher: decorators.triggerLabelをtriggerLabel属性に移行 + +v95では、`AppLauncher`コンポーネントから`decorators`属性が削除され、`triggerLabel`属性が追加されました。 + +#### 変更内容 + +- `decorators.triggerLabel` → `triggerLabel` 属性に移行 +- `triggerLabel`が指定されていない場合はsmarthr-uiの翻訳が自動適用(全9言語対応済み) +- 動的な値(例: featureName)を渡す必要がある場合のみ、`triggerLabel`属性を使用 + +#### 移行方法 + +**Before (v94):** +```tsx +// 固定値の場合 + 'Apps' }} /> + +// 動的な値の場合 + featureName }} /> +``` + +**After (v95):** +```tsx +// 固定値の場合 → decoratorsを削除してIntlProviderに任せる + + +// 動的な値の場合 → triggerLabel属性に移行 + +``` + +#### 自動修正可能なパターン + +以下のパターンは、ESLintの`--fix`オプションで自動的に修正されます: + +```tsx +// 固定値の場合 → decoratorsを削除 + "Apps" }} /> +→ + + 'アプリ' }} /> +→ + +// 動的な値の場合 → triggerLabel属性に移行 + featureName }} /> +→ + + getLabel() }} /> +→ + + labels.app }} /> +→ +``` + +**注意:** 引数ありの関数(例: `(lang) => ...`)やBlockStatement形式(例: `() => { return "Apps" }`)は自動修正できません。手動で対応してください。 + +### 2. InputFile: decorators属性の削除 + +v95では、`InputFile`コンポーネントから`decorators`属性が削除されました。削除ボタンのラベルはsmarthr-uiが提供する翻訳が自動的に適用されます(IntlProvider経由)。 + +#### 移行方法 + +**Before (v94):** +```tsx + '削除' }} /> +``` + +**After (v95):** +```tsx + +``` + +### 3. FormDialog: ボタン属性をObject形式に統合 + +v95では、`FormDialog`のボタン関連の属性がObject形式に統合されました。 + +#### 変更内容 + +以下のpropsが削除されました: +- `actionText` → `actionButton`で指定 +- `actionTheme` → `actionButton={{ text: "...", theme: "..." }}`で指定 +- `actionDisabled` → `actionButton={{ text: "...", disabled: true }}`で指定 +- `closeDisabled` → `closeButton={{ text: "...", disabled: true }}`で指定 +- `decorators.closeButtonLabel` → `closeButton="キャンセル"`で指定 + +#### 移行方法 + +**シンプルな使い方(文字列のみ指定):** +```tsx +// Before (v94) + 'キャンセル' }} +> + +// After (v95) + +``` + +**詳細な設定が必要な場合:** +```tsx +// Before (v94) + '閉じる' }} +> + +// After (v95) + +``` + +#### 自動修正可能なパターン + +以下のパターンは、ESLintの`--fix`オプションで自動的に修正されます: + +```tsx +// actionText のみの場合は自動でリネーム + +→ + +// 複数の属性がある場合は自動でObject形式に変換 + +→ + + +→ + +// decorators.closeButtonLabel(引数なしの関数の場合) + "キャンセル" }}> +→ + + buttonLabel }}> +→ + + getLabel() }}> +→ + + labels.close }}> +→ +``` + +**注意:** 引数ありの関数(例: `(lang) => ...`)やBlockStatement形式(例: `() => { return "OK" }`)は自動修正できません。手動で対応してください。 + +### 4. ActionDialog: ボタン属性をObject形式に統合 + +`ActionDialog`も`FormDialog`と同様に、ボタン関連の属性がObject形式に統合されました。 + +#### 変更内容と移行方法 + +FormDialogと同じです。詳細は「3. FormDialog」セクションを参照してください。 + +### 5. MessageDialog: decorators削除とcloseButton属性への統一 + +v95では、`MessageDialog`の`decorators.closeButtonLabel`が`closeButton`属性に統一されました。 + +#### 変更内容 + +以下のpropsが削除されました: +- `decorators.closeButtonLabel` → `closeButton`で指定 + +#### 移行方法 + +**デフォルトラベル使用の場合(省略可能):** +```tsx +// Before (v94) + + メッセージ本文 + + +// After (v95) - 変更なし + + メッセージ本文 + +``` + +**カスタムラベルが必要な場合:** +```tsx +// Before (v94) + 'OK' }} +> + メッセージ本文 + + +// After (v95) + + メッセージ本文 + +``` + +#### 自動修正可能なパターン + +以下のパターンは、ESLintの`--fix`オプションで自動的に修正されます: + +```tsx +// 引数なしの関数から値を抽出 + "OK" }} /> +→ + + buttonLabel }} /> +→ + + getLabel() }} /> +→ + + labels.close }} /> +→ +``` + +**注意:** 引数ありの関数(例: `(lang) => ...`)やBlockStatement形式(例: `() => { return "OK" }`)は自動修正できません。手動で対応してください。 + +## ESLintルールの使用方法 + +### .eslintrc.js の設定 + +```javascript +module.exports = { + rules: { + 'smarthr/autofixer-smarthr-ui-migration': ['error', { from: '94', to: '95' }], + }, +} +``` + +### 自動修正の実行 + +```bash +# エラーを確認 +pnpm run lint + +# 自動修正を実行 +pnpm run lint --fix +``` + +### 注意事項 + +- 以下のパターンは自動修正できません。エラーメッセージを確認して、手動で修正してください: + - `closeDisabled`属性の移行(`closeButton`のObject形式への変換が必要) + - 引数ありの関数: `decorators={{ closeButtonLabel: (lang) => ... }}`、`decorators={{ triggerLabel: (lang) => ... }}` + - BlockStatement形式の関数: `decorators={{ closeButtonLabel: () => { return "OK" } }}`、`decorators={{ triggerLabel: () => { return "Apps" } }}` + +### smarthrUiAlias オプション + +プロジェクト固有のsmarthr-ui aliasパスを使用している場合は、`smarthrUiAlias`オプションを指定してください。 + +```javascript +module.exports = { + rules: { + 'smarthr/autofixer-smarthr-ui-migration': [ + 'error', + { + from: '94', + to: '95', + smarthrUiAlias: '@/components/parts/smarthr-ui', // プロジェクト固有のalias + }, + ], + }, +} +``` + +## 参考リンク + +- [smarthr-ui v95.0.0 リリースノート](https://github.com/kufu/smarthr-ui/releases/tag/smarthr-ui-v95.0.0) diff --git a/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/REFERENCE.md b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/REFERENCE.md new file mode 100644 index 00000000..8d791438 --- /dev/null +++ b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/REFERENCE.md @@ -0,0 +1,656 @@ +# v94-to-v95 実装の参考ポイント + +このドキュメントは、v94→v95の移行ルール実装の構造と、新しいversionを追加する際の参考ポイントを説明します。 + +## v94→v95 特有の実装パターン + +### 1. LanguageSwitcher, InputFile の decorators 属性削除 + +v95では LanguageSwitcher, InputFile コンポーネントから `decorators` 属性が削除されました。v93-to-v94のThCheckboxと同じく、**新しい属性への移行はなく、完全に削除する**だけのシンプルなパターンです。 + +#### 1-1. decorators属性のチェッカー(シンプルな削除) + +```javascript +'JSXAttribute[name.name="decorators"]'(node) { + const componentName = node.parent.name.name + + // 対象コンポーネントのみ(LanguageSwitcher, InputFile) + if (!COMPONENTS_REMOVE_DECORATORS.includes(componentName)) return + + context.report({ + node, + messageId: 'removeDecorators', + data: { component: componentName, to: TARGET_VERSION }, + fix(fixer) { + // decorators属性を削除 + const tokenBefore = sourceCode.getTokenBefore(node) + if (tokenBefore && tokenBefore.range[1] < node.range[0]) { + return fixer.removeRange([tokenBefore.range[1], node.range[1]]) + } + return fixer.remove(node) + }, + }) +} +``` + +**ポイント:** +- v93-to-v94のThCheckboxと同じパターン +- **値の解析不要**: decoratorsの内容に関わらず削除するため、複雑な解析関数は不要 +- **条件分岐なし**: 常に削除するだけ +- **自動修正: 常に可能**: 手動対応が必要なケースなし + +### 1-2. AppLauncher の decorators.triggerLabel を triggerLabel 属性に移行 + +v95では AppLauncher コンポーネントの `decorators.triggerLabel` が `triggerLabel` 属性に移行されました。これは**値の抽出が必要な複雑なパターン**です。 + +#### 変更内容 + +**削除される属性:** +- `decorators.triggerLabel` → `triggerLabel` 属性(動的な値の場合のみ) + +**移行後の形式:** +```tsx +// 固定値の場合 → decoratorsを削除してIntlProviderに任せる + + +// 動的な値の場合 → triggerLabel属性に移行 + +``` + +#### 実装の制約と自動修正可能なパターン + +**自動修正可能:** +- 既に`triggerLabel`属性がある場合 → `decorators`を削除 +- 固定値(リテラル)の場合 → `decorators`を削除(IntlProviderに任せる) +- 動的な値(変数、関数呼び出しなど)の場合 → `triggerLabel`属性に移行 + +**自動修正不可(エラーのみ表示):** +- 引数ありの関数: `(lang) => ...` +- BlockStatement形式: `() => { return "Apps" }` + +#### 実装パターン + +```javascript +'JSXOpeningElement[name.name="AppLauncher"] > JSXAttribute[name.name="decorators"]'(node) { + // decorators属性の値を解析してtriggerLabelがあるかチェック + const decoratorsValue = sourceCode.getText(node.value) + if (decoratorsValue.includes('triggerLabel')) { + // triggerLabel属性が既にあるかチェック + const triggerLabelAttr = node.parent.attributes.find( + (attr) => attr.type === 'JSXAttribute' && attr.name.name === 'triggerLabel' + ) + + context.report({ + node, + messageId: 'migrateAppLauncherDecorators', + data: { to: TARGET_VERSION }, + fix(fixer) { + // triggerLabel属性が既にある場合は削除のみ + if (triggerLabelAttr) { + const tokenBefore = sourceCode.getTokenBefore(node) + if (tokenBefore && tokenBefore.range[1] < node.range[0]) { + return fixer.removeRange([tokenBefore.range[1], node.range[1]]) + } + return fixer.remove(node) + } + + // decorators={{ triggerLabel: () => "Apps" }}から値を抽出 + const extractedValue = extractDecoratorValue(node, 'triggerLabel') + if (extractedValue) { + // 固定値(リテラル)の場合 → decoratorsを削除するだけ + // 動的な値(変数など)の場合 → triggerLabel属性に移行 + if (extractedValue.startsWith('"') || extractedValue.startsWith("'")) { + // 固定値: decoratorsを削除 + const tokenBefore = sourceCode.getTokenBefore(node) + if (tokenBefore && tokenBefore.range[1] < node.range[0]) { + return fixer.removeRange([tokenBefore.range[1], node.range[1]]) + } + return fixer.remove(node) + } else { + // 動的な値: triggerLabel属性に移行 + return fixer.replaceText(node, `triggerLabel=${extractedValue}`) + } + } + + // 複雑なため、エラーのみ表示(手動対応) + return null + }, + }) + } +} +``` + +**ポイント:** +- `extractDecoratorValue(node, 'triggerLabel')`で値を抽出 +- 抽出した値がリテラル(`"`または`'`で始まる)の場合 → `decorators`を削除 +- 抽出した値がJSX式(`{...}`形式)の場合 → `triggerLabel={...}`に置換 +- 固定値の場合はIntlProviderに任せるため、属性自体を削除 +- 動的な値の場合のみ`triggerLabel`属性を追加 + +### 2. FormDialog/ActionDialog のボタン属性統合 + +v95では FormDialog と ActionDialog のボタン関連属性が Object 形式に統合されました。これは**複雑な移行パターン**です。 + +#### 2-1. 移行が必要な属性 + +**削除される属性:** +- `actionText` → `actionButton` (文字列 or Object) +- `actionTheme` → `actionButton={{ theme: "..." }}` (Object) +- `actionDisabled` → `actionButton={{ disabled: true }}` (Object) +- `closeDisabled` → `closeButton={{ disabled: true }}` (Object) +- `decorators.closeButtonLabel` → `closeButton` (文字列 or Object) + +**統合後の形式:** +```tsx +// シンプルな場合(文字列のみ) +actionButton="保存" +closeButton="キャンセル" + +// 詳細設定(Object形式) +actionButton={{ text: "削除", theme: "danger", disabled: false }} +closeButton={{ text: "閉じる", disabled: true }} +``` + +#### 2-2. 実装の制約と自動修正可能なパターン + +**自動修正可能:** +- `actionText`のみの場合 → `actionButton`にリネーム +- `actionText` + `actionTheme` → Object形式へ自動変換 +- `actionText` + `actionDisabled` → Object形式へ自動変換 +- `actionText` + `actionTheme` + `actionDisabled` → Object形式へ自動変換 +- 既に`actionButton`/`closeButton`がある場合 → 古い属性を削除 +- `decorators.closeButtonLabel`(引数なしの関数) → 値を抽出して`closeButton`に変換 +- `decorators.triggerLabel`(引数なしの関数) → 固定値は削除、動的な値は`triggerLabel`に変換 + +**自動修正不可(エラーのみ表示):** +- `closeDisabled` → Object形式への変換が複雑 +- `decorators`(引数あり、またはBlockStatement) + +#### 2-3. 実装パターン(段階的な対応) + +```javascript +'JSXOpeningElement'(node) { + const componentName = node.name.name + + // 対象コンポーネントのみ + if (!DIALOG_COMPONENTS_WITH_BUTTONS.includes(componentName)) return + + // 各属性を収集 + let actionTextAttr = null + let actionThemeAttr = null + let actionDisabledAttr = null + let closeDisabledAttr = null + let decoratorsAttr = null + let actionButtonAttr = null + let closeButtonAttr = null + + node.attributes.forEach((attr) => { + if (attr.type !== 'JSXAttribute') return + const attrName = attr.name.name + + if (attrName === 'actionText') actionTextAttr = attr + if (attrName === 'actionTheme') actionThemeAttr = attr + // ... 他の属性も同様 + }) + + const hasActionButton = !!actionButtonAttr + const hasCloseButton = !!closeButtonAttr + + // actionText を actionButton に移行 + if (actionTextAttr) { + context.report({ + node: actionTextAttr, + messageId: 'migrateActionText', + fix(fixer) { + if (hasActionButton) { + // actionButton属性が既にある場合は削除のみ + const tokenBefore = sourceCode.getTokenBefore(actionTextAttr) + if (tokenBefore && tokenBefore.range[1] < actionTextAttr.range[0]) { + return fixer.removeRange([tokenBefore.range[1], actionTextAttr.range[1]]) + } + return fixer.remove(actionTextAttr) + } + + // actionText のみの場合、actionButton にリネーム + if (!actionThemeAttr && !actionDisabledAttr) { + return fixer.replaceText(actionTextAttr.name, 'actionButton') + } + + // 複雑な場合はエラーのみ(手動対応) + return null + }, + }) + } + + // actionTheme, actionDisabled, closeDisabled も同様 + // ... +} +``` + +**ポイント:** +- **属性の収集**: すべての関連属性を先に収集 +- **既存の新属性チェック**: `actionButton`/`closeButton`が既にあるかチェック +- **段階的な修正**: + 1. 新属性が既にある → 古い属性を削除 + 2. 単一属性のみ → 単純なリネーム + 3. 複数属性 → Object形式へ自動変換(v94-to-v95で追加) + +**Object形式への自動変換(actionText + actionTheme/actionDisabled):** + +```javascript +if (actionTextAttr) { + context.report({ + node: actionTextAttr, + messageId: 'migrateActionText', + fix(fixer) { + if (hasActionButton) { + // actionButton属性が既にある場合は削除のみ + return removeAttribute(fixer, actionTextAttr) + } + + // actionButton属性がない場合 + if (!actionThemeAttr && !actionDisabledAttr) { + // actionTextのみ → 単純にリネーム + return fixer.replaceText(actionTextAttr.name, 'actionButton') + } + + // 複数の属性がある場合、Object形式に変換 + const fixes = [] + const textValue = getAttributeValue(actionTextAttr) + const themeValue = actionThemeAttr ? getAttributeValue(actionThemeAttr) : null + const disabledValue = actionDisabledAttr ? getAttributeValue(actionDisabledAttr) : null + + const objectParts = [`text: ${textValue}`] + if (themeValue) objectParts.push(`theme: ${themeValue}`) + if (disabledValue !== null) objectParts.push(`disabled: ${disabledValue}`) + + const newValue = `actionButton={{ ${objectParts.join(', ')} }}` + + // actionText属性をactionButton={{ ... }}に置換 + fixes.push(fixer.replaceText(actionTextAttr, newValue)) + + // actionTheme/actionDisabled属性を削除 + if (actionThemeAttr) { + fixes.push(removeAttribute(fixer, actionThemeAttr)) + } + if (actionDisabledAttr) { + fixes.push(removeAttribute(fixer, actionDisabledAttr)) + } + + return fixes + }, + }) +} + +// actionTheme/actionDisabledのfix関数では、actionTextが存在する場合はnullを返す +// (actionTextのfixでまとめて処理されるため) +``` + +**getAttributeValue ヘルパー関数:** + +```javascript +function getAttributeValue(attr) { + if (!attr || !attr.value) return '""' + + // 文字列リテラル: actionText="保存" + if (attr.value.type === 'Literal') { + return JSON.stringify(attr.value.value) + } + + // JSX式: actionTheme={"danger"} または actionDisabled={false} + if (attr.value.type === 'JSXExpressionContainer') { + const expr = attr.value.expression + if (expr.type === 'Literal') { + return JSON.stringify(expr.value) + } + // 変数や式の場合はソースコードをそのまま取得 + return sourceCode.getText(expr) + } + + return '""' +} +``` + +#### 2-4. decorators.closeButtonLabel の処理(値の自動抽出) + +v94→v95では、引数なしの関数(`() => "OK"`形式)から値を抽出する実装を追加しました。 + +```javascript +if (decoratorsAttr) { + // decorators属性の値を解析してcloseButtonLabelがあるかチェック + const decoratorsValue = sourceCode.getText(decoratorsAttr.value) + if (decoratorsValue.includes('closeButtonLabel')) { + context.report({ + node: decoratorsAttr, + messageId: 'migrateDecoratorsCloseButtonLabel', + fix(fixer) { + // closeButton属性が既にある場合は削除のみ + if (hasCloseButton) { + const tokenBefore = sourceCode.getTokenBefore(decoratorsAttr) + if (tokenBefore && tokenBefore.range[1] < decoratorsAttr.range[0]) { + return fixer.removeRange([tokenBefore.range[1], decoratorsAttr.range[1]]) + } + return fixer.remove(decoratorsAttr) + } + + // decorators={{ closeButtonLabel: () => "OK" }}から値を抽出 + const extractedValue = extractDecoratorValue(decoratorsAttr, 'closeButtonLabel') + if (extractedValue) { + // 自動修正可能: decoratorsを削除してcloseButtonを追加 + return fixer.replaceText(decoratorsAttr, `closeButton=${extractedValue}`) + } + + // 複雑なため、エラーのみ(手動対応) + return null + }, + }) + } +} +``` + +**extractDecoratorValue 関数:** + +```javascript +/** + * decorators属性から指定されたプロパティの値を抽出 + * + * decorators={{ closeButtonLabel: () => "OK" }} から "OK" を抽出 + * decorators={{ closeButtonLabel: () => variable }} から {variable} を抽出 + * + * @param {Object} decoratorsAttr - decorators属性のASTノード + * @param {string} propertyName - 抽出するプロパティ名 + * @returns {string|null} 抽出された値、または抽出不可の場合null + */ +function extractDecoratorValue(decoratorsAttr, propertyName) { + if (!decoratorsAttr || !decoratorsAttr.value) return null + if (decoratorsAttr.value.type !== 'JSXExpressionContainer') return null + + const expr = decoratorsAttr.value.expression + if (expr.type !== 'ObjectExpression') return null + + // プロパティを探す + const property = expr.properties.find((prop) => { + return ( + prop.type === 'Property' && + prop.key && + ((prop.key.type === 'Identifier' && prop.key.name === propertyName) || + (prop.key.type === 'Literal' && prop.key.value === propertyName)) + ) + }) + + if (!property || !property.value) return null + + // ArrowFunctionExpression: () => "OK" + if (property.value.type !== 'ArrowFunctionExpression') return null + + // 引数なしの場合のみ処理 + if (property.value.params.length !== 0) return null + + const body = property.value.body + + // BlockStatement(ブロック形式)は対応しない: () => { return "OK" } + if (body.type === 'BlockStatement') return null + + // Literal: () => "OK" → "OK" + if (body.type === 'Literal') { + return JSON.stringify(body.value) + } + + // その他のExpression: () => variable → {variable} + // () => a() → {a()} + // () => obj.prop → {obj.prop} + return `{${sourceCode.getText(body)}}` +} +``` + +**ポイント:** +- `ArrowFunctionExpression`の`params.length === 0`(引数なし)の場合のみ処理 +- `BlockStatement`(`() => { return "OK" }`)は対応しない +- `body.type === 'Literal'` → 文字列として返す(`"OK"`) +- その他のExpression → JSX式として返す + - `() => variable` → `{variable}` + - `() => a()` → `{a()}` + - `() => obj.prop` → `{obj.prop}` + - `sourceCode.getText(body)`でbody部分のソースコードをそのまま抽出 + +### 3. MessageDialog の decorators 削除 + +MessageDialogは FormDialog/ActionDialog と似ていますが、`closeButtonLabel`のみの対応です。FormDialogと同じく`extractDecoratorValue`を使って値を自動抽出します。 + +```javascript +'JSXOpeningElement[name.name="MessageDialog"] > JSXAttribute[name.name="decorators"]'(node) { + const decoratorsValue = sourceCode.getText(node.value) + if (decoratorsValue.includes('closeButtonLabel')) { + const closeButtonAttr = node.parent.attributes.find( + (attr) => attr.type === 'JSXAttribute' && attr.name.name === 'closeButton' + ) + + context.report({ + node, + messageId: 'migrateMessageDialogDecorators', + fix(fixer) { + // closeButton属性が既にある場合は削除のみ + if (closeButtonAttr) { + const tokenBefore = sourceCode.getTokenBefore(node) + if (tokenBefore && tokenBefore.range[1] < node.range[0]) { + return fixer.removeRange([tokenBefore.range[1], node.range[1]]) + } + return fixer.remove(node) + } + + // decorators={{ closeButtonLabel: () => "OK" }}から値を抽出 + const extractedValue = extractDecoratorValue(node, 'closeButtonLabel') + if (extractedValue) { + // 自動修正可能 + return fixer.replaceText(node, `closeButton=${extractedValue}`) + } + + // 複雑なため、エラーのみ(手動対応) + return null + }, + }) + } +} +``` + +## v93-to-v94との実装比較 + +### ThCheckbox (v93-to-v94) - シンプルな削除 + +```javascript +// 値の解析不要、条件分岐なし +context.report({ + node, + messageId: 'removeDecorators', + fix(fixer) { + // decorators削除のみ + const tokenBefore = sourceCode.getTokenBefore(node) + if (tokenBefore && tokenBefore.range[1] < node.range[0]) { + return fixer.removeRange([tokenBefore.range[1], node.range[1]]) + } + return fixer.remove(node) + } +}) +``` + +### FormDialog (v94-to-v95) - 複雑な属性統合 + +```javascript +// 複数の属性を収集 +let actionTextAttr = null +let actionThemeAttr = null +// ... + +// 条件分岐で段階的に対応 +if (actionTextAttr) { + context.report({ + fix(fixer) { + if (hasActionButton) { + // 削除のみ + return fixer.remove(actionTextAttr) + } + if (!actionThemeAttr && !actionDisabledAttr) { + // 単純なリネーム + return fixer.replaceText(actionTextAttr.name, 'actionButton') + } + // 複雑な場合はエラーのみ + return null + } + }) +} +``` + +## テストケースのパターン + +### valid(エラーにならないケース) + +```javascript +// decoratorsなし +'', +'', +'', + +// AppLauncher with triggerLabel +'', +'', + +// 既に新しい属性を使用 +'', +'', +'', +``` + +### invalid(エラーになるケース) + +```javascript +// decorators削除(自動修正可能) +{ + code: ' "Language" }} />', + output: '', + errors: [{ messageId: 'removeDecorators' }] +}, + +// AppLauncher decorators.triggerLabel(エラーのみ、自動修正なし) +{ + code: ' featureName }} />', + output: null, // 自動修正なし + errors: [{ messageId: 'migrateAppLauncherDecorators' }] +}, + +// AppLauncher(triggerLabel属性が既にある場合、decorators削除) +{ + code: ' "Apps" }} triggerLabel={featureName} />', + output: '', + errors: [{ messageId: 'migrateAppLauncherDecorators' }] +}, + +// actionText リネーム(自動修正可能) +{ + code: '', + output: '', + errors: [{ messageId: 'migrateActionText' }] +}, + +// 複数属性(エラーのみ、自動修正なし) +{ + code: '', + output: null, // 自動修正なし + errors: [ + { messageId: 'migrateActionText' }, + { messageId: 'migrateActionTheme' } + ] +}, + +// decorators.closeButtonLabel(エラーのみ、自動修正なし) +{ + code: ' "キャンセル" }} />', + output: null, // 自動修正なし + errors: [{ messageId: 'migrateDecoratorsCloseButtonLabel' }] +}, +``` + +## 新しいversionを追加する場合 + +### シンプルな削除パターン(LanguageSwitcherタイプ)の場合 + +1. **セレクター**: `JSXAttribute[name.name="属性名"]` +2. **コンポーネント判定**: `if (!TARGET_COMPONENTS.includes(componentName)) return` +3. **fix**: decorators属性を削除(前の空白も含む) +4. **テスト**: valid(属性なし、他コンポーネント)、invalid(属性あり → 削除) + +### 複雑な属性統合パターン(FormDialogタイプ)の場合 + +1. **属性収集**: すべての関連属性を先に収集 +2. **既存の新属性チェック**: 新しい属性が既にあるかチェック +3. **段階的な fix**: 単純なケースのみ自動修正、複雑なケースはエラーのみ +4. **テスト**: valid(新属性)、invalid(各パターン、自動修正可/不可) + +## 実装の参考ポイント + +**最新version:** [v94-to-v95/REFERENCE.md](./versions/v94-to-v95/REFERENCE.md) + +### ESLint fixer API + +- `fixer.remove(node)`: ノードを削除 +- `fixer.removeRange([start, end])`: 範囲を削除 +- `fixer.replaceText(node, text)`: ノードのテキストを置換 +- `fixer.insertTextAfter(node, text)`: ノードの後にテキスト挿入 +- `sourceCode.getTokenBefore(node)`: 前のトークンを取得 +- `sourceCode.getText(node)`: ノードのテキストを取得 + +### 段階的な自動修正のアプローチ + +v94-to-v95のように複雑な変換が必要な場合、完全な自動修正を目指すと実装が複雑になりすぎます。以下のアプローチを推奨します: + +**1. 単純なケースのみ自動修正:** +- `actionText` のみ → `actionButton` にリネーム +- 既に新属性がある → 古い属性を削除 + +**2. 複雑なケースはエラーのみ:** +- 複数属性の統合 → エラーメッセージで手動対応を促す +- 値の抽出が必要 → エラーメッセージで手動対応を促す + +**3. エラーメッセージで移行方法を明示:** +```javascript +messages: { + migrateActionText: 'smarthr-ui {{to}} では {{component}} の actionText 属性は actionButton に統合されました', + // READMEで詳しい移行方法を説明 +} +``` + +このアプローチにより: +- 実装がシンプルに保たれる +- 多くの一般的なケースは自動修正可能 +- 複雑なケースは手動で正確に対応できる + +## トラブルシューティング + +### 前の空白が残る + +**原因**: `fixer.remove(node)`のみだと、前の空白が残る + +**解決策**: +```javascript +const tokenBefore = sourceCode.getTokenBefore(node) +if (tokenBefore && tokenBefore.range[1] < node.range[0]) { + return fixer.removeRange([tokenBefore.range[1], node.range[1]]) +} +``` + +### 複数の属性を統合する自動修正が難しい + +**原因**: Object形式への変換は複雑 + +**解決策**: 段階的なアプローチ +- 単純なケース(単一属性)のみ自動修正 +- 複雑なケース(複数属性)はエラーのみ表示 +- READMEで手動での移行方法を詳しく説明 + +### decoratorsの値を抽出できない + +**原因**: `() => '文字列'` や `() => getLabel()` など、様々なパターンがある + +**解決策**: +- 完全な解析は避ける +- `sourceCode.getText(node.value).includes('closeButtonLabel')`で簡易チェック +- 値の抽出は手動対応とする diff --git a/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/index.js b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/index.js new file mode 100644 index 00000000..7933afa4 --- /dev/null +++ b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/index.js @@ -0,0 +1,472 @@ +/** + * smarthr-ui v94 → v95 移行ルール + * + * v95での破壊的変更に対応する自動修正を提供します。 + * + * 対応する破壊的変更: + * 1. LanguageSwitcher, AppLauncher の decorators 属性削除 + * 2. InputFile の decorators 属性削除 + * 3. FormDialog のボタン属性をObject形式に統合 + * 4. ActionDialog のボタン属性をObject形式に統合 + * 5. MessageDialog のdecorators削除とcloseButton属性への統一 + * + * 参考: https://github.com/kufu/smarthr-ui/releases/tag/smarthr-ui-v95.0.0 + */ + +const { setupSmarthrUiAliasOptions } = require('../../helpers') + +// ============================================================ +// 定数定義 +// ============================================================ + +// v95を示す定数(メッセージで使用) +const TARGET_VERSION = 'v95' + +// decoratorsを削除するコンポーネント(単純削除のみ) +const COMPONENTS_REMOVE_DECORATORS = ['LanguageSwitcher', 'InputFile'] + +// ボタン属性を統合するコンポーネント(FormDialog, ActionDialog) +const DIALOG_COMPONENTS_WITH_BUTTONS = ['FormDialog', 'ActionDialog'] + +// ============================================================ +// モジュールエクスポート +// ============================================================ + +module.exports = { + messages: { + removeDecorators: 'smarthr-ui {{to}} では {{component}} の decorators 属性は削除されました。翻訳はsmarthr-ui内で自動的に行われます', + migrateAppLauncherDecorators: 'smarthr-ui {{to}} では AppLauncher の decorators.triggerLabel は triggerLabel 属性に移行されました。動的な値を渡す場合のみ triggerLabel 属性を使用してください', + migrateActionText: 'smarthr-ui {{to}} では {{component}} の actionText 属性は actionButton に統合されました', + migrateActionTheme: 'smarthr-ui {{to}} では {{component}} の actionTheme 属性は actionButton に統合されました', + migrateActionDisabled: 'smarthr-ui {{to}} では {{component}} の actionDisabled 属性は actionButton に統合されました', + migrateCloseDisabled: 'smarthr-ui {{to}} では {{component}} の closeDisabled 属性は closeButton に統合されました', + migrateDecoratorsCloseButtonLabel: 'smarthr-ui {{to}} では {{component}} の decorators.closeButtonLabel 属性は closeButton に統合されました', + migrateMessageDialogDecorators: 'smarthr-ui {{to}} では MessageDialog の decorators.closeButtonLabel 属性は closeButton に統合されました', + }, + + createCheckers(context, sourceCode, options = {}) { + const { validSources, isAliasFile, filename } = setupSmarthrUiAliasOptions(context, options) + + const checkers = { + // ============================================================ + // 1, 2. LanguageSwitcher, AppLauncher, InputFile の decorators 属性削除 + // ============================================================ + + 'JSXAttribute[name.name="decorators"]'(node) { + const componentName = node.parent.name.name + + // 対象コンポーネントのみ + if (!COMPONENTS_REMOVE_DECORATORS.includes(componentName)) return + + context.report({ + node, + messageId: 'removeDecorators', + data: { component: componentName, to: TARGET_VERSION }, + fix(fixer) { + // decorators属性を削除 + const tokenBefore = sourceCode.getTokenBefore(node) + if (tokenBefore && tokenBefore.range[1] < node.range[0]) { + return fixer.removeRange([tokenBefore.range[1], node.range[1]]) + } + return fixer.remove(node) + }, + }) + }, + + // ============================================================ + // 3, 4. FormDialog/ActionDialog のボタン属性統合 + // ============================================================ + + // FormDialog/ActionDialog要素を検出 + 'JSXOpeningElement'(node) { + const componentName = node.name.name + + // 対象コンポーネントのみ + if (!DIALOG_COMPONENTS_WITH_BUTTONS.includes(componentName)) return + + // 各属性を収集 + let actionTextAttr = null + let actionThemeAttr = null + let actionDisabledAttr = null + let closeDisabledAttr = null + let decoratorsAttr = null + let actionButtonAttr = null + let closeButtonAttr = null + + node.attributes.forEach((attr) => { + if (attr.type !== 'JSXAttribute') return + const attrName = attr.name.name + + if (attrName === 'actionText') actionTextAttr = attr + if (attrName === 'actionTheme') actionThemeAttr = attr + if (attrName === 'actionDisabled') actionDisabledAttr = attr + if (attrName === 'closeDisabled') closeDisabledAttr = attr + if (attrName === 'decorators') decoratorsAttr = attr + if (attrName === 'actionButton') actionButtonAttr = attr + if (attrName === 'closeButton') closeButtonAttr = attr + }) + + // actionButton属性が既にある場合、古い属性は削除のみ + // closeButton属性が既にある場合、古い属性は削除のみ + const hasActionButton = !!actionButtonAttr + const hasCloseButton = !!closeButtonAttr + + // actionText を actionButton に移行 + if (actionTextAttr) { + context.report({ + node: actionTextAttr, + messageId: 'migrateActionText', + data: { component: componentName, to: TARGET_VERSION }, + fix(fixer) { + if (hasActionButton) { + // actionButton属性が既にある場合は削除のみ + const tokenBefore = sourceCode.getTokenBefore(actionTextAttr) + if (tokenBefore && tokenBefore.range[1] < actionTextAttr.range[0]) { + return fixer.removeRange([tokenBefore.range[1], actionTextAttr.range[1]]) + } + return fixer.remove(actionTextAttr) + } + + // actionButton属性がない場合、actionTextをactionButtonにリネーム + // 他の属性(actionTheme, actionDisabled)がある場合はObject形式に変換 + if (!actionThemeAttr && !actionDisabledAttr) { + // actionTextのみの場合は単純にリネーム + return fixer.replaceText(actionTextAttr.name, 'actionButton') + } + + // 複数の属性がある場合、Object形式に変換 + const fixes = [] + + // actionButtonの値を構築 + const textValue = getAttributeValue(actionTextAttr) + const themeValue = actionThemeAttr ? getAttributeValue(actionThemeAttr) : null + const disabledValue = actionDisabledAttr ? getAttributeValue(actionDisabledAttr) : null + + const objectParts = [`text: ${textValue}`] + if (themeValue) objectParts.push(`theme: ${themeValue}`) + if (disabledValue !== null) objectParts.push(`disabled: ${disabledValue}`) + + const newValue = `actionButton={{ ${objectParts.join(', ')} }}` + + // actionText属性をactionButton={{ ... }}に置換 + fixes.push(fixer.replaceText(actionTextAttr, newValue)) + + // actionTheme属性を削除 + if (actionThemeAttr) { + const tokenBefore = sourceCode.getTokenBefore(actionThemeAttr) + if (tokenBefore && tokenBefore.range[1] < actionThemeAttr.range[0]) { + fixes.push(fixer.removeRange([tokenBefore.range[1], actionThemeAttr.range[1]])) + } else { + fixes.push(fixer.remove(actionThemeAttr)) + } + } + + // actionDisabled属性を削除 + if (actionDisabledAttr) { + const tokenBefore = sourceCode.getTokenBefore(actionDisabledAttr) + if (tokenBefore && tokenBefore.range[1] < actionDisabledAttr.range[0]) { + fixes.push(fixer.removeRange([tokenBefore.range[1], actionDisabledAttr.range[1]])) + } else { + fixes.push(fixer.remove(actionDisabledAttr)) + } + } + + return fixes + }, + }) + } + + // actionTheme を actionButton に移行 + if (actionThemeAttr) { + context.report({ + node: actionThemeAttr, + messageId: 'migrateActionTheme', + data: { component: componentName, to: TARGET_VERSION }, + fix(fixer) { + // actionButton属性が既にある場合は削除のみ + if (hasActionButton) { + const tokenBefore = sourceCode.getTokenBefore(actionThemeAttr) + if (tokenBefore && tokenBefore.range[1] < actionThemeAttr.range[0]) { + return fixer.removeRange([tokenBefore.range[1], actionThemeAttr.range[1]]) + } + return fixer.remove(actionThemeAttr) + } + + // actionTextが存在する場合、actionTextのfixでまとめて処理されるためnull + if (actionTextAttr) { + return null + } + + // actionTextがなくactionThemeのみの場合はエラーのみ(想定外のパターン) + return null + }, + }) + } + + // actionDisabled を actionButton に移行 + if (actionDisabledAttr) { + context.report({ + node: actionDisabledAttr, + messageId: 'migrateActionDisabled', + data: { component: componentName, to: TARGET_VERSION }, + fix(fixer) { + // actionButton属性が既にある場合は削除のみ + if (hasActionButton) { + const tokenBefore = sourceCode.getTokenBefore(actionDisabledAttr) + if (tokenBefore && tokenBefore.range[1] < actionDisabledAttr.range[0]) { + return fixer.removeRange([tokenBefore.range[1], actionDisabledAttr.range[1]]) + } + return fixer.remove(actionDisabledAttr) + } + + // actionTextが存在する場合、actionTextのfixでまとめて処理されるためnull + if (actionTextAttr) { + return null + } + + // actionTextがなくactionDisabledのみの場合はエラーのみ(想定外のパターン) + return null + }, + }) + } + + // closeDisabled を closeButton に移行 + if (closeDisabledAttr) { + context.report({ + node: closeDisabledAttr, + messageId: 'migrateCloseDisabled', + data: { component: componentName, to: TARGET_VERSION }, + fix(fixer) { + // closeButton属性が既にある場合は削除のみ + if (hasCloseButton) { + const tokenBefore = sourceCode.getTokenBefore(closeDisabledAttr) + if (tokenBefore && tokenBefore.range[1] < closeDisabledAttr.range[0]) { + return fixer.removeRange([tokenBefore.range[1], closeDisabledAttr.range[1]]) + } + return fixer.remove(closeDisabledAttr) + } + + // 複雑なため、エラーのみ表示(手動対応) + return null + }, + }) + } + + // decorators.closeButtonLabel を closeButton に移行 + if (decoratorsAttr) { + // decorators属性の値を解析してcloseButtonLabelがあるかチェック + const decoratorsValue = sourceCode.getText(decoratorsAttr.value) + if (decoratorsValue.includes('closeButtonLabel')) { + context.report({ + node: decoratorsAttr, + messageId: 'migrateDecoratorsCloseButtonLabel', + data: { component: componentName, to: TARGET_VERSION }, + fix(fixer) { + // closeButton属性が既にある場合は削除のみ + if (hasCloseButton) { + const tokenBefore = sourceCode.getTokenBefore(decoratorsAttr) + if (tokenBefore && tokenBefore.range[1] < decoratorsAttr.range[0]) { + return fixer.removeRange([tokenBefore.range[1], decoratorsAttr.range[1]]) + } + return fixer.remove(decoratorsAttr) + } + + // decorators={{ closeButtonLabel: () => "OK" }}から値を抽出 + const extractedValue = extractDecoratorValue(decoratorsAttr, 'closeButtonLabel') + if (extractedValue) { + // 自動修正可能: decoratorsを削除してcloseButtonを追加 + return [ + fixer.replaceText(decoratorsAttr, `closeButton=${extractedValue}`), + ] + } + + // 複雑なため、エラーのみ表示(手動対応) + return null + }, + }) + } + } + }, + + // ============================================================ + // AppLauncher の decorators.triggerLabel を triggerLabel に移行 + // ============================================================ + + 'JSXOpeningElement[name.name="AppLauncher"] > JSXAttribute[name.name="decorators"]'(node) { + // decorators属性の値を解析してtriggerLabelがあるかチェック + const decoratorsValue = sourceCode.getText(node.value) + if (decoratorsValue.includes('triggerLabel')) { + // triggerLabel属性が既にあるかチェック + const triggerLabelAttr = node.parent.attributes.find( + (attr) => attr.type === 'JSXAttribute' && attr.name.name === 'triggerLabel' + ) + + context.report({ + node, + messageId: 'migrateAppLauncherDecorators', + data: { to: TARGET_VERSION }, + fix(fixer) { + // triggerLabel属性が既にある場合は削除のみ + if (triggerLabelAttr) { + const tokenBefore = sourceCode.getTokenBefore(node) + if (tokenBefore && tokenBefore.range[1] < node.range[0]) { + return fixer.removeRange([tokenBefore.range[1], node.range[1]]) + } + return fixer.remove(node) + } + + // decorators={{ triggerLabel: () => "Apps" }}から値を抽出 + const extractedValue = extractDecoratorValue(node, 'triggerLabel') + if (extractedValue) { + // 固定値(リテラル)の場合 → decoratorsを削除するだけ + // 動的な値(変数など)の場合 → triggerLabel属性に移行 + if (extractedValue.startsWith('"') || extractedValue.startsWith("'")) { + // 固定値: decoratorsを削除 + const tokenBefore = sourceCode.getTokenBefore(node) + if (tokenBefore && tokenBefore.range[1] < node.range[0]) { + return fixer.removeRange([tokenBefore.range[1], node.range[1]]) + } + return fixer.remove(node) + } else { + // 動的な値: triggerLabel属性に移行 + return fixer.replaceText(node, `triggerLabel=${extractedValue}`) + } + } + + // 複雑なため、エラーのみ表示(手動対応) + return null + }, + }) + } + }, + + // ============================================================ + // 5. MessageDialog の decorators 削除と closeButton への統一 + // ============================================================ + + 'JSXOpeningElement[name.name="MessageDialog"] > JSXAttribute[name.name="decorators"]'(node) { + // decorators属性の値を解析してcloseButtonLabelがあるかチェック + const decoratorsValue = sourceCode.getText(node.value) + if (decoratorsValue.includes('closeButtonLabel')) { + // closeButton属性が既にあるかチェック + const closeButtonAttr = node.parent.attributes.find( + (attr) => attr.type === 'JSXAttribute' && attr.name.name === 'closeButton' + ) + + context.report({ + node, + messageId: 'migrateMessageDialogDecorators', + data: { to: TARGET_VERSION }, + fix(fixer) { + // closeButton属性が既にある場合は削除のみ + if (closeButtonAttr) { + const tokenBefore = sourceCode.getTokenBefore(node) + if (tokenBefore && tokenBefore.range[1] < node.range[0]) { + return fixer.removeRange([tokenBefore.range[1], node.range[1]]) + } + return fixer.remove(node) + } + + // decorators={{ closeButtonLabel: () => "OK" }}から値を抽出 + const extractedValue = extractDecoratorValue(node, 'closeButtonLabel') + if (extractedValue) { + // 自動修正可能: decoratorsを削除してcloseButtonを追加 + return fixer.replaceText(node, `closeButton=${extractedValue}`) + } + + // 複雑なため、エラーのみ表示(手動対応) + return null + }, + }) + } + }, + } + + // ============================================================ + // ヘルパー関数 + // ============================================================ + + /** + * JSX属性の値を取得 + * + * @param {Object} attr - 属性のASTノード + * @returns {string} 属性値の文字列表現 + */ + function getAttributeValue(attr) { + if (!attr || !attr.value) return '""' + + // 文字列リテラル: actionText="保存" + if (attr.value.type === 'Literal') { + return JSON.stringify(attr.value.value) + } + + // JSX式: actionTheme={"danger"} または actionDisabled={false} + if (attr.value.type === 'JSXExpressionContainer') { + const expr = attr.value.expression + if (expr.type === 'Literal') { + return JSON.stringify(expr.value) + } + // 変数や式の場合はソースコードをそのまま取得 + return sourceCode.getText(expr) + } + + return '""' + } + + /** + * decorators属性から指定されたプロパティの値を抽出 + * + * decorators={{ closeButtonLabel: () => "OK" }} から "OK" を抽出 + * decorators={{ closeButtonLabel: () => variable }} から {variable} を抽出 + * + * @param {Object} decoratorsAttr - decorators属性のASTノード + * @param {string} propertyName - 抽出するプロパティ名(例: 'closeButtonLabel') + * @returns {string|null} 抽出された値の文字列表現、または抽出不可の場合null + */ + function extractDecoratorValue(decoratorsAttr, propertyName) { + if (!decoratorsAttr || !decoratorsAttr.value) return null + + // decorators={...}のJSXExpressionContainerを取得 + if (decoratorsAttr.value.type !== 'JSXExpressionContainer') return null + + const expr = decoratorsAttr.value.expression + // ObjectExpression: { closeButtonLabel: ... } + if (expr.type !== 'ObjectExpression') return null + + // closeButtonLabelプロパティを探す + const property = expr.properties.find((prop) => { + return ( + prop.type === 'Property' && + prop.key && + ((prop.key.type === 'Identifier' && prop.key.name === propertyName) || + (prop.key.type === 'Literal' && prop.key.value === propertyName)) + ) + }) + + if (!property || !property.value) return null + + // ArrowFunctionExpression: () => "OK" + if (property.value.type !== 'ArrowFunctionExpression') return null + + // 引数なしの場合のみ処理 + if (property.value.params.length !== 0) return null + + const body = property.value.body + + // BlockStatement(ブロック形式)は対応しない: () => { return "OK" } + if (body.type === 'BlockStatement') return null + + // Literal: () => "OK" → "OK" + if (body.type === 'Literal') { + return JSON.stringify(body.value) + } + + // その他のExpression: () => variable → {variable} + // () => a() → {a()} + // () => obj.prop → {obj.prop} + return `{${sourceCode.getText(body)}}` + } + + return checkers + }, +} diff --git a/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/test.js b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/test.js new file mode 100644 index 00000000..b8ee5024 --- /dev/null +++ b/packages/eslint-plugin-smarthr/rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/test.js @@ -0,0 +1,482 @@ +/** + * smarthr-ui v94 → v95 移行ルール テストケース + */ + +const v94ToV95Options = [{ from: '94', to: '95' }] + +// ============================================================ +// validテストケース(エラーにならないコード) +// ============================================================ + +const valid = [ + // decoratorsなし + { code: '', options: v94ToV95Options }, + { code: '', options: v94ToV95Options }, + { code: '', options: v94ToV95Options }, + + // AppLauncher with triggerLabel + { code: '', options: v94ToV95Options }, + { code: '', options: v94ToV95Options }, + + // 既に新しい属性を使用 + { code: '', options: v94ToV95Options }, + { code: '', options: v94ToV95Options }, + { code: '', options: v94ToV95Options }, + { code: '', options: v94ToV95Options }, + + // 対象外のコンポーネント + { code: ' "全選択" }} />', options: v94ToV95Options }, + { code: '', options: v94ToV95Options }, +] + +// ============================================================ +// invalidテストケース(エラーになり、自動修正されるコード) +// ============================================================ + +const invalid = [ + // ============================================================ + // LanguageSwitcher, AppLauncher の decorators 属性削除 + // ============================================================ + + // LanguageSwitcher + { + code: ' "Language" }} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'removeDecorators', + data: { component: 'LanguageSwitcher', to: 'v95' }, + }, + ], + }, + + // AppLauncher(固定値の場合、decoratorsを削除) + { + code: ' "Apps" }} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateAppLauncherDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // AppLauncher(シングルクォートの固定値、decoratorsを削除) + { + code: " 'Apps' }} />", + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateAppLauncherDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // AppLauncher(動的な値、triggerLabel属性に移行) + { + code: ' featureName }} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateAppLauncherDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // AppLauncher(関数呼び出し、triggerLabel属性に移行) + { + code: ' getLabel() }} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateAppLauncherDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // AppLauncher(オブジェクトプロパティ、triggerLabel属性に移行) + { + code: ' labels.app }} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateAppLauncherDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // AppLauncher(引数あり、自動修正不可) + { + code: ' lang === "ja" ? "アプリ" : "Apps" }} />', + output: null, + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateAppLauncherDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // AppLauncher(BlockStatement、自動修正不可) + { + code: ' { return "Apps" } }} />', + output: null, + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateAppLauncherDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // AppLauncher(triggerLabel属性が既にある場合、decoratorsを削除) + { + code: ' "Apps" }} triggerLabel={featureName} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateAppLauncherDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // 改行あり + { + code: ` "言語" }} +/>`, + output: ``, + options: v94ToV95Options, + errors: [ + { + messageId: 'removeDecorators', + data: { component: 'LanguageSwitcher', to: 'v95' }, + }, + ], + }, + + // ============================================================ + // InputFile の decorators 属性削除 + // ============================================================ + + { + code: ' "削除" }} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'removeDecorators', + data: { component: 'InputFile', to: 'v95' }, + }, + ], + }, + + // ============================================================ + // FormDialog のボタン属性統合 + // ============================================================ + + // actionText のみ(自動修正可能) + { + code: '', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateActionText', + data: { component: 'FormDialog', to: 'v95' }, + }, + ], + }, + + // actionText + actionTheme(自動修正可能) + { + code: '', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateActionText', + data: { component: 'FormDialog', to: 'v95' }, + }, + { + messageId: 'migrateActionTheme', + data: { component: 'FormDialog', to: 'v95' }, + }, + ], + }, + + // actionText + actionDisabled(自動修正可能) + { + code: '', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateActionText', + data: { component: 'FormDialog', to: 'v95' }, + }, + { + messageId: 'migrateActionDisabled', + data: { component: 'FormDialog', to: 'v95' }, + }, + ], + }, + + // actionText + actionTheme + actionDisabled(自動修正可能) + { + code: '', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateActionText', + data: { component: 'FormDialog', to: 'v95' }, + }, + { + messageId: 'migrateActionTheme', + data: { component: 'FormDialog', to: 'v95' }, + }, + { + messageId: 'migrateActionDisabled', + data: { component: 'FormDialog', to: 'v95' }, + }, + ], + }, + + // closeDisabled(エラーのみ、自動修正なし) + { + code: '', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateActionText', + data: { component: 'FormDialog', to: 'v95' }, + }, + { + messageId: 'migrateCloseDisabled', + data: { component: 'FormDialog', to: 'v95' }, + }, + ], + }, + + // decorators.closeButtonLabel(自動修正可能) + { + code: ' "キャンセル" }} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateDecoratorsCloseButtonLabel', + data: { component: 'FormDialog', to: 'v95' }, + }, + ], + }, + + // actionButton が既にある場合、actionText を削除 + { + code: '', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateActionText', + data: { component: 'FormDialog', to: 'v95' }, + }, + ], + }, + + // closeButton が既にある場合、decorators を削除 + { + code: ' "キャンセル" }} closeButton="閉じる" />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateDecoratorsCloseButtonLabel', + data: { component: 'FormDialog', to: 'v95' }, + }, + ], + }, + + // ============================================================ + // ActionDialog のボタン属性統合 + // ============================================================ + + // actionText のみ(自動修正可能) + { + code: '', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateActionText', + data: { component: 'ActionDialog', to: 'v95' }, + }, + ], + }, + + // actionText + actionTheme(自動修正可能) + { + code: '', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateActionText', + data: { component: 'ActionDialog', to: 'v95' }, + }, + { + messageId: 'migrateActionTheme', + data: { component: 'ActionDialog', to: 'v95' }, + }, + ], + }, + + // ============================================================ + // MessageDialog の decorators 削除 + // ============================================================ + + // decorators.closeButtonLabel(自動修正可能) + { + code: ' "OK" }} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateMessageDialogDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // closeButton が既にある場合、decorators を削除 + { + code: ' "OK" }} closeButton="閉じる" />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateMessageDialogDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // decorators.closeButtonLabel(シングルクォート、自動修正可能) + { + code: " 'OK' }} />", + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateMessageDialogDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // decorators.closeButtonLabel(変数、自動修正可能) + { + code: ' buttonLabel }} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateMessageDialogDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // decorators.closeButtonLabel(BlockStatement、自動修正不可) + { + code: ' { return "OK" } }} />', + output: null, + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateMessageDialogDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // decorators.closeButtonLabel(関数呼び出し、自動修正可能) + { + code: ' getLabel() }} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateMessageDialogDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // decorators.closeButtonLabel(オブジェクトプロパティ、自動修正可能) + { + code: ' labels.close }} />', + output: '', + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateMessageDialogDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // decorators.closeButtonLabel(引数あり、自動修正不可) + { + code: ' lang === "ja" ? "OK" : "Close" }} />', + output: null, + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateMessageDialogDecorators', + data: { to: 'v95' }, + }, + ], + }, + + // 改行あり + { + code: ` + 内容 +`, + output: ` + 内容 +`, + options: v94ToV95Options, + errors: [ + { + messageId: 'migrateActionText', + data: { component: 'FormDialog', to: 'v95' }, + }, + ], + }, +] + +module.exports = { valid, invalid } diff --git a/packages/eslint-plugin-smarthr/test/autofixer-smarthr-ui-migration.js b/packages/eslint-plugin-smarthr/test/autofixer-smarthr-ui-migration.js index a14cb36f..7cb31bce 100644 --- a/packages/eslint-plugin-smarthr/test/autofixer-smarthr-ui-migration.js +++ b/packages/eslint-plugin-smarthr/test/autofixer-smarthr-ui-migration.js @@ -4,6 +4,7 @@ const v90ToV91Tests = require('../rules/autofixer-smarthr-ui-migration/versions/ const v91ToV92Tests = require('../rules/autofixer-smarthr-ui-migration/versions/v91-to-v92/test') const v92ToV93Tests = require('../rules/autofixer-smarthr-ui-migration/versions/v92-to-v93/test') const v93ToV94Tests = require('../rules/autofixer-smarthr-ui-migration/versions/v93-to-v94/test') +const v94ToV95Tests = require('../rules/autofixer-smarthr-ui-migration/versions/v94-to-v95/test') const ruleTester = new RuleTester({ languageOptions: { @@ -21,6 +22,7 @@ ruleTester.run('autofixer-smarthr-ui-migration', rule, { ...v91ToV92Tests.valid, ...v92ToV93Tests.valid, ...v93ToV94Tests.valid, + ...v94ToV95Tests.valid, ], invalid: [ @@ -33,7 +35,7 @@ ruleTester.run('autofixer-smarthr-ui-migration', rule, { }, { code: `import { ActionDialog } from 'smarthr-ui'`, - options: [{ from: '94', to: '95' }], + options: [{ from: '95', to: '96' }], errors: [{ messageId: 'unsupportedVersion' }], }, @@ -42,9 +44,9 @@ ruleTester.run('autofixer-smarthr-ui-migration', rule, { // ============================================================ { code: `import { ThCheckbox } from 'smarthr-ui'`, - options: [{ from: '93', to: '95' }], + options: [{ from: '94', to: '96' }], errors: [ - { messageId: 'skippedVersion', data: { version: 'v95' } }, + { messageId: 'skippedVersion', data: { version: 'v96' } }, ], }, { @@ -69,5 +71,6 @@ ruleTester.run('autofixer-smarthr-ui-migration', rule, { ...v91ToV92Tests.invalid, ...v92ToV93Tests.invalid, ...v93ToV94Tests.invalid, + ...v94ToV95Tests.invalid, ], })