diff --git a/lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart b/lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart index 2ff07952..8fc5a28f 100644 --- a/lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart +++ b/lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart @@ -15,6 +15,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/utils/props_utils.dart'; import 'package:over_react_codemod/src/util.dart'; import 'package:over_react_codemod/src/util/component_usage.dart'; import 'package:analyzer/dart/ast/ast.dart'; @@ -103,11 +104,7 @@ class ClassComponentRequiredDefaultPropsMigrator // If this cascade is not assigning values to defaultProps, bail. if (!isDefaultProps) return; - final cascadedDefaultProps = node.cascadeSections - .whereType() - .where((assignment) => assignment.leftHandSide is PropertyAccess) - .map((assignment) => PropAssignment(assignment)) - .where((prop) => prop.node.writeElement?.displayName != null); + final cascadedDefaultProps = getCascadedProps(node); patchFieldDeclarations( getAllProps, cascadedDefaultProps, node, _propRequirednessRecommender); diff --git a/lib/src/dart3_suggestors/null_safety_prep/connect_required_props.dart b/lib/src/dart3_suggestors/null_safety_prep/connect_required_props.dart new file mode 100644 index 00000000..11d7f458 --- /dev/null +++ b/lib/src/dart3_suggestors/null_safety_prep/connect_required_props.dart @@ -0,0 +1,137 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:collection/collection.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/utils/props_utils.dart'; +import 'package:over_react_codemod/src/util.dart'; +import 'package:over_react_codemod/src/util/class_suggestor.dart'; + +import 'analyzer_plugin_utils.dart'; + +/// Suggestor that adds `@Props(disableRequiredPropValidation: {...})` annotations +/// for props that are set in `connect` components. +class ConnectRequiredProps extends RecursiveAstVisitor with ClassSuggestor { + /// Running list of props that should be ignored per mixin that will all be added + /// at the end in [generatePatches]. + final _ignoredPropsByMixin = >{}; + + @override + visitCascadeExpression(CascadeExpression node) { + super.visitCascadeExpression(node); + + // Verify the builder usage is within the `connect` method call. + final connect = node.thisOrAncestorMatching( + (n) => n is MethodInvocation && n.methodName.name == 'connect'); + if (connect == null) return; + + // Verify the builder usage is within one of the targeted connect args. + final connectArgs = + connect.argumentList.arguments.whereType(); + final connectArg = node.thisOrAncestorMatching((n) => + n is NamedExpression && + connectArgs.contains(n) && + connectArgNames.contains(n.name.label.name)); + if (connectArg == null) return; + + final cascadedProps = getCascadedProps(node).toList(); + + for (final field in cascadedProps) { + final propsElement = + node.staticType?.typeOrBound.tryCast()?.element; + if (propsElement == null) continue; + + // Keep a running list of props to ignore per props mixin. + final fieldName = field.name.name; + _ignoredPropsByMixin.putIfAbsent(propsElement, () => {}).add(fieldName); + } + } + + @override + Future generatePatches() async { + _ignoredPropsByMixin.clear(); + final result = await context.getResolvedUnit(); + if (result == null) { + throw Exception( + 'Could not get resolved result for "${context.relativePath}"'); + } + result.unit.accept(this); + + // Add the patches at the end so that all the props to be ignored can be collected + // from the different args in `connect` before adding patches to avoid duplicate patches. + _ignoredPropsByMixin.forEach((propsClass, propsToIgnore) { + final classNode = + NodeLocator2(propsClass.nameOffset).searchWithin(result.unit); + if (classNode != null && classNode is NamedCompilationUnitMember) { + final existingAnnotation = + classNode.metadata.where((c) => c.name.name == 'Props').firstOrNull; + + if (existingAnnotation == null) { + // Add full @Props annotation if it doesn't exist. + yieldPatch( + '@Props($annotationArg: {${propsToIgnore.map((p) => '\'$p\'').join(', ')}})\n', + classNode.offset, + classNode.offset); + } else { + final existingAnnotationArg = existingAnnotation.arguments?.arguments + .whereType() + .where((e) => e.name.label.name == annotationArg) + .firstOrNull; + + if (existingAnnotationArg == null) { + // Add disable validation arg to existing @Props annotation. + final offset = existingAnnotation.arguments?.leftParenthesis.end; + if (offset != null) { + yieldPatch( + '$annotationArg: {${propsToIgnore.map((p) => '\'$p\'').join(', ')}}${existingAnnotation.arguments?.arguments.isNotEmpty ?? false ? ', ' : ''}', + offset, + offset); + } + } else { + // Add props to disable validation for to the existing list of disabled + // props in the @Props annotation if they aren't already listed. + final existingList = + existingAnnotationArg.expression.tryCast(); + if (existingList != null) { + final alreadyIgnored = existingList.elements + .whereType() + .map((e) => e.stringValue) + .toList(); + final newPropsToIgnore = + propsToIgnore.where((p) => !alreadyIgnored.contains(p)); + if (newPropsToIgnore.isNotEmpty) { + final offset = existingList.leftBracket.end; + yieldPatch( + '${newPropsToIgnore.map((p) => '\'$p\'').join(', ')}, ', + offset, + offset); + } + } + } + } + } + }); + } + + static const connectArgNames = [ + 'mapStateToProps', + 'mapStateToPropsWithOwnProps', + 'mapDispatchToProps', + 'mapDispatchToPropsWithOwnProps', + ]; + static const annotationArg = 'disableRequiredPropValidation'; +} diff --git a/lib/src/dart3_suggestors/null_safety_prep/state_mixin_suggestor.dart b/lib/src/dart3_suggestors/null_safety_prep/state_mixin_suggestor.dart new file mode 100644 index 00000000..f7e19a3f --- /dev/null +++ b/lib/src/dart3_suggestors/null_safety_prep/state_mixin_suggestor.dart @@ -0,0 +1,67 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/utils/hint_detection.dart'; +import 'package:over_react_codemod/src/util.dart'; +import 'package:analyzer/dart/ast/ast.dart'; + +import '../../util/class_suggestor.dart'; + +/// Suggestor to assist with preparations for null-safety by adding +/// nullability (`?`) hints to state field types. +/// +/// This is intended to be run after [ClassComponentRequiredInitialStateMigrator] +/// to make the rest of the state fields nullable. +class StateMixinSuggestor extends RecursiveAstVisitor + with ClassSuggestor { + @override + void visitVariableDeclaration(VariableDeclaration node) { + super.visitVariableDeclaration(node); + + final isStateClass = (node.declaredElement?.enclosingElement + ?.tryCast() + ?.allSupertypes + .any((s) => s.element.name == 'UiState') ?? + false); + if (!isStateClass) return; + + final fieldDeclaration = node.parentFieldDeclaration; + if (fieldDeclaration == null) return; + if (fieldDeclaration.isStatic) return; + if (fieldDeclaration.fields.isConst) return; + + final type = fieldDeclaration.fields.type; + if (type != null && + (requiredHintAlreadyExists(type) || nullableHintAlreadyExists(type))) { + return; + } + + // Make state field optional. + if (type != null) { + yieldPatch(nullableHint, type.end, type.end); + } + } + + @override + Future generatePatches() async { + final r = await context.getResolvedUnit(); + if (r == null) { + throw Exception( + 'Could not get resolved result for "${context.relativePath}"'); + } + r.unit.accept(this); + } +} diff --git a/lib/src/dart3_suggestors/null_safety_prep/utils/props_utils.dart b/lib/src/dart3_suggestors/null_safety_prep/utils/props_utils.dart new file mode 100644 index 00000000..a902342c --- /dev/null +++ b/lib/src/dart3_suggestors/null_safety_prep/utils/props_utils.dart @@ -0,0 +1,11 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:over_react_codemod/src/util/component_usage.dart'; + +/// Returns a list of props from [cascade]. +Iterable getCascadedProps(CascadeExpression cascade) { + return cascade.cascadeSections + .whereType() + .where((assignment) => assignment.leftHandSide is PropertyAccess) + .map((assignment) => PropAssignment(assignment)) + .where((prop) => prop.node.writeElement?.displayName != null); +} diff --git a/lib/src/executables/null_safety_migrator_companion.dart b/lib/src/executables/null_safety_migrator_companion.dart index 2055085c..cf7335f6 100644 --- a/lib/src/executables/null_safety_migrator_companion.dart +++ b/lib/src/executables/null_safety_migrator_companion.dart @@ -17,14 +17,17 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:codemod/codemod.dart'; import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart'; +import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/connect_required_props.dart'; import 'package:over_react_codemod/src/util.dart'; import '../dart3_suggestors/null_safety_prep/callback_ref_hint_suggestor.dart'; +import '../dart3_suggestors/null_safety_prep/state_mixin_suggestor.dart'; +import '../util/package_util.dart'; const _changesRequiredOutput = """ To update your code, run the following commands in your repository: - pub global activate over_react_codemod - pub global run over_react_codemod:null_safety_prep + dart pub global activate over_react_codemod + dart pub global run over_react_codemod:null_safety_migrator_companion """; /// Codemods in this executable add nullability "hints" to assist with a @@ -36,14 +39,54 @@ void main(List args) async { final parser = ArgParser.allowAnything(); final parsedArgs = parser.parse(args); + final packageRoot = findPackageRootFor('.'); + await runPubGetIfNeeded(packageRoot); final dartPaths = allDartPathsExceptHiddenAndGenerated(); - exitCode = await runInteractiveCodemod( + exitCode = await runInteractiveCodemodSequence( dartPaths, - aggregate([ + [ CallbackRefHintSuggestor(), + ], + defaultYes: true, + args: parsedArgs.rest, + additionalHelpOutput: parser.usage, + changesRequiredOutput: _changesRequiredOutput, + ); + + if (exitCode != 0) return; + + exitCode = await runInteractiveCodemodSequence( + dartPaths, + [ ClassComponentRequiredInitialStateMigrator(), - ]), + ], + defaultYes: true, + args: parsedArgs.rest, + additionalHelpOutput: parser.usage, + changesRequiredOutput: _changesRequiredOutput, + ); + + if (exitCode != 0) return; + + exitCode = await runInteractiveCodemodSequence( + dartPaths, + [ + StateMixinSuggestor(), + ], + defaultYes: true, + args: parsedArgs.rest, + additionalHelpOutput: parser.usage, + changesRequiredOutput: _changesRequiredOutput, + ); + + if (exitCode != 0) return; + + exitCode = await runInteractiveCodemodSequence( + dartPaths, + [ + ConnectRequiredProps(), + ], defaultYes: true, args: parsedArgs.rest, additionalHelpOutput: parser.usage, diff --git a/test/dart3_suggestors/null_safety_prep/connect_required_props_test.dart b/test/dart3_suggestors/null_safety_prep/connect_required_props_test.dart new file mode 100644 index 00000000..6a3894f6 --- /dev/null +++ b/test/dart3_suggestors/null_safety_prep/connect_required_props_test.dart @@ -0,0 +1,262 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/connect_required_props.dart'; +import 'package:test/test.dart'; + +import '../../resolved_file_context.dart'; +import '../../util.dart'; +import '../../util/component_usage_migrator_test.dart'; + +void main() { + final resolvedContext = SharedAnalysisContext.overReact; + + // Warm up analysis in a setUpAll so that if getting the resolved AST times out + // (which is more common for the WSD context), it fails here instead of failing the first test. + setUpAll(resolvedContext.warmUpAnalysis); + + group( + 'ConnectRequiredProps - adds all connect props to disable required prop validation list', + () { + late SuggestorTester testSuggestor; + + String commonConnectFile(String source) { + return ''' + $overReactImport + import 'package:over_react/over_react_redux.dart'; + + // ignore: uri_has_not_been_generated + part 'main.over_react.g.dart'; + + class FooState { + num count; + } + $source'''; + } + + setUp(() { + testSuggestor = getSuggestorTester( + ConnectRequiredProps(), + resolvedContext: resolvedContext, + ); + }); + + test('', () async { + final input = ''' + mixin FooProps on UiProps { + num setInMapStateToProps; + Function() setInMapDispatchToProps; + num setInBoth; + String notSetInConnect; + } + + UiFactory Foo = connect( + mapStateToProps: (state) => (Foo() + ..addTestId('abc') + ..setInMapStateToProps = state.count + ..setInBoth = 1 + ), + mapDispatchToProps: (dispatch) => Foo()..setInMapDispatchToProps = (() => null)..setInBoth = 1, + )(uiFunction((props) => (Foo()..notSetInConnect = '1')(), _\$Foo)); + '''; + + await testSuggestor( + input: commonConnectFile(input), + expectedOutput: commonConnectFile(''' + @Props(disableRequiredPropValidation: {'setInMapStateToProps', 'setInBoth', 'setInMapDispatchToProps'}) + $input + '''), + ); + }); + + test('for multiple mixins', () async { + final input = ''' + class FooProps = UiProps with FooPropsMixin, OtherPropsMixin; + + mixin FooPropsMixin on UiProps { + /*late*/ num prop1; + Function()/*?*/ prop2; + String notSetInConnect; + } + + mixin OtherPropsMixin on UiProps { + String otherProp; + String notSetInConnect2; + } + + UiFactory Foo = connect( + mapStateToProps: (state) => (Foo() + ..addTestId('abc') + ..prop1 = state.count + ..otherProp = '1' + ), + mapDispatchToProps: (dispatch) => Foo()..prop2 = (() => null), + )(uiFunction((props) => (Foo()..notSetInConnect = '1'..notSetInConnect2 = '2')(), _\$Foo)); + '''; + + await testSuggestor( + input: commonConnectFile(input), + expectedOutput: commonConnectFile(''' + @Props(disableRequiredPropValidation: {'prop1', 'otherProp', 'prop2'}) + $input + '''), + ); + }); + + group('adds to existing annotations', () { + Future testAnnotations( + {required String input, required String expectedOutput}) async { + final connectBoilerplate = ''' + mixin FooProps on UiProps { + num connectProp1; + Function() connectProp2; + String nonConnectProp; + } + + UiFactory Foo = connect( + mapStateToProps: (state) => (Foo()..connectProp1 = 1), + mapDispatchToProps: (dispatch) => Foo()..connectProp2 = (() => null), + )(uiFunction((props) => (Foo()..nonConnectProp = '1')(), _\$Foo)); + '''; + await testSuggestor( + input: commonConnectFile(''' + $input + $connectBoilerplate + '''), + expectedOutput: commonConnectFile(''' + $expectedOutput + $connectBoilerplate + '''), + ); + } + + test('', () async { + await testAnnotations( + input: '@Props()', + expectedOutput: + '@Props(disableRequiredPropValidation: {\'connectProp1\', \'connectProp2\'})', + ); + }); + + test('with other args', () async { + await testAnnotations( + input: '@Props(keyNamespace: \'\')', + expectedOutput: + '@Props(disableRequiredPropValidation: {\'connectProp1\', \'connectProp2\'}, keyNamespace: \'\')', + ); + }); + + test('with disableRequiredPropValidation', () async { + await testAnnotations( + input: '@Props(disableRequiredPropValidation: {\'connectProp1\'})', + expectedOutput: + '@Props(disableRequiredPropValidation: {\'connectProp2\', \'connectProp1\'})', + ); + }); + }); + + test('recognizes different arg formats', () async { + final input = ''' + mixin FooProps on UiProps { + /*late*/ num count; + Function()/*?*/ increment; + String abc; + } + + UiFactory Foo = connect( + mapStateToProps: (state) { + return (Foo() + ..count = state.count + ); + }, + mapDispatchToProps: (dispatch) { + final foo = (Foo() + ..increment = (() => null) + ); + return foo; + }, + )(_\$Foo); + '''; + await testSuggestor( + input: commonConnectFile(input), + expectedOutput: commonConnectFile(''' + @Props(disableRequiredPropValidation: {'count', 'increment'}) + $input + '''), + ); + }); + + test('only adds props used in specific connect args', () async { + final input = ''' + mixin FooProps on UiProps { + num propInMapStateToProps; + num propInMapStateToPropsWithOwnProps; + num propInMapDispatchToProps; + num propInMapDispatchToPropsWithOwnProps; + num propInMergeProps; + String notUsed; + } + + UiFactory Foo = connect( + mapStateToProps: (_) => (Foo()..propInMapStateToProps = 1), + mapStateToPropsWithOwnProps: (_, __) => (Foo()..propInMapStateToPropsWithOwnProps = 1), + mapDispatchToProps: (_) => (Foo()..propInMapDispatchToProps = 1), + mapDispatchToPropsWithOwnProps: (_, __) => (Foo()..propInMapDispatchToPropsWithOwnProps = 1), + mergeProps: (_, __, ___) => (Foo()..propInMergeProps = 1), + )(_\$Foo); + '''; + await testSuggestor( + input: commonConnectFile(input), + expectedOutput: commonConnectFile(''' + @Props(disableRequiredPropValidation: {'propInMapStateToProps', 'propInMapStateToPropsWithOwnProps', 'propInMapDispatchToProps', 'propInMapDispatchToPropsWithOwnProps'}) + $input + '''), + ); + }); + + test('does not cover certain unlikely edge cases', () async { + final input = ''' + mixin FooProps on UiProps { + num inTearOff; + Function() notReturned; + String notUsed; + } + + final _mapStateToProps = (state) { + return (Foo() + ..inTearOff = state.count + ); + }; + + UiFactory Foo = connect( + mapStateToProps: _mapStateToProps, + mapDispatchToProps: (dispatch) { + final foo = (Foo() + ..notReturned = (() => null) + ); + foo; + return Foo(); + }, + )(_\$Foo); + '''; + await testSuggestor( + input: commonConnectFile(input), + expectedOutput: commonConnectFile(''' + @Props(disableRequiredPropValidation: {'notReturned'}) + $input + '''), + ); + }); + }); +} diff --git a/test/dart3_suggestors/null_safety_prep/state_mixin_suggestor_test.dart b/test/dart3_suggestors/null_safety_prep/state_mixin_suggestor_test.dart new file mode 100644 index 00000000..4466acef --- /dev/null +++ b/test/dart3_suggestors/null_safety_prep/state_mixin_suggestor_test.dart @@ -0,0 +1,151 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/state_mixin_suggestor.dart'; +import 'package:test/test.dart'; + +import '../../resolved_file_context.dart'; +import '../../util.dart'; +import '../../util/component_usage_migrator_test.dart' show withOverReactImport; + +void main() { + final resolvedContext = SharedAnalysisContext.overReact; + + // Warm up analysis in a setUpAll so that if getting the resolved AST times out + // (which is more common for the WSD context), it fails here instead of failing the first test. + setUpAll(resolvedContext.warmUpAnalysis); + + group('StateMixinSuggestor', () { + late SuggestorTester testSuggestor; + + setUp(() { + testSuggestor = getSuggestorTester( + StateMixinSuggestor(), + resolvedContext: resolvedContext, + ); + }); + + test('patches state fields in mixins', () async { + await testSuggestor( + expectedPatchCount: 3, + input: withOverReactImport(/*language=dart*/ r''' + // ignore: undefined_identifier + UiFactory Foo = castUiFactory(_$Foo); + mixin FooProps on UiProps { + String prop1; + } + mixin FooStateMixin on UiState { + String state1; + num state2; + /// This is a doc comment + /*late*/ String/*!*/ alreadyPatched; + /*late*/ String/*?*/ alreadyPatchedButNoDocComment; + String/*?*/ alreadyPatchedOptional; + } + mixin SomeOtherStateMixin on UiState { + String state3; + String/*?*/ alreadyPatchedOptional2; + } + class FooState = UiState with FooStateMixin, SomeOtherStateMixin; + class FooComponent extends UiStatefulComponent2 { + @override + render() => null; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + // ignore: undefined_identifier + UiFactory Foo = castUiFactory(_$Foo); + mixin FooProps on UiProps { + String prop1; + } + mixin FooStateMixin on UiState { + String/*?*/ state1; + num/*?*/ state2; + /// This is a doc comment + /*late*/ String/*!*/ alreadyPatched; + /*late*/ String/*?*/ alreadyPatchedButNoDocComment; + String/*?*/ alreadyPatchedOptional; + } + mixin SomeOtherStateMixin on UiState { + String/*?*/ state3; + String/*?*/ alreadyPatchedOptional2; + } + class FooState = UiState with FooStateMixin, SomeOtherStateMixin; + class FooComponent extends UiStatefulComponent2 { + @override + render() => null; + } + '''), + ); + }); + + test('patches state fields in legacy classes', () async { + await testSuggestor( + expectedPatchCount: 3, + input: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @Props() + class FooProps extends UiProps { + String prop1; + } + @StateMixin() + mixin SomeOtherStateMixin on UiState { + num state1; + } + @State() + class FooState extends UiState with SomeOtherStateMixin { + String state2; + num state3; + /// This is a doc comment + /*late*/ String/*!*/ alreadyPatched; + /*late*/ String/*?*/ alreadyPatchedButNoDocComment; + String/*?*/ alreadyPatchedOptional; + } + @Component() + class FooComponent extends UiStatefulComponent { + @override + render() => null; + } + '''), + expectedOutput: withOverReactImport(/*language=dart*/ r''' + @Factory() + UiFactory Foo = _$Foo; // ignore: undefined_identifier + @Props() + class FooProps extends UiProps { + String prop1; + } + @StateMixin() + mixin SomeOtherStateMixin on UiState { + num/*?*/ state1; + } + @State() + class FooState extends UiState with SomeOtherStateMixin { + String/*?*/ state2; + num/*?*/ state3; + /// This is a doc comment + /*late*/ String/*!*/ alreadyPatched; + /*late*/ String/*?*/ alreadyPatchedButNoDocComment; + String/*?*/ alreadyPatchedOptional; + } + @Component() + class FooComponent extends UiStatefulComponent { + @override + render() => null; + } + '''), + ); + }); + }); +} diff --git a/test/executables/null_safety_migrator_companion_test.dart b/test/executables/null_safety_migrator_companion_test.dart new file mode 100644 index 00000000..55f60fe0 --- /dev/null +++ b/test/executables/null_safety_migrator_companion_test.dart @@ -0,0 +1,77 @@ +// Copyright 2024 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:over_react_codemod/src/util/package_util.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +import 'required_props_collect_and_codemod_test.dart'; + +void main() { + group('null_safety_migrator_companion codemod, end-to-end behavior:', () { + final companionScript = p.join(findPackageRootFor(p.current), + 'bin/null_safety_migrator_companion.dart'); + + const name = 'test_package'; + late d.DirectoryDescriptor projectDir; + + setUp(() async { + projectDir = d.DirectoryDescriptor.fromFilesystem( + name, + p.join(findPackageRootFor(p.current), + 'test/test_fixtures/required_props/test_package')); + await projectDir.create(); + }); + + test('adds hints as expected in different cases', () async { + await testCodemod( + script: companionScript, + args: [ + '--yes-to-all', + ], + input: projectDir, + expectedOutput: d.dir(projectDir.name, [ + d.dir('lib', [ + d.dir('src', [ + d.file('test_state.dart', contains(''' +@Props(disableRequiredPropValidation: {\'prop1\'}) +mixin FooProps on UiProps { + int prop1; + int prop2; +} + +mixin FooState on UiState { + String/*?*/ state1; + /*late*/ int/*!*/ initializedState; + void Function()/*?*/ state2; +} + +class FooComponent extends UiStatefulComponent2 { + @override + get initialState => (newState()..initializedState = 1); + + @override + render() { + ButtonElement/*?*/ _ref; + return (Dom.div()..ref = (ButtonElement/*?*/ r) => _ref = r)(); + } +}''')), + ]), + ]), + ]), + ); + }); + }, timeout: Timeout(Duration(minutes: 2))); +} diff --git a/test/test_fixtures/required_props/test_package/lib/src/test_state.dart b/test/test_fixtures/required_props/test_package/lib/src/test_state.dart new file mode 100644 index 00000000..e2262318 --- /dev/null +++ b/test/test_fixtures/required_props/test_package/lib/src/test_state.dart @@ -0,0 +1,33 @@ +import 'dart:html'; + +import 'package:over_react/over_react.dart'; +import 'package:over_react/over_react_redux.dart'; + +// ignore: uri_has_not_been_generated +part 'test_state.over_react.g.dart'; + +UiFactory Foo = connect( + mapStateToPropsWithOwnProps: (state, props) => Foo()..prop1 = 1, +)(castUiFactory(_$Foo)); // ignore: undefined_identifier + +mixin FooProps on UiProps { + int prop1; + int prop2; +} + +mixin FooState on UiState { + String state1; + int initializedState; + void Function() state2; +} + +class FooComponent extends UiStatefulComponent2 { + @override + get initialState => (newState()..initializedState = 1); + + @override + render() { + ButtonElement _ref; + return (Dom.div()..ref = (ButtonElement r) => _ref = r)(); + } +}