mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(02-12): route scoped records to granted domains
- keep shared navigation independent of actor module preferences\n- open scoped records at their first allowed destination\n- announce record access and kind in doorway names
This commit is contained in:
1 parent
34073cf8dc
commit
6c4dd2d96b
6 files changed
+77
-15
No files matched your search
@@ -161,7 +161,7 @@ describe("<LookingAfterCard>", () => {
|
||||
});
|
||||
|
||||
expect(html).toMatch(
|
||||
/aria-label="Open Managed profile's record; Can read the record, add to it, and change or remove what is in it; Managed profile"/,
|
||||
/aria-label="Open Managed profile's record; Can read the record, add to it, and change or remove what is in it; Managed profile;/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveRecordPresentation,
|
||||
type RecordPresentation,
|
||||
} from "@/lib/navigation/record-presentation";
|
||||
import { sharedRecordLandingHref } from "@/lib/navigation/shared-record";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
@@ -83,6 +84,9 @@ export function LookingAfterCard() {
|
||||
<ul className="divide-y">
|
||||
{accounts.map((entry) => {
|
||||
const presentation = resolveRecordPresentation(entry);
|
||||
const level = levelLabel(entry, presentation, t);
|
||||
const kind = recordKindLabel(entry, t);
|
||||
const scope = scopeLabel(entry, t);
|
||||
|
||||
return (
|
||||
<li key={entry.accountId}>
|
||||
@@ -93,10 +97,20 @@ export function LookingAfterCard() {
|
||||
data-access-level={presentation.access}
|
||||
data-record-kind={presentation.recordKind}
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(entry.accountId)}
|
||||
aria-label={t("recordSharing.lookingAfter.open", {
|
||||
name: accountLabel(entry),
|
||||
})}
|
||||
onClick={() =>
|
||||
switchAccount.switchTo(
|
||||
entry.accountId,
|
||||
sharedRecordLandingHref(entry.sections),
|
||||
)
|
||||
}
|
||||
aria-label={[
|
||||
t("recordSharing.lookingAfter.open", {
|
||||
name: accountLabel(entry),
|
||||
}),
|
||||
level,
|
||||
kind,
|
||||
scope,
|
||||
].join("; ")}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -104,13 +118,13 @@ export function LookingAfterCard() {
|
||||
{accountLabel(entry)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{levelLabel(entry, presentation, t)}
|
||||
{level}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{recordKindLabel(entry, t)}
|
||||
{kind}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{scopeLabel(entry, t)}
|
||||
{scope}
|
||||
</span>
|
||||
</span>
|
||||
{switchAccount.isPending && (
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveRecordPresentation } from "@/lib/navigation/record-presentation";
|
||||
import { sharedRecordLandingHref } from "@/lib/navigation/shared-record";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
@@ -92,7 +93,12 @@ export function AccountSwitcherMenuItems() {
|
||||
data-record-kind={presentation.recordKind}
|
||||
className="cursor-pointer"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(account.accountId)}
|
||||
onClick={() =>
|
||||
switchAccount.switchTo(
|
||||
account.accountId,
|
||||
sharedRecordLandingHref(account.sections),
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{accountLabel(account)}</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Activity,
|
||||
Bell,
|
||||
Brain,
|
||||
ClipboardList,
|
||||
Droplets,
|
||||
FileScan,
|
||||
FlaskConical,
|
||||
@@ -77,6 +78,8 @@ export interface NavDestination {
|
||||
* owner gave for their own use, so those surfaces stay owner-only in v1.
|
||||
*/
|
||||
sharedRecord?: boolean;
|
||||
/** A record-only view, intentionally absent from the actor's own nav. */
|
||||
sharedRecordOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +173,14 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
tourId: "nav-labs",
|
||||
requiresModule: "labs",
|
||||
},
|
||||
{
|
||||
href: "/profile",
|
||||
sharedRecord: true,
|
||||
sharedRecordOnly: true,
|
||||
tKey: "settings.sections.anamnesis.title",
|
||||
icon: ClipboardList,
|
||||
tourId: "nav-profile",
|
||||
},
|
||||
// v1.18.1 — the illness/condition journal sits in the clinical spine
|
||||
// next to Labs. Born-gated: `requiresModule: "illness"` reads the
|
||||
// opt-in `illness` key from the resolved module map, so the entry is
|
||||
@@ -316,6 +327,11 @@ function isNavDestinationVisible(
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// A shared record's server-resolved scope decides which health-domain doors
|
||||
// exist. The actor's module preferences describe their own dashboard and
|
||||
// must not hide a domain the target record explicitly granted.
|
||||
if (sharedRecord) return true;
|
||||
if (d.sharedRecordOnly) return false;
|
||||
if (!d.requiresModule) return true;
|
||||
if (!mounted) return false;
|
||||
return modules?.[d.requiresModule] !== false;
|
||||
|
||||
@@ -102,10 +102,13 @@ async function postSwitch(accountId: string | null): Promise<SwitchResponse> {
|
||||
* sequence. Two copies of "wipe, then reload" is one copy too many for a step
|
||||
* whose order is the safety property.
|
||||
*/
|
||||
async function landOnRecord(accountId: string | null): Promise<never> {
|
||||
async function landOnRecord(
|
||||
accountId: string | null,
|
||||
destination = "/",
|
||||
): Promise<never> {
|
||||
setRecordScope(accountId);
|
||||
await clearOfflineCachesForRecordSwitch();
|
||||
window.location.assign("/");
|
||||
window.location.assign(destination);
|
||||
// The document is being replaced. Nothing after this runs, and the promise
|
||||
// deliberately never settles so no caller paints a "done" state over a page
|
||||
// that is on its way out.
|
||||
@@ -124,15 +127,21 @@ export function useAccountSwitch() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
const mutation = useMutation({
|
||||
mutationKey: queryKeys.accountSwitchMutation(),
|
||||
mutationFn: async (accountId: string | null) => {
|
||||
mutationFn: async ({
|
||||
accountId,
|
||||
destination,
|
||||
}: {
|
||||
accountId: string | null;
|
||||
destination: string;
|
||||
}) => {
|
||||
await postSwitch(accountId);
|
||||
// The in-memory cache holds the record we are leaving. Clearing it here
|
||||
// as well as reloading costs nothing and closes the window in which a
|
||||
// failed navigation would leave the old entries reachable.
|
||||
queryClient.clear();
|
||||
return landOnRecord(accountId);
|
||||
return landOnRecord(accountId, destination);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
@@ -142,6 +151,16 @@ export function useAccountSwitch() {
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...mutation,
|
||||
/** Preserve the established own-record and exit call sites. */
|
||||
mutate: (accountId: string | null) =>
|
||||
mutation.mutate({ accountId, destination: "/" }),
|
||||
/** Enter a scoped record directly at a server-presentable destination. */
|
||||
switchTo: (accountId: string, destination: string) =>
|
||||
mutation.mutate({ accountId, destination }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
|
||||
measurements: ["/measurements"],
|
||||
medications: ["/medications"],
|
||||
labs: ["/labs"],
|
||||
profile: ["/settings/anamnesis"],
|
||||
profile: ["/profile"],
|
||||
illness: ["/illness"],
|
||||
mind: ["/mood", "/mental-wellbeing"],
|
||||
cycle: ["/cycle"],
|
||||
@@ -87,6 +87,13 @@ export function resolveSharedRecordNavigation(
|
||||
};
|
||||
}
|
||||
|
||||
/** The first scope-presentable location to load after a record switch. */
|
||||
export function sharedRecordLandingHref(
|
||||
sections: readonly ShareDomain[] | null,
|
||||
): string {
|
||||
return resolveSharedRecordNavigation(sections).destinationHrefs[0] ?? "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the server-resolved scope make this browser route presentable?
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user