mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(coach): say when the attachment picker list is cut
The Coach's "Choose from documents" picker reads the newest fifty documents and dropped the route's nextCursor, so with a longer vault an older report seemed not to exist unless one already knew to search for it. A cut list now ends with the same line the document picker shows: how many are shown and that the search reaches the rest. The document row moves into its own component, which takes AttachmentPicker under the function-size cap.
This commit is contained in:
1 parent
24cca975da
commit
aacf06b7b3
4 files changed
+145
-48
No files matched your search
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The Coach's "Choose from documents" picker reads one page of the vault, the
|
||||
* newest fifty. With a longer vault it listed those and said nothing, so an
|
||||
* older report seemed not to exist unless one already knew to search for it.
|
||||
*
|
||||
* ResponsiveSheet portals its body, which static markup does not render, so it
|
||||
* is mocked down to a wrapper that always shows its children.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const page = vi.hoisted(() => ({ nextCursor: null as string | null }));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-sheet">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
// A cut page is a full one.
|
||||
documents: Array.from({ length: page.nextCursor ? 50 : 3 }, (_, i) => ({
|
||||
id: `d${i}`,
|
||||
kind: "LAB_RESULT",
|
||||
title: `Blood panel ${i}`,
|
||||
filename: null,
|
||||
hasContentIndex: true,
|
||||
})),
|
||||
nextCursor: page.nextCursor,
|
||||
},
|
||||
isPending: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AttachmentPicker } from "../attachment-picker";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AttachmentPicker
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
remainingSlots={3}
|
||||
excludeIds={[]}
|
||||
onConfirm={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AttachmentPicker", () => {
|
||||
it("says the list is cut, and how to reach the rest", () => {
|
||||
page.nextCursor = "d49";
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="coach-attachment-picker-capped"');
|
||||
expect(html).toContain("Showing the newest 50");
|
||||
});
|
||||
|
||||
it("says nothing when every document is listed", () => {
|
||||
page.nextCursor = null;
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="coach-attachment-picker-capped"');
|
||||
expect(
|
||||
html.match(/data-slot="coach-attachment-picker-item"/g),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,58 @@ export interface AttachmentPickerProps {
|
||||
onConfirm: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
/** One document in the picker: a toggle, disabled until it is indexed. */
|
||||
function PickerRow({
|
||||
doc,
|
||||
isSelected,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
doc: InboundDocumentDto;
|
||||
isSelected: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const title = doc.title ?? doc.filename ?? t("documents.card.untitled");
|
||||
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
disabled={disabled}
|
||||
onClick={() => doc.hasContentIndex && onToggle()}
|
||||
data-slot="coach-attachment-picker-item"
|
||||
data-selected={isSelected ? "true" : undefined}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
|
||||
"focus-visible:ring-input-focus focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
isSelected && "border-primary/40 bg-primary/5",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<Icon className="text-foreground size-5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{title}</span>
|
||||
{!doc.hasContentIndex ? (
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("insights.coach.attach.pickerNotIndexed")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"text-primary size-4 shrink-0",
|
||||
isSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttachmentPicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -107,6 +159,8 @@ export function AttachmentPicker({
|
||||
);
|
||||
|
||||
const capReached = selected.length >= remainingSlots;
|
||||
// More match than one page: say so, and that the search reaches the rest.
|
||||
const cut = Boolean(list.data?.nextCursor);
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((prev) => {
|
||||
@@ -200,55 +254,28 @@ export function AttachmentPicker({
|
||||
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
|
||||
{documents.map((doc) => {
|
||||
const isSelected = selected.includes(doc.id);
|
||||
const selectable = doc.hasContentIndex;
|
||||
const disabled = !selectable || (!isSelected && capReached);
|
||||
const title =
|
||||
doc.title ?? doc.filename ?? t("documents.card.untitled");
|
||||
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
|
||||
return (
|
||||
<li key={doc.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
disabled={disabled}
|
||||
onClick={() => selectable && toggle(doc.id)}
|
||||
data-slot="coach-attachment-picker-item"
|
||||
data-selected={isSelected ? "true" : undefined}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
|
||||
"focus-visible:ring-input-focus focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
isSelected && "border-primary/40 bg-primary/5",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-60 hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-foreground size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{title}
|
||||
</span>
|
||||
{!selectable ? (
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("insights.coach.attach.pickerNotIndexed")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"text-primary size-4 shrink-0",
|
||||
isSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
<PickerRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
isSelected={isSelected}
|
||||
disabled={!doc.hasContentIndex || (!isSelected && capReached)}
|
||||
onToggle={() => toggle(doc.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{cut && !list.isError ? (
|
||||
<p
|
||||
data-slot="coach-attachment-picker-capped"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("documents.picker.capped", {
|
||||
count: String(list.data?.documents.length ?? 0),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user