Files
growstuff/app/javascript/components/GardenCards.jsx
T
Brenda WallaceandClaude Sonnet 5 53e945554b Edit a garden in a dialog over the garden list
Three dots on a garden card > edit opens its form in a dialog, and saves
without leaving the page. A garden saved as inactive leaves the list, with
a note saying where it went.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:42:26 +12:00

190 lines
7.4 KiB
React

import React, {useEffect, useRef, useState} from 'react';
import AddPhotoModal from './AddPhotoModal';
import EditGardenModal from './EditGardenModal';
import EditPlantingModal from './EditPlantingModal';
import GardenCard from './GardenCard';
import MarkFinishedModal from './MarkFinishedModal';
import PlantSomethingModal from './PlantSomethingModal';
import RecordHarvestModal from './RecordHarvestModal';
import SaveSeedsModal from './SaveSeedsModal';
const plantingIds = (garden) => [...garden.perennials, ...garden.annuals].map((planting) => planting.id);
// The list of garden cards on the gardens index. Props come from
// GardensHelper#garden_cards_props. It owns the cards' state so that saving a
// planting from the "Add planting" dialog can swap in the garden's
// updated card, and the new planting appears without a page reload.
export default function GardenCards({gardens: initialGardens, default_icon_url: defaultIconUrl, spade_icon_url: spadeIconUrl, harvest_icon_url: harvestIconUrl, seed_icon_url: seedIconUrl, photo_icon_url: photoIconUrl, finish_icon_url: finishIconUrl}) {
const [gardens, setGardens] = useState(initialGardens);
const [plantingIn, setPlantingIn] = useState(null); // the garden the dialog is open for
const [editing, setEditing] = useState(null); // the planting the edit dialog is open for
const [harvesting, setHarvesting] = useState(null); // the planting the harvest dialog is open for
const [savingSeeds, setSavingSeeds] = useState(null); // the planting the save seeds dialog is open for
const [addingPhoto, setAddingPhoto] = useState(null); // {label, href}: what the add photo dialog is open for
const [finishing, setFinishing] = useState(null); // the planting the mark as finished dialog is open for
const [editingGarden, setEditingGarden] = useState(null); // the garden the edit dialog is open for
const [notice, setNotice] = useState(null);
const [highlight, setHighlight] = useState(null); // {id, kind}: the planting just added or harvested
const highlightTimer = useRef(null);
// The "jump to" links point at #garden-<id>, but the cards only exist once
// React has mounted, after the browser has already tried to scroll.
useEffect(() => {
if (!window.location.hash) return;
const target = document.getElementsByName(window.location.hash.slice(1))[0];
if (target) target.scrollIntoView();
}, []);
useEffect(() => () => clearTimeout(highlightTimer.current), []);
// The response leaves out the owner (the page already has it); keep ours.
function replaceCard(updatedCard) {
setGardens((current) => current.map((garden) => (garden.id === updatedCard.id ? {...updatedCard, owner: garden.owner} : garden)));
}
function created(updatedCard, crop) {
const before = gardens.find((garden) => garden.id === updatedCard.id);
const known = new Set(plantingIds(before));
const added = plantingIds(updatedCard).find((id) => !known.has(id));
replaceCard(updatedCard);
setPlantingIn(null);
setNotice(`Planted ${crop ? crop.name : 'something'} in ${updatedCard.name}.`);
flash(added, 'added');
}
// Picks out a planting for a few seconds, so you can see which one changed.
function flash(id, kind) {
setHighlight({id, kind});
clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => setHighlight(null), 5000);
}
function edited(updatedCard, planting) {
replaceCard(updatedCard);
setEditing(null);
setNotice(`Saved changes to ${planting.crop.name}.`);
}
function harvested(updatedCard, planting) {
replaceCard(updatedCard);
setHarvesting(null);
setNotice(`Recorded a harvest of ${planting.crop.name} in ${updatedCard.name}.`);
flash(planting.id, 'harvested');
}
function seedsSaved(seed, planting) {
setSavingSeeds(null);
setNotice(<>Saved {planting.crop.name} seeds to your stash. <a href={seed.url}>See them</a>.</>);
}
function photoAdded(result, target) {
if (result.garden) replaceCard(result.garden); // a garden's picture may now be this one
setAddingPhoto(null);
setNotice(<>Added a photo to {target.label}. <a href={result.photo.url}>See it</a>.</>);
}
function finished(updatedCard, planting) {
replaceCard(updatedCard); // the planting is no longer in progress, so it leaves the card
setFinishing(null);
setNotice(`Marked ${planting.crop.name} as finished.`);
}
// Saved from the garden's edit dialog. A garden marked inactive is not on this
// list, so it leaves it, and its plantings with it.
function gardenEdited(updatedCard) {
setEditingGarden(null);
if (updatedCard.active) {
replaceCard(updatedCard);
setNotice(`Saved changes to ${updatedCard.name}.`);
} else {
setGardens((current) => current.filter((garden) => garden.id !== updatedCard.id));
setNotice(`${updatedCard.name} is now inactive, and its plantings are finished. You will find it under inactive gardens.`);
}
}
return (
<>
{notice && (
<div className="alert alert-success alert-dismissible" role="status">
<i className="fa fa-check-circle" aria-hidden="true" /> {notice}
<button type="button" className="btn-close" aria-label="Dismiss" onClick={() => setNotice(null)} />
</div>
)}
{gardens.map((garden) => (
<GardenCard
key={garden.id}
garden={garden}
defaultIconUrl={defaultIconUrl}
onPlant={setPlantingIn}
highlightedId={highlight && highlight.id}
highlightKind={highlight && highlight.kind}
onPlantingUpdated={replaceCard}
onEditPlanting={setEditing}
onHarvestPlanting={setHarvesting}
onSaveSeedsPlanting={setSavingSeeds}
onAddPhoto={setAddingPhoto}
onFinishPlanting={setFinishing}
onEditGarden={setEditingGarden}
/>
))}
{editing && (
<EditPlantingModal
planting={editing}
onClose={() => setEditing(null)}
onSaved={(updatedCard) => edited(updatedCard, editing)}
/>
)}
{harvesting && (
<RecordHarvestModal
planting={harvesting}
iconUrl={harvestIconUrl}
onClose={() => setHarvesting(null)}
onSaved={(updatedCard) => harvested(updatedCard, harvesting)}
/>
)}
{savingSeeds && (
<SaveSeedsModal
planting={savingSeeds}
iconUrl={seedIconUrl}
onClose={() => setSavingSeeds(null)}
onSaved={(seed) => seedsSaved(seed, savingSeeds)}
/>
)}
{editingGarden && (
<EditGardenModal
garden={editingGarden}
onClose={() => setEditingGarden(null)}
onSaved={gardenEdited}
/>
)}
{finishing && (
<MarkFinishedModal
planting={finishing}
iconUrl={finishIconUrl}
onClose={() => setFinishing(null)}
onSaved={(updatedCard) => finished(updatedCard, finishing)}
/>
)}
{addingPhoto && (
<AddPhotoModal
label={addingPhoto.label}
newUrl={addingPhoto.href}
iconUrl={photoIconUrl}
onClose={() => setAddingPhoto(null)}
onAdded={(result) => photoAdded(result, addingPhoto)}
/>
)}
{plantingIn && (
<PlantSomethingModal
garden={plantingIn}
iconUrl={spadeIconUrl}
onClose={() => setPlantingIn(null)}
onCreated={created}
/>
)}
</>
);
}