Source GlobalSearch
Source GlobalSearch
Cette page est générée automatiquement à partir du dépôt local au moment de la génération de la documentation.
Fichiers inclus
packages/common/src/lib/widgets/global-search/global-search.declaration.tspackages/common/src/lib/widgets/global-search/global-search.config.tspackages/common/src/lib/widgets/global-search/csw-display/csw-result-display.config.tspackages/common/src/lib/widgets/global-search/csw-display/CSWResultDisplay.sveltepackages/common/src/lib/widgets/global-search/DisplayGlobalSearchResult.sveltepackages/common/src/lib/widgets/global-search/DisplayGroupResults.sveltepackages/common/src/lib/widgets/global-search/global-search.i18n.tspackages/common/src/lib/widgets/global-search/GlobalSearch.sveltepackages/common/src/lib/widgets/global-search/history/history.model.svelte.tspackages/common/src/lib/widgets/global-search/history/HistoryList.sveltepackages/common/src/lib/widgets/global-search/models/global-search-group-query.svelte.tspackages/common/src/lib/widgets/global-search/models/global-search.state.svelte.tspackages/common/src/lib/widgets/global-search/models/global.search.models.ts
packages/common/src/lib/widgets/global-search/global-search.declaration.ts
import { widgetFactorySvelte, type WidgetProps } from '$lib/api/managers/widget';import type { WidgetInitializer } from '$lib/api/managers/widget/widget-registry';import { type GlobalSearchFullConfig, globalSearchFullConfigSchema,} from '$lib/widgets/global-search/global-search.config';
export const declaration = { factory: () => import('./GlobalSearch.svelte').then((GlobalSearch) => widgetFactorySvelte(GlobalSearch)), schema: () => globalSearchFullConfigSchema,} satisfies WidgetInitializer;
export type GlobalSearchProps = WidgetProps<GlobalSearchFullConfig>;packages/common/src/lib/widgets/global-search/global-search.config.ts
import { iconSchema } from '$lib/api/icons';import { defineWidgetConfig } from '$lib/api/managers/configuration';import { inToolbarSchemaFrom } from '$lib/api/managers/configuration/models/widget/widget-in-toolbar.schema';import { apiFeatureSymbolsSchema } from '$lib/api/symbol';import { cadMapQueryParamsSchema, coordsQueryParamsSchema, esriLayerQueryParamsSchema, metawalApiFilterByTitleQueryParamsSchema, QueryType, segmentationParamSchema, spwSearchAllParamsSchema,} from '$lib/api/tools/query';import { z } from 'zod';import { i18nDataSchema, i18nSchemaFrom } from '$lib/api/managers/i18n';import { globalSearchTranslations } from './global-search.i18n';
const defaultGroups: z.input<typeof globalSearchQueryGroupSchema>[] = [ { title: { fr: 'Aller à ...', nl: 'NL - Aller à ...', }, queries: [ { type: QueryType.SPW_SEARCH_ALL, }, ], }, { title: { fr: 'Ajouter une donnée', nl: 'NL - Ajouter une donnée', }, queries: [ { type: QueryType.METAWAL_API, }, ], }, { title: { fr: 'Aller au plan parcellaire cadastral', nl: 'NL - Aller au plan parcellaire cadastral', }, queries: [ { type: QueryType.CADMAP, }, ], }, { title: { fr: 'Route régionale', nl: 'NL - Route régionale', }, queries: [ { type: QueryType.SEGMENTATION, }, ], }, { title: { fr: 'Coordonnées', nl: 'NL - Coordonnées', }, queries: [ { type: QueryType.COORDS, }, ], },];
export const globalSearchQueryParams = z.union([ esriLayerQueryParamsSchema, spwSearchAllParamsSchema, metawalApiFilterByTitleQueryParamsSchema, coordsQueryParamsSchema, segmentationParamSchema, cadMapQueryParamsSchema,]);export type GlobalSearchQueryParams = z.infer<typeof globalSearchQueryParams>;
export const globalSearchQueryGroupSchema = z.object({ title: i18nDataSchema.optional(), queries: z.array(globalSearchQueryParams), order: z.number().optional().default(-1), groupIcon: iconSchema.default({ lucide: 'Search' }), featureIcon: iconSchema.default({ lucide: 'LocateFixed' }),});export type QueryGroupConfig = z.infer<typeof globalSearchQueryGroupSchema>;
export const globalSearchConfigSchema = z.object({ groups: z.array(globalSearchQueryGroupSchema).default(defaultGroups), startQueryDelay: z.number().default(1500), zoomOnFeature: z.boolean().default(false), minimumCharactersBeforeSearchStart: z.number().default(2), showNoDataMessage: z.boolean().default(false), symbolConfig: apiFeatureSymbolsSchema.prefault({}), cswResultsDetailsMode: z.boolean().default(true), closeResultsOnFocusLost: z.boolean().default(true), advancedSearchIcon: iconSchema.default({ geoviewer: 'advanced-search' }), showAdvancedSearchButton: z.boolean().default(true), advancedSearchWidgetId: z.string().default('advancedSearchWidgetId'), tocWidgetId: z.string().default('toc'),});
export const globalSearchFullConfigSchema = defineWidgetConfig({ inToolbar: inToolbarSchemaFrom(false), config: globalSearchConfigSchema.optional().prefault({}), i18n: i18nSchemaFrom(globalSearchTranslations),});
export type GlobalSearchFullConfig = z.infer<typeof globalSearchFullConfigSchema>;export type GlobalSearchConfig = z.infer<typeof globalSearchConfigSchema>;packages/common/src/lib/widgets/global-search/csw-display/csw-result-display.config.ts
export const cswResultDisplayI18nSchema = { 'view-complete': { fr: 'Ouvrir la fiche descriptive', nl: 'NL - Ouvrir la fiche descriptive', },};packages/common/src/lib/widgets/global-search/csw-display/CSWResultDisplay.svelte
<script lang="ts"> import type { ApiCswRecord } from '$lib/api/clients'; import type { Icon as ApiIcon } from '$lib/api/icons'; import { getI18n } from '$lib/api/managers/i18n'; import { getMapManager } from '$lib/api/map'; import { getGeoportailUrlFromUuid } from '$lib/api/utils'; import { cswResultDisplayI18nSchema } from '$lib/widgets/global-search/csw-display/csw-result-display.config'; import ResultDisplay from '$lib/widgets/global-search/DisplayGlobalSearchResult.svelte'; import { ExternalLink } from 'lucide-svelte'; import { Button } from '$lib/components/shadcn/ui/button';
interface Props { cswRecord: ApiCswRecord; icon?: ApiIcon; showIcon?: boolean; showIconTooltip?: string; closeIconTooltip?: string; displayField: string; addToMapOnClick: boolean; dataTestId: string; detailsMode?: boolean; infoVisible: boolean; onClick?: () => void; onInfoIconClick?: (e: MouseEvent) => void; }
let { cswRecord, icon, showIcon = true, showIconTooltip, closeIconTooltip, dataTestId, displayField, addToMapOnClick, detailsMode = false, infoVisible, onClick, onInfoIconClick, }: Props = $props();
const mapManager = getMapManager(); const metawal = mapManager.services.metawal; const i18n = getI18n(cswResultDisplayI18nSchema);
const metadataUrl = getGeoportailUrlFromUuid(cswRecord.identifier);
function onInfoClick(evt: MouseEvent): void { evt.stopPropagation(); if (detailsMode) { infoVisible = !infoVisible; } else { window.open(metadataUrl, '_blank'); } onInfoIconClick?.(evt); }
function onCswRecordClicked() { if (addToMapOnClick) { metawal.addCswRecordToMap(cswRecord); } onClick?.(); }</script>
<ResultDisplay {dataTestId} {showIcon} {showIconTooltip} {closeIconTooltip} {infoVisible} {onInfoClick} onClick={() => onCswRecordClicked()} {icon} info={metadataUrl} attributes={cswRecord} {displayField}> <div class="gv-flex gv-gap-4"> {#if cswRecord.vignetteUrl} <div class="gv-flex-shrink-0"> <img class="gv-max-w-[100px]" src={cswRecord.vignetteUrl} alt={cswRecord.title} /> </div> {/if}
<div class="gv-flex gv-flex-col gv-gap-2"> <div class="gv-text-left gv-break-words gv-whitespace-normal gv-overflow-y-auto gv-h-full" data-test-id={`${dataTestId}-Description`} > {cswRecord.description} </div> <Button href={metadataUrl} variant="link" size="sm" target="_blank" data-test-id={`${dataTestId}-ResultLink`} title="" class="gv-w-fit" > <span class="gv-align-middle">{i18n('view-complete')}</span> <ExternalLink class="gv-size-4 gv-align-middle" /> </Button> </div> </div></ResultDisplay>packages/common/src/lib/widgets/global-search/DisplayGlobalSearchResult.svelte
<script lang="ts"> import type { PropsWithChildren } from '$lib/api/utils/index.js'; import { Info } from 'lucide-svelte'; import { type Icon as ApiIcon } from '$lib/api/icons'; import Icon from '$lib/components/icon/Icon.svelte'; import { interpolate } from '$lib/api/utils'; import { cn } from '$lib/components/shadcn/utils'; import X from 'lucide-svelte/icons/x';
type Props = { attributes: Record<string, any> | undefined; displayField: string; icon: ApiIcon | undefined; showIcon?: boolean; showIconTooltip?: string; closeIconTooltip?: string; dataTestId: string; infoVisible?: boolean; info?: string | undefined; onClick: () => void; onInfoClick?: (evt: MouseEvent) => void; onMouseOut?: () => void; onMouseOver?: () => void; }; let { attributes = {}, displayField, icon, showIcon = true, showIconTooltip, closeIconTooltip, infoVisible = false, info, dataTestId, onClick, onInfoClick, onMouseOut, onMouseOver, children, }: PropsWithChildren<Props> = $props();</script>
<button class="gv-flex gv-w-full gv-flex-col gv-gap-2" onclick={onClick} onmouseout={onMouseOut} onblur={onMouseOut} onmouseover={onMouseOver} onfocus={onMouseOver} data-test-id={dataTestId}> <div class="gv-flex gv-items-center gv-justify-between gv-gap-2 gv-w-full"> <div class="gv-text-left gv-flex-grow gv-max-w-[95%]"> {interpolate(displayField, attributes)} </div>
{#if info !== undefined} <button title={infoVisible ? closeIconTooltip : showIconTooltip} onclick={onInfoClick} class="gv-flex-shrink-0" data-test-id={`${dataTestId}-InfoButton`} > {#if infoVisible} <X class={cn(`gv-opacity-50 gv-text-grey gv-w-4 gv-h-4`)} /> {:else} <Info class={cn(`gv-opacity-50 gv-text-grey gv-w-4 gv-h-4`)} /> {/if} </button> {/if}
{#if showIcon} <Icon class="gv-opacity-50 gv-w-4 gv-h-4 gv-flex-shrink-0" {icon} /> {/if} </div>
{#if infoVisible} <div class="gv-truncate gv-whitespace-nowrap gv-overflow-hidden gv-text-overflow-ellipsis gv-max-w-[90%]"> {@render children?.()} </div> {/if}</button>packages/common/src/lib/widgets/global-search/DisplayGroupResults.svelte
<script lang="ts"> import { type ApiFeature, QueryType } from '$lib/api/tools'; import { interpolateDisplayField, isApiCswRecord, isApiFeature, } from '$lib/widgets/global-search/models/global.search.models'; import Loader from '$lib/components/common/Loader.svelte'; import { CommandGroup, CommandItem } from '$lib/components/shadcn/ui/command/index'; import Error from '$lib/components/common/Error.svelte'; import NoResult from '$lib/components/common/NoResult.svelte'; import CSWResultDisplay from '$lib/widgets/global-search/csw-display/CSWResultDisplay.svelte'; import type { GlobalSearchFullConfig, GlobalSearchQueryParams, } from '$lib/widgets/global-search/global-search.config'; import type { GlobalSearchState } from '$lib/widgets/global-search/models/global-search.state.svelte'; import type { ApiCswRecord } from '$lib/api/clients'; import DisplayGlobalSearchResult from '$lib/widgets/global-search/DisplayGlobalSearchResult.svelte'; import type { GlobalSearchGroupQuery } from '$lib/widgets/global-search/models/global-search-group-query.svelte'; import { getI18n } from '$lib/api/managers/i18n';
interface Props { fullConfig: GlobalSearchFullConfig; globalSearchState: GlobalSearchState; groupQuery: GlobalSearchGroupQuery; groupIndex: number; currentOpenInfo?: string; onResultClick: ( feature: ApiFeature | ApiCswRecord, searchConfig: GlobalSearchQueryParams, forceClose?: boolean, ) => void; onInfoIconClick: (feature: ApiFeature | ApiCswRecord, evt: Event) => void; onMouseOut: (feature: ApiFeature | ApiCswRecord, queryType: QueryType) => void; onMouseOver: (feature: ApiFeature | ApiCswRecord, queryType: QueryType) => void; }
let { fullConfig, groupQuery, groupIndex, currentOpenInfo = $bindable(), onResultClick = () => {}, onMouseOut = () => {}, onMouseOver = () => {}, onInfoIconClick = () => {}, }: Props = $props(); let config = fullConfig.config; const i18n = getI18n();</script>
{#if groupQuery.loading || groupQuery.error || !groupQuery.featuresAreEmpty || config.showNoDataMessage} <CommandGroup class="gv-command-group-heading-overrides" data-test-id={`GlobalSearchGroupResultTitle-${groupIndex}`} heading={i18n.translate(groupQuery.title)} > {#if groupQuery.loading} <Loader class="gv-p-1" /> {/if} {#if groupQuery.featuresAreEmpty && !groupQuery.loading && config.showNoDataMessage} <NoResult class="gv-p-1" /> {:else if groupQuery.error} <Error class="gv-text-sm" /> {:else if groupQuery.data} <div class="gv-max-h-40 gv-overflow-y-scroll"> {#each groupQuery.data.queryResults as queryResult} {#each queryResult.features as feature} {@const dataTestId = `GlobalSearchGroupResultFeature-${i18n.translate(groupQuery.title)}-${interpolateDisplayField(queryResult.displayField, feature)}`} {#if queryResult.dataType === QueryType.METAWAL_API && isApiCswRecord(feature)} <CommandItem onclick={() => onResultClick(feature, queryResult.queryConfig)}> <CSWResultDisplay {dataTestId} cswRecord={feature} detailsMode={config.cswResultsDetailsMode} onInfoIconClick={(evt) => onInfoIconClick(feature, evt)} infoVisible={currentOpenInfo === feature.identifier} addToMapOnClick={false} displayField={queryResult.displayField} /> </CommandItem> {:else if isApiFeature(feature)} <CommandItem value={feature.id} onSelect={() => onResultClick(feature, queryResult.queryConfig, true)} > <DisplayGlobalSearchResult {dataTestId} icon={groupQuery.icon} onClick={() => onResultClick(feature, queryResult.queryConfig)} onMouseOut={() => onMouseOut(feature, queryResult.dataType)} onMouseOver={() => onMouseOver(feature, queryResult.dataType)} attributes={feature.attributes} displayField={queryResult.displayField} /> </CommandItem> {/if} {/each} {/each} </div> {/if} </CommandGroup>{/if}packages/common/src/lib/widgets/global-search/global-search.i18n.ts
import type { I18nRegistry } from '$lib/api/managers/i18n';
export const globalSearchTranslations = { 'search-placeholder': { fr: 'Chercher un lieu, une adresse, une donnée', nl: 'NL - Chercher un lieu, une adresse, une donnée', }, 'advanced-search-title': { fr: 'Recherche avancée', nl: 'NL - Recherche avancée', }, 'no-result-found': { fr: "Nous n'avons trouvé aucun résultat. Essayez avec des mots différents.", nl: "NL - Nous n'avons trouvé aucun résultat. Essayez avec des mots différents.", }, 'clear-input': { fr: 'Vider la recherche', nl: 'NL - Vider la recherche', },} satisfies I18nRegistry;packages/common/src/lib/widgets/global-search/GlobalSearch.svelte
<script lang="ts"> import { getI18n } from '$lib/api/managers/i18n'; import { getMapManager } from '$lib/api/map'; import { isApiCswRecord, isApiFeature } from './models/global.search.models'; import { onMount } from 'svelte'; import Icon from '$lib/components/icon/Icon.svelte'; import X from 'lucide-svelte/icons/x'; import { focusEventOutOfContainer } from '$lib/api/utils'; import { QueryType } from '$lib/api/tools/query'; import { Command, CommandInput, CommandItem, CommandList } from '$lib/components/shadcn/ui/command'; import type { GlobalSearchQueryParams } from './global-search.config'; import { getWidgetManager } from '$lib/api/managers/widget'; import type { ApiFeature } from '$lib/api/feature'; import HistoryList from './history/HistoryList.svelte'; import { GlobalSearchState } from './models/global-search.state.svelte'; import DisplayGroupResults from './DisplayGroupResults.svelte'; import { cn } from '$lib/components/shadcn/utils'; import { highlightServiceInToc } from '$lib/widgets/toc/toc.utils'; import type { GlobalSearchProps } from './global-search.declaration'; import { type GetSegmentParams, isSegmentFeature } from '$lib/api/services'; import type { ApiCswRecord } from '$lib/api/clients';
let { fullConfig }: GlobalSearchProps = $props(); const { config } = fullConfig;
const mapManager = getMapManager(); const widgetManager = getWidgetManager(); const services = mapManager.services; const metawal = mapManager.services.metawal; const i18n = getI18n(fullConfig.i18n); export const globalSearchState = new GlobalSearchState( config, config.startQueryDelay, config.minimumCharactersBeforeSearchStart, services, );
let currentOpenInfo = $state<string>(); let containerElement: HTMLElement | undefined; let inputElement = $state<HTMLInputElement | undefined>();
let currentClickedFeature: ApiFeature | undefined; let currentHighlightedFeature: ApiFeature | undefined;
onMount(() => { config.groups.forEach((group, groupIndex) => { group.order = group.order == undefined || group.order == -1 ? groupIndex : group.order; }); });
mapManager.tools.events.on('click', async () => { resetResultDisplay(); });
function resetResultDisplay() { unHighlightFeature(currentClickedFeature); unHighlightFeature(currentHighlightedFeature); globalSearchState.closeResults(); }
async function zoomToSegment(feature: ApiFeature, searchConfig: GlobalSearchQueryParams) { if (searchConfig.type === QueryType.SEGMENTATION && isSegmentFeature(feature)) { const getSegmentParams: GetSegmentParams = { code: feature.attributes.code!, cumuleeEnd: feature.attributes.cumulee_end! / 1000, cumuleeStart: feature.attributes.cumulee_start! / 1000, systemType: `${feature.attributes.type!}`, }; const segmentFeature = await services.segmentation.getSegmentWithShape(getSegmentParams); onFeatureClicked(segmentFeature); globalSearchState.closeResults(); } }
function highlightFeature(feature: ApiFeature): void { unHighlightFeature(currentHighlightedFeature); currentHighlightedFeature = feature; mapManager.tools.highlight.highlightFeature(feature); }
function unHighlightFeature(feature: ApiFeature | undefined): void { if (feature) { mapManager.tools.highlight.unhighlightFeature(feature); } }
function onFeatureClicked(feature: ApiFeature) { unHighlightFeature(currentClickedFeature); currentClickedFeature = feature; mapManager.tools.highlight.highlightFeature(feature); mapManager.tools.zoom.zoomToFeature(feature); }
function onFocusLost(event: FocusEvent) { if (focusEventOutOfContainer(event, containerElement) && config.closeResultsOnFocusLost) { globalSearchState.onFocusLost(); } }
function onFocusResume() { unHighlightFeature(currentClickedFeature); unHighlightFeature(currentHighlightedFeature); globalSearchState.onFocusResume(); }
function onInfoIconClick(feature: ApiFeature | ApiCswRecord, evt: Event) { evt.stopPropagation(); if (!isApiCswRecord(feature)) return; if (config.cswResultsDetailsMode) { currentOpenInfo = feature.identifier; } }
export async function openAdvancedSearch() { resetResultDisplay(); return widgetManager.getReference(config.advancedSearchWidgetId).activate(); }
export function closeAdvancedSearch() { widgetManager.getReference(config.advancedSearchWidgetId).deactivate(); }
function onResultClick( feature: ApiFeature | ApiCswRecord, searchConfig: GlobalSearchQueryParams, forceClose = false, ) { if (!isApiFeature(feature)) { const mapService = metawal.addCswRecordToMap(feature); if (mapService) { highlightServiceInToc(mapService.id, widgetManager, mapManager); } globalSearchState.closeResults(); } else { if (searchConfig.type === QueryType.SEGMENTATION) { zoomToSegment(feature, searchConfig); } else { onFeatureClicked(feature); } if (forceClose) { globalSearchState.closeResults(); } } globalSearchState.addIntoHistory(globalSearchState.searchInputValue); }
function onMouseOver(feature: ApiFeature | ApiCswRecord, queryType: QueryType) { if (!isApiFeature(feature)) return; if (queryType != QueryType.SEGMENTATION) { highlightFeature(feature); } }
function onMouseOut(feature: ApiFeature | ApiCswRecord, queryType: QueryType) { if (!isApiFeature(feature)) return; if (queryType != QueryType.SEGMENTATION) { unHighlightFeature(feature); } }
function clearSearch() { globalSearchState.searchInputValue = ''; inputElement?.focus(); }</script>
<div class="gv-w-full" onfocusout={onFocusLost} bind:this={containerElement}> <Command shouldFilter={false} class="gv-relative gv-w-full gv-bg-transparent"> <CommandInput onclick={onFocusResume} onfocus={onFocusResume} data-test-id="GlobalSearchInputField" bind:el={inputElement} bind:value={globalSearchState.searchInputValue} placeholder={i18n('search-placeholder')} class="gv-px-5 gv-shadow-4md gv-py-3.5 gv-h-12 gv-bg-background" inputClass="gv-text-xl" > {#snippet right()} <div class="gv-flex gv-items-center gv-gap-2"> {#if globalSearchState.searchInputValue} <button title={i18n('clear-input')} onclick={clearSearch} data-test-id="GlobalSearch-ClearInputButton" class="gv-cursor-pointer" > <X class="gv-size-4" /> </button> {/if} <button title={i18n('advanced-search-title')} onclick={openAdvancedSearch} data-test-id="GlobalSearch-AdvancedSearchButton" class={cn(config.showAdvancedSearchButton ? 'gv-inline-block' : 'gv-hidden')} > <Icon class="gv-size-4 gv-text-primary-500" icon={config.advancedSearchIcon} /> </button> </div> {/snippet} </CommandInput> {#if globalSearchState.isListVisible} <CommandList class="gv-max-h-[80vh] gv-rounded-2xl"> {#each globalSearchState.groupQueries as groupQuery, groupIndex} <DisplayGroupResults {onMouseOver} {onMouseOut} {onInfoIconClick} {onResultClick} {groupQuery} {groupIndex} {globalSearchState} {fullConfig} {currentOpenInfo} /> {/each} {#if globalSearchState.noResultFound} <CommandItem disabled> {i18n('no-result-found')} </CommandItem> {/if} </CommandList> {/if} {#if globalSearchState.showHistory} <HistoryList {globalSearchState} class="gv-rounded-2xl" /> {/if} </Command></div>packages/common/src/lib/widgets/global-search/history/history.model.svelte.ts
export class GlobalSearchHistory { private readonly LOCAL_STORAGE_KEY = 'GlobalSearchHistory_Items';
public historyItems = $state<string[]>([]);
public selectedItem = $state<string | undefined>();
constructor() { const fromLocalStorage = localStorage.getItem(this.LOCAL_STORAGE_KEY); this.historyItems = fromLocalStorage ? JSON.parse(fromLocalStorage) : []; }
addHistoryItem(item: string) { for (const existingItem of this.historyItems) { if (existingItem.toLowerCase() === item.toLowerCase()) { this.removeItem(existingItem); } }
this.historyItems.push(item); if (this.historyItems.length > 5) { this.removeItem(this.historyItems[0]); } this.saveHistory(); }
removeItem(item: string) { this.historyItems = this.historyItems.filter((x) => x != item); }
saveHistory(): void { localStorage.setItem(this.LOCAL_STORAGE_KEY, JSON.stringify(Array.from(this.historyItems))); }}packages/common/src/lib/widgets/global-search/history/HistoryList.svelte
<script lang="ts"> import { CommandItem, CommandList } from '$lib/components/shadcn/ui/command'; import { History } from 'lucide-svelte'; import { reverse } from '$lib/api/utils'; import type { GlobalSearchState } from '$lib/widgets/global-search/models/global-search.state.svelte';
interface Props { globalSearchState: GlobalSearchState; class?: string; } let { globalSearchState, class: className }: Props = $props();</script>
<CommandList class={className}> {#each reverse(Array.from(globalSearchState.historyItems)) as historyItem} <CommandItem class="gv-cursor-pointer" onSelect={() => globalSearchState.onHistoryItemSelected(historyItem)}> <History class="gv-opacity-50 gv-w-4 gv-h-4 gv-mr-2" /> {historyItem} </CommandItem> {/each}</CommandList>packages/common/src/lib/widgets/global-search/models/global-search-group-query.svelte.ts
import { queryState, type QueryState } from '$lib/api/utils';import type { ApiFeature } from '$lib/api/feature';import type { QueryGroupConfig } from '$lib/widgets/global-search/global-search.config';import type { ApiCswRecord } from '$lib/api/clients';import type { GroupResult } from '$lib/widgets/global-search/models/global.search.models';import type { Icon } from '$lib/api/icons';import type { I18nData } from '$lib/api/managers/i18n';import type { ServiceImplementations } from '$lib/api/services/service-implementations.model';
export class GlobalSearchGroupQuery { private _query: QueryState<GroupResult>;
public featuresAreEmpty = $derived.by(() => { if (this.loading) { return false; } return !this.data || this.data.queryResults.filter((queryRes) => queryRes.features.length > 0).length === 0; });
constructor( private queryGroupConfig: QueryGroupConfig, private searchText: string, private services: ServiceImplementations, ) { this._query = queryState({ queryFn: () => this.resolveGroupQueries(), }); }
private async resolveGroupQueries(): Promise<GroupResult> { const currentGroupQueryPromises: Promise<ApiFeature[] | ApiCswRecord[]>[] = this.queryGroupConfig.queries.map( (queryConfig) => { return this.services.dynamicQuery({ searchText: this.searchText, queryParams: queryConfig, }); }, ); const currentGroupResults = await Promise.all(currentGroupQueryPromises); const groupQueriesResults = this.queryGroupConfig.queries.map((queryConfig, queryIndex) => { return { displayField: queryConfig.displayField, features: currentGroupResults[queryIndex], dataType: queryConfig.type, queryConfig: queryConfig, }; }); return { title: this.queryGroupConfig.title, queryResults: groupQueriesResults, order: this.queryGroupConfig.order, groupIcon: this.queryGroupConfig.groupIcon, featureIcon: this.queryGroupConfig.featureIcon, }; }
get title(): I18nData { return this.queryGroupConfig.title; }
get icon(): Icon { return this.queryGroupConfig.featureIcon; }
get error(): Error | undefined { return this._query.error; }
get loading(): boolean { return this._query.loading; }
get data(): GroupResult | undefined { return this._query.data; }
get loaded(): boolean { return this._query.loaded; }}packages/common/src/lib/widgets/global-search/models/global-search.state.svelte.ts
import type { GlobalSearchConfig } from '$lib/widgets/global-search/global-search.config';import { type DebounceState, debounceState } from '$lib/api/utils';import { GlobalSearchHistory } from '$lib/widgets/global-search/history/history.model.svelte';import { GlobalSearchGroupQuery } from '$lib/widgets/global-search/models/global-search-group-query.svelte';import type { ServiceImplementations } from '$lib/api/services/service-implementations.model';
export class GlobalSearchState { public searchInputValue = $state<string>(''); public showHistory = $derived.by(() => this.searchInputHasFocus && this.searchInputValue.length === 0);
private _isListVisible = $state<boolean>(false); private _searchInputHasFocus = $state<boolean>(false); private globalSearchHistory = new GlobalSearchHistory(); private currentQuerySearch: string | undefined; private _groupQueries = $state<GlobalSearchGroupQuery[]>([]); private debouncedFilter: DebounceState<string>; private debouncedSearchText = $derived.by(() => this.debouncedFilter.debounced?.length >= this.minimumCharactersBeforeSearchStart ? this.debouncedFilter.debounced : null, );
constructor( private config: GlobalSearchConfig, private queryDelay: number, private minimumCharactersBeforeSearchStart: number, private services: ServiceImplementations, ) { this.debouncedFilter = debounceState(() => this.searchInputValue, this.queryDelay);
$effect(() => { if ( this.debouncedSearchText && this.debouncedSearchText != this.currentQuerySearch && this.debouncedSearchText.length >= this.minimumCharactersBeforeSearchStart ) { this.startSearch(); } });
$effect(() => { if (this.debouncedSearchText?.length === 0) { this._groupQueries = []; } });
$effect(() => { if (this.searchInputValue.length === 0) { this._isListVisible = false; } else { this._isListVisible = this.searchInputValue === this.currentQuerySearch; } });
$effect(() => { if (this.globalSearchHistory.selectedItem) { this.searchInputValue = this.globalSearchHistory.selectedItem; this.startSearch(); } }); }
private startSearch() { this.globalSearchHistory.selectedItem = undefined; this._isListVisible = true; this._groupQueries = []; this.currentQuerySearch = this.debouncedSearchText!; this._groupQueries = this.config.groups.map((currentGroup) => { return new GlobalSearchGroupQuery(currentGroup, this.debouncedSearchText!.trim(), this.services); }); }
public onFocusResume() { this._isListVisible = this.searchInputValue.length > 0; this._searchInputHasFocus = true; }
public onFocusLost() { this.closeResults(); this._searchInputHasFocus = false; }
public onHistoryItemSelected(historyItem: string) { this.globalSearchHistory.selectedItem = historyItem; }
public addIntoHistory(historyItem: string): void { this.globalSearchHistory.addHistoryItem(historyItem); }
public closeResults() { this._isListVisible = false; }
get historyItems(): string[] { return this.globalSearchHistory.historyItems; }
get isListVisible(): boolean { return this._isListVisible; }
get searchInputHasFocus(): boolean { return this._searchInputHasFocus; }
get groupQueries(): GlobalSearchGroupQuery[] { return this._groupQueries; }
get noResultFound(): boolean { return ( !this.debouncedFilter.debouncing && this.groupQueries.length > 0 && this.groupQueries.every((g) => g.loaded && g.featuresAreEmpty) ); }}packages/common/src/lib/widgets/global-search/models/global.search.models.ts
import type { QueryType } from '$lib/api/tools/query';import type { Icon } from '$lib/api/icons';import type { GlobalSearchQueryParams } from '$lib/widgets/global-search/global-search.config';import type { ApiCswRecord } from '$lib/api/clients';import type { ApiFeature } from '$lib/api/feature';import { interpolate } from '$lib/api/utils';import type { I18nData } from '$lib/api/managers/i18n';
export interface QueryResult { displayField: string; features: ApiFeature[] | ApiCswRecord[]; dataType: QueryType; queryConfig: GlobalSearchQueryParams;}
export interface GroupResult { title?: I18nData; groupIcon?: Icon; featureIcon?: Icon; queryResults: QueryResult[]; order?: number; loading?: boolean; inError?: boolean;}
export function isApiFeature(feature: any): feature is ApiFeature { const apiFeatureTypes = ['point', 'polyline', 'polygon', 'multi-polygon', 'multi-point', 'multi-polyline']; return feature && !!feature['geometry'] && !!feature['wkid'] && apiFeatureTypes.indexOf(feature['type']) > -1;}
export function isApiCswRecord(apiCswRecord: any): apiCswRecord is ApiCswRecord { return ( apiCswRecord && !!apiCswRecord['identifier'] && !!apiCswRecord['mapServiceUrl'] && !!apiCswRecord['mapServiceType'] );}
export function interpolateDisplayField(displayField: string, feature: ApiFeature | ApiCswRecord): string { const attributes: Record<string, any> = (isApiFeature(feature) ? feature.attributes : (feature as unknown)) ?? {}; return interpolate(displayField, attributes);}