Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/components/ui/commandPalettes/CommandPaletteDialog.tsx
T

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;
}