Skip to content

Commit 86c480a

Browse files
Merge pull request #302 from Workiva/connect-required-props
FED-3143 Null Safety Codemod for connect props
2 parents e99d2cc + 2e2e684 commit 86c480a

File tree

7 files changed

+433
-7
lines changed

7 files changed

+433
-7
lines changed

lib/src/dart3_suggestors/null_safety_prep/class_component_required_default_props.dart

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
// See the License for the specific language governing permissions and
1616
// limitations under the License.
1717

18+
import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/utils/props_utils.dart';
1819
import 'package:over_react_codemod/src/util.dart';
1920
import 'package:over_react_codemod/src/util/component_usage.dart';
2021
import 'package:analyzer/dart/ast/ast.dart';
@@ -103,11 +104,7 @@ class ClassComponentRequiredDefaultPropsMigrator
103104
// If this cascade is not assigning values to defaultProps, bail.
104105
if (!isDefaultProps) return;
105106

106-
final cascadedDefaultProps = node.cascadeSections
107-
.whereType<AssignmentExpression>()
108-
.where((assignment) => assignment.leftHandSide is PropertyAccess)
109-
.map((assignment) => PropAssignment(assignment))
110-
.where((prop) => prop.node.writeElement?.displayName != null);
107+
final cascadedDefaultProps = getCascadedProps(node);
111108

