Copy a garden (deep duplicate) from the gardens list (#46)
Build image / build-and-push (push) Successful in 16s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #46.
This commit is contained in:
2026-07-21 03:58:39 +00:00
committed by steve
parent e74fb308c1
commit e22bdd6cab
15 changed files with 752 additions and 52 deletions
@@ -0,0 +1,64 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { TextField } from '@/components/ui/TextField'
import { toast } from '@/components/ui/toast'
import { errorMessage } from '@/lib/api'
import { defaultCopyName, useCopyGarden, type Garden } from '@/lib/gardens'
/**
* Duplicate a garden under a new name. The name is prefilled with the server's
* own default so what you see is what you get; the beds and everything currently
* planted in them come along, while the source's share link and shares do not.
* On success we land in the copy — the point of copying is to start editing it.
*/
export function CopyGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const copy = useCopyGarden()
const navigate = useNavigate()
const [name, setName] = useState(() => defaultCopyName(garden.name))
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
try {
const created = await copy.mutateAsync({ id: garden.id, name: name.trim() })
toast.info(`Copied to “${created.name}”.`)
onClose()
navigate({ to: '/gardens/$gardenId', params: { gardenId: String(created.id) } })
} catch (err) {
setError(errorMessage(err, 'Could not copy the garden.'))
}
}
return (
<Modal title="Copy garden" onClose={onClose} busy={copy.isPending}>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<p className="text-sm text-muted">
Make a copy of <span className="font-medium text-fg">{garden.name}</span> with its beds and
everything planted in them. The copy is private the original's share link and people you've
shared it with aren't carried over.
</p>
<TextField
label="Name"
name="name"
required
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
/>
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={copy.isPending}>
Cancel
</Button>
<Button type="submit" disabled={copy.isPending || name.trim() === ''}>
{copy.isPending ? 'Copying' : 'Copy garden'}
</Button>
</div>
</form>
</Modal>
)
}
+8 -2
View File
@@ -5,14 +5,16 @@ import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
/**
* One garden as a card: the body links into the editor. The footer differs by
* role — the owner gets Share / Edit / Delete; a recipient sees a "shared · role"
* badge and a Leave action (garden metadata edit + sharing are owner-only).
* role — the owner gets Share / Copy / Edit / Delete; a recipient sees a
* "shared · role" badge and a Leave action (garden metadata edit, sharing and
* copying are owner-only).
* Ownership is the authoritative ownerId==me check, not the my_role hint.
*/
export function GardenCard({
garden,
currentUserId,
onShare,
onCopy,
onEdit,
onDelete,
onLeave,
@@ -20,6 +22,7 @@ export function GardenCard({
garden: Garden
currentUserId?: number
onShare: () => void
onCopy: () => void
onEdit: () => void
onDelete: () => void
onLeave: () => void
@@ -51,6 +54,9 @@ export function GardenCard({
<button type="button" onClick={onShare} className={cardActionClass}>
Share
</button>
<button type="button" onClick={onCopy} className={cardActionClass}>
Copy
</button>
<button type="button" onClick={onEdit} className={cardActionClass}>
Edit
</button>
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { defaultCopyName } from './gardens'
// The server caps a garden name at 200 BYTES and derives a copy's name the same
// way (copyName in internal/service/gardens.go). These cases pin the mirror: an
// over-long prefill would come back as a 400 rather than a copy.
describe('defaultCopyName', () => {
it('appends the suffix to a short name', () => {
expect(defaultCopyName('Home')).toBe('Home (copy)')
})
it('keeps a derived name within the 200-byte cap', () => {
const name = 'a'.repeat(200)
const derived = defaultCopyName(name)
expect(new TextEncoder().encode(derived).length).toBeLessThanOrEqual(200)
expect(derived.endsWith(' (copy)')).toBe(true)
})
it('truncates multi-byte names on a code-point boundary', () => {
const name = 'é'.repeat(100) // 200 bytes, exactly at the cap
const derived = defaultCopyName(name)
expect(new TextEncoder().encode(derived).length).toBeLessThanOrEqual(200)
// No replacement character: nothing was cut mid-code-point.
expect(derived).not.toContain('')
})
it('does not leave a dangling space before the suffix', () => {
const name = 'x'.repeat(192) + ' y' // truncation lands on the space
expect(defaultCopyName(name)).toBe('x'.repeat(192) + ' (copy)')
})
})
+44
View File
@@ -86,6 +86,50 @@ export function useUpdateGarden() {
})
}
// Mirrors the server's garden-name cap (maxGardenNameLen in
// internal/service/gardens.go). It is a BYTE cap, not a character count.
const MAX_GARDEN_NAME_BYTES = 200
const COPY_SUFFIX = ' (copy)'
/** Trim s to at most maxBytes of UTF-8, never splitting a code point. */
function truncateUtf8(s: string, maxBytes: number): string {
const enc = new TextEncoder()
if (enc.encode(s).length <= maxBytes) return s
let out = ''
let used = 0
for (const ch of s) {
const size = enc.encode(ch).length
if (used + size > maxBytes) break
out += ch
used += size
}
return out
}
/**
* The name a copy gets by default. Mirrors the server's copyName (same file as
* the cap above) so the prefilled value is one the server will accept — without
* the byte-capped truncation, copying a garden whose name is already at the cap
* would prefill an over-long name and fail with a 400.
*/
export function defaultCopyName(name: string): string {
return truncateUtf8(name, MAX_GARDEN_NAME_BYTES - COPY_SUFFIX.length).trim() + COPY_SUFFIX
}
/**
* Duplicate a garden the actor owns, including its objects and current plantings.
* An omitted/blank name lets the server derive "<source> (copy)". The copy does
* not inherit the source's public link or shares.
*/
export function useCopyGarden() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ id, name }: { id: number; name?: string }): Promise<Garden> =>
gardenSchema.parse(await api.post(`/gardens/${id}/copy`, { name: name ?? '' })),
onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }),
})
}
export function useDeleteGarden() {
const qc = useQueryClient()
return useMutation({
+5 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { CopyGardenModal } from '@/components/gardens/CopyGardenModal'
import { DeleteGardenModal } from '@/components/gardens/DeleteGardenModal'
import { GardenCard } from '@/components/gardens/GardenCard'
import { GardenFormModal } from '@/components/gardens/GardenFormModal'
@@ -10,12 +11,13 @@ import { useMe } from '@/lib/auth'
import { useGardens, type Garden } from '@/lib/gardens'
import { usePageTitle } from '@/lib/usePageTitle'
// Which modal is open, if any. edit/delete/share/leave carry the target garden.
// Which modal is open, if any. edit/delete/share/copy/leave carry the target garden.
type Dialog =
| { kind: 'create' }
| { kind: 'edit'; garden: Garden }
| { kind: 'delete'; garden: Garden }
| { kind: 'share'; garden: Garden }
| { kind: 'copy'; garden: Garden }
| { kind: 'leave'; garden: Garden }
| null
@@ -56,6 +58,7 @@ export function GardensPage() {
garden={g}
currentUserId={me.data?.id}
onShare={() => setDialog({ kind: 'share', garden: g })}
onCopy={() => setDialog({ kind: 'copy', garden: g })}
onEdit={() => setDialog({ kind: 'edit', garden: g })}
onDelete={() => setDialog({ kind: 'delete', garden: g })}
onLeave={() => setDialog({ kind: 'leave', garden: g })}
@@ -69,6 +72,7 @@ export function GardensPage() {
{dialog?.kind === 'edit' && <GardenFormModal garden={dialog.garden} onClose={close} />}
{dialog?.kind === 'delete' && <DeleteGardenModal garden={dialog.garden} onClose={close} />}
{dialog?.kind === 'share' && <ShareGardenModal garden={dialog.garden} onClose={close} />}
{dialog?.kind === 'copy' && <CopyGardenModal garden={dialog.garden} onClose={close} />}
{dialog?.kind === 'leave' && <LeaveGardenModal garden={dialog.garden} onClose={close} />}
</section>
)