feat: shared back navigation on detail pages
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)]" />}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user