mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
feat: enhance document mention editor and picker for better user experience
- Added document type icons in InlineMentionEditor for improved visual context. - Updated DocumentMentionPicker to include client-side filtering, reducing false positives in search results. - Enhanced loading state management by incorporating fetching indicators for better UX during data retrieval.
This commit is contained in:
parent
720c13667e
commit
0b5568d7ab
2 changed files with 35 additions and 6 deletions
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import ReactDOMServer from "react-dom/server";
|
import ReactDOMServer from "react-dom/server";
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
import type { Document } from "@/contracts/types/document.types";
|
||||||
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface MentionedDocument {
|
export interface MentionedDocument {
|
||||||
|
|
@ -166,12 +167,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
|
chip.setAttribute(CHIP_DOCTYPE_ATTR, doc.document_type ?? "UNKNOWN");
|
||||||
chip.contentEditable = "false";
|
chip.contentEditable = "false";
|
||||||
chip.className =
|
chip.className =
|
||||||
"inline-flex items-center gap-0.5 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none";
|
"inline-flex items-center gap-1 mx-0.5 pl-1 pr-0.5 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary border border-primary/10 select-none";
|
||||||
chip.style.userSelect = "none";
|
chip.style.userSelect = "none";
|
||||||
chip.style.verticalAlign = "baseline";
|
chip.style.verticalAlign = "baseline";
|
||||||
|
|
||||||
|
// Add document type icon
|
||||||
|
const iconSpan = document.createElement("span");
|
||||||
|
iconSpan.className = "shrink-0 flex items-center text-muted-foreground";
|
||||||
|
iconSpan.innerHTML = ReactDOMServer.renderToString(
|
||||||
|
getConnectorIcon(doc.document_type ?? "UNKNOWN", "h-3 w-3")
|
||||||
|
);
|
||||||
|
|
||||||
const titleSpan = document.createElement("span");
|
const titleSpan = document.createElement("span");
|
||||||
titleSpan.className = "max-w-[80px] truncate";
|
titleSpan.className = "max-w-[120px] truncate";
|
||||||
titleSpan.textContent = doc.title;
|
titleSpan.textContent = doc.title;
|
||||||
titleSpan.title = doc.title;
|
titleSpan.title = doc.title;
|
||||||
|
|
||||||
|
|
@ -197,6 +205,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
focusAtEnd();
|
focusAtEnd();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
chip.appendChild(iconSpan);
|
||||||
chip.appendChild(titleSpan);
|
chip.appendChild(titleSpan);
|
||||||
chip.appendChild(removeBtn);
|
chip.appendChild(removeBtn);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
// Use the new lightweight endpoint for document title search
|
// Use the new lightweight endpoint for document title search
|
||||||
// TanStack Query provides signal for automatic request cancellation
|
// TanStack Query provides signal for automatic request cancellation
|
||||||
// keepPreviousData: shows old results while fetching new ones (no spinner flicker)
|
// keepPreviousData: shows old results while fetching new ones (no spinner flicker)
|
||||||
const { data: titleSearchResults, isLoading: isTitleSearchLoading } = useQuery({
|
const { data: titleSearchResults, isLoading: isTitleSearchLoading, isFetching: isTitleSearchFetching } = useQuery({
|
||||||
queryKey: ["document-titles", titleSearchParams],
|
queryKey: ["document-titles", titleSearchParams],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }, signal),
|
documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }, signal),
|
||||||
|
|
@ -168,7 +168,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
// Use query for fetching first page of SurfSense docs
|
// Use query for fetching first page of SurfSense docs
|
||||||
// TanStack Query provides signal for automatic request cancellation
|
// TanStack Query provides signal for automatic request cancellation
|
||||||
// keepPreviousData: shows old results while fetching new ones (no spinner flicker)
|
// keepPreviousData: shows old results while fetching new ones (no spinner flicker)
|
||||||
const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({
|
const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading, isFetching: isSurfsenseDocsFetching } = useQuery({
|
||||||
queryKey: ["surfsense-docs-mention", debouncedSearch, isSearchValid],
|
queryKey: ["surfsense-docs-mention", debouncedSearch, isSearchValid],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
|
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
|
||||||
|
|
@ -177,6 +177,16 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Client-side filter to verify search term is actually in the title (handles backend fuzzy false positives)
|
||||||
|
const filterBySearchTerm = useCallback(
|
||||||
|
(docs: Pick<Document, "id" | "title" | "document_type">[]) => {
|
||||||
|
if (!isSearchValid) return docs; // No filtering when not searching
|
||||||
|
const searchLower = debouncedSearch.trim().toLowerCase();
|
||||||
|
return docs.filter((doc) => doc.title.toLowerCase().includes(searchLower));
|
||||||
|
},
|
||||||
|
[debouncedSearch, isSearchValid]
|
||||||
|
);
|
||||||
|
|
||||||
// Update accumulated documents when first page loads - combine both sources
|
// Update accumulated documents when first page loads - combine both sources
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage === 0) {
|
if (currentPage === 0) {
|
||||||
|
|
@ -199,9 +209,10 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
setHasMore(titleSearchResults.has_more);
|
setHasMore(titleSearchResults.has_more);
|
||||||
}
|
}
|
||||||
|
|
||||||
setAccumulatedDocuments(combinedDocs);
|
// Apply client-side filter to remove fuzzy false positives
|
||||||
|
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
|
||||||
}
|
}
|
||||||
}, [titleSearchResults, surfsenseDocs, currentPage]);
|
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
|
||||||
|
|
||||||
// Function to load next page using lightweight endpoint
|
// Function to load next page using lightweight endpoint
|
||||||
const loadNextPage = useCallback(async () => {
|
const loadNextPage = useCallback(async () => {
|
||||||
|
|
@ -246,10 +257,14 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
|
|
||||||
const actualDocuments = accumulatedDocuments;
|
const actualDocuments = accumulatedDocuments;
|
||||||
const actualLoading = (isTitleSearchLoading || isSurfsenseDocsLoading) && currentPage === 0;
|
const actualLoading = (isTitleSearchLoading || isSurfsenseDocsLoading) && currentPage === 0;
|
||||||
|
const isFetchingResults = isTitleSearchFetching || isSurfsenseDocsFetching;
|
||||||
|
|
||||||
// Show hint when search is too short
|
// Show hint when search is too short
|
||||||
const showSearchHint = shouldSearch && !isSearchValid;
|
const showSearchHint = shouldSearch && !isSearchValid;
|
||||||
|
|
||||||
|
// Hide popup entirely when user is searching and no documents match (only after fetch completes)
|
||||||
|
const hasNoSearchResults = isSearchValid && !actualLoading && !isFetchingResults && actualDocuments.length === 0;
|
||||||
|
|
||||||
// Split documents into SurfSense docs and user docs for grouped rendering
|
// Split documents into SurfSense docs and user docs for grouped rendering
|
||||||
const surfsenseDocsList = useMemo(
|
const surfsenseDocsList = useMemo(
|
||||||
() => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"),
|
() => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"),
|
||||||
|
|
@ -345,6 +360,11 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
|
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Don't show popup when user is searching and no documents match
|
||||||
|
if (hasNoSearchResults) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col w-[280px] sm:w-[320px] bg-popover rounded-lg"
|
className="flex flex-col w-[280px] sm:w-[320px] bg-popover rounded-lg"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue