From a4a3318de3819e83010e4b3eaa3dbf599f7f124c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 13:37:46 +0200 Subject: [PATCH] fix(mood): open the reminder's entry sheet only in the own record --- .../__tests__/add-param-record-scope.test.tsx | 106 ++++++++++++++++++ src/app/mood/page-client.tsx | 41 +++++-- src/lib/notifications/tap-target.ts | 5 +- 3 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 src/app/mood/__tests__/add-param-record-scope.test.tsx diff --git a/src/app/mood/__tests__/add-param-record-scope.test.tsx b/src/app/mood/__tests__/add-param-record-scope.test.tsx new file mode 100644 index 000000000..684dc743c --- /dev/null +++ b/src/app/mood/__tests__/add-param-record-scope.test.tsx @@ -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 ?
: 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().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); + }); +}); diff --git a/src/app/mood/page-client.tsx b/src/app/mood/page-client.tsx index 25464b0b7..5891e6a15 100644 --- a/src/app/mood/page-client.tsx +++ b/src/app/mood/page-client.tsx @@ -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() { /> } > setDialogOpen(false)} - onCancel={() => setDialogOpen(false)} + onSuccess={() => setSheetOpen(false)} + onCancel={() => setSheetOpen(false)} footerSlot={footerEl} /> diff --git a/src/lib/notifications/tap-target.ts b/src/lib/notifications/tap-target.ts index 10bb30de4..3552e4ace 100644 --- a/src/lib/notifications/tap-target.ts +++ b/src/lib/notifications/tap-target.ts @@ -36,7 +36,10 @@ export const NOTIFICATION_TAP_PATHS: Record = { 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",