Files
gitmost/apps/client/src/features/search/components/search-spotlight.tsx
claude code agent 227 9e1d057878 fix(qa): resolve QA-pass issues #122–#134
Batch of fixes from the automated QA pass on develop. Each was reproduced and
then verified fixed live (browser/curl); logic-bearing fixes have unit tests.

Functional bugs:
- #122 collab-token was capped by the anonymous public-share-AI throttler (5/min);
  skip all non-AUTH named throttlers on this auth-guarded, client-cached route.
- #123 editor onAuthenticationFailed threw `jwtDecode(undefined)` and never
  reconnected; read the token via a ref, guard the decode (incl. missing exp),
  and refetch+reconnect on any auth failure.
- #124 a slash command containing a space ("/Heading 1") inserted literal text;
  enable allowSpaces and close the menu when the query matches no items.
- #125 space slug auto-gen produced uppercase initials for multi-word names;
  computeSpaceSlug now yields a lowercase alphanumeric slug.
- #126 AI chat window position/size now persisted (atomWithStorage) across reload;
  also fixes a latent ResizeObserver-attach bug on first open.
- #127 workspace name update accepted URLs; add @NoUrls (parity with setup).
- #132 icon-columns 4/5 passed calc() into SVG width/height attrs (console spam);
  size via style. share-for-page query returns null instead of undefined.
- #134 "Reindex now" counter looked stuck: reindex runs async; the client now
  polls coverage (bounded) so the counter climbs live; misleading server comment
  reworded.

UX / consistency:
- #128 add success toasts to favorite/label/avatar/member-(de)activate.
- #129 "1 result found" pluralization; hide the single-option Type filter.
- #130 replace raw Zod strings with friendly messages (name/password/group).
- #131 unify "Untitled" casing in tree/breadcrumb/tab; stop force-uppercasing
  space-name chips; fix confirm-dialog labels (Cancel / Remove), invite
  placeholder typo, Export/Move-to-space labels.
- #133 disable profile Save when clean; toast on unsupported avatar image;
  style the invalid-invitation page with a CTA; hide Share for read-only users;
  align the dictation "not configured" message; "Go to login page" typo.

Tests: computeSpaceSlug, workspace-name NoUrls DTO, share-query null
normalization, slash getSuggestionItems empty-close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:47:40 +03:00

114 lines
3.4 KiB
TypeScript

import { Spotlight } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import { Group, VisuallyHidden } from "@mantine/core";
import { useState, useMemo } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { searchSpotlightStore } from "../constants.ts";
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { SearchResultItem } from "./search-result-item.tsx";
interface SearchSpotlightProps {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const [filters, setFilters] = useState<{
spaceId?: string | null;
contentType?: string;
}>({
contentType: "page",
});
// Build unified search params
const searchParams = useMemo(() => {
const params: any = {
query: debouncedSearchQuery,
contentType: filters.contentType || "page", // Only used for frontend routing
};
// Handle space filtering - only pass spaceId if a specific space is selected
if (filters.spaceId) {
params.spaceId = filters.spaceId;
}
return params;
}, [debouncedSearchQuery, filters]);
const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
const resultItems = (searchResults || []).map((result) => (
<SearchResultItem
key={result.id}
result={result}
isAttachmentResult={filters.contentType === "attachment"}
showSpace={!filters.spaceId}
/>
));
const handleFiltersChange = (newFilters: any) => {
setFilters(newFilters);
};
return (
<>
<Spotlight.Root
size="xl"
maxHeight={600}
store={searchSpotlightStore}
query={query}
onQueryChange={setQuery}
scrollable
overlayProps={{
backgroundOpacity: 0.55,
}}
>
<Group gap="xs" px="sm" pt="sm" pb="xs">
<Spotlight.Search
placeholder={t("Search...")}
aria-label={t("Search")}
leftSection={<IconSearch size={20} stroke={1.5} />}
style={{ flex: 1 }}
/>
</Group>
<div
style={{
padding: "4px 16px",
}}
>
<SearchSpotlightFilters
onFiltersChange={handleFiltersChange}
spaceId={spaceId}
/>
</div>
<VisuallyHidden role="status" aria-live="polite">
{query.length > 0 && !isLoading
? resultItems.length === 0
? t("No results found")
: // Singular/plural handling so 1 result is not announced as
// "1 results found".
t("{{count}} result found", { count: resultItems.length })
: ""}
</VisuallyHidden>
<Spotlight.ActionsList>
{query.length === 0 && resultItems.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && !isLoading && resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>}
</Spotlight.ActionsList>
</Spotlight.Root>
</>
);
}