mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
merge: remove the bug-report integration for v1.23
# Conflicts: # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/pl.json # src/app/admin/[section]/renderer.tsx
This commit is contained in:
commit
b271a39fe4
68 files changed
+115
-3344
No files matched your search
@@ -6,7 +6,7 @@ React components. The UI primitives are shadcn/ui (new-york style, zinc base) ov
|
||||
|
||||
- **`ui/`** — shadcn/ui primitives (button, dialog, sheet, table, …). Generated by the shadcn CLI; keep edits minimal and on-style.
|
||||
- **`layout/`** — the app shell: sidebar, topbar, bottom nav.
|
||||
- **`providers.tsx`, `app-settings-provider.tsx`, `version-poller.tsx`** — top-level context providers and the self-healing stale-shell poller.
|
||||
- **`providers.tsx`, `version-poller.tsx`** — top-level context providers and the self-healing stale-shell poller.
|
||||
- **`i18n/`** — translation context + locale switcher.
|
||||
|
||||
## Feature groups
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// `<ErrorDetails>` is a client component used by every Next.js
|
||||
// `error.tsx` boundary. It surfaces a "Report bug" link whose
|
||||
// visibility must mirror the admin's `bugReportEnabled` toggle —
|
||||
// otherwise the user lands on a `/bugreport` page that immediately
|
||||
// renders a "Bug reports are disabled" notice.
|
||||
|
||||
const mockSettingsRef = { value: { bugReportEnabled: true } };
|
||||
vi.mock("@/components/app-settings-provider", () => ({
|
||||
useAppSettings: () => mockSettingsRef.value,
|
||||
}));
|
||||
// `<ErrorDetails>` is a client component used by every Next.js `error.tsx`
|
||||
// boundary. It surfaces the error message plus a copy-to-clipboard diagnostic
|
||||
// payload — and, since v1.23, no longer links out to a bug-report surface.
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ErrorDetails } from "../error-details";
|
||||
|
||||
function render({
|
||||
bugReportEnabled = true,
|
||||
}: { bugReportEnabled?: boolean } = {}) {
|
||||
mockSettingsRef.value = { bugReportEnabled };
|
||||
function render() {
|
||||
const error = Object.assign(new Error("test failure"), {
|
||||
digest: "abc123",
|
||||
});
|
||||
@@ -29,14 +19,14 @@ function render({
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ErrorDetails> bug-report toggle", () => {
|
||||
it("shows the Report bug button when the admin flag is enabled", () => {
|
||||
const html = render({ bugReportEnabled: true });
|
||||
expect(html).toContain('href="/bugreport"');
|
||||
describe("<ErrorDetails>", () => {
|
||||
it("renders the error message", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("test failure");
|
||||
});
|
||||
|
||||
it("hides the Report bug button when the admin flag is disabled", () => {
|
||||
const html = render({ bugReportEnabled: false });
|
||||
it("does not link out to a bug-report surface", () => {
|
||||
const html = render();
|
||||
expect(html).not.toContain('href="/bugreport"');
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,6 @@ import { AiQualitySection } from "../ai-quality-section";
|
||||
import { ApiTokenOverviewSection } from "../api-token-overview-section";
|
||||
import { BackupsSection } from "../backups-section";
|
||||
import { DangerZoneSection } from "../danger-zone-section";
|
||||
import { FeedbackInboxSection } from "../feedback-inbox-section";
|
||||
import { GeneralSettingsSection } from "../general-settings-section";
|
||||
import { IntegrationsGroupSection } from "../integrations-group-section";
|
||||
import { LoginOverviewSection } from "../login-overview-section";
|
||||
@@ -89,22 +88,13 @@ describe("admin sections — SSR smoke", () => {
|
||||
expect(html).toContain("Telegram");
|
||||
});
|
||||
|
||||
it("<IntegrationsGroupSection> renders all four sub-sections", () => {
|
||||
it("<IntegrationsGroupSection> renders all sub-sections", () => {
|
||||
const html = render(<IntegrationsGroupSection />);
|
||||
expect(html).toContain("Umami");
|
||||
expect(html).toContain("GlitchTip");
|
||||
expect(html).toContain("Web Push");
|
||||
});
|
||||
|
||||
it("<FeedbackInboxSection> renders", () => {
|
||||
const html = render(<FeedbackInboxSection />);
|
||||
// F-08 dedupe: card no longer carries a duplicate "Feedback"
|
||||
// header. Anchor on the empty-state copy + status tabs instead.
|
||||
expect(html).toContain("All caught up");
|
||||
expect(html).toContain("Open");
|
||||
expect(html).toContain("Resolved");
|
||||
});
|
||||
|
||||
it("<RemindersSection> renders", () => {
|
||||
const html = render(<RemindersSection />);
|
||||
expect(html).toContain("Medication Reminders");
|
||||
|
||||
@@ -60,7 +60,6 @@ export interface SystemStatus {
|
||||
umami: { configured: boolean; enabled: boolean } | null;
|
||||
glitchtip: { configured: boolean; enabled: boolean } | null;
|
||||
webPush: { configured: boolean } | null;
|
||||
bugReport: { configured: boolean } | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,9 +79,6 @@ export interface AdminSettings {
|
||||
glitchtipEnabled: boolean;
|
||||
glitchtipDsn: string | null;
|
||||
glitchtipEnvironment: string | null;
|
||||
bugReportRepo: string | null;
|
||||
bugReportConfigured: boolean;
|
||||
bugReportEnabled: boolean;
|
||||
reminderLateMinutes: number;
|
||||
reminderMissedMinutes: number;
|
||||
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver".
|
||||
@@ -116,54 +112,9 @@ export interface ApiTokenInfo {
|
||||
user: { id: string; username: string };
|
||||
}
|
||||
|
||||
export type FeedbackStatusType =
|
||||
| "OPEN"
|
||||
| "ACKNOWLEDGED"
|
||||
| "RESOLVED"
|
||||
| "ARCHIVED";
|
||||
export type FeedbackCategoryType =
|
||||
| "BUG"
|
||||
| "FEATURE_REQUEST"
|
||||
| "QUESTION"
|
||||
| "OTHER";
|
||||
|
||||
export interface FeedbackItem {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
email: string | null;
|
||||
category: FeedbackCategoryType;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: FeedbackStatusType;
|
||||
adminNote: string | null;
|
||||
gitHubIssueUrl: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
screenshotBase64: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: { username: string } | null;
|
||||
}
|
||||
|
||||
export interface FeedbackListResponse {
|
||||
items: FeedbackItem[];
|
||||
meta: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
countsByStatus: Partial<Record<FeedbackStatusType, number>>;
|
||||
};
|
||||
}
|
||||
|
||||
export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [
|
||||
"OPEN",
|
||||
"ACKNOWLEDGED",
|
||||
"RESOLVED",
|
||||
"ARCHIVED",
|
||||
];
|
||||
|
||||
/** Shared green "configured" pill — the GlitchTip, Umami, bug-report,
|
||||
* and Web-Push VAPID cards all surface the same state; one component
|
||||
* keeps the tint and copy in lockstep. */
|
||||
/** Shared green "configured" pill — the GlitchTip, Umami, and Web-Push
|
||||
* VAPID cards all surface the same state; one component keeps the tint
|
||||
* and copy in lockstep. */
|
||||
export function ConfiguredBadge() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
FileText,
|
||||
Info,
|
||||
KeyRound,
|
||||
Inbox,
|
||||
Plug,
|
||||
Radio,
|
||||
ScrollText,
|
||||
@@ -90,11 +89,6 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
titleKey: "admin.section.module-availability.title",
|
||||
icon: Blocks,
|
||||
},
|
||||
{
|
||||
slug: "feedback",
|
||||
titleKey: "admin.section.feedback.title",
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
slug: "reminders",
|
||||
titleKey: "admin.section.reminders.title",
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bug, Loader2 } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
ConfiguredBadge,
|
||||
PasswordInput,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function BugReportSection() {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [bugReportRepoDraft, setBugReportRepoDraft] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [bugReportTokenDraft, setBugReportTokenDraft] = useState("");
|
||||
|
||||
const bugReportRepoValue =
|
||||
bugReportRepoDraft ?? settings?.bugReportRepo ?? "";
|
||||
const configured = settings?.bugReportConfigured ?? false;
|
||||
const enabled = settings?.bugReportEnabled ?? true;
|
||||
|
||||
function saveBugReportSettings() {
|
||||
const payload: Record<string, unknown> = {
|
||||
bugReportRepo: bugReportRepoValue,
|
||||
};
|
||||
if (bugReportTokenDraft.trim().length > 0) {
|
||||
payload.bugReportToken = bugReportTokenDraft.trim();
|
||||
}
|
||||
|
||||
updateSettings.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
setBugReportRepoDraft(null);
|
||||
setBugReportTokenDraft("");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEnabled(next: boolean) {
|
||||
updateSettings.mutate({ bugReportEnabled: next });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Bug}
|
||||
title={t("admin.bugReportGithub")}
|
||||
titleAccessory={
|
||||
// v1.18.10 (W7) — the feedback / bug-report feature is deprecated.
|
||||
// The badge + note signal the upcoming removal without pulling the
|
||||
// functionality yet, so existing operators keep the surface working.
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="bug-report-deprecated-badge"
|
||||
className="border-amber-500/50 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{t("admin.bugReportDeprecatedBadge")}
|
||||
</Badge>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<p>{t("admin.bugReportGithubDescription")}</p>
|
||||
<p className="text-amber-600 dark:text-amber-400">
|
||||
{t("admin.bugReportDeprecatedNote")}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="bg-muted/40 mt-4 ml-7 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("admin.bugReportEnabledLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.bugReportEnabledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={toggleEnabled}
|
||||
disabled={updateSettings.isPending}
|
||||
aria-label={t("admin.bugReportEnabledLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-bugreport-repo" className="text-xs">
|
||||
{t("admin.bugReportRepo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-bugreport-repo"
|
||||
value={bugReportRepoValue}
|
||||
onChange={(event) => setBugReportRepoDraft(event.target.value)}
|
||||
placeholder={t("admin.bugReportRepoPlaceholder")}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-bugreport-token" className="text-xs">
|
||||
{t("admin.bugReportToken")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="admin-bugreport-token"
|
||||
value={bugReportTokenDraft}
|
||||
onChange={(event) => setBugReportTokenDraft(event.target.value)}
|
||||
placeholder={t("admin.bugReportTokenPlaceholder")}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveBugReportSettings}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
Inbox,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
FEEDBACK_STATUS_TABS,
|
||||
type FeedbackCategoryType,
|
||||
type FeedbackItem,
|
||||
type FeedbackListResponse,
|
||||
type FeedbackStatusType,
|
||||
useSystemStatus,
|
||||
} from "./_shared";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
export function FeedbackInboxSection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: status } = useSystemStatus();
|
||||
const githubConfigured = Boolean(status?.integrations?.bugReport?.configured);
|
||||
|
||||
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
|
||||
const [selected, setSelected] = useState<FeedbackItem | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.adminFeedback(activeStatus),
|
||||
queryFn: async () => {
|
||||
return apiGet<FeedbackListResponse>(
|
||||
`/api/admin/feedback?status=${activeStatus}&limit=100`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const counts = data?.meta?.countsByStatus ?? {};
|
||||
|
||||
function refresh() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminFeedbackRoot() });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
{/* v1.4.19 A8 / F-08 + F-20: this is the only card on
|
||||
`/admin/feedback`, so the page header already provides the
|
||||
title + description — the card-level repetition was just
|
||||
visual noise. The icon stays as a low-key card affordance. */}
|
||||
<Inbox className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
|
||||
<Tabs
|
||||
value={activeStatus}
|
||||
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
|
||||
className="mt-4"
|
||||
>
|
||||
{/* v1.4.27 MB7 / CF-55 — wrap the TabsList in an
|
||||
overflow-x-auto strip so the 4 status pills + their count
|
||||
badges scroll horizontally on Pixel 5 / Galaxy Fold
|
||||
instead of squashing the badge text below the legibility
|
||||
floor. Hide the scrollbar visually but keep the gesture
|
||||
working (matches the Insights tab-strip pattern). */}
|
||||
<div className="-mx-1 [scrollbar-width:none] overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden">
|
||||
<TabsList>
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
<span>
|
||||
{t(
|
||||
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{counts[s] ?? 0}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsContent key={s} value={s} className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : !data?.items?.length ? (
|
||||
// v1.4.15 phase-C5: per-tab empty state. Compact size so
|
||||
// it sits naturally under the tab strip without dwarfing
|
||||
// a typical 1-2-row inbox once entries arrive.
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={<Inbox className="size-5" />}
|
||||
title={t("admin.feedback.emptyTitle")}
|
||||
description={t("admin.feedback.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.createdAt")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.category")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.subject")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.user")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.feedback.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{data.items.map((item, i) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
|
||||
onClick={() => setSelected(item)}
|
||||
>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDateTime(item.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
</td>
|
||||
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
|
||||
{item.subject}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelected(item);
|
||||
}}
|
||||
>
|
||||
{t("admin.feedback.details")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{selected && (
|
||||
<FeedbackDetailDialog
|
||||
item={selected}
|
||||
open={!!selected}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
githubConfigured={githubConfigured}
|
||||
onMutated={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCategoryBadge({
|
||||
category,
|
||||
}: {
|
||||
category: FeedbackCategoryType;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const map: Record<
|
||||
FeedbackCategoryType,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
BUG: {
|
||||
label: t("admin.feedback.categoryBug"),
|
||||
// Bug = something that's broken → semantic `destructive` token instead
|
||||
// of raw `dracula-red`. Visually identical in dark mode (both resolve
|
||||
// to #ff5555); light mode now picks up the AA-contrast `#cb3a2a`.
|
||||
className: "bg-destructive/15 text-destructive border-destructive/30",
|
||||
},
|
||||
FEATURE_REQUEST: {
|
||||
label: t("admin.feedback.categoryFeature"),
|
||||
className:
|
||||
"bg-dracula-purple/15 text-dracula-purple border-dracula-purple/30",
|
||||
},
|
||||
QUESTION: {
|
||||
label: t("admin.feedback.categoryQuestion"),
|
||||
className: "bg-dracula-cyan/15 text-dracula-cyan border-dracula-cyan/30",
|
||||
},
|
||||
OTHER: {
|
||||
label: t("admin.feedback.categoryOther"),
|
||||
className: "bg-muted text-muted-foreground border-border",
|
||||
},
|
||||
};
|
||||
const cfg = map[category];
|
||||
return (
|
||||
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackDetailDialog({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
githubConfigured,
|
||||
onMutated,
|
||||
}: {
|
||||
item: FeedbackItem;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
githubConfigured: boolean;
|
||||
onMutated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [note, setNote] = useState(item.adminNote ?? "");
|
||||
const [issueUrl, setIssueUrl] = useState(item.gitHubIssueUrl);
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
status?: FeedbackStatusType;
|
||||
adminNote?: string | null;
|
||||
}) => {
|
||||
await apiPatch(`/api/admin/feedback/${item.id}`, payload);
|
||||
},
|
||||
onSuccess: (_, vars) => {
|
||||
onMutated();
|
||||
if (vars.adminNote !== undefined) {
|
||||
toast.success(t("admin.feedback.noteSaved"));
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiDelete(`/api/admin/feedback/${item.id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
onMutated();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<{ issueUrl: string }>(
|
||||
`/api/admin/feedback/${item.id}/github`,
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setIssueUrl(data.issueUrl);
|
||||
toast.success(t("admin.feedback.publishSuccess"));
|
||||
onMutated();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.publishFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const meta = item.metadata ?? {};
|
||||
const url = typeof meta.url === "string" ? meta.url : null;
|
||||
const locale = typeof meta.locale === "string" ? meta.locale : null;
|
||||
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
|
||||
const appVersion =
|
||||
typeof meta.appVersion === "string" ? meta.appVersion : null;
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
{item.subject}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
<span>
|
||||
{t("admin.feedback.submittedBy")}:{" "}
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(item.createdAt)}</span>
|
||||
{issueUrl && (
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1"
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{t("admin.feedback.viewIssue")}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
className="sm:max-w-2xl"
|
||||
footer={
|
||||
<div className="flex w-full flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
|
||||
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionAcknowledge")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={update.isPending || item.status === "RESOLVED"}
|
||||
onClick={() => update.mutate({ status: "RESOLVED" })}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionResolve")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={archive.isPending || item.status === "ARCHIVED"}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.feedback.archiveConfirm")}
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => archive.mutate()}
|
||||
variant="destructive"
|
||||
disabled={archive.isPending}
|
||||
aria-busy={archive.isPending || undefined}
|
||||
>
|
||||
{archive.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{githubConfigured && !issueUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={publish.isPending}
|
||||
onClick={() => publish.mutate()}
|
||||
className="ml-auto"
|
||||
>
|
||||
{publish.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<GitPullRequest className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.feedback.actionPublishGithub")}
|
||||
</Button>
|
||||
)}
|
||||
{issueUrl && (
|
||||
<Badge className="border-success/30 bg-success/15 text-success ml-auto">
|
||||
{t("admin.feedback.publishedToGithub")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
||||
{item.description}
|
||||
</div>
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
|
||||
sequential beneath the ResponsiveSheet's h2 title. */}
|
||||
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h3>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{url && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUrl")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{url}</dd>
|
||||
</>
|
||||
)}
|
||||
{locale && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaLocale")}
|
||||
</dt>
|
||||
<dd className="font-mono">{locale}</dd>
|
||||
</>
|
||||
)}
|
||||
{appVersion && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaAppVersion")}
|
||||
</dt>
|
||||
<dd className="font-mono">{appVersion}</dd>
|
||||
</>
|
||||
)}
|
||||
{userAgent && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUserAgent")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{userAgent}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.screenshotHeading")}
|
||||
</h3>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.screenshotBase64}
|
||||
alt="Screenshot"
|
||||
className="border-border max-h-72 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-feedback-note" className="text-xs">
|
||||
{t("admin.feedback.adminNote")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="admin-feedback-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || note === (item.adminNote ?? "")}
|
||||
onClick={() => update.mutate({ adminNote: note || null })}
|
||||
>
|
||||
{update.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.feedback.saveNote")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,11 @@
|
||||
/**
|
||||
* `<IntegrationsGroupSection>` — composite admin route at `/admin/integrations`.
|
||||
*
|
||||
* Wraps the four standalone integration sub-sections (Umami, GlitchTip, Web
|
||||
* Push, Bug Report) under one route so each existing component stays the
|
||||
* source of truth for its own settings while admins still get a single
|
||||
* landing page mirroring the v1.4 layout.
|
||||
* Wraps the standalone integration sub-sections (Umami, GlitchTip, Web Push)
|
||||
* under one route so each existing component stays the source of truth for its
|
||||
* own settings while admins still get a single landing page.
|
||||
*/
|
||||
|
||||
import { BugReportSection } from "./bug-report-section";
|
||||
import { GlitchtipSection } from "./glitchtip-section";
|
||||
import { UmamiSection } from "./umami-section";
|
||||
import { WebPushVapidSection } from "./web-push-vapid-section";
|
||||
@@ -20,7 +18,6 @@ export function IntegrationsGroupSection() {
|
||||
<UmamiSection />
|
||||
<GlitchtipSection />
|
||||
<WebPushVapidSection />
|
||||
<BugReportSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export const ADMIN_SECTION_SLUGS = [
|
||||
// (was stacked under Coach). It gates every module, not just the coach,
|
||||
// so it reads better next to Coach than buried inside it.
|
||||
"module-availability",
|
||||
"feedback",
|
||||
"reminders",
|
||||
"users",
|
||||
// v1.16.0 — registration invites get their own section. They started
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
BellRing,
|
||||
Bug,
|
||||
Clock,
|
||||
Cog,
|
||||
Database,
|
||||
@@ -160,14 +159,6 @@ export function SystemStatusSection() {
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
{status.integrations.bugReport && (
|
||||
<StatusItem
|
||||
icon={Bug}
|
||||
label={t("admin.integrationBugReport")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.27 R5 — offline GeoLite2 availability. Renders only
|
||||
when /api/version returns the new field so legacy
|
||||
responses do not produce a placeholder row. */}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Client-side feature-flag context — exposes admin-managed app settings to
|
||||
* layout components so nav surfaces can hide entries when the admin flips a
|
||||
* toggle.
|
||||
*
|
||||
* Today this only carries the `bugReportEnabled` flag (the "Bug Report"
|
||||
* sidebar entry, the bottom-nav, the topbar dropdown, and the user-facing
|
||||
* "Report bug" buttons inside `<ErrorDetails>` all gate on it). Add new
|
||||
* flags here when more admin-managed feature toggles need to reach the
|
||||
* client.
|
||||
*
|
||||
* Source: `GET /api/bugreport/status` already returns `{ enabled }` for any
|
||||
* authenticated user. We piggy-back on that endpoint instead of standing up
|
||||
* a parallel `/api/app-settings` route — the data is identical and the
|
||||
* caching / rate-limit story is already solved upstream.
|
||||
*
|
||||
* Defaults to "all flags ON" so unauthenticated layouts (login, register)
|
||||
* and the brief window before the query resolves don't flicker the entry
|
||||
* out from underneath the user. The admin's "off" state only takes effect
|
||||
* once the data has actually arrived.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface AppSettings {
|
||||
/** Whether the admin has enabled in-app bug-report / feedback submission. */
|
||||
bugReportEnabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
bugReportEnabled: true,
|
||||
};
|
||||
|
||||
const AppSettingsContext = createContext<AppSettings>(DEFAULT_SETTINGS);
|
||||
|
||||
export function useAppSettings(): AppSettings {
|
||||
return useContext(AppSettingsContext);
|
||||
}
|
||||
|
||||
interface BugReportStatusPayload {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches `bugReportEnabled` once per session and exposes it via context. The
|
||||
* provider lives inside `Providers` so anything beneath the React tree —
|
||||
* including the global nav surfaces — can observe the flag without
|
||||
* each component re-fetching the same status payload.
|
||||
*/
|
||||
export function AppSettingsProvider({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.bugreportStatus(),
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async () => {
|
||||
return apiGet<BugReportStatusPayload>("/api/bugreport/status");
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Default to ON when the query is still loading, has errored, or the user
|
||||
// is unauthenticated — the nav must not flicker the entry out under the
|
||||
// user's cursor on a slow network.
|
||||
const settings: AppSettings = {
|
||||
bugReportEnabled: data?.enabled ?? true,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppSettingsContext.Provider value={settings}>
|
||||
{children}
|
||||
</AppSettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, CheckCircle2, RotateCcw, Bug } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Copy, CheckCircle2, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAppSettings } from "@/components/app-settings-provider";
|
||||
|
||||
export interface ErrorDetailsProps {
|
||||
/** The error object, whether it's a runtime Error or a typed API error. */
|
||||
@@ -16,8 +14,6 @@ export interface ErrorDetailsProps {
|
||||
requestId?: string | null;
|
||||
/** Extra fields to include in the diagnostic payload. */
|
||||
context?: Record<string, unknown>;
|
||||
/** Override the "Report bug" link target. */
|
||||
reportHref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,10 +26,8 @@ export function ErrorDetails({
|
||||
reset,
|
||||
requestId,
|
||||
context,
|
||||
reportHref = "/bugreport",
|
||||
}: ErrorDetailsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { bugReportEnabled } = useAppSettings();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
@@ -102,14 +96,6 @@ export function ErrorDetails({
|
||||
)}
|
||||
{copied ? t("common.copied") : t("common.copyDetails")}
|
||||
</Button>
|
||||
{bugReportEnabled && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={reportHref}>
|
||||
<Bug className="h-4 w-4" />
|
||||
{t("common.reportIssue")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -74,28 +74,14 @@ describe("nav-model utility tail (N-1 — one shared list)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lists the shared utility surfaces (Bug Report, Settings, Notifications)", () => {
|
||||
it("lists the shared utility surfaces (Settings, Notifications)", () => {
|
||||
const hrefs = NAV_UTILITY_DESTINATIONS.map((d) => d.href);
|
||||
expect(hrefs).toContain("/bugreport");
|
||||
expect(hrefs).toContain("/settings/account");
|
||||
expect(hrefs).toContain("/notifications");
|
||||
});
|
||||
|
||||
it("drops the bug-report entry unless the operator flag is on", () => {
|
||||
const off = visibleUtilityDestinations(false).map((d) => d.href);
|
||||
const on = visibleUtilityDestinations(true).map((d) => d.href);
|
||||
expect(off).not.toContain("/bugreport");
|
||||
expect(on).toContain("/bugreport");
|
||||
// Non-gated entries are present either way.
|
||||
expect(off).toContain("/settings/account");
|
||||
expect(off).toContain("/notifications");
|
||||
});
|
||||
|
||||
it("keeps a stable order (Bug Report → Settings → Notifications)", () => {
|
||||
const hrefs = visibleUtilityDestinations(true).map((d) => d.href);
|
||||
expect(hrefs.indexOf("/bugreport")).toBeLessThan(
|
||||
hrefs.indexOf("/settings/account"),
|
||||
);
|
||||
it("keeps a stable order (Settings → Notifications)", () => {
|
||||
const hrefs = visibleUtilityDestinations().map((d) => d.href);
|
||||
expect(hrefs.indexOf("/settings/account")).toBeLessThan(
|
||||
hrefs.indexOf("/notifications"),
|
||||
);
|
||||
@@ -178,16 +164,13 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
it("is exactly the visible feature destinations minus the primary slots, then the utility tail", () => {
|
||||
const opts = {
|
||||
modules: { cycle: false } as const,
|
||||
bugReportEnabled: false,
|
||||
};
|
||||
const hub = mobileMoreHubDestinations(opts).map((d) => d.href);
|
||||
|
||||
const expectedFeatures = visibleNavDestinations(opts.modules)
|
||||
.map((d) => d.href)
|
||||
.filter((href) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(href));
|
||||
const expectedTail = visibleUtilityDestinations(opts.bugReportEnabled).map(
|
||||
(d) => d.href,
|
||||
);
|
||||
const expectedTail = visibleUtilityDestinations().map((d) => d.href);
|
||||
|
||||
expect(hub).toEqual([...expectedFeatures, ...expectedTail]);
|
||||
});
|
||||
@@ -195,7 +178,6 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
it("never contains a primary slot (Home / Meds / Insights)", () => {
|
||||
const hub = mobileMoreHubDestinations({
|
||||
modules: { cycle: true },
|
||||
bugReportEnabled: true,
|
||||
}).map((d) => d.href);
|
||||
for (const slot of BOTTOM_NAV_PRIMARY_SLOT_HREFS) {
|
||||
expect(hub).not.toContain(slot);
|
||||
@@ -205,33 +187,27 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
it("keeps the feature destinations and the utility tail reachable in the hub", () => {
|
||||
const hub = mobileMoreHubDestinations({
|
||||
modules: { cycle: false },
|
||||
bugReportEnabled: true,
|
||||
}).map((d) => d.href);
|
||||
// Feature surfaces that left the always-visible strip stay reachable.
|
||||
expect(hub).toContain("/measurements");
|
||||
expect(hub).toContain("/mood");
|
||||
expect(hub).toContain("/checkups");
|
||||
// The shared utility tail rides at the end.
|
||||
expect(hub).toContain("/bugreport");
|
||||
expect(hub).toContain("/settings/account");
|
||||
expect(hub).toContain("/notifications");
|
||||
});
|
||||
|
||||
it("gates Cycle by the module map and Bug Report by the operator flag, same as the sidebar", () => {
|
||||
it("gates Cycle by the module map, same as the sidebar", () => {
|
||||
const off = mobileMoreHubDestinations({
|
||||
modules: { cycle: false, mood: false },
|
||||
bugReportEnabled: false,
|
||||
}).map((d) => d.href);
|
||||
const on = mobileMoreHubDestinations({
|
||||
modules: { cycle: true, mood: true },
|
||||
bugReportEnabled: true,
|
||||
}).map((d) => d.href);
|
||||
expect(off).not.toContain("/cycle");
|
||||
expect(off).not.toContain("/mood");
|
||||
expect(off).not.toContain("/bugreport");
|
||||
expect(on).toContain("/cycle");
|
||||
expect(on).toContain("/mood");
|
||||
expect(on).toContain("/bugreport");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,13 +43,6 @@ vi.mock("@/components/providers", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The bug-report flag — each test mutates `mockSettingsRef.value` before
|
||||
// rendering so the sidebar reads the desired state.
|
||||
const mockSettingsRef = { value: { bugReportEnabled: true } };
|
||||
vi.mock("@/components/app-settings-provider", () => ({
|
||||
useAppSettings: () => mockSettingsRef.value,
|
||||
}));
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SidebarNav } from "../sidebar-nav";
|
||||
@@ -58,17 +51,14 @@ import { visibleUtilityDestinations } from "../nav-model";
|
||||
|
||||
function render({
|
||||
pathname = "/",
|
||||
bugReportEnabled = true,
|
||||
role = "USER" as "USER" | "ADMIN",
|
||||
modules = {} as Record<string, boolean>,
|
||||
}: {
|
||||
pathname?: string;
|
||||
bugReportEnabled?: boolean;
|
||||
role?: "USER" | "ADMIN";
|
||||
modules?: Record<string, boolean>;
|
||||
} = {}) {
|
||||
mockPathnameRef.value = pathname;
|
||||
mockSettingsRef.value = { bugReportEnabled };
|
||||
mockUserRef.value = { ...mockUserRef.value, role, modules };
|
||||
return renderToStaticMarkup(
|
||||
// The nav reads `useQueryClient()` for the medications intent
|
||||
@@ -82,21 +72,6 @@ function render({
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SidebarNav> bug-report toggle", () => {
|
||||
it("renders the Bug Report entry when the admin flag is enabled", () => {
|
||||
const html = render({ bugReportEnabled: true });
|
||||
expect(html).toContain('href="/bugreport"');
|
||||
// i18n string for the entry — sanity check that the link is the actual
|
||||
// bug-report nav entry, not e.g. an error-page report-bug button.
|
||||
expect(html).toContain("Bug Report");
|
||||
});
|
||||
|
||||
it("hides the Bug Report entry when the admin flag is disabled", () => {
|
||||
const html = render({ bugReportEnabled: false });
|
||||
expect(html).not.toContain('href="/bugreport"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> cycle entry gate (v1.15.0 / v1.18.0 module map)", () => {
|
||||
it("renders the Cycle entry when the cycle module is enabled", () => {
|
||||
const html = render({ modules: { cycle: true } });
|
||||
@@ -212,28 +187,17 @@ describe("<SidebarNav> shared utility tail (v1.17.1 N-1 parity guard)", () => {
|
||||
// therefore the shared list minus `/notifications`. Asserting they all
|
||||
// render straight from the shared list is the parity net that keeps the
|
||||
// sidebar footer from drifting away from the mobile More-hub tail.
|
||||
const footerUtilities = (bugReportEnabled: boolean) =>
|
||||
visibleUtilityDestinations(bugReportEnabled).filter(
|
||||
(d) => d.href !== "/notifications",
|
||||
);
|
||||
const footerUtilities = () =>
|
||||
visibleUtilityDestinations().filter((d) => d.href !== "/notifications");
|
||||
|
||||
it("renders every footer utility destination straight from the shared list", () => {
|
||||
const html = render({ bugReportEnabled: true });
|
||||
for (const dest of footerUtilities(true)) {
|
||||
const html = render();
|
||||
for (const dest of footerUtilities()) {
|
||||
expect(html).toContain(`href="${dest.href}"`);
|
||||
}
|
||||
// Bug Report and Settings are the footer utilities; both are present.
|
||||
expect(html).toContain('href="/bugreport"');
|
||||
// Settings is the footer utility entry; it is present.
|
||||
expect(html).toContain('href="/settings/account"');
|
||||
});
|
||||
|
||||
it("drops the bug-report utility entry when the operator flag is off", () => {
|
||||
const html = render({ bugReportEnabled: false });
|
||||
for (const dest of footerUtilities(false)) {
|
||||
expect(html).toContain(`href="${dest.href}"`);
|
||||
}
|
||||
expect(html).not.toContain('href="/bugreport"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", () => {
|
||||
|
||||
@@ -261,8 +261,8 @@ export function AuthShell({
|
||||
>
|
||||
{/*
|
||||
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
|
||||
(1280 px) so dashboard / settings / admin / bugreport all
|
||||
hit the same content frame. Pre-v1.4.33 the dashboard
|
||||
(1280 px) so dashboard / settings / admin all hit the
|
||||
same content frame. Pre-v1.4.33 the dashboard
|
||||
shell capped at `max-w-[76.8rem]` (1228 px) while the
|
||||
settings/admin shells used `max-w-screen-xl`, producing a
|
||||
52 px lateral wobble on every route switch. Same audit
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "@/components/layout/nav-model";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAppSettings } from "@/components/app-settings-provider";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -83,21 +82,19 @@ export function BottomNav() {
|
||||
// v1.16.7 — touch intent on the medications tab starts the list
|
||||
// request before the navigation commits (see sidebar-nav).
|
||||
const medsIntent = medicationsPrefetchIntentProps(queryClient);
|
||||
const { bugReportEnabled } = useAppSettings();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
|
||||
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
|
||||
// feature destination that isn't a primary slot, plus the shared utility
|
||||
// tail. Cycle + Bug Report are gated by the same flags the sidebar uses,
|
||||
// so the two surfaces gate identically and cannot drift.
|
||||
// tail. Cycle is gated by the same flag the sidebar uses, so the two
|
||||
// surfaces gate identically and cannot drift.
|
||||
const moreHub = useMemo<ReadonlyArray<NavLink>>(
|
||||
() =>
|
||||
mobileMoreHubDestinations({
|
||||
modules: user?.modules,
|
||||
bugReportEnabled,
|
||||
}),
|
||||
[user?.modules, bugReportEnabled],
|
||||
[user?.modules],
|
||||
);
|
||||
|
||||
// v1.18.0 — drop a module-gated primary slot (Insights) when the account
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Bug,
|
||||
Droplets,
|
||||
FlaskConical,
|
||||
Home,
|
||||
@@ -168,17 +167,15 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
/**
|
||||
* v1.17.1 (F-1 residue) — the shared UTILITY tail.
|
||||
*
|
||||
* Bug Report, Settings and Notifications are reachable on both bars but are
|
||||
* not feature destinations: on desktop they live in the sidebar footer +
|
||||
* avatar menu, on mobile at the tail of the "More" hub. They used to be a
|
||||
* second hand-curated list on each bar — the exact drift the one-model
|
||||
* contract above set out to kill, just pushed down a level. This list is
|
||||
* the single source both bars consume for the tail, so the two surfaces
|
||||
* can no longer disagree on which utility links exist or in which order.
|
||||
* Settings and Notifications are reachable on both bars but are not feature
|
||||
* destinations: on desktop they live in the sidebar footer + avatar menu, on
|
||||
* mobile at the tail of the "More" hub. They used to be a second hand-curated
|
||||
* list on each bar — the exact drift the one-model contract above set out to
|
||||
* kill, just pushed down a level. This list is the single source both bars
|
||||
* consume for the tail, so the two surfaces can no longer disagree on which
|
||||
* utility links exist or in which order.
|
||||
*
|
||||
* Order is the footer/hub order: Bug Report (gated) → Settings →
|
||||
* Notifications. `bugReportGated` entries drop when the operator flag is
|
||||
* off, exactly as the feature list drops cycle-gated entries.
|
||||
* Order is the footer/hub order: Settings → Notifications.
|
||||
*
|
||||
* Admin is intentionally NOT here: it is a role-gated, desktop-sidebar-only
|
||||
* surface (the mobile bar never exposes it), so it is not a shared tail
|
||||
@@ -189,33 +186,20 @@ export interface NavUtilityDestination {
|
||||
/** i18n key under the `nav.*` namespace. */
|
||||
tKey: string;
|
||||
icon: LucideIcon;
|
||||
/** Drop the entry unless the operator's bug-report flag is on. */
|
||||
bugReportGated?: boolean;
|
||||
}
|
||||
|
||||
export const NAV_UTILITY_DESTINATIONS: ReadonlyArray<NavUtilityDestination> = [
|
||||
{
|
||||
href: "/bugreport",
|
||||
tKey: "nav.bugreport",
|
||||
icon: Bug,
|
||||
bugReportGated: true,
|
||||
},
|
||||
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
|
||||
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
|
||||
];
|
||||
|
||||
/**
|
||||
* The utility tail visible to this account — drops the bug-report entry
|
||||
* when the operator flag is off. Both bars consume this for their tail
|
||||
* (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
|
||||
* The utility tail visible to this account. Both bars consume this for their
|
||||
* tail (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
|
||||
* surfaces share one definition of the utility links.
|
||||
*/
|
||||
export function visibleUtilityDestinations(
|
||||
bugReportEnabled: boolean | undefined,
|
||||
): NavUtilityDestination[] {
|
||||
return NAV_UTILITY_DESTINATIONS.filter(
|
||||
(d) => !d.bugReportGated || bugReportEnabled === true,
|
||||
);
|
||||
export function visibleUtilityDestinations(): NavUtilityDestination[] {
|
||||
return [...NAV_UTILITY_DESTINATIONS];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,19 +260,18 @@ export interface MobileMoreHubEntry {
|
||||
/**
|
||||
* The ordered "More" hub for the mobile bottom-nav: every visible feature
|
||||
* destination that isn't a primary slot, in model order, followed by the
|
||||
* shared utility tail (Bug Report behind its flag, Settings, Notifications).
|
||||
* The desktop sidebar renders the same feature list inline and consumes the
|
||||
* same utility tail in its footer + avatar menu, so the two bars cannot
|
||||
* drift into two hand-curated lists.
|
||||
* shared utility tail (Settings, Notifications). The desktop sidebar renders
|
||||
* the same feature list inline and consumes the same utility tail in its
|
||||
* footer + avatar menu, so the two bars cannot drift into two hand-curated
|
||||
* lists.
|
||||
*/
|
||||
export function mobileMoreHubDestinations(opts: {
|
||||
modules: ModuleVisibilityMap | undefined;
|
||||
bugReportEnabled: boolean | undefined;
|
||||
}): MobileMoreHubEntry[] {
|
||||
const features = visibleNavDestinations(opts.modules)
|
||||
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
|
||||
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
||||
const tail = visibleUtilityDestinations(opts.bugReportEnabled).map((d) => ({
|
||||
const tail = visibleUtilityDestinations().map((d) => ({
|
||||
href: d.href,
|
||||
tKey: d.tKey,
|
||||
icon: d.icon,
|
||||
|
||||
@@ -26,7 +26,6 @@ import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
import { useTheme } from "@/components/providers";
|
||||
import { useAppSettings } from "@/components/app-settings-provider";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
@@ -229,7 +228,6 @@ export function SidebarNav() {
|
||||
// list request before the navigation commits, so the due-time cells
|
||||
// hydrate from a warm cache instead of serialising behind the chunk.
|
||||
const medsIntent = medicationsPrefetchIntentProps(queryClient);
|
||||
const { bugReportEnabled } = useAppSettings();
|
||||
const isAdmin = user?.role === "ADMIN";
|
||||
// Match `/admin` exactly or any `/admin/...` sub-route for active-link
|
||||
// styling. Plain `startsWith("/admin")` would also flip for a
|
||||
@@ -276,10 +274,8 @@ export function SidebarNav() {
|
||||
// role-gated, sidebar-only surface and is inserted separately below.
|
||||
const footerUtilityItems = useMemo(
|
||||
() =>
|
||||
visibleUtilityDestinations(bugReportEnabled).filter(
|
||||
(d) => d.href !== "/notifications",
|
||||
),
|
||||
[bugReportEnabled],
|
||||
visibleUtilityDestinations().filter((d) => d.href !== "/notifications"),
|
||||
[],
|
||||
);
|
||||
|
||||
function isUtilityActive(href: string) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AppSettingsProvider } from "@/components/app-settings-provider";
|
||||
import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
@@ -211,14 +210,12 @@ export function Providers({
|
||||
initialLocale={initialLocale}
|
||||
initialMessages={initialMessages}
|
||||
>
|
||||
<AppSettingsProvider>
|
||||
<QueryPersistenceBridge />
|
||||
<DashboardSnapshotPreloader />
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<VersionPoller />
|
||||
<ServiceWorkerRegistrar />
|
||||
</AppSettingsProvider>
|
||||
<QueryPersistenceBridge />
|
||||
<DashboardSnapshotPreloader />
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<VersionPoller />
|
||||
<ServiceWorkerRegistrar />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
Reference in new issue
Block a user