276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useFetcher, useNavigate } from "react-router";
|
|
import {
|
|
buildSpotlightSearchGroups,
|
|
type SpotlightSearchDialogVM,
|
|
type SpotlightSearchEntityType,
|
|
type SpotlightSearchItemVM,
|
|
type SpotlightSearchRecentVM,
|
|
} from "@hivegrid/view-models";
|
|
import { UI_ACTION_IDS } from "~/application/ui/uiRegistry";
|
|
import type { GlobalSearchEntityType, GlobalSearchResponse } from "~/services/search/globalSearch.server";
|
|
import { getCommandRegistryActions, type CommandRegistryLayout, type ScopedCommandAction } from "~/services/command-registry/commandRegistry";
|
|
import { appPath } from "~/utils/app/CanonicalUrlUtils";
|
|
import type { AppLoaderData } from "~/utils/data/useAppData";
|
|
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
|
import { useRootData } from "~/utils/data/useRootData";
|
|
import { SpotlightSearchDialogView } from "./SpotlightSearchDialogView";
|
|
|
|
const INITIAL_RESULT_LIMIT = 20;
|
|
const MAX_RESULT_LIMIT = 50;
|
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
const globalSearchEntityTypes: GlobalSearchEntityType[] = ["contact", "company", "lead", "project", "file", "task", "employee", "user"];
|
|
type SearchFetcherData = GlobalSearchResponse | { error: string };
|
|
|
|
export default function CommandPaletteDialog({
|
|
actions,
|
|
layout,
|
|
open,
|
|
setOpen,
|
|
}: {
|
|
actions?: ScopedCommandAction[];
|
|
layout?: CommandRegistryLayout;
|
|
open: boolean;
|
|
setOpen: (open: boolean) => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const fetcher = useFetcher({ key: "command-palette-actions" });
|
|
const searchFetcher = useFetcher<SearchFetcherData>({ key: "command-palette-search" });
|
|
const recentFetcher = useFetcher({ key: "command-palette-recent" });
|
|
const appData = useAppOrAdminData() as AppLoaderData | undefined;
|
|
const rootData = useRootData();
|
|
const [query, setQuery] = useState("");
|
|
const [selectedEntityTypes, setSelectedEntityTypes] = useState<GlobalSearchEntityType[]>(globalSearchEntityTypes);
|
|
const [resultLimit, setResultLimit] = useState(INITIAL_RESULT_LIMIT);
|
|
const [expectedRequestId, setExpectedRequestId] = useState<string | null>(null);
|
|
const [acceptedRequestId, setAcceptedRequestId] = useState<string | null>(null);
|
|
const [isDebouncing, setIsDebouncing] = useState(false);
|
|
const [searchFailed, setSearchFailed] = useState(false);
|
|
const [retryVersion, setRetryVersion] = useState(0);
|
|
const requestSequence = useRef(0);
|
|
const pendingRequestId = useRef<string | null>(null);
|
|
const openSelectionInNewTab = useRef(false);
|
|
const searchLoad = useRef(searchFetcher.load);
|
|
searchLoad.current = searchFetcher.load;
|
|
const canUseGlobalSearch = layout === "app" && !!appData?.currentTenant?.slug && appData.permissions?.includes("search.use");
|
|
const trimmedQuery = query.trim();
|
|
const isSearchQuery = trimmedQuery.length >= 2;
|
|
const canonicalSelectedEntityTypes = useMemo(() => globalSearchEntityTypes.filter((type) => selectedEntityTypes.includes(type)), [selectedEntityTypes]);
|
|
const activeTypeParams = useMemo(
|
|
() => canonicalSelectedEntityTypes.map((type) => `type=${encodeURIComponent(type)}`).join("&"),
|
|
[canonicalSelectedEntityTypes]
|
|
);
|
|
const currentResponse =
|
|
isGlobalSearchResponse(searchFetcher.data) &&
|
|
searchFetcher.data.requestId &&
|
|
searchFetcher.data.requestId === expectedRequestId &&
|
|
searchFetcher.data.requestId === acceptedRequestId
|
|
? searchFetcher.data
|
|
: undefined;
|
|
const searchResults = isSearchQuery && currentResponse?.query === trimmedQuery ? currentResponse.results : [];
|
|
const recentSearches = !isSearchQuery && currentResponse?.query === "" ? currentResponse.recentSearches : [];
|
|
const searchGroups = useMemo(() => buildSpotlightSearchGroups(searchResults), [searchResults]);
|
|
const isLoading = canUseGlobalSearch && !searchFailed && (isDebouncing || searchFetcher.state !== "idle" || expectedRequestId !== acceptedRequestId);
|
|
const hiddenEntityTypes = currentResponse?.hiddenEntityTypes ?? [];
|
|
const canLoadMore = isSearchQuery && !isLoading && !searchFailed && resultLimit === INITIAL_RESULT_LIMIT && searchResults.length === INITIAL_RESULT_LIMIT;
|
|
|
|
useEffect(() => {
|
|
if (!open || !canUseGlobalSearch || !appData?.currentTenant?.slug) return;
|
|
|
|
const requestId = `spotlight-${++requestSequence.current}`;
|
|
pendingRequestId.current = requestId;
|
|
setExpectedRequestId(requestId);
|
|
setAcceptedRequestId(null);
|
|
setSearchFailed(false);
|
|
|
|
const typeSuffix = activeTypeParams ? `&${activeTypeParams}` : "";
|
|
const loadSearch = (path: string) => {
|
|
void searchLoad.current(appPath({ tenantSlug: appData.currentTenant.slug, path, mode: "host-aware" })).catch(() => {
|
|
if (pendingRequestId.current === requestId) {
|
|
setSearchFailed(true);
|
|
setIsDebouncing(false);
|
|
}
|
|
});
|
|
};
|
|
|
|
if (!isSearchQuery) {
|
|
setIsDebouncing(false);
|
|
loadSearch(`search/api?limit=6&requestId=${encodeURIComponent(requestId)}`);
|
|
return;
|
|
}
|
|
|
|
setIsDebouncing(true);
|
|
const timeout = window.setTimeout(() => {
|
|
setIsDebouncing(false);
|
|
loadSearch(`search/api?q=${encodeURIComponent(trimmedQuery)}&limit=${resultLimit}${typeSuffix}&requestId=${encodeURIComponent(requestId)}`);
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [activeTypeParams, appData?.currentTenant?.slug, canUseGlobalSearch, isSearchQuery, open, resultLimit, retryVersion, trimmedQuery]);
|
|
|
|
useEffect(() => {
|
|
if (searchFetcher.state !== "idle" || !searchFetcher.data) return;
|
|
if (!isGlobalSearchResponse(searchFetcher.data)) {
|
|
setSearchFailed(true);
|
|
setIsDebouncing(false);
|
|
return;
|
|
}
|
|
if (!searchFetcher.data.requestId) return;
|
|
if (searchFetcher.data.requestId !== pendingRequestId.current) return;
|
|
setAcceptedRequestId(searchFetcher.data.requestId);
|
|
setSearchFailed(false);
|
|
}, [searchFetcher.data, searchFetcher.state]);
|
|
|
|
const resolvedActions =
|
|
actions ??
|
|
(layout
|
|
? getCommandRegistryActions({
|
|
layout,
|
|
t,
|
|
i18n,
|
|
navigate,
|
|
submitLogout: () => {
|
|
const form = new FormData();
|
|
if (rootData?.csrf) form.set("csrf", rootData.csrf);
|
|
fetcher.submit(form, { method: "post", action: "/logout" });
|
|
},
|
|
appData,
|
|
rootData,
|
|
})
|
|
: []);
|
|
const visibleActions = useMemo(() => {
|
|
const normalizedQuery = trimmedQuery.toLocaleLowerCase();
|
|
const matches = normalizedQuery
|
|
? resolvedActions.filter((action) =>
|
|
[action.name, action.subtitle, action.keywords, action.id].filter(Boolean).join(" ").toLocaleLowerCase().includes(normalizedQuery)
|
|
)
|
|
: resolvedActions;
|
|
return matches.slice(0, isSearchQuery ? 4 : 8);
|
|
}, [isSearchQuery, resolvedActions, trimmedQuery]);
|
|
|
|
function updateQuery(value: string) {
|
|
setQuery(value);
|
|
setResultLimit(INITIAL_RESULT_LIMIT);
|
|
}
|
|
|
|
function toggleEntityType(type: GlobalSearchEntityType) {
|
|
setSelectedEntityTypes((current) => {
|
|
// Default state is "all selected": the first tile click narrows to that category only.
|
|
if (current.length === globalSearchEntityTypes.length) {
|
|
return [type];
|
|
}
|
|
if (current.includes(type)) {
|
|
const next = current.filter((entry) => entry !== type);
|
|
// Deselecting the last active category resets back to "all selected".
|
|
return next.length > 0 ? next : globalSearchEntityTypes;
|
|
}
|
|
return globalSearchEntityTypes.filter((entry) => entry === type || current.includes(entry));
|
|
});
|
|
setResultLimit(INITIAL_RESULT_LIMIT);
|
|
}
|
|
|
|
function recordCommittedSelection(item: SpotlightSearchItemVM) {
|
|
if (!appData?.currentTenant?.slug || !rootData?.csrf) return;
|
|
const form = new FormData();
|
|
form.set("intent", "recordRecent");
|
|
form.set("query", trimmedQuery);
|
|
form.set("resultCount", String(searchResults.length));
|
|
canonicalSelectedEntityTypes.forEach((type) => form.append("type", type));
|
|
form.set("selectedEntityType", item.entityType);
|
|
form.set("selectedEntityId", item.id);
|
|
form.set("csrf", rootData.csrf);
|
|
recentFetcher.submit(form, {
|
|
method: "post",
|
|
action: appPath({ tenantSlug: appData.currentTenant.slug, path: "search/api", mode: "host-aware" }),
|
|
});
|
|
}
|
|
|
|
function openResult(item: SpotlightSearchItemVM) {
|
|
recordCommittedSelection(item);
|
|
const shouldOpenNewTab = openSelectionInNewTab.current;
|
|
openSelectionInNewTab.current = false;
|
|
if (shouldOpenNewTab) {
|
|
window.open(item.href, "_blank", "noopener,noreferrer");
|
|
} else {
|
|
navigate(item.href);
|
|
}
|
|
setOpen(false);
|
|
}
|
|
|
|
const searchState: SpotlightSearchDialogVM["searchState"] = !canUseGlobalSearch
|
|
? "idle"
|
|
: isLoading
|
|
? "loading"
|
|
: searchFailed
|
|
? "error"
|
|
: isSearchQuery && currentResponse && searchGroups.length === 0
|
|
? "empty"
|
|
: currentResponse
|
|
? "ready"
|
|
: "idle";
|
|
const vm = {
|
|
open,
|
|
query,
|
|
selectedEntityTypes: canonicalSelectedEntityTypes,
|
|
canUseGlobalSearch,
|
|
isSearchQuery,
|
|
searchState,
|
|
hasResolvedResponse: !!currentResponse,
|
|
searchGroups: searchGroups.map((group) => ({ ...group, items: [...group.items] })),
|
|
recentSearches: recentSearches.map(({ id, queryText, entityTypes, resultCount }) => ({ id, queryText, entityTypes, resultCount })),
|
|
hiddenEntityTypes,
|
|
canLoadMore,
|
|
visibleActions: visibleActions.map((action) => ({
|
|
id: action.id,
|
|
name: action.name,
|
|
subtitle: action.subtitle ?? null,
|
|
searchValue: [action.name, action.subtitle, action.keywords, action.id].filter(Boolean).join(" "),
|
|
})),
|
|
} satisfies SpotlightSearchDialogVM;
|
|
|
|
return (
|
|
<SpotlightSearchDialogView
|
|
vm={vm}
|
|
actions={{
|
|
onOpenChange: setOpen,
|
|
onQueryChange: updateQuery,
|
|
onToggleEntityType: (entityType: SpotlightSearchEntityType) => toggleEntityType(entityType),
|
|
onSelectRecent: (recent: SpotlightSearchRecentVM) => {
|
|
if (recent.entityTypes.length > 0) setSelectedEntityTypes(recent.entityTypes);
|
|
updateQuery(recent.queryText);
|
|
},
|
|
onPrepareResultSelection: () => {
|
|
openSelectionInNewTab.current = false;
|
|
},
|
|
onSelectResult: openResult,
|
|
onLoadMore: () => setResultLimit(MAX_RESULT_LIMIT),
|
|
onRetry: () => setRetryVersion((current) => current + 1),
|
|
onSelectAction: (actionId) => {
|
|
const action = visibleActions.find((candidate) => candidate.id === actionId);
|
|
action?.perform?.(action);
|
|
setOpen(false);
|
|
},
|
|
onCommandKeyDownCapture: (event) => {
|
|
if (event.key === "Enter") openSelectionInNewTab.current = event.metaKey || event.ctrlKey;
|
|
},
|
|
onCloseAutoFocus: (event) => {
|
|
const triggers =
|
|
typeof document !== "undefined"
|
|
? Array.from(document.querySelectorAll<HTMLElement>(`[data-ui-id="${UI_ACTION_IDS.searchMobile}"], [data-ui-id="${UI_ACTION_IDS.search}"]`))
|
|
: [];
|
|
const trigger = triggers.find((candidate) => candidate.offsetParent !== null) ?? triggers[0];
|
|
if (trigger) {
|
|
event.preventDefault();
|
|
trigger.focus();
|
|
}
|
|
},
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function isGlobalSearchResponse(value: SearchFetcherData | undefined): value is GlobalSearchResponse {
|
|
return !!value && "results" in value && "requestKey" in value;
|
|
}
|