Skip to content

Commit b4dbaad

Browse files
Merge pull request #268 from Workiva/batch/fedx/FED-1919_required_flux_actions
FED-1919 Codemod to add required flux actions / store prop(s)
2 parents bb73b48 + 7798be7 commit b4dbaad

File tree

12 files changed

+2020
-19
lines changed

12 files changed

+2020
-19
lines changed

bin/required_flux_props.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright 2023 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+
export 'package:over_react_codemod/src/executables/required_flux_props.dart';
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// Copyright 2023 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/analysis/results.dart';
16+
import 'package:analyzer/dart/ast/ast.dart';
17+
import 'package:analyzer/dart/ast/visitor.dart';
18+
import 'package:analyzer/dart/element/element.dart';
19+
import 'package:analyzer/dart/element/type.dart';
20+
import 'package:collection/collection.dart';
21+
import 'package:meta/meta.dart';
22+
import 'package:over_react_codemod/src/util.dart';
23+
import 'package:over_react_codemod/src/util/class_suggestor.dart';
24+
25+
/// Suggestor that adds required `store` and/or `actions` prop(s) to the
26+
/// call-site of `FluxUiComponent` instances that omit them since version
27+
/// 5.0.0 of over_react makes flux `store`/`actions` props required.
28+
///
29+
/// In the case of a component that is rendered in a scope where a store/actions
30+
/// instance is available, but simply not passed along to the component, those
31+
/// instance(s) will be used as the value for `props.store`/`props.actions`,
32+
/// even though the component itself may not make use of them internally.
33+
///
34+
/// In the case of a component that is rendered in a scope where a store/actions
35+
/// instance is not available, `null` will be used as the value for the prop(s).
36+
class RequiredFluxProps extends RecursiveAstVisitor with ClassSuggestor {
37+
ResolvedUnitResult? _result;
38+
39+
static const fluxPropsMixinName = 'FluxUiPropsMixin';
40+
41+
@visibleForTesting
42+
static String getTodoForPossiblyValidStoreVar(String fluxStoreVarName) {
43+
return ' // TODO: There is a valid flux store value in scope that could be set here (`$fluxStoreVarName`). Should it be set?';
44+
}
45+
46+
@override
47+
visitCascadeExpression(CascadeExpression node) {
48+
final cascadeWriteEl = node.staticType?.element;
49+
if (cascadeWriteEl is! ClassElement) return;
50+
const typesToIgnore = {
51+
'_PanelTitleProps',
52+
'PanelTitleProps',
53+
'PanelTitleV2Props',
54+
'_PanelToolbarProps',
55+
'PanelToolbarProps',
56+
};
57+
if (typesToIgnore.contains(cascadeWriteEl.name)) {
58+
return;
59+
}
60+
final isReturnedAsDefaultProps = node.ancestors
61+
.whereType<MethodDeclaration>()
62+
.firstOrNull
63+
?.name
64+
.lexeme
65+
.contains(RegExp(r'getDefaultProps|defaultProps')) ??
66+
false;
67+
if (isReturnedAsDefaultProps) return;
68+
69+
final maybeFluxUiPropsMixin = cascadeWriteEl.mixins
70+
.singleWhereOrNull((e) => e.element.name == fluxPropsMixinName);
71+
if (maybeFluxUiPropsMixin == null) return;
72+
73+
final fluxActionsType = maybeFluxUiPropsMixin.typeArguments[0];
74+
final fluxStoreType = maybeFluxUiPropsMixin.typeArguments[1];
75+
76+
final cascadingAssignments =
77+
node.cascadeSections.whereType<AssignmentExpression>();
78+
var storeAssigned = cascadingAssignments.any((cascade) {
79+
final lhs = cascade.leftHandSide;
80+
return lhs is PropertyAccess && lhs.propertyName.name == 'store';
81+
});
82+
var actionsAssigned = cascadingAssignments.any((cascade) {
83+
final lhs = cascade.leftHandSide;
84+
return lhs is PropertyAccess && lhs.propertyName.name == 'actions';
85+
});
86+
87+
if (!storeAssigned) {
88+
storeAssigned = true;
89+
final storeValue =
90+
_getNameOfVarOrFieldInScopeWithType(node, fluxStoreType);
91+
if (storeValue != null) {
92+
final todoComment = getTodoForPossiblyValidStoreVar(storeValue);
93+
yieldNewCascadeSection(node, '$todoComment\n..store = null');
94+
} else {
95+
yieldNewCascadeSection(node, '..store = null');
96+
}
97+
}
98+
99+
if (!actionsAssigned) {
100+
actionsAssigned = true;
101+
final actionsValue =
102+
_getNameOfVarOrFieldInScopeWithType(node, fluxActionsType) ?? 'null';
103+
yieldNewCascadeSection(node, '..actions = $actionsValue');
104+
}
105+
}
106+
107+
void yieldNewCascadeSection(CascadeExpression node, String newSection) {
108+
final offset = node.target.end;
109+
yieldPatch(newSection, offset, offset);
110+
}
111+
112+
@override
113+
Future<void> generatePatches() async {
114+
_result = await context.getResolvedUnit();
115+
if (_result == null) {
116+
throw Exception(
117+
'Could not get resolved result for "${context.relativePath}"');
118+
}
119+
_result!.unit.accept(this);
120+
}
121+
}
122+
123+
class InScopeVariable {
124+
final String name;
125+
final DartType? type;
126+
127+
InScopeVariable(this.name, this.type);
128+
}
129+
130+
String? _getNameOfVarOrFieldInScopeWithType(AstNode node, DartType type) {
131+
if (type is DynamicType || type.isDartCoreNull) return null;
132+
133+
final mostInScopeVariables = node.ancestors.expand((ancestor) sync* {
134+
if (ancestor is FunctionDeclaration) {
135+
// Function arguments
136+
final element = ancestor.declaredElement;
137+
if (element != null) {
138+
yield* element.parameters.map((p) => InScopeVariable(p.name, p.type));
139+
}
140+
} else if (ancestor is Block) {
141+
// Variables declared in the block (function body, if/else block, etc.)
142+
yield* ancestor.statements
143+
.whereType<VariableDeclarationStatement>()
144+
.expand((d) => d.variables.variables)
145+
.map((v) => InScopeVariable(v.name.lexeme, v.declaredElement?.type));
146+
} else if (ancestor is ClassDeclaration) {
147+
// Class fields
148+
final element = ancestor.declaredElement;
149+
if (element != null) {
150+
yield* element.fields.map((f) => InScopeVariable(f.name, f.type));
151+
}
152+
} else if (ancestor is CompilationUnit) {
153+
// Top-level variables
154+
yield* ancestor.declarations
155+
.whereType<TopLevelVariableDeclaration>()
156+
.expand((d) => d.variables.variables)
157+
.map((v) => InScopeVariable(v.name.lexeme, v.declaredElement?.type));
158+
}
159+
});
160+
161+
// Usually we'd grab typeSystem from the ResolvedUnitResult, but we don't have access to that
162+
// in this class, so just get it from the compilation unit.
163+
final typeSystem =
164+
(node.root as CompilationUnit).declaredElement!.library.typeSystem;
165+
bool isMatchingType(DartType? maybeMatchingType) =>
166+
maybeMatchingType != null &&
167+
maybeMatchingType is! DynamicType &&
168+
typeSystem.isAssignableTo(maybeMatchingType, type);
169+
170+
final inScopeVarName = mostInScopeVariables
171+
.firstWhereOrNull((v) => isMatchingType(v.type))
172+
?.name;
173+
174+
final componentScopePropDetector = _ComponentScopeFluxPropsDetector();
175+
// Find actions/store in props of class components
176+
componentScopePropDetector.handlePotentialClassComponent(
177+
node.thisOrAncestorOfType<ClassDeclaration>());
178+
// Find actions/store in props of fn components
179+
componentScopePropDetector.handlePotentialFunctionComponent(
180+
node.thisOrAncestorOfType<MethodInvocation>());
181+
182+
final inScopePropName =
183+
componentScopePropDetector.found.firstWhereOrNull((el) {
184+
final maybeMatchingType = componentScopePropDetector.getAccessorType(el);
185+
return maybeMatchingType?.element?.name == type.element?.name;
186+
})?.name;
187+
188+
if (inScopeVarName != null && inScopePropName != null) {
189+
// TODO: Do we need to handle this edge case with something better than returning null?
190+
// No way to determine which should be used - the scoped variable or the field on props
191+
// so return null to avoid setting the incorrect value on the consumer's code.
192+
return null;
193+
}
194+
195+
if (inScopePropName != null) {
196+
return '${componentScopePropDetector.propsName}.${inScopePropName}';
197+
}
198+
199+
return inScopeVarName;
200+
}
201+
202+
bool _isFnComponentDeclaration(Expression? varInitializer) =>
203+
varInitializer is MethodInvocation &&
204+
varInitializer.methodName.name.startsWith('uiF');
205+
206+
/// A visitor to detect store/actions values in a props class (supports both class and fn components)
207+
class _ComponentScopeFluxPropsDetector {
208+
final Map<PropertyAccessorElement, DartType> _foundWithMappedTypes;
209+
210+
List<PropertyAccessorElement> get found =>
211+
_foundWithMappedTypes.keys.toList();
212+
213+
_ComponentScopeFluxPropsDetector() : _foundWithMappedTypes = {};
214+
215+
String _propsName = 'props';
216+
217+
/// The name of the function component props arg, or the class component `props` instance field.
218+
String get propsName => _propsName;
219+
220+
DartType? getAccessorType(PropertyAccessorElement el) =>
221+
_foundWithMappedTypes[el];
222+
223+
void _lookForFluxStoreAndActionsInPropsClass(Element? elWithProps) {
224+
if (elWithProps is ClassElement) {
225+
final fluxPropsEl = elWithProps.mixins.singleWhereOrNull(
226+
(e) => e.element.name == RequiredFluxProps.fluxPropsMixinName);
227+
228+
if (fluxPropsEl != null) {
229+
final actionsType = fluxPropsEl.typeArguments[0];
230+
final storeType = fluxPropsEl.typeArguments[1];
231+
fluxPropsEl.accessors.forEach((a) {
232+
final accessorTypeName = a.declaration.variable.type.element?.name;
233+
if (accessorTypeName == 'ActionsT') {
234+
_foundWithMappedTypes.putIfAbsent(a.declaration, () => actionsType);
235+
} else if (accessorTypeName == 'StoresT') {
236+
_foundWithMappedTypes.putIfAbsent(a.declaration, () => storeType);
237+
}
238+
});
239+
}
240+
}
241+
}
242+
243+
/// Visit function components
244+
void handlePotentialFunctionComponent(MethodInvocation? node) {
245+
if (node == null) return;
246+
if (!_isFnComponentDeclaration(node)) return;
247+
248+
final nodeType = node.staticType;
249+
if (nodeType is FunctionType) {
250+
final propsArg =
251+
node.argumentList.arguments.firstOrNull as FunctionExpression?;
252+
final propsArgName =
253+
propsArg?.parameters?.parameterElements.firstOrNull?.name;
254+
if (propsArgName != null) {
255+
_propsName = propsArgName;
256+
}
257+
_lookForFluxStoreAndActionsInPropsClass(nodeType.returnType.element);
258+
}
259+
}
260+
261+
/// Visit composite (class) components
262+
void handlePotentialClassComponent(ClassDeclaration? node) {
263+
if (node == null) return;
264+
final elWithProps =
265+
node.declaredElement?.supertype?.typeArguments.singleOrNull?.element;
266+
_lookForFluxStoreAndActionsInPropsClass(elWithProps);
267+
}
268+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright 2023 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 'dart:io';
16+
17+
import 'package:args/args.dart';
18+
import 'package:codemod/codemod.dart';
19+
import 'package:logging/logging.dart';
20+
import 'package:over_react_codemod/src/dart3_suggestors/null_safety_prep/required_flux_props.dart';
21+
import 'package:over_react_codemod/src/ignoreable.dart';
22+
import 'package:over_react_codemod/src/util.dart';
23+
import 'package:over_react_codemod/src/util/package_util.dart';
24+
25+
const _changesRequiredOutput = """
26+
To update your code, run the following commands in your repository:
27+
pub global activate over_react_codemod
28+
pub global run over_react_codemod:required_flux_props
29+
""";
30+
31+
final _log = Logger('orcm.required_flux_props');
32+
33+
Future<void> pubGetForAllPackageRoots(Iterable<String> files) async {
34+
_log.info(
35+
'Running `pub get` if needed so that all Dart files can be resolved...');
36+
final packageRoots = files.map(findPackageRootFor).toSet();
37+
for (final packageRoot in packageRoots) {
38+
await runPubGetIfNeeded(packageRoot);
39+
}
40+
}
41+
42+
void main(List<String> args) async {
43+
final parser = ArgParser.allowAnything();
44+
45+
final parsedArgs = parser.parse(args);
46+
final dartPaths = allDartPathsExceptHidden();
47+
48+
await pubGetForAllPackageRoots(dartPaths);
49+
50+
exitCode = await runInteractiveCodemod(
51+
dartPaths,
52+
aggregate([
53+
RequiredFluxProps(),
54+
].map((s) => ignoreable(s))),
55+
defaultYes: true,
56+
args: parsedArgs.rest,
57+
additionalHelpOutput: parser.usage,
58+
changesRequiredOutput: _changesRequiredOutput,
59+
);
60+
}

lib/src/intl_suggestors/intl_migrator.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ class ConstantStringMigrator extends GeneralizingAstVisitor
108108
if (node.isConst &&
109109
node.initializer != null &&
110110
node.initializer is SimpleStringLiteral) {
111-
SimpleStringLiteral literal = node.initializer as SimpleStringLiteral;
111+
SimpleStringLiteral literal = node.initializer! as SimpleStringLiteral;
112112
var string = literal.stringValue;
113113
// I don't see how the parent could possibly be null, but if it's true, bail out.
114114
if (node.parent == null || string == null || string.length <= 1) return;
@@ -147,7 +147,7 @@ class ConstantStringMigrator extends GeneralizingAstVisitor
147147
} else {
148148
// Use a content-based name.
149149
var contentBasedName =
150-
toVariableName(stringContent(node.initializer as StringLiteral)!);
150+
toVariableName(stringContent(node.initializer! as StringLiteral)!);
151151
return contentBasedName;
152152
}
153153
}

lib/src/intl_suggestors/message_parser.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ class MessageParser {
112112
String withCorrectedNameParameter(MethodDeclaration declaration) {
113113
var invocation = intlMethodInvocation(declaration);
114114
var nameParameter = nameParameterFrom(invocation);
115-
var className = (declaration.parent as ClassDeclaration).name.lexeme;
115+
var className = (declaration.parent! as ClassDeclaration).name.lexeme;
116116
var expected = "'${className}_${declaration.name.lexeme}'";
117117
var actual = nameParameter?.expression.toSource();
118118
var basicString = '$declaration';

lib/src/react16_suggestors/react_style_maps_updater.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ class ReactStyleMapsUpdater extends GeneralizingAstVisitor
162162
// Handle `toRem(1).toString()`
163163
if (invocation.methodName.name == 'toString' &&
164164
invocation.target is MethodInvocation) {
165-
invocation = invocation.target as MethodInvocation;
165+
invocation = invocation.target! as MethodInvocation;
166166
}
167167

168168
if (!const ['toPx', 'toRem'].contains(invocation.methodName.name)) {

0 commit comments

Comments
 (0)