112109
patchFieldDeclarations(
113110
getAllProps, cascadedDefaultProps, node, _propRequirednessRecommender);
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright 2024 Workiva Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'package:analyzer/dart/ast/ast.dart';
16+
import 'package:analyzer/dart/ast/visitor.dart';
17+
import 'package:analyzer/dart/element/element.dart';
18+
import 'package:analyzer/dart/element/type.dart';
19+
import 'package:collection/collection.dart';
20+
import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/utils/props_utils.dart';
21+
import 'package:over_react_codemod/src/util.dart';
22+
import 'package:over_react_codemod/src/util/class_suggestor.dart';
23+
24+
import 'analyzer_plugin_utils.dart';
25+
26+
/// Suggestor that adds `@Props(disableRequiredPropValidation: {...})` annotations
27+
/// for props that are set in `connect` components.
28+
class ConnectRequiredProps extends RecursiveAstVisitor with ClassSuggestor {
29+
/// Running list of props that should be ignored per mixin that will all be added
30+
/// at the end in [generatePatches].
31+
final _ignoredPropsByMixin = <InterfaceElement, Set<String>>{};
32+
33+
@override
34+
visitCascadeExpression(CascadeExpression node) {
35+
super.visitCascadeExpression(node);
36+
37+
// Verify the builder usage is within the `connect` method call.
38+
final connect = node.thisOrAncestorMatching<MethodInvocation>(
39+
(n) => n is MethodInvocation && n.methodName.name == 'connect');
40+
if (connect == null) return;
41+
42+
// Verify the builder usage is within one of the targeted connect args.
43+
final connectArgs =
44+
connect.argumentList.arguments.whereType<NamedExpression>();
45+
final connectArg = node.thisOrAncestorMatching<NamedExpression>((n) =>
46+
n is NamedExpression &&
47+
connectArgs.contains(n) &&
48+
connectArgNames.contains(n.name.label.name));
49+
if (connectArg == null) return;
50+
51+
final cascadedProps = getCascadedProps(node).toList();
52+
53+
for (final field in cascadedProps) {
54+
final propsElement =
55+
node.staticType?.typeOrBound.tryCast<InterfaceType>()?.element;
56+
if (propsElement == null) continue;
57+
58+
// Keep a running list of props to ignore per props mixin.
59+
final fieldName = field.name.name;
60+
_ignoredPropsByMixin.putIfAbsent(propsElement, () => {}).add(fieldName);
61+
}
62+
}
63+
64+
@override
65+
Future<void> generatePatches() async {
66+
_ignoredPropsByMixin.clear();
67+
final result = await context.getResolvedUnit();
68+
if (result == null) {
69+
throw Exception(
70+
'Could not get resolved result for "${context.relativePath}"');
71+
}
72+
result.unit.accept(this);
73+
74+
// Add the patches at the end so that all the props to be ignored can be collected
75+
// from the different args in `connect` before adding patches to avoid duplicate patches.
76+
_ignoredPropsByMixin.forEach((propsClass, propsToIgnore) {
77+
final classNode =
78+
NodeLocator2(propsClass.nameOffset).searchWithin(result.unit);
79+
if (classNode != null && classNode is NamedCompilationUnitMember) {
80+
final existingAnnotation =
81+
classNode.metadata.where((c) => c.name.name == 'Props').firstOrNull;
82+
83+
if (existingAnnotation == null) {
84+
// Add full @Props annotation if it doesn't exist.
85+
yieldPatch(
86+
'@Props($annotationArg: {${propsToIgnore.map((p) => '\'$p\'').join(', ')}})\n',
87+
classNode.offset,
88+
classNode.offset);
89+
} else {
90+
final existingAnnotationArg = existingAnnotation.arguments?.arguments
91+
.whereType<NamedExpression>()
92+
.where((e) => e.name.label.name == annotationArg)
93+
.firstOrNull;
94+
95+
if (existingAnnotationArg == null) {
96+
// Add disable validation arg to existing @Props annotation.
97+
final offset = existingAnnotation.arguments?.leftParenthesis.end;
98+
if (offset != null) {
99+
yieldPatch(
100+
'$annotationArg: {${propsToIgnore.map((p) => '\'$p\'').join(', ')}}${existingAnnotation.arguments?.arguments.isNotEmpty ?? false ? ', ' : ''}',
101+
offset,
102+
offset);
103+
}
104+
} else {
105+
// Add props to disable validation for to the existing list of disabled
106+
// props in the @Props annotation if they aren't already listed.
107+
final existingList =
108+
existingAnnotationArg.expression.tryCast<SetOrMapLiteral>();
109+
if (existingList != null) {
110+
final alreadyIgnored = existingList.elements
111+
.whereType<SimpleStringLiteral>()
112+
.map((e) => e.stringValue)
113+
.toList();
114+
final newPropsToIgnore =
115+
propsToIgnore.where((p) => !alreadyIgnored.contains(p));
116+
if (newPropsToIgnore.isNotEmpty) {
117+
final offset = existingList.leftBracket.end;
118+
yieldPatch(
119+
'${newPropsToIgnore.map((p) => '\'$p\'').join(', ')}, ',
120+
offset,
121+
offset);
122+
}
123+
}
124+
}
125+
}
126+
}
127+
});
128+
}
129+
130+
static const connectArgNames = [
131+
'mapStateToProps',
132+
'mapStateToPropsWithOwnProps',
133+
'mapDispatchToProps',
134+
'mapDispatchToPropsWithOwnProps',
135+
];
136+
static const annotationArg = 'disableRequiredPropValidation';
137+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import 'package:analyzer/dart/ast/ast.dart';
2+
import 'package:over_react_codemod/src/util/component_usage.dart';
3+
4+
/// Returns a list of props from [cascade].
5+
Iterable<PropAssignment> getCascadedProps(CascadeExpression cascade) {
6+
return cascade.cascadeSections
7+
.whereType<AssignmentExpression>()
8+
.where((assignment) => assignment.leftHandSide is PropertyAccess)
9+
.map((assignment) => PropAssignment(assignment))
10+
.where((prop) => prop.node.writeElement?.displayName != null);
11+
}

lib/src/executables/null_safety_migrator_companion.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import 'dart:io';
1717
import 'package:args/args.dart';
1818
import 'package:codemod/codemod.dart';
1919
import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/class_component_required_initial_state.dart';
20+
import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/connect_required_props.dart';
2021
import 'package:over_react_codemod/src/util.dart';
2122

2223
import '../dart3_suggestors/null_safety_prep/callback_ref_hint_suggestor.dart';
@@ -78,4 +79,17 @@ void main(List<String> args) async {
7879
additionalHelpOutput: parser.usage,
7980
changesRequiredOutput: _changesRequiredOutput,
8081
);
82+
83+
if (exitCode != 0) return;
84+
85+
exitCode = await runInteractiveCodemodSequence(
86+
dartPaths,
87+
[
88+
ConnectRequiredProps(),
89+
],
90+
defaultYes: true,
91+
args: parsedArgs.rest,
92+
additionalHelpOutput: parser.usage,
93+
changesRequiredOutput: _changesRequiredOutput,
94+
);
8195
}

0 commit comments

Comments
 (0)