Skip to content

Commit 399c6ef

Browse files
authored
Merge pull request #116 from toddaheath/feature/opening-client-validation
Add client-side opening validation to DesignPanel
2 parents cb198fb + 38e21f1 commit 399c6ef

File tree

2 files changed

+164
-81
lines changed

2 files changed

+164
-81
lines changed

src/shed-builder-ui/src/components/DesignPanel.tsx

Lines changed: 102 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -135,87 +135,108 @@ export default memo(function DesignPanel({ design, onChange, saveStatus }: Props
135135
</Button>
136136
</Stack>
137137

138-
{(design.openings || []).map((opening, index) => (
139-
<Box key={`${opening.type}-${opening.wall}-${index}`} sx={{ mb: 2, p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
140-
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={1}>
141-
<Typography variant="body2" fontWeight="bold">
142-
{opening.type} #{index + 1}
143-
</Typography>
144-
<IconButton size="small" onClick={() => removeOpening(index)} aria-label={`Remove ${opening.type} #${index + 1}`}>
145-
<DeleteIcon fontSize="small" />
146-
</IconButton>
147-
</Stack>
148-
149-
<Stack direction="row" spacing={1} mb={1}>
150-
<FormControl size="small" sx={{ minWidth: 80 }}>
151-
<InputLabel>Type</InputLabel>
152-
<Select
153-
value={opening.type}
154-
label="Type"
155-
onChange={(e) => updateOpening(index, { type: e.target.value as OpeningType })}
156-
>
157-
<MenuItem value="Door">Door</MenuItem>
158-
<MenuItem value="Window">Window</MenuItem>
159-
</Select>
160-
</FormControl>
161-
<FormControl size="small" sx={{ minWidth: 80 }}>
162-
<InputLabel>Wall</InputLabel>
163-
<Select
164-
value={opening.wall}
165-
label="Wall"
166-
onChange={(e) => updateOpening(index, { wall: e.target.value as WallSide })}
167-
>
168-
<MenuItem value="Front">Front</MenuItem>
169-
<MenuItem value="Back">Back</MenuItem>
170-
<MenuItem value="Left">Left</MenuItem>
171-
<MenuItem value="Right">Right</MenuItem>
172-
</Select>
173-
</FormControl>
174-
</Stack>
175-
176-
<Stack direction="row" spacing={1} mb={1}>
177-
<TextField
178-
label="Width (in)"
179-
type="number"
180-
size="small"
181-
value={opening.widthInches}
182-
onChange={(e) => updateOpening(index, { widthInches: Number(e.target.value) })}
183-
inputProps={{ min: 12, max: 120 }}
184-
sx={{ flex: 1 }}
185-
/>
186-
<TextField
187-
label="Height (in)"
188-
type="number"
189-
size="small"
190-
value={opening.heightInches}
191-
onChange={(e) => updateOpening(index, { heightInches: Number(e.target.value) })}
192-
inputProps={{ min: 12, max: 120 }}
193-
sx={{ flex: 1 }}
194-
/>
195-
</Stack>
196-
197-
<Stack direction="row" spacing={1}>
198-
<TextField
199-
label="Offset (in)"
200-
type="number"
201-
size="small"
202-
value={opening.offsetInches}
203-
onChange={(e) => updateOpening(index, { offsetInches: Number(e.target.value) })}
204-
inputProps={{ min: 0 }}
205-
sx={{ flex: 1 }}
206-
/>
207-
<TextField
208-
label="Sill (in)"
209-
type="number"
210-
size="small"
211-
value={opening.sillHeightInches}
212-
onChange={(e) => updateOpening(index, { sillHeightInches: Number(e.target.value) })}
213-
inputProps={{ min: 0 }}
214-
sx={{ flex: 1 }}
215-
/>
216-
</Stack>
217-
</Box>
218-
))}
138+
{(design.openings || []).map((opening, index) => {
139+
const wallWidthIn = (opening.wall === 'Front' || opening.wall === 'Back')
140+
? design.widthFeet * 12 + design.widthInches
141+
: design.depthFeet * 12 + design.depthInches;
142+
const wallHeightIn = design.heightFeet * 12 + design.heightInches;
143+
const tooWide = opening.offsetInches + opening.widthInches > wallWidthIn;
144+
const tooTall = opening.sillHeightInches + opening.heightInches > wallHeightIn;
145+
146+
return (
147+
<Box key={`${opening.type}-${opening.wall}-${index}`} sx={{ mb: 2, p: 1.5, border: '1px solid', borderColor: tooWide || tooTall ? 'error.main' : 'divider', borderRadius: 1 }}>
148+
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={1}>
149+
<Typography variant="body2" fontWeight="bold">
150+
{opening.type} #{index + 1}
151+
</Typography>
152+
<IconButton size="small" onClick={() => removeOpening(index)} aria-label={`Remove ${opening.type} #${index + 1}`}>
153+
<DeleteIcon fontSize="small" />
154+
</IconButton>
155+
</Stack>
156+
157+
<Stack direction="row" spacing={1} mb={1}>
158+
<FormControl size="small" sx={{ minWidth: 80 }}>
159+
<InputLabel>Type</InputLabel>
160+
<Select
161+
value={opening.type}
162+
label="Type"
163+
onChange={(e) => updateOpening(index, { type: e.target.value as OpeningType })}
164+
>
165+
<MenuItem value="Door">Door</MenuItem>
166+
<MenuItem value="Window">Window</MenuItem>
167+
</Select>
168+
</FormControl>
169+
<FormControl size="small" sx={{ minWidth: 80 }}>
170+
<InputLabel>Wall</InputLabel>
171+
<Select
172+
value={opening.wall}
173+
label="Wall"
174+
onChange={(e) => updateOpening(index, { wall: e.target.value as WallSide })}
175+
>
176+
<MenuItem value="Front">Front</MenuItem>
177+
<MenuItem value="Back">Back</MenuItem>
178+
<MenuItem value="Left">Left</MenuItem>
179+
<MenuItem value="Right">Right</MenuItem>
180+
</Select>
181+
</FormControl>
182+
</Stack>
183+
184+
<Stack direction="row" spacing={1} mb={1}>
185+
<TextField
186+
label="Width (in)"
187+
type="number"
188+
size="small"
189+
value={opening.widthInches}
190+
onChange={(e) => updateOpening(index, { widthInches: Number(e.target.value) })}
191+
inputProps={{ min: 12, max: 120 }}
192+
error={tooWide}
193+
helperText={tooWide ? 'Exceeds wall' : undefined}
194+
sx={{ flex: 1 }}
195+
/>
196+
<TextField
197+
label="Height (in)"
198+
type="number"
199+
size="small"
200+
value={opening.heightInches}
201+
onChange={(e) => updateOpening(index, { heightInches: Number(e.target.value) })}
202+
inputProps={{ min: 12, max: 120 }}
203+
error={tooTall}
204+
helperText={tooTall ? 'Exceeds wall' : undefined}
205+
sx={{ flex: 1 }}
206+
/>
207+
</Stack>
208+
209+
<Stack direction="row" spacing={1}>
210+
<TextField
211+
label="Offset (in)"
212+
type="number"
213+
size="small"
214+
value={opening.offsetInches}
215+
onChange={(e) => updateOpening(index, { offsetInches: Number(e.target.value) })}
216+
inputProps={{ min: 0 }}
217+
error={tooWide}
218+
sx={{ flex: 1 }}
219+
/>
220+
<TextField
221+
label="Sill (in)"
222+
type="number"
223+
size="small"
224+
value={opening.sillHeightInches}
225+
onChange={(e) => updateOpening(index, { sillHeightInches: Number(e.target.value) })}
226+
inputProps={{ min: 0 }}
227+
error={tooTall}
228+
sx={{ flex: 1 }}
229+
/>
230+
</Stack>
231+
232+
{(tooWide || tooTall) && (
233+
<Typography variant="caption" color="error" sx={{ mt: 0.5, display: 'block' }}>
234+
Opening does not fit on the {opening.wall.toLowerCase()} wall ({wallWidthIn}&quot; × {wallHeightIn}&quot;)
235+
</Typography>
236+
)}
237+
</Box>
238+
);
239+
})}
219240
</Box>
220241
);
221242
});

src/shed-builder-ui/src/components/__tests__/DesignPanel.test.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,68 @@ describe('DesignPanel', () => {
217217
});
218218
});
219219

