diff --git a/MIGRATION_GUIDE_v5.md b/MIGRATION_GUIDE_v5.md index 32767eb1..6bc684aa 100644 --- a/MIGRATION_GUIDE_v5.md +++ b/MIGRATION_GUIDE_v5.md @@ -194,3 +194,80 @@ itemWrapper={(params, makeItem, { compact }) => makeItem(params)} // After (v5.0) itemWrapper={(params, makeItem, { pinned }) => makeItem(params)} ``` + +## Step-by-Step Migration + +### Step 1: Update Dependencies + +```bash +npm install @gravity-ui/navigation@^5.0.0 +``` + +### Step 2: Run Automated Migration + +The easiest way to migrate `compact` → `isExpanded` props is to use our codemod: + +```bash +# Complete migration (recommended) +npx navigation-codemod v5 src/ +``` + +The codemod automatically handles: + +- **Literal values**: `compact={true}` → `isExpanded={false}` +- **Variable references**: `compact={isCompact}` → `isExpanded={!isCompact}` +- **Double negation removal**: `compact={!isExpanded}` → `isExpanded={isExpanded}` +- **Complex expressions**: `compact={a && b}` → `isExpanded={!(a && b)}` +- **Shorthand props**: `compact` → `isExpanded={false}` +- **Destructuring in callbacks**: `({ compact })` → `({ isExpanded })` +- **Callback parameters**: `(node, compact)` → `(node, isExpanded)` +- **Pass-through props**: `compact={compact}` in callbacks → `isExpanded={isExpanded}` (no double inversion) + +### Step 3: Manual Updates (if needed) + +The codemod handles most cases, but you may need to manually update: + +#### Conditional logic using renamed variables + +```tsx +// Before (v4.x) +renderFooter={({ compact }) => ( +
...
+)} + +// After codemod (parameter renamed, but ternary logic needs manual fix) +renderFooter={({ isExpanded }) => ( +
...
// ← swap branches manually +)} +``` + +#### Update CSS variable usage + +Replace deprecated CSS variables with zone-specific alternatives (see CSS Variables section above). + +### Step 4: Verify and Test + +1. **TypeScript Compilation**: Ensure all type errors are resolved +2. **Runtime Testing**: Test navigation expand/collapse functionality +3. **Visual Regression**: Check that UI appears correctly in both states + +## Codemod Limitations + +Our codemod handles most cases automatically, but may not cover: + +1. **Conditional expressions using renamed variables**: Ternary operators like `compact ? 'a' : 'b'` need manual logic inversion +2. **Dynamic property access**: `item['compact']` +3. **Computed property names**: `item[propName]` +4. **Spread patterns with compact**: `{...props, compact: true}` +5. **Non-target components**: Only `FooterItem`, `MobileLogo`, and `Item` components are transformed + +## Migration Checklist + +- [ ] **Dependencies**: Update to @gravity-ui/navigation@^5.0.0 +- [ ] **Run Codemod**: Execute `compact-to-is-expanded` transform on your codebase +- [ ] **Review Conditionals**: Manually check ternary expressions using renamed variables +- [ ] **CSS Variables**: Update deprecated CSS variable names to zone-specific alternatives +- [ ] **Context Usage**: Update any direct usage of `AsideHeaderContext` with new prop names +- [ ] **TypeScript**: Resolve any remaining type errors +- [ ] **Tests**: Update test assertions and mocks +- [ ] **Visual Testing**: Verify navigation works correctly in both expanded and collapsed states diff --git a/codemods/bin/cli.js b/codemods/bin/cli.js index 50a585f1..6a079cbb 100755 --- a/codemods/bin/cli.js +++ b/codemods/bin/cli.js @@ -9,7 +9,7 @@ const {program} = require('commander'); const PACKAGE_DIR = path.dirname(__dirname); const TRANSFORMS_DIR = path.join(PACKAGE_DIR, 'transforms'); -const AVAILABLE_TRANSFORMS = ['v4']; +const AVAILABLE_TRANSFORMS = ['v4', 'v5']; // Get available transforms const availableTransforms = fs @@ -114,18 +114,30 @@ program }); }); -// Default command to run v4 transforms +// Command to run v4 transforms program .command('v4 ') - .description('Run all transforms in sequence') + .description('Run v4 migration transforms') .option('-d, --dry', 'Dry run (no changes will be made)') .option('-v, --verbose', 'Verbose output') .option('--ignore-pattern ', 'Ignore files matching this pattern') .action((targetPath, options) => { - console.log('Running all transforms in sequence...'); + console.log('Running v4 migration transforms...'); runTransform('v4', targetPath, options); }); +// Command to run v5 transforms +program + .command('v5 ') + .description('Run v5 migration transforms (compact → isExpanded)') + .option('-d, --dry', 'Dry run (no changes will be made)') + .option('-v, --verbose', 'Verbose output') + .option('--ignore-pattern ', 'Ignore files matching this pattern') + .action((targetPath, options) => { + console.log('Running v5 migration transforms...'); + runTransform('v5', targetPath, options); + }); + // Help command program .command('help') @@ -138,8 +150,9 @@ program program.on('--help', () => { console.log(''); console.log('Examples:'); - console.log(' $ navigation-codemod transform v4 ./src'); - console.log(' $ navigation-codemod transform v4 ./src --dry'); + console.log(' $ navigation-codemod v4 ./src'); + console.log(' $ navigation-codemod v5 ./src'); + console.log(' $ navigation-codemod v5 ./src --dry'); console.log(' $ navigation-codemod list'); console.log(''); console.log('Available transforms:'); diff --git a/codemods/transforms/__testfixtures__/compactToIsExpanded.input.tsx b/codemods/transforms/__testfixtures__/compactToIsExpanded.input.tsx new file mode 100644 index 00000000..006ead42 --- /dev/null +++ b/codemods/transforms/__testfixtures__/compactToIsExpanded.input.tsx @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +// @ts-nocheck +import React from 'react'; +import {AsideHeader, FooterItem, MobileLogo} from '@gravity-ui/navigation'; + +// Test 1: Literal boolean values +function LiteralBooleans() { + return ( + <> + + + + + ); +} + +// Test 2: Shorthand boolean (compact without value means compact={true}) +function ShorthandBoolean() { + return ; +} + +// Test 3: Variable reference +function VariableReference() { + const isCompact = true; + const someState = false; + + return ( + <> + + + + ); +} + +// Test 4: Already negated expression - should remove double negation +function NegatedExpression() { + const isExpanded = true; + const someVar = false; + + return ( + <> + + + + ); +} + +// Test 5: Complex expressions +function ComplexExpressions() { + const a = true; + const b = false; + const getValue = () => true; + + return ( + <> + + + + ); +} + +// Test 6: Destructuring in renderFooter callback (JSX) +function RenderFooterCallback() { + return ( + ( + + )} + /> + ); +} + +// Test 7: Destructuring in renderFooter callback (object) +const config = { + renderFooter: ({compact}) => { + return ; + }, +}; + +// Test 8: Logo wrapper with second parameter +const logoConfig = { + logo: { + wrapper: (node, compact) => {node}, + }, +}; + +// Test 9: collapseButtonWrapper +function CollapseButtonWrapper() { + return ( + ( +
{node}
+ )} + /> + ); +} + +// Test 10: Non-target component should NOT be transformed +function NonTargetComponent() { + return ; +} diff --git a/codemods/transforms/__testfixtures__/compactToIsExpanded.output.tsx b/codemods/transforms/__testfixtures__/compactToIsExpanded.output.tsx new file mode 100644 index 00000000..e913349b --- /dev/null +++ b/codemods/transforms/__testfixtures__/compactToIsExpanded.output.tsx @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +// @ts-nocheck +import React from 'react'; +import {AsideHeader, FooterItem, MobileLogo} from '@gravity-ui/navigation'; + +// Test 1: Literal boolean values +function LiteralBooleans() { + return ( + <> + + + + + ); +} + +// Test 2: Shorthand boolean (compact without value means compact={true}) +function ShorthandBoolean() { + return ; +} + +// Test 3: Variable reference +function VariableReference() { + const isCompact = true; + const someState = false; + + return ( + <> + + + + ); +} + +// Test 4: Already negated expression - should remove double negation +function NegatedExpression() { + const isExpanded = true; + const someVar = false; + + return ( + <> + + + + ); +} + +// Test 5: Complex expressions +function ComplexExpressions() { + const a = true; + const b = false; + const getValue = () => true; + + return ( + <> + + + + ); +} + +// Test 6: Destructuring in renderFooter callback (JSX) +function RenderFooterCallback() { + return ( + ( + + )} + /> + ); +} + +// Test 7: Destructuring in renderFooter callback (object) +const config = { + renderFooter: ({isExpanded}) => { + return ; + }, +}; + +// Test 8: Logo wrapper with second parameter +const logoConfig = { + logo: { + wrapper: (node, isExpanded) => {node}, + }, +}; + +// Test 9: collapseButtonWrapper +function CollapseButtonWrapper() { + return ( + ( +
{node}
+ )} + /> + ); +} + +// Test 10: Non-target component should NOT be transformed +function NonTargetComponent() { + return ; +} diff --git a/codemods/transforms/__testfixtures__/v5.input.tsx b/codemods/transforms/__testfixtures__/v5.input.tsx new file mode 100644 index 00000000..a962394f --- /dev/null +++ b/codemods/transforms/__testfixtures__/v5.input.tsx @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +// @ts-nocheck +import React from 'react'; +import {AsideHeader, FooterItem, MobileLogo} from '@gravity-ui/navigation'; + +// Basic FooterItem with compact prop +function BasicExample() { + return ; +} + +// MobileLogo with compact prop +function MobileExample() { + return ; +} + +// renderFooter callback with destructuring +function CallbackExample() { + return ( + ( + + )} + /> + ); +} diff --git a/codemods/transforms/__testfixtures__/v5.output.tsx b/codemods/transforms/__testfixtures__/v5.output.tsx new file mode 100644 index 00000000..e53b7615 --- /dev/null +++ b/codemods/transforms/__testfixtures__/v5.output.tsx @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +// @ts-nocheck +import React from 'react'; +import {AsideHeader, FooterItem, MobileLogo} from '@gravity-ui/navigation'; + +// Basic FooterItem with compact prop +function BasicExample() { + return ; +} + +// MobileLogo with compact prop +function MobileExample() { + return ; +} + +// renderFooter callback with destructuring +function CallbackExample() { + return ( + ( + + )} + /> + ); +} diff --git a/codemods/transforms/__tests__/compactToIsExpanded.test.ts b/codemods/transforms/__tests__/compactToIsExpanded.test.ts new file mode 100644 index 00000000..f8edf3db --- /dev/null +++ b/codemods/transforms/__tests__/compactToIsExpanded.test.ts @@ -0,0 +1,7 @@ +import {defineTest} from 'jscodeshift/src/testUtils'; + +const testName = 'compactToIsExpanded'; + +defineTest(__dirname, testName, null, testName, { + parser: 'tsx', +}); diff --git a/codemods/transforms/__tests__/v5.test.ts b/codemods/transforms/__tests__/v5.test.ts new file mode 100644 index 00000000..b4646c24 --- /dev/null +++ b/codemods/transforms/__tests__/v5.test.ts @@ -0,0 +1,7 @@ +import {defineTest} from 'jscodeshift/src/testUtils'; + +const testName = 'v5'; + +defineTest(__dirname, testName, null, testName, { + parser: 'tsx', +}); diff --git a/codemods/transforms/compactToIsExpanded.ts b/codemods/transforms/compactToIsExpanded.ts new file mode 100644 index 00000000..71345b0a --- /dev/null +++ b/codemods/transforms/compactToIsExpanded.ts @@ -0,0 +1,345 @@ +/* eslint-disable no-param-reassign */ +import {execSync} from 'child_process'; + +import type { + API, + ASTPath, + ArrowFunctionExpression, + Collection, + FileInfo, + FunctionExpression, + JSCodeshift, + JSXAttribute, + JSXIdentifier, + ObjectPattern, + ObjectProperty, +} from 'jscodeshift'; + +/** + * Codemod to transform `compact` prop to `isExpanded` with inverted boolean logic. + * + * Transformations: + * - compact={true} → isExpanded={false} + * - compact={false} → isExpanded={true} + * - compact (shorthand) → isExpanded={false} + * - compact={someVar} → isExpanded={!someVar} + * - compact={!someVar} → isExpanded={someVar} + * - compact={expression} → isExpanded={!(expression)} + * + * Special case for destructured parameters: + * - renderFooter={({ compact }) => } + * - → renderFooter={({ isExpanded }) => } + * - (No inversion when the value is the renamed destructured parameter) + * + * Also handles: + * - Function parameter destructuring: ({ compact }) => → ({ isExpanded }) => + * - Callback second parameter: (node, compact) => → (node, isExpanded) => + */ + +const TARGET_JSX_COMPONENTS = new Set(['FooterItem', 'MobileLogo', 'Item']); + +const RENDER_CALLBACK_PROPS = new Set(['renderFooter', 'collapseButtonWrapper']); + +export default function transformer(file: FileInfo, api: API) { + const j = api.jscodeshift; + const root = j(file.source); + + // First pass: rename destructuring parameters and callback params + const hasDestructuringUpdate = updateDestructuringParams(root, j); + const hasCallbackParamUpdate = updateCallbackSecondParam(root, j); + + // Second pass: update JSX props with awareness of renamed identifiers + const hasJsxPropsUpdate = updateJsxProps(root, j); + + if (!hasJsxPropsUpdate && !hasDestructuringUpdate && !hasCallbackParamUpdate) { + return null; + } + + const output = root.toSource(); + + // Format with Prettier to ensure consistent output + // Uses project's prettier config if available (searches from cwd) + // Falls back to unformatted output if Prettier is not installed or fails + try { + const formatted = execSync('prettier --parser typescript', { + input: output, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return formatted; + } catch { + // Prettier not installed or failed - return unformatted output + // User can run their own formatter afterwards + return output; + } +} + +/** + * Inverts a JSX expression value. + * - true → false + * - false → true + * - someVar → !someVar + * - !someVar → someVar + * - (expression) → !(expression) + */ +function invertExpression(j: JSCodeshift, expr: any): any { + // Handle literal booleans - modify in place to preserve formatting + if (j.BooleanLiteral.check(expr) || j.Literal.check(expr)) { + const value = expr.value; + if (typeof value === 'boolean') { + expr.value = !value; + return expr; + } + } + + // Handle unary negation: !something → something + if (j.UnaryExpression.check(expr) && expr.operator === '!') { + return expr.argument; + } + + // Handle identifiers and other expressions: something → !something + return j.unaryExpression('!', expr); +} + +/** + * Update JSX props: compact → isExpanded with inverted value + * Special handling: if value is an identifier that was renamed from destructuring, + * just rename it without inversion. + */ +function updateJsxProps(root: Collection, j: JSCodeshift): boolean { + let hasChanges = false; + + root.find(j.JSXAttribute, { + name: {type: 'JSXIdentifier', name: 'compact'}, + }).forEach((path: ASTPath) => { + const jsxElement = path.parentPath.parentPath; + + // Check if this is on a target component + if (jsxElement && j.JSXOpeningElement.check(jsxElement.node)) { + const elementName = jsxElement.node.name; + let componentName: string | null = null; + + if (j.JSXIdentifier.check(elementName)) { + componentName = elementName.name; + } + + // Only transform if it's a target component or if we can't determine + if (componentName && !TARGET_JSX_COMPONENTS.has(componentName)) { + return; + } + } + + const attrNode = path.node; + const attrValue = attrNode.value; + + // Rename compact → isExpanded + (attrNode.name as JSXIdentifier).name = 'isExpanded'; + + if (attrValue === null) { + // Shorthand: compact → isExpanded={false} + attrNode.value = j.jsxExpressionContainer(j.booleanLiteral(false)); + hasChanges = true; + } else if (j.JSXExpressionContainer.check(attrValue)) { + const expr = attrValue.expression; + + if (!j.JSXEmptyExpression.check(expr)) { + // Check if this is a simple identifier named 'compact' + // that might be from a renamed destructuring parameter + if (j.Identifier.check(expr) && expr.name === 'compact') { + // Just rename it to isExpanded in place (no inversion) + // This handles the case: compact={compact} → isExpanded={isExpanded} + expr.name = 'isExpanded'; + } else if (j.Identifier.check(expr) && expr.name === 'isExpanded') { + // Already renamed by destructuring pass, leave as is + } else { + // Invert the expression + attrValue.expression = invertExpression(j, expr); + } + } + hasChanges = true; + } else if (j.StringLiteral.check(attrValue)) { + // Edge case: compact="true" or compact="false" (unusual but handle it) + const strValue = attrValue.value; + if (strValue === 'true') { + attrNode.value = j.jsxExpressionContainer(j.booleanLiteral(false)); + hasChanges = true; + } else if (strValue === 'false') { + attrNode.value = j.jsxExpressionContainer(j.booleanLiteral(true)); + hasChanges = true; + } + } + }); + + return hasChanges; +} + +/** + * Update destructuring parameters in render callbacks: + * renderFooter={({ compact }) => ...} → renderFooter={({ isExpanded }) => ...} + */ +function updateDestructuringParams(root: Collection, j: JSCodeshift): boolean { + let hasChanges = false; + + // Find JSX attributes that are render callbacks + root.find(j.JSXAttribute).forEach((attrPath: ASTPath) => { + const attrName = attrPath.node.name; + if (!j.JSXIdentifier.check(attrName)) return; + + if (!RENDER_CALLBACK_PROPS.has(attrName.name)) return; + + const attrValue = attrPath.node.value; + if (!j.JSXExpressionContainer.check(attrValue)) return; + + const expr = attrValue.expression; + if (!j.ArrowFunctionExpression.check(expr) && !j.FunctionExpression.check(expr)) return; + + const funcExpr = expr as ArrowFunctionExpression | FunctionExpression; + const params = funcExpr.params; + + // Look for destructuring pattern with 'compact' + params.forEach((param) => { + if (j.ObjectPattern.check(param)) { + const objPattern = param as ObjectPattern; + objPattern.properties.forEach((prop) => { + if (j.ObjectProperty.check(prop) || j.Property.check(prop)) { + const key = (prop as ObjectProperty).key; + if (j.Identifier.check(key) && key.name === 'compact') { + key.name = 'isExpanded'; + hasChanges = true; + + // Also update the value if it's a shorthand (compact → isExpanded) + const value = (prop as ObjectProperty).value; + if (j.Identifier.check(value) && value.name === 'compact') { + value.name = 'isExpanded'; + + // Rename all references to 'compact' in the function body to 'isExpanded' + // and invert ternary expressions where compact is the test + j(funcExpr) + .find(j.Identifier, {name: 'compact'}) + .forEach((identPath) => { + // Skip if this is the parameter itself + if (identPath.value !== value) { + identPath.value.name = 'isExpanded'; + + // Check if this identifier is the test of a conditional expression + if ( + identPath.parent && + j.ConditionalExpression.check( + identPath.parent.value, + ) && + identPath.parent.value.test === identPath.value + ) { + // Swap consequent and alternate to invert the logic + const temp = identPath.parent.value.consequent; + identPath.parent.value.consequent = + identPath.parent.value.alternate; + identPath.parent.value.alternate = temp; + } + } + }); + } + } + } + }); + } + }); + }); + + // Also find object properties that are render callbacks + root.find(j.ObjectProperty).forEach((propPath: ASTPath) => { + const propKey = propPath.node.key; + if (!j.Identifier.check(propKey)) return; + + if (!RENDER_CALLBACK_PROPS.has(propKey.name) && propKey.name !== 'wrapper') return; + + const propValue = propPath.node.value; + if (!j.ArrowFunctionExpression.check(propValue) && !j.FunctionExpression.check(propValue)) + return; + + const funcExpr = propValue as ArrowFunctionExpression | FunctionExpression; + const params = funcExpr.params; + + // Look for destructuring pattern with 'compact' + params.forEach((param) => { + if (j.ObjectPattern.check(param)) { + const objPattern = param as ObjectPattern; + objPattern.properties.forEach((prop) => { + if (j.ObjectProperty.check(prop) || j.Property.check(prop)) { + const key = (prop as ObjectProperty).key; + if (j.Identifier.check(key) && key.name === 'compact') { + key.name = 'isExpanded'; + hasChanges = true; + + const value = (prop as ObjectProperty).value; + if (j.Identifier.check(value) && value.name === 'compact') { + value.name = 'isExpanded'; + + // Rename all references to 'compact' in the function body to 'isExpanded' + // and invert ternary expressions where compact is the test + j(funcExpr) + .find(j.Identifier, {name: 'compact'}) + .forEach((identPath) => { + // Skip if this is the parameter itself + if (identPath.value !== value) { + identPath.value.name = 'isExpanded'; + + // Check if this identifier is the test of a conditional expression + if ( + identPath.parent && + j.ConditionalExpression.check( + identPath.parent.value, + ) && + identPath.parent.value.test === identPath.value + ) { + // Swap consequent and alternate to invert the logic + const temp = identPath.parent.value.consequent; + identPath.parent.value.consequent = + identPath.parent.value.alternate; + identPath.parent.value.alternate = temp; + } + } + }); + } + } + } + }); + } + }); + }); + + return hasChanges; +} + +/** + * Update callback second parameter: + * wrapper: (node, compact) => ... → wrapper: (node, isExpanded) => ... + */ +function updateCallbackSecondParam(root: Collection, j: JSCodeshift): boolean { + let hasChanges = false; + + // Find 'wrapper' property in objects (e.g., logo={{ wrapper: (node, compact) => ... }}) + root.find(j.ObjectProperty, { + key: {type: 'Identifier', name: 'wrapper'}, + }).forEach((propPath: ASTPath) => { + const propValue = propPath.node.value; + + if (!j.ArrowFunctionExpression.check(propValue) && !j.FunctionExpression.check(propValue)) { + return; + } + + const funcExpr = propValue as ArrowFunctionExpression | FunctionExpression; + const params = funcExpr.params; + + // Check if second parameter is 'compact' + if (params.length >= 2) { + const secondParam = params[1]; + if (j.Identifier.check(secondParam) && secondParam.name === 'compact') { + secondParam.name = 'isExpanded'; + hasChanges = true; + } + } + }); + + return hasChanges; +} diff --git a/codemods/transforms/v5.ts b/codemods/transforms/v5.ts new file mode 100644 index 00000000..69dffb2a --- /dev/null +++ b/codemods/transforms/v5.ts @@ -0,0 +1,32 @@ +import type {API, FileInfo, Options} from 'jscodeshift'; + +import compactToIsExpanded from './compactToIsExpanded'; + +export default function transform(file: FileInfo, api: API, options: Options) { + let source = file.source; + let hasChanges = false; + + const transforms = [{name: 'compact-to-is-expanded', transform: compactToIsExpanded}]; + + for (const {name, transform: transformFn} of transforms) { + try { + const result = transformFn({source, path: file.path}, api); + if (result && result !== source) { + source = result; + hasChanges = true; + + if (options && options.verbose) { + console.log(`✓ Applied ${name}`); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Error applying ${name}:`, errorMessage); + if (options && options.verbose && error instanceof Error) { + console.error(error.stack); + } + } + } + + return hasChanges ? source : null; +}