feat: shared back navigation on detail pages

This commit is contained in:
ginnoir
2026-07-04 18:23:15 -05:00
parent 1a1c080d18
commit d090200ec8
10 changed files with 115 additions and 39 deletions
+4 -2
View File
@@ -1,7 +1,8 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getContainer } from "@/modules/garden/server/queries"; import { DetailBackLink } from "@/components/detail-back-link";
import { ContainerDetail } from "@/modules/garden/components/container-detail";
import { getShareLinksForEntity } from "@/modules/_core/share"; import { getShareLinksForEntity } from "@/modules/_core/share";
import { ContainerDetail } from "@/modules/garden/components/container-detail";
import { getContainer } from "@/modules/garden/server/queries";
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) { export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@@ -12,6 +13,7 @@ export default async function ContainerPage({ params }: { params: Promise<{ id:
if (!container) notFound(); if (!container) notFound();
return ( return (
<div className="page-content"> <div className="page-content">
<DetailBackLink href="/garden" label="Garden" className="mb-4" />
<ContainerDetail container={container} shareLinks={shareLinks} /> <ContainerDetail container={container} shareLinks={shareLinks} />
</div> </div>
); );
+3 -15
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { DetailBackLink } from "@/components/detail-back-link";
import { createContainer } from "@/modules/garden/server/actions"; import { createContainer } from "@/modules/garden/server/actions";
export default function NewContainerPage() { export default function NewContainerPage() {
@@ -14,21 +15,8 @@ export default function NewContainerPage() {
return ( return (
<div className="page-content max-w-lg"> <div className="page-content max-w-lg">
<div className="flex items-center gap-3 mb-1"> <div className="mb-1 flex flex-col gap-2">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden"> <DetailBackLink href="/garden" label="Garden" />
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</a>
<h1 className="page-title">New container</h1> <h1 className="page-title">New container</h1>
</div> </div>
<form action={handleCreate} className="flex flex-col gap-4 mt-4"> <form action={handleCreate} className="flex flex-col gap-4 mt-4">
+3 -1
View File
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getPlant, listContainers } from "@/modules/garden/server/queries"; import { DetailBackLink } from "@/components/detail-back-link";
import { PlantForm } from "@/modules/garden/components/plant-form"; import { PlantForm } from "@/modules/garden/components/plant-form";
import { getPlant, listContainers } from "@/modules/garden/server/queries";
export default async function EditPlantPage({ params }: { params: Promise<{ id: string }> }) { export default async function EditPlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@@ -9,6 +10,7 @@ export default async function EditPlantPage({ params }: { params: Promise<{ id:
return ( return (
<div className="page-content max-w-xl"> <div className="page-content max-w-xl">
<DetailBackLink href={`/garden/plants/${plant.id}`} label={plant.name} className="mb-3" />
<h1 className="page-title">Edit Plant</h1> <h1 className="page-title">Edit Plant</h1>
<PlantForm existingPlant={plant} containers={containers} /> <PlantForm existingPlant={plant} containers={containers} />
</div> </div>
+4 -2
View File
@@ -1,8 +1,9 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { DetailBackLink } from "@/components/detail-back-link";
import { getShareLinksForEntity } from "@/modules/_core/share";
import { PlantDetail } from "@/modules/garden/components/plant-detail";
import { listCalendars } from "@/modules/garden/server/calendar-bridge"; import { listCalendars } from "@/modules/garden/server/calendar-bridge";
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries"; import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
import { PlantDetail } from "@/modules/garden/components/plant-detail";
import { getShareLinksForEntity } from "@/modules/_core/share";
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) { export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@@ -17,6 +18,7 @@ export default async function PlantPage({ params }: { params: Promise<{ id: stri
return ( return (
<div className="page-content"> <div className="page-content">
<DetailBackLink href="/garden?tab=plants" label="Plants" className="mb-4" />
<PlantDetail <PlantDetail
plant={plant} plant={plant}
careLogs={careLogs} careLogs={careLogs}
+4 -16
View File
@@ -1,5 +1,6 @@
import { listContainers } from "@/modules/garden/server/queries"; import { DetailBackLink } from "@/components/detail-back-link";
import { PlantForm } from "@/modules/garden/components/plant-form"; import { PlantForm } from "@/modules/garden/components/plant-form";
import { listContainers } from "@/modules/garden/server/queries";
export default async function NewPlantPage({ export default async function NewPlantPage({
searchParams, searchParams,
@@ -11,21 +12,8 @@ export default async function NewPlantPage({
return ( return (
<div className="page-content max-w-xl"> <div className="page-content max-w-xl">
<div className="flex items-center gap-3 mb-1"> <div className="mb-1 flex flex-col gap-2">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden"> <DetailBackLink href="/garden?tab=plants" label="Plants" />
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</a>
<h1 className="page-title">Add Plant</h1> <h1 className="page-title">Add Plant</h1>
</div> </div>
<PlantForm containers={containers} defaultContainerId={params.containerId ?? null} /> <PlantForm containers={containers} defaultContainerId={params.containerId ?? null} />
+5 -3
View File
@@ -1,10 +1,11 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { DetailBackLink } from "@/components/detail-back-link";
import { getCurrentSession } from "@/lib/session";
import { householdMembers, users } from "@/modules/_core/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { householdMembers, users } from "@/modules/_core/schema";
import { renameHousehold } from "./actions"; import { renameHousehold } from "./actions";
export default async function HouseholdSettingsPage() { export default async function HouseholdSettingsPage() {
@@ -18,6 +19,7 @@ export default async function HouseholdSettingsPage() {
return ( return (
<div className="container max-w-2xl py-8 space-y-6"> <div className="container max-w-2xl py-8 space-y-6">
<DetailBackLink href="/settings" label="Settings" />
<h1 className="text-2xl font-semibold">Household Settings</h1> <h1 className="text-2xl font-semibold">Household Settings</h1>
<Card> <Card>
+25
View File
@@ -0,0 +1,25 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { cn } from "@/lib/utils";
type DetailBackLinkProps = {
href: string;
label: string;
className?: string;
};
export function DetailBackLink({ href, label, className }: DetailBackLinkProps) {
return (
<Link
href={href}
aria-label={`Back to ${label}`}
className={cn(
"inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground",
className,
)}
>
<ArrowLeft aria-hidden="true" />
<span>{label}</span>
</Link>
);
}
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Archive, Plus, Trash2 } from "lucide-react"; import { Archive, Plus, Trash2 } from "lucide-react";
import { useEffect, useRef, useState, useTransition } from "react"; import { useEffect, useRef, useState, useTransition } from "react";
import { DetailBackLink } from "@/components/detail-back-link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ShareButton } from "@/components/share-button"; import { ShareButton } from "@/components/share-button";
@@ -99,6 +100,7 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
return ( return (
<div className="mx-auto grid w-full max-w-3xl gap-4"> <div className="mx-auto grid w-full max-w-3xl gap-4">
<DetailBackLink href="/lists" label="Lists" />
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex items-center gap-3 flex-1"> <div className="min-w-0 flex items-center gap-3 flex-1">
<Input <Input
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Pin, PinOff, Save, Trash2 } from "lucide-react"; import { Pin, PinOff, Save, Trash2 } from "lucide-react";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { DetailBackLink } from "@/components/detail-back-link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -65,6 +66,7 @@ export function NoteEditor({ note }: { note?: NoteDto }) {
return ( return (
<div className="mx-auto grid w-full max-w-6xl gap-4"> <div className="mx-auto grid w-full max-w-6xl gap-4">
<DetailBackLink href="/notes" label="Notes" />
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
{pinned && <Pin className="size-3.5 text-[var(--accent)]" />} {pinned && <Pin className="size-3.5 text-[var(--accent)]" />}
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from "@playwright/test";
test.describe("detail page back navigation", () => {
test("plant detail returns to plants tab", async ({ page }) => {
const suffix = Date.now().toString();
const plantName = `Back Nav Plant ${suffix}`;
await page.goto("/garden?tab=plants");
await page.getByRole("link", { name: /add plant/i }).click();
await page.getByLabel("Name").fill(plantName);
await page.getByRole("button", { name: /create/i }).click();
await expect(page).toHaveURL(/\/garden\/plants\//);
const backLink = page.getByRole("link", { name: "Back to Plants" });
await expect(backLink).toBeVisible();
await backLink.click();
await expect(page).toHaveURL(/\/garden\?tab=plants$/);
await expect(page.getByRole("heading", { name: "Garden" })).toBeVisible();
await page.getByText(plantName).click();
await page.getByRole("button", { name: /delete/i }).click();
await page.getByRole("button", { name: /confirm/i }).click();
});
test("note detail returns to notes index", async ({ page }) => {
const suffix = Date.now().toString();
const title = `Back Nav Note ${suffix}`;
await page.goto("/notes/new");
await page.getByLabel("Title").fill(title);
await page.getByRole("button", { name: "Save note" }).click();
await expect(page).toHaveURL(/\/notes\/[0-9a-f-]+$/);
const backLink = page.getByRole("link", { name: "Back to Notes" });
await expect(backLink).toBeVisible();
await backLink.click();
await expect(page).toHaveURL(/\/notes$/);
await expect(page.getByRole("heading", { name: "Notes" })).toBeVisible();
await page.getByRole("link", { name: new RegExp(title) }).click();
await page.getByRole("button", { name: "Delete note" }).click();
});
test("list detail returns to lists index", async ({ page }) => {
const suffix = Date.now().toString();
const listName = `Back Nav List ${suffix}`;
await page.goto("/lists");
await page.getByLabel("Type").fill("task");
await page.getByLabel("Name").fill(listName);
await page.getByRole("button", { name: "New list" }).click();
await page.getByRole("link", { name: new RegExp(listName) }).click();
const backLink = page.getByRole("link", { name: "Back to Lists" });
await expect(backLink).toBeVisible();
await backLink.click();
await expect(page).toHaveURL(/\/lists$/);
await expect(page.getByRole("heading", { name: "Lists" })).toBeVisible();
});
});