Skip to content

Commit 1ccf374

Browse files
authored
Merge pull request #105 from toddaheath/feature/design-duplication
Add design duplication feature
2 parents 5824db4 + b5c76e0 commit 1ccf374

File tree

3 files changed

+83
-2
lines changed

3 files changed

+83
-2
lines changed

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import LogoutIcon from '@mui/icons-material/Logout';
2424
import CircularProgress from '@mui/material/CircularProgress';
2525
import { ThemeProvider, createTheme } from '@mui/material/styles';
2626
import useMediaQuery from '@mui/material/useMediaQuery';
27-
import type { Design, UpdateDesignRequest } from '../types';
27+
import type { Design, UpdateDesignRequest, CreateDesignRequest } from '../types';
2828
import { api, getStoredToken, setStoredToken, extractApiError } from '../services/api';
2929
import { useDesignApi } from '../hooks/useDesignApi';
3030
import { useAutoSave } from '../hooks/useAutoSave';
@@ -341,6 +341,25 @@ function AuthenticatedApp({ mode, toggleDarkMode, onSignOut }: AuthenticatedAppP
341341
[createDesign]
342342
);
343343

344+
const handleDuplicateDesign = useCallback(
345+
(design: Design) => {
346+
const req: CreateDesignRequest = {
347+
name: `${design.name} (Copy)`,
348+
widthFeet: design.widthFeet,
349+
widthInches: design.widthInches,
350+
depthFeet: design.depthFeet,
351+
depthInches: design.depthInches,
352+
heightFeet: design.heightFeet,
353+
heightInches: design.heightInches,
354+
roofPitch: design.roofPitch,
355+
roofType: design.roofType,
356+
openings: design.openings,
357+
};
358+
createDesign(req).then((d) => { if (d) setLocalDesign(d); });
359+
},
360+
[createDesign]
361+
);
362+
344363
return (
345364
<Box sx={{ display: 'flex', height: '100vh' }}>
346365
<a href="#main-content" style={{
@@ -404,6 +423,7 @@ function AuthenticatedApp({ mode, toggleDarkMode, onSignOut }: AuthenticatedAppP
404423
onSelect={handleSelectDesign}
405424
onCreate={handleCreateDesign}
406425
onDelete={deleteDesign}
426+
onDuplicate={handleDuplicateDesign}
407427
/>
408428
</nav>
409429
</Drawer>

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
DialogActions,
1717
} from '@mui/material';
1818
import DeleteIcon from '@mui/icons-material/Delete';
19+
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
1920
import AddIcon from '@mui/icons-material/Add';
2021
import SearchIcon from '@mui/icons-material/Search';
2122
import type { Design } from '../types';
@@ -26,9 +27,10 @@ interface Props {
2627
onSelect: (id: string) => void;
2728
onCreate: (name: string) => void;
2829
onDelete: (id: string) => void;
30+
onDuplicate?: (design: Design) => void;
2931
}
3032

31-
export default memo(function DesignList({ designs, selectedId, onSelect, onCreate, onDelete }: Props) {
33+
export default memo(function DesignList({ designs, selectedId, onSelect, onCreate, onDelete, onDuplicate }: Props) {
3234
const [dialogOpen, setDialogOpen] = useState(false);
3335
const [newName, setNewName] = useState('');
3436
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
@@ -90,6 +92,18 @@ export default memo(function DesignList({ designs, selectedId, onSelect, onCreat
9092
secondary={`${d.widthFeet}'${d.widthInches ? d.widthInches + '"' : ''} × ${d.depthFeet}'${d.depthInches ? d.depthInches + '"' : ''}`}
9193
/>
9294
<ListItemSecondaryAction>
95+
{onDuplicate && (
96+
<IconButton
97+
size="small"
98+
aria-label={`Duplicate ${d.name}`}
99+
onClick={(e) => {
100+
e.stopPropagation();
101+
onDuplicate(d);
102+
}}
103+
>
104+
<ContentCopyIcon fontSize="small" />
105+
</IconButton>
106+
)}
93107
<IconButton
94108
edge="end"
95109
size="small"

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,51 @@ describe('DesignList', () => {
199199

200200
expect(onCreate).not.toHaveBeenCalled();
201201
});
202+
203+
it('shows duplicate button when onDuplicate is provided', () => {
204+
render(
205+
<DesignList
206+
designs={mockDesigns}
207+
selectedId={null}
208+
onSelect={vi.fn()}
209+
onCreate={vi.fn()}
210+
onDelete={vi.fn()}
211+
onDuplicate={vi.fn()}
212+
/>
213+
);
214+
215+
expect(screen.getByLabelText('Duplicate Shed A')).toBeInTheDocument();
216+
expect(screen.getByLabelText('Duplicate Shed B')).toBeInTheDocument();
217+
});
218+
219+
it('calls onDuplicate with design when duplicate button is clicked', async () => {
220+
const onDuplicate = vi.fn();
221+
render(
222+
<DesignList
223+
designs={mockDesigns}
224+
selectedId={null}
225+
onSelect={vi.fn()}
226+
onCreate={vi.fn()}
227+
onDelete={vi.fn()}
228+
onDuplicate={onDuplicate}
229+
/>
230+
);
231+
232+
await userEvent.click(screen.getByLabelText('Duplicate Shed A'));
233+
expect(onDuplicate).toHaveBeenCalledWith(mockDesigns[0]);
234+
});
235+
236+
it('hides duplicate button when onDuplicate is not provided', () => {
237+
render(
238+
<DesignList
239+
designs={mockDesigns}
240+
selectedId={null}
241+
onSelect={vi.fn()}
242+
onCreate={vi.fn()}
243+
onDelete={vi.fn()}
244+
/>
245+
);
246+
247+
expect(screen.queryByLabelText('Duplicate Shed A')).not.toBeInTheDocument();
248+
});
202249
});

0 commit comments

Comments
 (0)