diff --git a/ApiCore-Frontend/src/app/(app)/sidebar.tsx b/ApiCore-Frontend/src/app/(app)/sidebar.tsx index d351959..e0d53f0 100644 --- a/ApiCore-Frontend/src/app/(app)/sidebar.tsx +++ b/ApiCore-Frontend/src/app/(app)/sidebar.tsx @@ -142,7 +142,7 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) { {item.label} diff --git a/ApiCore-Frontend/src/components/empty-state.tsx b/ApiCore-Frontend/src/components/empty-state.tsx index faae328..221c77d 100644 --- a/ApiCore-Frontend/src/components/empty-state.tsx +++ b/ApiCore-Frontend/src/components/empty-state.tsx @@ -1,21 +1,25 @@ +import { ReactNode } from "react"; import { LucideIcon } from "lucide-react"; interface EmptyStateProps { icon: LucideIcon; title: string; description?: string; + /** The one obvious next step, e.g. a link button "Erstes Volk anlegen". */ + action?: ReactNode; } -export function EmptyState({ icon: Icon, title, description }: EmptyStateProps) { +export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) { return ( -
+
- +

{title}

{description ?

{description}

: null}
+ {action ?
{action}
: null}
); } diff --git a/ApiCore-Frontend/src/components/query-state.spec.tsx b/ApiCore-Frontend/src/components/query-state.spec.tsx new file mode 100644 index 0000000..831df46 --- /dev/null +++ b/ApiCore-Frontend/src/components/query-state.spec.tsx @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryState } from "./query-state"; + +const base = { isLoading: false, isError: false, isFetching: false, refetch: vi.fn() }; + +describe("QueryState", () => { + it("never shows the empty state for a failed load, and offers a retry", async () => { + const refetch = vi.fn(); + render( + Noch keine Völker

} + > + {() =>

Daten

} +
, + ); + expect(screen.queryByText("Noch keine Völker")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" })); + expect(refetch).toHaveBeenCalled(); + }); + + it("shows the empty state for an empty list", () => { + render( + Noch keine Völker

}> + {() =>

Daten

} +
, + ); + expect(screen.getByText("Noch keine Völker")).toBeInTheDocument(); + }); + + it("renders the data", () => { + render( + + {(data) =>

{data.length} Volk

} +
, + ); + expect(screen.getByText("1 Volk")).toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/components/query-state.tsx b/ApiCore-Frontend/src/components/query-state.tsx new file mode 100644 index 0000000..72d5cdc --- /dev/null +++ b/ApiCore-Frontend/src/components/query-state.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { ReactNode } from "react"; +import { AlertCircle, RotateCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface ErrorStateProps { + /** What could not be loaded, e.g. "Völker". */ + what: string; + onRetry?: () => void; + isRetrying?: boolean; +} + +/** + * A failed load says what failed and offers the next step right there - + * never a dead end, and never mistaken for "there is nothing here". + */ +export function ErrorState({ what, onRetry, isRetrying }: ErrorStateProps) { + return ( +
+

+ + {what} konnten nicht geladen werden. Meist hilft es, es gleich noch einmal zu versuchen. +

+ {onRetry ? ( + + ) : null} +
+ ); +} + +export function LoadingState({ what }: { what: string }) { + return ( +

+ {what} werden geladen… +

+ ); +} + +interface QueryLike { + data: T | undefined; + isLoading: boolean; + isError: boolean; + isFetching?: boolean; + refetch: () => unknown; +} + +interface QueryStateProps { + query: QueryLike; + /** Plural noun for messages: "Völker", "Ernten". */ + what: string; + /** Rendered when the data is an empty list. */ + empty?: ReactNode; + children: (data: T) => ReactNode; +} + +/** + * Loading, error (with retry), empty and data - always in that order, so + * a failed request can never look like an empty list. + */ +export function QueryState({ query, what, empty, children }: QueryStateProps) { + if (query.isLoading) { + return ; + } + if (query.isError || query.data === undefined) { + return ( + void query.refetch()} + isRetrying={query.isFetching} + /> + ); + } + if (empty && Array.isArray(query.data) && query.data.length === 0) { + return <>{empty}; + } + return <>{children(query.data)}; +} diff --git a/ApiCore-Frontend/src/components/stat-bar-list.tsx b/ApiCore-Frontend/src/components/stat-bar-list.tsx index 8362f23..703dae1 100644 --- a/ApiCore-Frontend/src/components/stat-bar-list.tsx +++ b/ApiCore-Frontend/src/components/stat-bar-list.tsx @@ -30,7 +30,7 @@ export function StatBarList({ items, emptyText = "Keine Daten vorhanden.", forma
0 ? Math.max((item.value / max) * 100, 4) : 0}%` }} />
diff --git a/ApiCore-Frontend/src/components/ui/button.tsx b/ApiCore-Frontend/src/components/ui/button.tsx index 0ffcaee..422cea3 100644 --- a/ApiCore-Frontend/src/components/ui/button.tsx +++ b/ApiCore-Frontend/src/components/ui/button.tsx @@ -21,10 +21,11 @@ const buttonVariants = cva( link: "text-primary underline-offset-4 hover:underline", }, size: { + // Phones get at least 40 px: tapped with a thumb, often with gloves. default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + "h-10 sm:h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + sm: "h-9 sm:h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", // 44 px: reliable with gloves or sticky fingers at the apiary. touch: "h-11 gap-2 px-4 text-[0.95rem] [&_svg:not([class*='size-'])]:size-5", diff --git a/ApiCore-Frontend/src/components/ui/dialog.tsx b/ApiCore-Frontend/src/components/ui/dialog.tsx index f526bf5..78ec8c3 100644 --- a/ApiCore-Frontend/src/components/ui/dialog.tsx +++ b/ApiCore-Frontend/src/components/ui/dialog.tsx @@ -53,7 +53,7 @@ function DialogContent({ ) { type={type} data-slot="input" className={cn( - "h-9 w-full min-w-0 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", + "h-11 w-full min-w-0 sm:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className )} {...props} diff --git a/ApiCore-Frontend/src/components/ui/select.tsx b/ApiCore-Frontend/src/components/ui/select.tsx index 5724aa8..fe7448c 100644 --- a/ApiCore-Frontend/src/components/ui/select.tsx +++ b/ApiCore-Frontend/src/components/ui/select.tsx @@ -41,7 +41,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all outline-none select-none hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all outline-none select-none hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 sm:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} diff --git a/ApiCore-Frontend/src/components/ui/sheet.tsx b/ApiCore-Frontend/src/components/ui/sheet.tsx index 78c0a76..d5d1dfe 100644 --- a/ApiCore-Frontend/src/components/ui/sheet.tsx +++ b/ApiCore-Frontend/src/components/ui/sheet.tsx @@ -53,7 +53,7 @@ function SheetContent({ data-slot="sheet-content" data-side={side} className={cn( - "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", + "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t motion-full:data-[side=bottom]:data-ending-style:translate-y-[2.5rem] motion-full:data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r motion-full:data-[side=left]:data-ending-style:translate-x-[-2.5rem] motion-full:data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l motion-full:data-[side=right]:data-ending-style:translate-x-[2.5rem] motion-full:data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b motion-full:data-[side=top]:data-ending-style:translate-y-[-2.5rem] motion-full:data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", className )} {...props}