220+
describe('opening validation', () => {
221+
it('shows error when opening exceeds wall width', () => {
222+
const designWithWideOpening: Design = {
223+
...mockDesign,
224+
widthFeet: 10,
225+
widthInches: 0, // wall is 120 inches
226+
openings: [
227+
{ type: 'Door', wall: 'Front', offsetInches: 60, widthInches: 72, heightInches: 80, sillHeightInches: 0 },
228+
],
229+
};
230+
render(<DesignPanel design={designWithWideOpening} onChange={vi.fn()} saveStatus="idle" />);
231+
// offset(60) + width(72) = 132 > 120
232+
expect(screen.getByText(/Exceeds wall/)).toBeInTheDocument();
233+
expect(screen.getByText(/does not fit on the front wall/)).toBeInTheDocument();
234+
});
235+
236+
it('shows error when opening exceeds wall height', () => {
237+
const designWithTallOpening: Design = {
238+
...mockDesign,
239+
heightFeet: 8,
240+
heightInches: 0, // wall is 96 inches
241+
openings: [
242+
{ type: 'Window', wall: 'Front', offsetInches: 12, widthInches: 36, heightInches: 60, sillHeightInches: 48 },
243+
],
244+
};
245+
render(<DesignPanel design={designWithTallOpening} onChange={vi.fn()} saveStatus="idle" />);
246+
// sill(48) + height(60) = 108 > 96
247+
expect(screen.getByText(/does not fit on the front wall/)).toBeInTheDocument();
248+
});
249+
250+
it('does not show error when opening fits on wall', () => {
251+
const designWithFittingOpening: Design = {
252+
...mockDesign,
253+
widthFeet: 10,
254+
widthInches: 0,
255+
heightFeet: 8,
256+
heightInches: 0,
257+
openings: [
258+
{ type: 'Door', wall: 'Front', offsetInches: 24, widthInches: 36, heightInches: 80, sillHeightInches: 0 },
259+
],
260+
};
261+
render(<DesignPanel design={designWithFittingOpening} onChange={vi.fn()} saveStatus="idle" />);
262+
// offset(24) + width(36) = 60 < 120, sill(0) + height(80) = 80 < 96
263+
expect(screen.queryByText(/does not fit/)).not.toBeInTheDocument();
264+
expect(screen.queryByText(/Exceeds wall/)).not.toBeInTheDocument();
265+
});
266+
267+
it('uses depth for side walls', () => {
268+
const designWithSideOpening: Design = {
269+
...mockDesign,
270+
depthFeet: 8,
271+
depthInches: 0, // left wall is 96 inches wide
272+
openings: [
273+
{ type: 'Window', wall: 'Left', offsetInches: 60, widthInches: 48, heightInches: 36, sillHeightInches: 36 },
274+
],
275+
};
276+
render(<DesignPanel design={designWithSideOpening} onChange={vi.fn()} saveStatus="idle" />);
277+
// offset(60) + width(48) = 108 > 96
278+
expect(screen.getByText(/does not fit on the left wall/)).toBeInTheDocument();
279+
});
280+
});
281+
220282
describe('remaining dimension inputs', () => {
221283
it('calls onChange when depth feet is changed', async () => {
222284
const onChange = vi.fn();

0 commit comments

Comments
 (0)