mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(documents): surface content search in the vault list
The list search already unions matches from the encrypted content index server-side; surface it so it is legible and reachable: - a subtle "search now looks inside your documents too (whole words)" hint under the search box once any document is indexed; - a per-row "searchable" marker on indexed documents; - an "index all for search" affordance that fires the corpus backfill when some documents are not yet indexed, driven off the usage coverage gauge. All of it is gated on the same provider/consent precondition as the assist layer — with no provider, search degrades to title and filename exactly as before.
This commit is contained in:
1 parent
afeb693562
commit
2319988a96
4 files changed
+119
-3
No files matched your search
@@ -84,6 +84,33 @@ describe("<DocumentCard>", () => {
|
||||
expect(html).toContain("befund.docx");
|
||||
});
|
||||
|
||||
it("marks a content-indexed document as searchable", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc({ hasContentIndex: true })}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="document-searchable"');
|
||||
expect(html).toContain("Contents searchable");
|
||||
});
|
||||
|
||||
it("shows no searchable marker when the document is not indexed", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc({ hasContentIndex: false, conditionLinks: [] })}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="document-searchable"');
|
||||
});
|
||||
|
||||
it("falls back to the untitled label and rings when highlighted", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* hover / focus / while selected. The whole card is clickable through an
|
||||
* invisible overlay button; the checkbox floats above it.
|
||||
*/
|
||||
import { Download, X } from "lucide-react";
|
||||
import { Download, ScanSearch, X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -119,7 +119,8 @@ export function DocumentCard({
|
||||
{showFilename ? ` · ${document.filename}` : ""}
|
||||
</p>
|
||||
{document.conditionLinks.length > 0 ||
|
||||
document.servingClass === "attachment" ? (
|
||||
document.servingClass === "attachment" ||
|
||||
document.hasContentIndex ? (
|
||||
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{document.conditionLinks.map((link) => (
|
||||
<span
|
||||
@@ -138,6 +139,20 @@ export function DocumentCard({
|
||||
{t("documents.card.attachmentBadge")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{document.hasContentIndex ? (
|
||||
// Subtle "contents searchable" marker — icon-only to keep the
|
||||
// row calm; the label rides an accessible name.
|
||||
<span
|
||||
data-slot="document-searchable"
|
||||
className="text-muted-foreground inline-flex items-center"
|
||||
title={t("documents.card.searchableBadge")}
|
||||
>
|
||||
<ScanSearch className="size-3.5" aria-hidden />
|
||||
<span className="sr-only">
|
||||
{t("documents.card.searchableBadge")}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* sticks below the top edge inside the shell's scroll container (sticky,
|
||||
* translucent background, border-b — no viewport-height tricks).
|
||||
*/
|
||||
import { Search, X } from "lucide-react";
|
||||
import { ScanSearch, Search, X } from "lucide-react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -45,6 +45,10 @@ export function DocumentFilterBar({
|
||||
onToggleYear,
|
||||
activeCount,
|
||||
onClearAll,
|
||||
contentSearchActive = false,
|
||||
showIndexAll = false,
|
||||
indexAllPending = false,
|
||||
onIndexAll,
|
||||
}: {
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
@@ -60,6 +64,12 @@ export function DocumentFilterBar({
|
||||
onToggleYear: (year: number) => void;
|
||||
activeCount: number;
|
||||
onClearAll: () => void;
|
||||
/** Search already matches indexed document CONTENT (whole words). */
|
||||
contentSearchActive?: boolean;
|
||||
/** Some documents are not yet indexed — offer the corpus backfill. */
|
||||
showIndexAll?: boolean;
|
||||
indexAllPending?: boolean;
|
||||
onIndexAll?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -205,6 +215,35 @@ export function DocumentFilterBar({
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{contentSearchActive || showIndexAll ? (
|
||||
<div
|
||||
data-slot="content-search-hint"
|
||||
className="text-muted-foreground mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
|
||||
{contentSearchActive
|
||||
? t("documents.contentIndex.searchHint")
|
||||
: t("documents.contentIndex.indexPrompt")}
|
||||
</span>
|
||||
{showIndexAll ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
data-slot="content-index-all"
|
||||
onClick={onIndexAll}
|
||||
disabled={indexAllPending}
|
||||
className="text-primary h-auto p-0 text-xs"
|
||||
>
|
||||
{indexAllPending
|
||||
? t("documents.contentIndex.indexAllPending")
|
||||
: t("documents.contentIndex.indexAll")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import { DocumentDetailSheet } from "./document-detail-sheet";
|
||||
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
|
||||
import { DocumentTimeline } from "./document-timeline";
|
||||
import { UploadZone } from "./upload-zone";
|
||||
import { useReindexAll } from "./use-content-index";
|
||||
import { useDocumentUpload } from "./use-document-upload";
|
||||
import { usePageFileDrop } from "./use-page-file-drop";
|
||||
import {
|
||||
@@ -244,6 +245,34 @@ export function DocumentsView() {
|
||||
|
||||
const episodes = useIllnessEpisodes(true);
|
||||
|
||||
// Content-search coverage: fire the corpus backfill when indexing is
|
||||
// available and some documents are not yet searchable. The list search
|
||||
// already unions content matches server-side — this only surfaces the hint
|
||||
// and the "index all" affordance from the usage gauge.
|
||||
const reindexAll = useReindexAll();
|
||||
const contentIndex = usage.data?.contentIndex;
|
||||
const canIndexContent = contentIndex?.enabled ?? false;
|
||||
const showIndexAll =
|
||||
canIndexContent &&
|
||||
contentIndex !== undefined &&
|
||||
contentIndex.totalCount > 0 &&
|
||||
contentIndex.indexedCount < contentIndex.totalCount;
|
||||
const contentSearchActive =
|
||||
canIndexContent && (contentIndex?.indexedCount ?? 0) > 0;
|
||||
const handleIndexAll = useCallback(() => {
|
||||
reindexAll.mutate(undefined, {
|
||||
onSuccess: (result) =>
|
||||
toast.success(
|
||||
result.enqueued > 0
|
||||
? t("documents.contentIndex.indexAllQueued", {
|
||||
count: result.enqueued,
|
||||
})
|
||||
: t("documents.contentIndex.indexAllNothing"),
|
||||
),
|
||||
onError: () => toast.error(t("documents.contentIndex.indexAllError")),
|
||||
});
|
||||
}, [reindexAll, t]);
|
||||
|
||||
// Condition chips: every episode carrying at least one live document
|
||||
// link, served by the usage endpoint (NOT derived from the loaded corpus
|
||||
// — an old linked document pages deep must still surface its chip), plus
|
||||
@@ -557,6 +586,10 @@ export function DocumentsView() {
|
||||
onToggleYear={toggleYear}
|
||||
activeCount={activeCount}
|
||||
onClearAll={clearFilters}
|
||||
contentSearchActive={contentSearchActive}
|
||||
showIndexAll={showIndexAll}
|
||||
indexAllPending={reindexAll.isPending}
|
||||
onIndexAll={handleIndexAll}
|
||||
/>
|
||||
|
||||
{list.isPending ? (
|
||||
@@ -618,6 +651,8 @@ export function DocumentsView() {
|
||||
documentId={detailId}
|
||||
open={detailOpen}
|
||||
onOpenChange={handleDetailOpenChange}
|
||||
assistAvailable={usage.data?.assistAvailable}
|
||||
contentIndexEnabled={usage.data?.contentIndex.enabled}
|
||||
/>
|
||||
|
||||
{selectedIds.size > 0 ? (
|
||||
|
||||
Reference in new issue
Block a user