Skip to content

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.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

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

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

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

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

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

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

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

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

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

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

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

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

Aller plus loin