fix(mood): open the reminder's entry sheet only in the own record

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:37:46 +02:00
1 parent 3c4e053dcd
commit a4a3318de3
3 files changed
+143 -9

No files matched your search

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
/**
* `/mood?add=1` is the mood reminder's tap target. It opens the entry sheet
* only in the caller's own record: a session that is inside a managed
* profile or a shared record opens the page without the sheet, so a reminder
* meant for the recipient never becomes a write form on somebody else.
*/
const caps = {
current: {
inSharedRecord: false,
recordSessionPending: undefined as boolean | undefined,
accessRefused: undefined as boolean | undefined,
canManageDomain: (_d: string) => true,
},
};
const params = { current: new URLSearchParams("add=1") };
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => caps.current,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { modules: {} },
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
vi.mock("@/hooks/use-pull-to-refresh", () => ({
usePullToRefresh: () => ({}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: () => {} }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: () => {}, push: () => {} }),
useSearchParams: () => params.current,
}));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({ open }: { open: boolean; children?: ReactNode }) =>
open ? <div data-slot="mood-sheet-open" /> : null,
}));
vi.mock("@/components/mood/mood-form", () => ({ MoodForm: () => null }));
vi.mock("@/components/mood/mood-list", () => ({ MoodList: () => null }));
vi.mock("@/components/ui/pull-to-refresh-indicator", () => ({
PullToRefreshIndicator: () => null,
}));
import MoodPageClient from "../page-client";
function sheetOpen(): boolean {
return renderToStaticMarkup(<MoodPageClient />).includes(
'data-slot="mood-sheet-open"',
);
}
beforeEach(() => {
params.current = new URLSearchParams("add=1");
caps.current = {
inSharedRecord: false,
recordSessionPending: undefined,
accessRefused: undefined,
canManageDomain: () => true,
};
});
describe("/mood ?add=1 and the record on screen", () => {
it("opens the entry sheet in the caller's own record", () => {
expect(sheetOpen()).toBe(true);
});
it("keeps the sheet closed without the param", () => {
params.current = new URLSearchParams();
expect(sheetOpen()).toBe(false);
});
it("does not open the sheet inside a managed profile the caller manages", () => {
// A guardian switched into a managed profile holds MANAGE on mind, so
// the sheet would otherwise open and save onto that profile.
caps.current = { ...caps.current, inSharedRecord: true };
expect(sheetOpen()).toBe(false);
});
it("does not open the sheet while the record context is pending or refused", () => {
caps.current = {
...caps.current,
inSharedRecord: true,
recordSessionPending: true,
canManageDomain: () => false,
};
expect(sheetOpen()).toBe(false);
caps.current = {
...caps.current,
recordSessionPending: undefined,
accessRefused: true,
};
expect(sheetOpen()).toBe(false);
});
});
+33 -8
View File
@@ -21,7 +21,12 @@ import { useTranslations } from "@/lib/i18n/context";
export default function MoodPageClient() {
const { user, isAuthenticated, isLoading } = useAuth();
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
const {
canManageDomain,
inSharedRecord,
recordSessionPending,
accessRefused,
} = useRecordCapabilities();
// A mood entry is created at MANAGE under the mind section.
const canAddMood = canManageDomain("mind");
const mounted = useMounted();
@@ -33,15 +38,35 @@ export default function MoodPageClient() {
// hydration render agree; the effect below strips the one-shot param so the
// back button does not reopen the sheet. A later client-side navigation
// back onto `?add=1` is handled render-side, the measurements-page pattern.
//
// The request is held apart from the sheet's own open state because it is
// honoured only in the caller's own record. A reminder is about the person
// who receives it; a session that is currently inside a managed profile or
// a shared record would otherwise open a write form there, and an entry
// saved from it lands on the wrong person. In that case the page opens
// without the sheet and the shell's shared-record banner offers the way
// back. The answer is read once the account query settled (`isLoading`
// gates the content render), so the request is dropped, not deferred: a
// later switch back to the own record must not pop a stale sheet.
const addParam = searchParams.get("add") === "1";
const [dialogOpen, setDialogOpen] = useState(() => addParam);
const [dialogOpen, setDialogOpen] = useState(false);
const [addRequested, setAddRequested] = useState(() => addParam);
const [consumedAdd, setConsumedAdd] = useState(() => addParam);
if (!addParam && consumedAdd) {
setConsumedAdd(false);
} else if (addParam && !consumedAdd) {
setConsumedAdd(true);
setDialogOpen(true);
setAddRequested(true);
}
const ownRecord = !inSharedRecord && !recordSessionPending && !accessRefused;
if (addRequested && mounted && !isLoading && !ownRecord) {
setAddRequested(false);
}
const sheetOpen = (dialogOpen || (addRequested && ownRecord)) && canAddMood;
const setSheetOpen = useCallback((open: boolean) => {
setDialogOpen(open);
if (!open) setAddRequested(false);
}, []);
// The list's empty state carries the add action while there is nothing
// to list, so the header drops its own then.
const [listEmpty, setListEmpty] = useState(false);
@@ -68,7 +93,7 @@ export default function MoodPageClient() {
);
const pull = usePullToRefresh({
onRefresh: refreshVisible,
disabled: dialogOpen,
disabled: sheetOpen,
});
useEffect(() => {
@@ -149,14 +174,14 @@ export default function MoodPageClient() {
/>
<ResponsiveSheet
open={dialogOpen && canAddMood}
onOpenChange={setDialogOpen}
open={sheetOpen}
onOpenChange={setSheetOpen}
title={t("dashboard.quickAddMood")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<MoodForm
onSuccess={() => setDialogOpen(false)}
onCancel={() => setDialogOpen(false)}
onSuccess={() => setSheetOpen(false)}
onCancel={() => setSheetOpen(false)}
footerSlot={footerEl}
/>
</ResponsiveSheet>
+4 -1
View File
@@ -36,7 +36,10 @@ export const NOTIFICATION_TAP_PATHS: Record<EventType, string | null> = {
WITHINGS_SYNC_FAILED: "/settings/integrations",
SYSTEM_ALERT: "/",
PERSONAL_RECORD: "/achievements",
// `?add=1` opens the mood entry sheet on the first paint.
// `?add=1` opens the mood entry sheet on the first paint, and only in the
// recipient's own record: a session switched into another record opens the
// page without the sheet (`src/app/mood/page-client.tsx`). No other target
// here opens a write form.
MOOD_REMINDER: "/mood?add=1",
CYCLE_PERIOD_SOON: "/cycle",
CYCLE_PERIOD_CONFIRM: "/cycle",