@codyswann/lisa 1.55.2 → 1.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/all/deletions.json +0 -1
- package/cdk/copy-overwrite/tsconfig.json +3 -0
- package/expo/copy-overwrite/tsconfig.json +7 -0
- package/expo/deletions.json +1 -0
- package/nestjs/copy-overwrite/tsconfig.json +6 -0
- package/package.json +6 -3
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/skills/reduce-complexity/SKILL.md +251 -0
- package/plugins/lisa-expo/skills/reduce-complexity/references/extraction-strategies.md +456 -0
- package/plugins/lisa-expo/skills/reduce-complexity/references/refactoring-patterns.md +557 -0
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/src/expo/skills/reduce-complexity/SKILL.md +251 -0
- package/plugins/src/expo/skills/reduce-complexity/references/extraction-strategies.md +456 -0
- package/plugins/src/expo/skills/reduce-complexity/references/refactoring-patterns.md +557 -0
- package/tsconfig/base.json +1 -2
- package/tsconfig/expo.json +0 -5
- package/tsconfig/nestjs.json +1 -5
- package/typescript/copy-overwrite/tsconfig.json +3 -0
- package/scripts/strip-workspaces-for-pack.sh +0 -20
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reduce-complexity
|
|
3
|
+
description: This skill provides strategies and patterns for reducing cognitive complexity in React components. It should be used when ESLint reports sonarjs/cognitive-complexity violations, when refactoring complex View components, or when planning how to break down large components. The skill enforces this project's Container/View pattern requirements when extracting components.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Reduce Complexity
|
|
7
|
+
|
|
8
|
+
This skill provides systematic approaches for reducing cognitive complexity in React components while adhering to this project's Container/View pattern requirements.
|
|
9
|
+
|
|
10
|
+
## When to Use This Skill
|
|
11
|
+
|
|
12
|
+
- ESLint reports `sonarjs/cognitive-complexity` violations (threshold: 28)
|
|
13
|
+
- A View component exceeds 200 lines
|
|
14
|
+
- A component has deeply nested conditionals or repeated patterns
|
|
15
|
+
- Planning refactoring of a complex component
|
|
16
|
+
- Deciding between extracting helper functions vs full components
|
|
17
|
+
|
|
18
|
+
## Complexity Sources in React Components
|
|
19
|
+
|
|
20
|
+
Cognitive complexity increases with:
|
|
21
|
+
|
|
22
|
+
| Source | Complexity Impact | Common in View Components |
|
|
23
|
+
| ---------------------- | ----------------- | ------------------------- |
|
|
24
|
+
| Nested conditionals | +1 per nesting | Yes |
|
|
25
|
+
| Ternary expressions | +1 each | Yes |
|
|
26
|
+
| Logical operators (&&) | +1 each | Yes |
|
|
27
|
+
| Loops (map, filter) | +1 each | Yes |
|
|
28
|
+
| Switch/case statements | +1 per case | Rare |
|
|
29
|
+
| Catch blocks | +1 each | No (Container only) |
|
|
30
|
+
| Nested functions | +1 per nesting | Yes |
|
|
31
|
+
|
|
32
|
+
## Decision Framework: Helper Function vs Full Component
|
|
33
|
+
|
|
34
|
+
Before extracting code, determine the appropriate strategy:
|
|
35
|
+
|
|
36
|
+
### Extract as Helper Function When:
|
|
37
|
+
|
|
38
|
+
- The JSX renders a static or simple section with no logic of its own
|
|
39
|
+
- The section does not need its own state, hooks, or callbacks
|
|
40
|
+
- The pattern appears only in this file
|
|
41
|
+
- The complexity comes from rendering, not behavior
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
// Helper function - no logic, just rendering
|
|
45
|
+
function renderSectionHeader(props: {
|
|
46
|
+
readonly title: string;
|
|
47
|
+
readonly count: number;
|
|
48
|
+
}) {
|
|
49
|
+
return (
|
|
50
|
+
<HStack className="justify-between">
|
|
51
|
+
<Text className="font-bold">{props.title}</Text>
|
|
52
|
+
<Text className="text-sm">({props.count})</Text>
|
|
53
|
+
</HStack>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Extract as Full Component (Container/View) When:
|
|
59
|
+
|
|
60
|
+
- The section has reusable logic or could be used elsewhere
|
|
61
|
+
- The section would benefit from its own state management
|
|
62
|
+
- The pattern repeats across multiple files
|
|
63
|
+
- The section has 3+ props that could be simplified with a Container
|
|
64
|
+
- Extracting would create a meaningful, named abstraction
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
FilterChipList/
|
|
68
|
+
├── FilterChipListContainer.tsx # Handles selection logic
|
|
69
|
+
├── FilterChipListView.tsx # Renders chip list
|
|
70
|
+
└── index.tsx # Exports Container
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Refactoring Process
|
|
74
|
+
|
|
75
|
+
### Step 1: Analyze Complexity Sources
|
|
76
|
+
|
|
77
|
+
Run ESLint to identify the violation:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
bun run lint 2>&1 | grep "cognitive-complexity"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
> **Note:** Replace `bun` with your project's package manager (`npm`, `yarn`, `pnpm`) as needed.
|
|
84
|
+
|
|
85
|
+
Read the file and identify:
|
|
86
|
+
|
|
87
|
+
1. Which function has the violation (line number from ESLint)
|
|
88
|
+
2. What patterns repeat (copy-pasted JSX with slight variations)
|
|
89
|
+
3. What conditionals nest deeply (ternaries inside ternaries)
|
|
90
|
+
4. What could be pre-computed in Container
|
|
91
|
+
|
|
92
|
+
### Step 2: Choose Extraction Strategy
|
|
93
|
+
|
|
94
|
+
Use the decision framework above. For View components:
|
|
95
|
+
|
|
96
|
+
| Situation | Strategy |
|
|
97
|
+
| -------------------------- | --------------------------------- |
|
|
98
|
+
| Repeated JSX, no logic | Helper function |
|
|
99
|
+
| Repeated JSX, needs props | Helper function with props object |
|
|
100
|
+
| Repeated pattern, 3+ files | Full Container/View component |
|
|
101
|
+
| Complex section, own state | Full Container/View component |
|
|
102
|
+
| Deeply nested ternaries | Pre-compute flags in Container |
|
|
103
|
+
|
|
104
|
+
### Step 3: Write Tests First (TDD)
|
|
105
|
+
|
|
106
|
+
Before refactoring, ensure test coverage exists:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Check existing coverage
|
|
110
|
+
bun run test:unit --coverage --collectCoverageFrom='<file-path>'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
If no tests exist, write tests that verify current behavior before refactoring.
|
|
114
|
+
|
|
115
|
+
### Step 4: Implement Extraction
|
|
116
|
+
|
|
117
|
+
For helper functions, see `references/extraction-strategies.md`.
|
|
118
|
+
For full components, use the Container/View pattern skill.
|
|
119
|
+
|
|
120
|
+
### Step 5: Verify Complexity Resolved
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
bun run lint 2>&1 | grep "cognitive-complexity"
|
|
124
|
+
bun run test:unit
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Quick Fixes for Common Patterns
|
|
128
|
+
|
|
129
|
+
### Repeated Conditional Styling
|
|
130
|
+
|
|
131
|
+
**Before (high complexity):**
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
<Pressable
|
|
135
|
+
style={{
|
|
136
|
+
backgroundColor: selected.includes(item) ? colors.primary : colors.bg,
|
|
137
|
+
borderColor: selected.includes(item) ? colors.primary : colors.border,
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<Text style={{ color: selected.includes(item) ? "#FFF" : colors.text }}>
|
|
141
|
+
{item}
|
|
142
|
+
</Text>
|
|
143
|
+
</Pressable>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**After (reduced complexity):**
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
// In Container - pre-compute selection state
|
|
150
|
+
const itemStates = useMemo(
|
|
151
|
+
() =>
|
|
152
|
+
items.map(item => ({
|
|
153
|
+
item,
|
|
154
|
+
isSelected: selected.includes(item),
|
|
155
|
+
})),
|
|
156
|
+
[items, selected]
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// In View - simple conditional
|
|
160
|
+
<Pressable style={isSelected ? styles.selected : styles.default}>
|
|
161
|
+
<Text style={isSelected ? styles.selectedText : styles.defaultText}>
|
|
162
|
+
{item}
|
|
163
|
+
</Text>
|
|
164
|
+
</Pressable>;
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Repeated Section Patterns
|
|
168
|
+
|
|
169
|
+
**Before (4x repeated pattern = high complexity):**
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
{positions.length > 0 && (
|
|
173
|
+
<VStack>
|
|
174
|
+
<Text>Positions</Text>
|
|
175
|
+
<HStack>{positions.map(p => <Chip key={p} ... />)}</HStack>
|
|
176
|
+
</VStack>
|
|
177
|
+
)}
|
|
178
|
+
{tags.length > 0 && (
|
|
179
|
+
<VStack>
|
|
180
|
+
<Text>Tags</Text>
|
|
181
|
+
<HStack>{tags.map(t => <Chip key={t.id} ... />)}</HStack>
|
|
182
|
+
</VStack>
|
|
183
|
+
)}
|
|
184
|
+
// ... repeated 2 more times
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**After (extract FilterChipList component):**
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
<FilterChipList
|
|
191
|
+
title="Positions"
|
|
192
|
+
items={positions}
|
|
193
|
+
selectedItems={filters.positions}
|
|
194
|
+
onToggle={onPositionToggle}
|
|
195
|
+
/>
|
|
196
|
+
<FilterChipList
|
|
197
|
+
title="Tags"
|
|
198
|
+
items={tags}
|
|
199
|
+
selectedItems={filters.tags}
|
|
200
|
+
onToggle={onTagToggle}
|
|
201
|
+
/>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Nested Ternaries
|
|
205
|
+
|
|
206
|
+
**Before:**
|
|
207
|
+
|
|
208
|
+
```tsx
|
|
209
|
+
{
|
|
210
|
+
isLoading ? (
|
|
211
|
+
<Spinner />
|
|
212
|
+
) : hasError ? (
|
|
213
|
+
<Error />
|
|
214
|
+
) : isEmpty ? (
|
|
215
|
+
<Empty />
|
|
216
|
+
) : (
|
|
217
|
+
<Content />
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**After (pre-compute state in Container):**
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
// Container
|
|
226
|
+
const viewState = useMemo(() => {
|
|
227
|
+
if (isLoading) return "loading";
|
|
228
|
+
if (hasError) return "error";
|
|
229
|
+
if (isEmpty) return "empty";
|
|
230
|
+
return "content";
|
|
231
|
+
}, [isLoading, hasError, isEmpty]);
|
|
232
|
+
|
|
233
|
+
// View - map directly
|
|
234
|
+
const VIEW_STATES = {
|
|
235
|
+
loading: <Spinner />,
|
|
236
|
+
error: <Error />,
|
|
237
|
+
empty: <Empty />,
|
|
238
|
+
content: <Content />,
|
|
239
|
+
} as const;
|
|
240
|
+
|
|
241
|
+
{
|
|
242
|
+
VIEW_STATES[viewState];
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Reference Documentation
|
|
247
|
+
|
|
248
|
+
For detailed patterns and complete examples:
|
|
249
|
+
|
|
250
|
+
- `references/extraction-strategies.md` - Helper function patterns and when to use each
|
|
251
|
+
- `references/refactoring-patterns.md` - Step-by-step refactoring examples with before/after code
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# Extraction Strategies
|
|
2
|
+
|
|
3
|
+
This reference provides detailed patterns for extracting code to reduce cognitive complexity.
|
|
4
|
+
|
|
5
|
+
## Strategy 1: Helper Functions in View Files
|
|
6
|
+
|
|
7
|
+
Helper functions are the simplest extraction strategy. Use them for rendering logic that doesn't need its own component lifecycle.
|
|
8
|
+
|
|
9
|
+
### Pattern: Render Helper with Props Object
|
|
10
|
+
|
|
11
|
+
Always use a props object (not positional arguments) for type safety and clarity:
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
/**
|
|
15
|
+
* Renders a section header with title and count.
|
|
16
|
+
* @param props - Helper function properties
|
|
17
|
+
* @param props.title - Section title text
|
|
18
|
+
* @param props.count - Item count to display
|
|
19
|
+
* @param props.colors - Theme colors object
|
|
20
|
+
*/
|
|
21
|
+
function renderSectionHeader(props: {
|
|
22
|
+
readonly title: string;
|
|
23
|
+
readonly count: number;
|
|
24
|
+
readonly colors: KanbanColors;
|
|
25
|
+
}) {
|
|
26
|
+
const { title, count, colors } = props;
|
|
27
|
+
return (
|
|
28
|
+
<HStack className="items-center justify-between py-2">
|
|
29
|
+
<Text
|
|
30
|
+
className="text-xs font-semibold uppercase tracking-wider"
|
|
31
|
+
style={{ color: colors.textMuted }}
|
|
32
|
+
>
|
|
33
|
+
{title}
|
|
34
|
+
</Text>
|
|
35
|
+
<Text className="text-xs" style={{ color: colors.textSecondary }}>
|
|
36
|
+
({count})
|
|
37
|
+
</Text>
|
|
38
|
+
</HStack>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Pattern: Conditional Render Helper
|
|
44
|
+
|
|
45
|
+
For sections that render conditionally based on data:
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
/**
|
|
49
|
+
* Renders the empty state when no items exist.
|
|
50
|
+
* @param props - Helper function properties
|
|
51
|
+
* @param props.message - Empty state message
|
|
52
|
+
* @param props.colors - Theme colors object
|
|
53
|
+
*/
|
|
54
|
+
function renderEmptyState(props: {
|
|
55
|
+
readonly message: string;
|
|
56
|
+
readonly colors: KanbanColors;
|
|
57
|
+
}) {
|
|
58
|
+
const { message, colors } = props;
|
|
59
|
+
return (
|
|
60
|
+
<Box
|
|
61
|
+
className="items-center justify-center p-8"
|
|
62
|
+
style={{ backgroundColor: colors.cardBackground }}
|
|
63
|
+
>
|
|
64
|
+
<Text style={{ color: colors.textMuted }}>{message}</Text>
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Usage in View
|
|
70
|
+
const ListView = ({ items, isEmpty, colors }: Props) => (
|
|
71
|
+
<Box>
|
|
72
|
+
{isEmpty
|
|
73
|
+
? renderEmptyState({ message: "No items found", colors })
|
|
74
|
+
: items.map(item => <Item key={item.id} item={item} />)}
|
|
75
|
+
</Box>
|
|
76
|
+
);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Pattern: List Item Render Helper
|
|
80
|
+
|
|
81
|
+
For repeated item rendering with complex styling:
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
/**
|
|
85
|
+
* Renders a selectable chip/pill item.
|
|
86
|
+
* @param props - Helper function properties
|
|
87
|
+
* @param props.label - Display text for the chip
|
|
88
|
+
* @param props.isSelected - Whether the chip is currently selected
|
|
89
|
+
* @param props.colors - Theme colors for styling
|
|
90
|
+
* @param props.onPress - Callback when chip is pressed
|
|
91
|
+
*/
|
|
92
|
+
function renderChip(props: {
|
|
93
|
+
readonly label: string;
|
|
94
|
+
readonly isSelected: boolean;
|
|
95
|
+
readonly colors: KanbanColors;
|
|
96
|
+
readonly onPress: () => void;
|
|
97
|
+
}) {
|
|
98
|
+
const { label, isSelected, colors, onPress } = props;
|
|
99
|
+
return (
|
|
100
|
+
<Pressable
|
|
101
|
+
onPress={onPress}
|
|
102
|
+
style={{
|
|
103
|
+
paddingHorizontal: 10,
|
|
104
|
+
paddingVertical: 5,
|
|
105
|
+
borderRadius: 6,
|
|
106
|
+
backgroundColor: isSelected ? colors.primary : colors.cardBackground,
|
|
107
|
+
borderWidth: 1,
|
|
108
|
+
borderColor: isSelected ? colors.primary : colors.border,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<Text
|
|
112
|
+
className="text-xs font-medium"
|
|
113
|
+
style={{ color: isSelected ? "#FFFFFF" : colors.textSecondary }}
|
|
114
|
+
>
|
|
115
|
+
{label}
|
|
116
|
+
</Text>
|
|
117
|
+
</Pressable>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### When NOT to Use Helper Functions
|
|
123
|
+
|
|
124
|
+
Do not use helper functions when:
|
|
125
|
+
|
|
126
|
+
1. **The helper would need hooks** - Extract as full component instead
|
|
127
|
+
2. **The helper is used in multiple files** - Extract as shared component
|
|
128
|
+
3. **The helper has complex event handling** - Container should handle this
|
|
129
|
+
4. **The helper exceeds 30 lines** - Consider full component extraction
|
|
130
|
+
|
|
131
|
+
## Strategy 2: Pre-compute in Container
|
|
132
|
+
|
|
133
|
+
Move complexity from View to Container by pre-computing values.
|
|
134
|
+
|
|
135
|
+
### Pattern: Selection State Pre-computation
|
|
136
|
+
|
|
137
|
+
**Before (complexity in View):**
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
// View has repeated .includes() checks
|
|
141
|
+
<Pressable
|
|
142
|
+
style={{
|
|
143
|
+
backgroundColor: filters.positions.includes(position)
|
|
144
|
+
? colors.primary
|
|
145
|
+
: colors.cardBackground,
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**After (pre-computed in Container):**
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
// Container
|
|
154
|
+
const positionItems = useMemo(
|
|
155
|
+
() =>
|
|
156
|
+
availablePositions.map(position => ({
|
|
157
|
+
value: position,
|
|
158
|
+
label: position,
|
|
159
|
+
isSelected: filters.positions.includes(position),
|
|
160
|
+
})),
|
|
161
|
+
[availablePositions, filters.positions]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// View - simple prop access
|
|
165
|
+
{
|
|
166
|
+
positionItems.map(({ value, label, isSelected }) => (
|
|
167
|
+
<Chip
|
|
168
|
+
key={value}
|
|
169
|
+
label={label}
|
|
170
|
+
isSelected={isSelected}
|
|
171
|
+
onPress={() => onPositionToggle(value)}
|
|
172
|
+
/>
|
|
173
|
+
));
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Pattern: View State Enumeration
|
|
178
|
+
|
|
179
|
+
**Before (nested ternaries):**
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
{
|
|
183
|
+
isLoading ? (
|
|
184
|
+
<Spinner />
|
|
185
|
+
) : error ? (
|
|
186
|
+
<ErrorView error={error} />
|
|
187
|
+
) : data.length === 0 ? (
|
|
188
|
+
<EmptyState />
|
|
189
|
+
) : (
|
|
190
|
+
<DataList data={data} />
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**After (enumerated state):**
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
// Container
|
|
199
|
+
type ViewState = "loading" | "error" | "empty" | "ready";
|
|
200
|
+
|
|
201
|
+
const viewState = useMemo((): ViewState => {
|
|
202
|
+
if (isLoading) return "loading";
|
|
203
|
+
if (error) return "error";
|
|
204
|
+
if (data.length === 0) return "empty";
|
|
205
|
+
return "ready";
|
|
206
|
+
}, [isLoading, error, data.length]);
|
|
207
|
+
|
|
208
|
+
// View - switch or map
|
|
209
|
+
const ComponentView = ({ viewState, data, error }: Props) => (
|
|
210
|
+
<Box>
|
|
211
|
+
{viewState === "loading" && <Spinner />}
|
|
212
|
+
{viewState === "error" && <ErrorView error={error} />}
|
|
213
|
+
{viewState === "empty" && <EmptyState />}
|
|
214
|
+
{viewState === "ready" && <DataList data={data} />}
|
|
215
|
+
</Box>
|
|
216
|
+
);
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Pattern: Style Pre-computation
|
|
220
|
+
|
|
221
|
+
**Before (inline style objects create complexity and perf issues):**
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
<Box
|
|
225
|
+
style={{
|
|
226
|
+
backgroundColor: isDark ? colors.dark.bg : colors.light.bg,
|
|
227
|
+
padding: isCompact ? 8 : 16,
|
|
228
|
+
borderRadius: isRounded ? 12 : 0,
|
|
229
|
+
}}
|
|
230
|
+
/>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**After (computed style in Container):**
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
// Container
|
|
237
|
+
const boxStyle = useMemo(
|
|
238
|
+
() => ({
|
|
239
|
+
backgroundColor: isDark ? colors.dark.bg : colors.light.bg,
|
|
240
|
+
padding: isCompact ? 8 : 16,
|
|
241
|
+
borderRadius: isRounded ? 12 : 0,
|
|
242
|
+
}),
|
|
243
|
+
[isDark, isCompact, isRounded, colors]
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// View
|
|
247
|
+
<Box style={boxStyle} />;
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Strategy 3: Full Component Extraction
|
|
251
|
+
|
|
252
|
+
When helper functions aren't sufficient, extract a full Container/View component.
|
|
253
|
+
|
|
254
|
+
### Decision Checklist
|
|
255
|
+
|
|
256
|
+
Extract as full component when 3+ of these apply:
|
|
257
|
+
|
|
258
|
+
- [ ] Pattern repeats in 2+ files
|
|
259
|
+
- [ ] Section needs its own state
|
|
260
|
+
- [ ] Section has 4+ props
|
|
261
|
+
- [ ] Section has callbacks that could be simplified
|
|
262
|
+
- [ ] Section represents a meaningful domain concept
|
|
263
|
+
- [ ] Section could be tested independently
|
|
264
|
+
|
|
265
|
+
### Extraction Steps
|
|
266
|
+
|
|
267
|
+
1. **Identify the repeated/complex pattern**
|
|
268
|
+
2. **Define the component's props interface**
|
|
269
|
+
3. **Create directory structure:**
|
|
270
|
+
```text
|
|
271
|
+
ComponentName/
|
|
272
|
+
├── ComponentNameContainer.tsx
|
|
273
|
+
├── ComponentNameView.tsx
|
|
274
|
+
└── index.tsx
|
|
275
|
+
```
|
|
276
|
+
4. **Write Container with logic/state**
|
|
277
|
+
5. **Write View with pure rendering**
|
|
278
|
+
6. **Update parent to use new component**
|
|
279
|
+
7. **Write tests for new component**
|
|
280
|
+
|
|
281
|
+
### Example: Extracting FilterChipList
|
|
282
|
+
|
|
283
|
+
**Before (in FilterModalView.tsx):**
|
|
284
|
+
|
|
285
|
+
```tsx
|
|
286
|
+
{
|
|
287
|
+
/* Positions - repeated pattern */
|
|
288
|
+
}
|
|
289
|
+
{
|
|
290
|
+
availablePositions.length > 0 && (
|
|
291
|
+
<VStack className="gap-3">
|
|
292
|
+
<Text style={{ color: colors.textMuted }}>Positions</Text>
|
|
293
|
+
<HStack className="flex-wrap gap-2">
|
|
294
|
+
{availablePositions.map(position => (
|
|
295
|
+
<Pressable
|
|
296
|
+
key={position}
|
|
297
|
+
onPress={() => onPositionToggle(position)}
|
|
298
|
+
style={{
|
|
299
|
+
backgroundColor: filters.positions.includes(position)
|
|
300
|
+
? colors.primary
|
|
301
|
+
: colors.cardBackground,
|
|
302
|
+
// ... more styles
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
<Text>{position}</Text>
|
|
306
|
+
</Pressable>
|
|
307
|
+
))}
|
|
308
|
+
</HStack>
|
|
309
|
+
</VStack>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
{
|
|
314
|
+
/* Tags - same pattern with slight variation */
|
|
315
|
+
}
|
|
316
|
+
{
|
|
317
|
+
availableTags.length > 0 && (
|
|
318
|
+
<VStack className="gap-3">
|
|
319
|
+
<Text style={{ color: colors.textMuted }}>Tags</Text>
|
|
320
|
+
<HStack className="flex-wrap gap-2">
|
|
321
|
+
{availableTags.map(tag => (
|
|
322
|
+
<Pressable
|
|
323
|
+
key={tag.id}
|
|
324
|
+
onPress={() => onTagToggle(tag.id)}
|
|
325
|
+
// ... same pattern
|
|
326
|
+
>
|
|
327
|
+
<Text>{tag.name}</Text>
|
|
328
|
+
</Pressable>
|
|
329
|
+
))}
|
|
330
|
+
</HStack>
|
|
331
|
+
</VStack>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**After (FilterChipList component):**
|
|
337
|
+
|
|
338
|
+
```tsx
|
|
339
|
+
// FilterChipListContainer.tsx
|
|
340
|
+
interface FilterChipListProps {
|
|
341
|
+
readonly title: string;
|
|
342
|
+
readonly items: readonly { id: string; label: string; color?: string }[];
|
|
343
|
+
readonly selectedIds: readonly string[];
|
|
344
|
+
readonly colors: KanbanColors;
|
|
345
|
+
readonly onToggle: (id: string) => void;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const FilterChipListContainer = ({
|
|
349
|
+
title,
|
|
350
|
+
items,
|
|
351
|
+
selectedIds,
|
|
352
|
+
colors,
|
|
353
|
+
onToggle,
|
|
354
|
+
}: FilterChipListProps) => {
|
|
355
|
+
const chipItems = useMemo(
|
|
356
|
+
() =>
|
|
357
|
+
items.map(item => ({
|
|
358
|
+
...item,
|
|
359
|
+
isSelected: selectedIds.includes(item.id),
|
|
360
|
+
})),
|
|
361
|
+
[items, selectedIds]
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const handleToggle = useCallback(
|
|
365
|
+
(id: string) => () => onToggle(id),
|
|
366
|
+
[onToggle]
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<FilterChipListView
|
|
371
|
+
title={title}
|
|
372
|
+
items={chipItems}
|
|
373
|
+
colors={colors}
|
|
374
|
+
onToggle={handleToggle}
|
|
375
|
+
/>
|
|
376
|
+
);
|
|
377
|
+
};
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
// FilterChipListView.tsx
|
|
382
|
+
interface FilterChipListViewProps {
|
|
383
|
+
readonly title: string;
|
|
384
|
+
readonly items: readonly {
|
|
385
|
+
id: string;
|
|
386
|
+
label: string;
|
|
387
|
+
color?: string;
|
|
388
|
+
isSelected: boolean;
|
|
389
|
+
}[];
|
|
390
|
+
readonly colors: KanbanColors;
|
|
391
|
+
readonly onToggle: (id: string) => () => void;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const FilterChipListView = ({
|
|
395
|
+
title,
|
|
396
|
+
items,
|
|
397
|
+
colors,
|
|
398
|
+
onToggle,
|
|
399
|
+
}: FilterChipListViewProps) => (
|
|
400
|
+
<VStack className="gap-3">
|
|
401
|
+
<Text
|
|
402
|
+
className="text-xs font-semibold uppercase tracking-wider"
|
|
403
|
+
style={{ color: colors.textMuted }}
|
|
404
|
+
>
|
|
405
|
+
{title}
|
|
406
|
+
</Text>
|
|
407
|
+
<HStack className="flex-wrap gap-2">
|
|
408
|
+
{items.map(({ id, label, color, isSelected }) => (
|
|
409
|
+
<Pressable
|
|
410
|
+
key={id}
|
|
411
|
+
onPress={onToggle(id)}
|
|
412
|
+
style={{
|
|
413
|
+
paddingHorizontal: 10,
|
|
414
|
+
paddingVertical: 5,
|
|
415
|
+
borderRadius: 6,
|
|
416
|
+
backgroundColor: isSelected
|
|
417
|
+
? (color ?? colors.primary)
|
|
418
|
+
: colors.cardBackground,
|
|
419
|
+
borderWidth: 1,
|
|
420
|
+
borderColor: isSelected ? (color ?? colors.primary) : colors.border,
|
|
421
|
+
}}
|
|
422
|
+
>
|
|
423
|
+
<Text
|
|
424
|
+
className="text-xs font-medium"
|
|
425
|
+
style={{ color: isSelected ? "#FFFFFF" : colors.textSecondary }}
|
|
426
|
+
>
|
|
427
|
+
{label}
|
|
428
|
+
</Text>
|
|
429
|
+
</Pressable>
|
|
430
|
+
))}
|
|
431
|
+
</HStack>
|
|
432
|
+
</VStack>
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
FilterChipListView.displayName = "FilterChipListView";
|
|
436
|
+
export default memo(FilterChipListView);
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
**Usage in parent:**
|
|
440
|
+
|
|
441
|
+
```tsx
|
|
442
|
+
<FilterChipList
|
|
443
|
+
title="Positions"
|
|
444
|
+
items={positionItems}
|
|
445
|
+
selectedIds={filters.positions}
|
|
446
|
+
colors={colors}
|
|
447
|
+
onToggle={onPositionToggle}
|
|
448
|
+
/>
|
|
449
|
+
<FilterChipList
|
|
450
|
+
title="Tags"
|
|
451
|
+
items={tagItems}
|
|
452
|
+
selectedIds={filters.tags}
|
|
453
|
+
colors={colors}
|
|
454
|
+
onToggle={onTagToggle}
|
|
455
|
+
/>
|
|
456
|
+
```
|