Skip to content

Source ServiceDataTable

Source ServiceDataTable

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/service-data-table/service-data-table.declaration.ts

packages/common/src/lib/widgets/service-data-table/service-data-table.declaration.ts
import { widgetFactorySvelte, type WidgetProps } from '$lib/api/managers/widget';
import { resultsTableContainerConfigSchema, type ServiceDataTableFullConfig } from './service-data-table.config';
import type { WidgetDeclaration } from '$lib/api/managers/widget/widget-declaration';
import { ServiceDataTableState } from '$lib/widgets/service-data-table/service-data-table.state.svelte';
export const declaration = {
factory: () =>
import('./ServiceDataTableWidget.svelte').then((ServiceDataTableWidget) =>
widgetFactorySvelte(ServiceDataTableWidget),
),
schema: () => resultsTableContainerConfigSchema,
state: (props) => new ServiceDataTableState(props),
} satisfies WidgetDeclaration;
export type ServiceDataTableProps = WidgetProps<ServiceDataTableFullConfig, ServiceDataTableState>;

packages/common/src/lib/widgets/service-data-table/service-data-table.config.ts

packages/common/src/lib/widgets/service-data-table/service-data-table.config.ts
import { inToolbarSchemaFrom } from '$lib/api/managers/configuration/models/widget/widget-in-toolbar.schema';
import { i18nSchemaFrom } from '$lib/api/managers/i18n';
import { z } from 'zod';
import { defineWidgetConfig } from '$lib/api/managers/configuration';
import { serviceDataTableI18n } from '$lib/widgets/service-data-table/service-data-table.i18n';
const resultsTableConfigSchema = z.object({
highlightOnOver: z.boolean().default(true),
zoomOnHover: z.boolean().default(false),
zoomOnSelect: z.boolean().default(true),
allowExport: z.boolean().default(true),
allowAdvancedFilters: z.boolean().default(true),
allowAdvancedFiltersReset: z.boolean().default(true),
allowSpatialFilter: z.boolean().default(true),
allowSpatialFilterReset: z.boolean().default(true),
});
export const resultsTableContainerConfigSchema = defineWidgetConfig({
title: serviceDataTableI18n.title,
inToolbar: inToolbarSchemaFrom(false),
i18n: i18nSchemaFrom(serviceDataTableI18n),
config: resultsTableConfigSchema.prefault({}),
});
export type ServiceDataTableFullConfig = z.infer<typeof resultsTableContainerConfigSchema>;
export type ServiceDataTableI18n = ServiceDataTableFullConfig['i18n'];

packages/common/src/lib/widgets/service-data-table/advanced-filters/AdvancedFiltersDialog.svelte

packages/common/src/lib/widgets/service-data-table/advanced-filters/AdvancedFiltersDialog.svelte
<script lang="ts">
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '$lib/components/shadcn/ui/dialog';
import { Button } from '$lib/components/shadcn/ui/button';
import { deepClone, type PropsWithChildren } from '$lib/api/utils/index.js';
import { getLayoutManager } from '$lib/api/managers/layout';
import { getI18n } from '$lib/api/managers/i18n';
import { ConditionOperation, type QueryConditionGroup } from '$lib/api/tools';
import ConditionGroupConfigurator from './ConditionGroupConfigurator.svelte';
import type { ApiFieldDescription } from '$lib/api/domain';
import { queryLayerI18n } from './query-layer.i18n';
interface Props {
open?: boolean;
title: string;
condition: QueryConditionGroup;
fields: ApiFieldDescription[];
allowGroups?: boolean;
allowReset?: boolean;
addDefaultConditionIfEmpty?: boolean;
}
const layoutManager = getLayoutManager();
const i18n = getI18n();
let {
open = $bindable(false),
condition = $bindable(),
title,
fields,
allowGroups,
children,
allowReset = true,
addDefaultConditionIfEmpty = false,
}: PropsWithChildren<Props> = $props();
if (addDefaultConditionIfEmpty && condition.conditions.length === 0) {
condition.conditions.push({
field: fields[0],
operation: ConditionOperation.EQUALS,
value: null,
});
}
$effect(() => {
if (condition) {
innerCondition = deepClone($state.snapshot(condition));
}
});
// Use a deep copy of the original object to only apply the condition change on validation
let innerCondition = $state(deepClone($state.snapshot(condition)));
function applyFilters() {
condition = deepClone($state.snapshot(innerCondition));
open = false;
}
function reset() {
condition = {
operator: condition.operator,
conditions: [],
};
}
</script>
<Dialog bind:open portal={layoutManager.layout.root}>
<DialogTrigger>
{@render children?.()}
</DialogTrigger>
<DialogContent class="gv-max-w-[45vw]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<ConditionGroupConfigurator bind:condition={innerCondition} {fields} i18n={queryLayerI18n} {allowGroups} />
<DialogFooter>
{#if allowReset}
<Button variant="outline" onclick={reset} size="lg" data-test-id="Advanced-Filters-Reset">
{i18n('common.reset')}
</Button>
{/if}
<Button onclick={applyFilters} size="lg" data-test-id="Advanced-Filters-Search">
{i18n('common.research')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

packages/common/src/lib/widgets/service-data-table/advanced-filters/ConditionConfigurator.svelte

packages/common/src/lib/widgets/service-data-table/advanced-filters/ConditionConfigurator.svelte
<script lang="ts">
import { type ApiFieldDescription, ApiFieldType, ApiFieldTypeToOperations } from '$lib/api/domain';
import { getI18n } from '$lib/api/managers/i18n';
import type { ConditionOperation, QueryCondition } from '$lib/api/tools/query';
import { ApiSelect, type ApiSelectItem } from '$lib/components/api-select';
import DynamicInput from '$lib/components/dynamic-input/DynamicInput.svelte';
import Trash2 from 'lucide-svelte/icons/trash-2';
import type { QueryLayerI18n } from './query-layer.i18n';
import { Button } from '$lib/components/shadcn/ui/button';
import { cn } from '$lib/components/shadcn/utils';
interface Props {
fields: ApiFieldDescription[];
condition: QueryCondition;
i18n: QueryLayerI18n;
onRemove: () => void;
class?: string;
}
let { fields, condition = $bindable(), i18n: i18nConfig, onRemove, class: className }: Props = $props();
const i18n = getI18n(i18nConfig);
const selectedField = $derived<ApiFieldDescription>(condition.field);
const operatorsList = $derived.by<ApiSelectItem<ConditionOperation>[]>(() => {
if (selectedField) {
return ApiFieldTypeToOperations[selectedField.type].map((operator) => ({
label: getOperatorLabel(operator),
value: operator,
'data-test-id': `Advanced-Filters-Operation-${operator}`,
}));
}
return [];
});
const fieldsList = $derived<ApiSelectItem<ApiFieldDescription>[]>(
fields
.filter((f) => f.type !== ApiFieldType.GEOMETRY)
.map((field) => ({
label: field.label,
value: field,
})),
);
$effect(() => {
const fieldInList = fields.find((field) => field.key === condition.field?.key);
if (!condition.field || !fieldInList) {
condition.field = fields[0];
}
});
$effect(() => {
const operatorInList = operatorsList.find((operator) => operator.value === condition.operation);
if (!condition.operation || !operatorInList) {
condition.operation = operatorsList[0]?.value;
}
});
function getOperatorLabel(operator: ConditionOperation) {
return i18n(`condition-operation-${operator}`);
}
</script>
<div class={cn('gv-flex gv-gap-1.5', className)}>
<ApiSelect
options={fieldsList}
bind:value={condition.field}
getKey={(v) => v.key}
class="gv-w-50"
dataTestId="Advanced-Filters-Field"
/>
<ApiSelect
options={operatorsList}
bind:value={condition.operation}
class="gv-w-28"
dataTestId="Advanced-Filters-Operation"
/>
<DynamicInput
field={selectedField}
bind:value={condition.value}
class="gv-flex-1"
data-test-id="Advanced-Filters-Input"
/>
<Button
onclick={onRemove}
title={i18n('delete-condition')}
variant="link"
class="gv-px-0"
data-test-id="Advanced-Filters-Remove"
>
<Trash2 class="gv-size-4" />
</Button>
</div>

packages/common/src/lib/widgets/service-data-table/advanced-filters/ConditionGroupConfigurator.svelte

packages/common/src/lib/widgets/service-data-table/advanced-filters/ConditionGroupConfigurator.svelte
<script lang="ts">
import { type ApiFieldDescription } from '$lib/api/domain';
import { getI18n } from '$lib/api/managers/i18n';
import {
ConditionOperation,
isQueryConditionGroup,
LogicalOperator,
type QueryCondition,
type QueryConditionGroup,
} from '$lib/api/tools/query';
import { Button } from '$lib/components/shadcn/ui/button';
import ConditionConfigurator from './ConditionConfigurator.svelte';
import ConditionGroupConfigurator from './ConditionGroupConfigurator.svelte';
import type { QueryLayerI18n } from './query-layer.i18n';
import Plus from 'lucide-svelte/icons/plus';
import ListFilterPlus from 'lucide-svelte/icons/list-filter-plus';
import Trash2 from 'lucide-svelte/icons/trash-2';
import { Switch } from '$lib/components/shadcn/ui/switch';
import { cn } from '$lib/components/shadcn/utils';
interface Props {
i18n: QueryLayerI18n;
fields: ApiFieldDescription[];
condition: QueryConditionGroup;
allowGroups?: boolean;
class?: string;
}
const {
condition = $bindable(),
allowGroups = false,
fields,
i18n: i18nConfig,
class: className,
}: Props = $props();
const i18n = getI18n(i18nConfig);
function getDefaultCondition(): QueryCondition {
return { field: fields[0], operation: ConditionOperation.EQUALS, value: null };
}
function addCondition() {
condition.conditions.push(getDefaultCondition());
}
function removeCondition(index: number) {
condition.conditions.splice(index, 1);
}
function handleAddConditionGroup() {
condition.conditions.push({
operator: LogicalOperator.AND,
conditions: [getDefaultCondition()],
});
}
</script>
<div class={cn('gv-space-y-2 gv-p-2', className)}>
<!--Operator switch-->
<div class="gv-flex gv-align-middle gv-mb-2">
<button
onclick={() => (condition.operator = LogicalOperator.AND)}
class={cn(
'gv-flex gv-align-middle gv-text-body',
condition.operator === LogicalOperator.AND ? 'gv-font-bold' : 'gv-opacity-60',
)}
data-test-id="Advanced-Filters-Condition-Operator-And"
>
{i18n('condition-operator-and')}
</button>
<Switch
bind:checked={
() => condition.operator === LogicalOperator.OR,
(c) => (condition.operator = c ? LogicalOperator.OR : LogicalOperator.AND)
}
class="gv-mr-1 gv-ml-1"
data-test-id="Advanced-Filters-Condition-Operator"
/>
<button
onclick={() => (condition.operator = LogicalOperator.OR)}
class={cn(
'gv-flex gv-align-middle gv-text-body',
condition.operator === LogicalOperator.OR ? 'gv-font-bold' : 'gv-opacity-60',
)}
data-test-id="Advanced-Filters-Condition-Operator-Or"
>
{i18n('condition-operator-or')}
</button>
</div>
{#each condition.conditions as subCondition, i (subCondition)}
{#if isQueryConditionGroup(condition.conditions[i])}
<div class="gv-flex gv-gap-1.5">
<ConditionGroupConfigurator
i18n={i18nConfig}
bind:condition={condition.conditions[i]}
{fields}
allowGroups
class="gv-border gv-border-dashed gv-flex-1 has-[+button:hover]:gv-border-primary-600"
/>
<Button title={i18n('delete-group')} onclick={() => removeCondition(i)} variant="link" class="gv-px-0">
<Trash2 class="gv-size-4" />
</Button>
</div>
{:else}
<ConditionConfigurator
i18n={i18nConfig}
onRemove={() => removeCondition(i)}
bind:condition={condition.conditions[i]}
{fields}
class="gv-w-full"
/>
{/if}
{/each}
<Button onclick={addCondition} variant="outline" data-test-id="Advanced-Filters-Add-Condition">
<Plus class="gv-size-4" />
{i18n('add-condition')}
</Button>
{#if allowGroups}
<Button onclick={handleAddConditionGroup} variant="outline" data-test-id="Advanced-Filters-Add-Group">
<ListFilterPlus class="gv-size-4" />
{i18n('add-group')}
</Button>
{/if}
</div>

packages/common/src/lib/widgets/service-data-table/advanced-filters/query-layer.i18n.ts

packages/common/src/lib/widgets/service-data-table/advanced-filters/query-layer.i18n.ts
import type { I18nRegistry } from '$lib/api/managers/i18n';
export const queryLayerI18n = {
'condition-operator-and': {
fr: 'ET',
nl: 'NL - ET',
},
'condition-operator-or': {
fr: 'OU',
nl: 'NL - OU',
},
title: {
fr: 'Query layer',
nl: 'NL - Query layer',
},
condition: {
fr: 'Condition N° {{conditionNumber}}',
nl: 'Voorwaarde N° {{conditionNumber}}',
},
search: {
fr: 'Recherche dans "{{name}}"',
nl: 'NL - Recherche dans "{{name}}"',
},
'attribute-filter': {
fr: 'Filtre attributaire',
nl: 'NL - Filtre attributaire',
},
'spatial-filter': {
fr: 'Filtre spatial',
nl: 'NL - Filtre spatial',
},
'missing-filter': {
fr: 'Veuillez remplir un filtre attributaire ou un filtre spatial.',
nl: 'NL - Veuillez remplir un filtre attributaire ou un filtre spatial.',
},
'delete-condition': {
fr: 'Surppimer la condition',
nl: 'Negeer de voorwaarde',
},
'add-condition': {
fr: 'Ajouter une condition',
nl: 'Voeg een voorwaarde toe',
},
group: {
fr: 'Groupe N° {{groupNumber}}',
nl: 'Groep N° {{groupNumber}}',
},
'add-group': {
fr: 'Ajouter un groupe',
nl: 'Voeg een groep',
},
'delete-group': {
fr: 'Supprimer le groupe',
nl: 'Groep verwijderen',
},
'apply-filter': {
fr: 'Appliquer le filtre',
nl: 'Filter toepassen',
},
results: {
fr: 'Résultats',
nl: 'Resultaten',
},
'condition-operation-EQUALS': {
fr: 'Egal',
nl: 'Gelijkwaardig',
},
'condition-operation-NOT_EQUALS': {
fr: 'Pas égal',
nl: 'Niet gelijk',
},
'condition-operation-CONTAINS': {
fr: 'Contient',
nl: 'Bevat',
},
'condition-operation-GREATER_THAN': {
fr: 'Plus grand',
nl: 'Groter',
},
'condition-operation-GREATER_THAN_OR_EQUAL': {
fr: 'Plus grand ou egal',
nl: 'Groter of gelijk',
},
'condition-operation-LESS_THAN': {
fr: 'Plus petit',
nl: 'Kleiner',
},
'condition-operation-LESS_THAN_OR_EQUAL': {
fr: 'Plus petit ou egal',
nl: 'Kleiner of gelijk',
},
} satisfies I18nRegistry;
export type QueryLayerI18n = typeof queryLayerI18n;

packages/common/src/lib/widgets/service-data-table/quick-draw-selector/index.ts

packages/common/src/lib/widgets/service-data-table/quick-draw-selector/index.ts
export * from './QuickDrawSelector.svelte';

packages/common/src/lib/widgets/service-data-table/quick-draw-selector/quick-draw-selector.model.ts

packages/common/src/lib/widgets/service-data-table/quick-draw-selector/quick-draw-selector.model.ts
import { type DrawCreateType } from '$lib/api/tools';
import type { Icon } from '$lib/api/icons';
export type QuickDrawSelectorItem = {
type: DrawCreateType;
icon: Icon;
};
export const quickDrawSelectorItems: QuickDrawSelectorItem[] = [
{
type: 'point',
icon: {
lucide: 'Dot',
},
},
{
type: 'polyline',
icon: {
lucide: 'Waypoints',
},
},
{
type: 'polygon',
icon: {
geoviewer: 'geoviewer-polygon',
},
},
];

packages/common/src/lib/widgets/service-data-table/quick-draw-selector/QuickDrawSelector.svelte

packages/common/src/lib/widgets/service-data-table/quick-draw-selector/QuickDrawSelector.svelte
<script lang="ts">
import { ToggleGroup, ToggleGroupItem } from '$lib/components/shadcn/ui/toggle-group';
import { quickDrawSelectorItems } from './quick-draw-selector.model';
import type { DrawCreateType } from '$lib/api/tools';
import { getMapManager } from '$lib/api/map';
import { initGraphicMapServiceConfiguration } from '$lib/api/managers/configuration';
import { onDestroy } from 'svelte';
import StringUtils from '$lib/api/utils/string.utils';
import type { ToggleSize, ToggleVariant } from '$lib/components/shadcn/ui/toggle';
import { Icon } from '$lib/components/icon';
import type { ApiFeature } from '$lib/api/feature';
import type { ServiceDataTableI18n } from '$lib/widgets/service-data-table/service-data-table.config';
import { getI18n } from '$lib/api/managers/i18n';
interface Props {
activeDraw?: DrawCreateType | null;
feature?: ApiFeature;
variant?: ToggleVariant;
size?: ToggleSize;
i18nConfig: ServiceDataTableI18n;
}
let { activeDraw = $bindable(), feature = $bindable(), variant, size, i18nConfig }: Props = $props();
const i18n = getI18n(i18nConfig);
const mapManager = getMapManager();
const drawFactory = mapManager.tools.draw;
const serviceId = StringUtils.uuid();
const graphicMapService = mapManager.addGraphicMapService(
initGraphicMapServiceConfiguration({
id: serviceId,
label: '',
toc: {
visible: false,
},
}),
);
const drawTool = drawFactory.create({ layer: graphicMapService });
$effect(() => {
if (!feature) {
graphicMapService.removeAll();
}
});
$effect(() => {
if (activeDraw) {
drawTool.create({
type: activeDraw,
onDrawComplete: (f: ApiFeature) => {
if (feature) {
drawTool.delete(feature);
}
feature = f;
activeDraw = null;
},
});
} else {
drawTool.stop();
}
});
onDestroy(() => {
feature = undefined;
drawTool.destroy();
mapManager.removeMapService(serviceId);
});
</script>
<ToggleGroup type="single" bind:value={activeDraw} {variant} {size}>
{#each quickDrawSelectorItems as item}
<ToggleGroupItem
class="gv-border-solid gv-border-[0.1rem] gv-border-primary/70 gv-px-1"
value={item.type}
title={i18n(`filter-on-${item.type}`)}
data-test-id={`Draw-Selector-${item.type}`}
>
<Icon icon={item.icon} class="gv-size-4" />
</ToggleGroupItem>
{/each}
</ToggleGroup>

packages/common/src/lib/widgets/service-data-table/service-data-table.i18n.ts

packages/common/src/lib/widgets/service-data-table/service-data-table.i18n.ts
import type { I18nRegistry } from '$lib/api/managers/i18n';
export const serviceDataTableI18n = {
title: {
fr: 'Tableau de résultats',
nl: 'NL - Tableau de résultats',
},
'advanced-filters': {
fr: 'Filtre avancé',
nl: 'NL - Filtre avancé',
},
'nothing-to-export': {
fr: "Il n'y a rien à exporter. Changer de filtre pour afficher des résultats dans le tableau",
nl: "NL - Il n'y a rien à exporter. Changer de filtre pour afficher des résultats dans le tableau",
},
'filter-on-point': {
fr: "Filtrer sur la localisation d'un point",
nl: "NL - Filtrer sur la localisation d'un point",
},
'filter-on-polyline': {
fr: "Filtrer sur le tracé d'une ligne",
nl: "NL - Filtrer sur le tracé d'une ligne",
},
'filter-on-polygon': {
fr: "Filtrer sur l'emprise d'un polygone",
nl: "NL - Filtrer sur l'emprise d'un polygone",
},
'table-selection-state': {
fr: '{{rows}} ligne(s) sur {{totalRows}} sélectionnée(s)',
nl: 'NL - {{rows}} ligne(s) sur {{totalRows}} sélectionnée(s)',
},
'table-page-state': {
fr: '{{first}} - {{last}} sur {{total}} lignes',
nl: 'NL - {{first}} - {{last}} sur {{total}} lignes',
},
'table-page-size': {
fr: 'Lignes par page',
nl: 'NL - Lignes par page',
},
'filter-on-map-extent': {
fr: 'Filtrer sur le cadrage de la carte',
nl: 'NL - Filtrer sur le cadrage de la carte',
},
'reset-spatial-filter': {
fr: 'Réinitialiser le filtre spatial',
nl: 'NL - Réinitialiser le filtre spatial',
},
'no-results': {
fr: 'Pas de résultats',
nl: 'NL - Pas de résultats',
},
'reset-all': {
fr: 'Réinitialiser tout',
nl: 'NL - Réinitialiser tout',
},
'reset-selection': {
fr: 'Vider la sélection',
nl: 'NL - Vider la sélection',
},
'select-all': {
fr: 'Sélectionner tous les éléments',
nl: 'NL - Sélectionner tous les éléments',
},
} satisfies I18nRegistry;

packages/common/src/lib/widgets/service-data-table/service-data-table.state.svelte.ts

packages/common/src/lib/widgets/service-data-table/service-data-table.state.svelte.ts
import type { WidgetStateProps } from '$lib/api/managers/widget/widget-declaration';
import type { Queryable } from '$lib/api/utils';
import { InMemoryFeaturePageQuery } from '$lib/widgets/service-data-table/service-data-table/in-memory-feature-page-query.svelte';
import type { ServiceDataTableTab } from '$lib/widgets/service-data-table/service-data-table/service-data-table.model';
export class ServiceDataTableState {
readonly itemStates = new Map<Queryable, ServiceDataTableItemState>();
constructor(private readonly props: WidgetStateProps) {}
public add(tab: ServiceDataTableTab) {
const service = tab.service;
if (this.itemStates.has(service)) {
return this.itemStates.get(service)!;
}
const state = new ServiceDataTableItemState(tab);
this.itemStates.set(service, state);
return state;
}
public get(service: Queryable) {
return this.itemStates.get(service);
}
public remove(item: ServiceDataTableItemState) {
this.itemStates.delete(item.tab.service);
}
}
export class ServiceDataTableItemState {
readonly pageQuery = $state<InMemoryFeaturePageQuery>()!;
constructor(public readonly tab: ServiceDataTableTab) {
this.pageQuery = new InMemoryFeaturePageQuery(this.tab.service, this.tab.allowSelection || false);
}
}

packages/common/src/lib/widgets/service-data-table/service-data-table/in-memory-feature-page-query.svelte.ts

packages/common/src/lib/widgets/service-data-table/service-data-table/in-memory-feature-page-query.svelte.ts
import type { ColumnFiltersState, PaginationState, RowSelectionState, SortingState } from '@tanstack/table-core';
import { LogicalOperator, type QueryCondition, type QueryConditionGroup } from '$lib/api/tools';
import type { ApiFeature } from '$lib/api/feature';
import { defaultOperation } from '$lib/api/domain';
import { type Queryable, queryState } from '$lib/api/utils';
import { showToast } from '$lib/components/toast/toast.utils';
import { getI18n } from '$lib/api/managers/i18n';
export class InMemoryFeaturePageQuery {
pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 10 });
sorting = $state<SortingState>([]);
filters = $state<ColumnFiltersState>([]);
selection = $state<RowSelectionState>({});
defaultFilter = {
operator: LogicalOperator.AND,
conditions: [],
};
additionalFilter = $state<QueryConditionGroup>(this.defaultFilter);
spatialFilter = $state<ApiFeature>();
allResults = $derived.by(() => {
return this.#query?.data ?? [];
});
i18n = getI18n();
constructor(
private readonly service: Queryable,
private readonly allowSelection: boolean,
) {}
readonly #filtersAsCondition = $derived.by<QueryConditionGroup>(() => {
const columnFilters = this.filters;
const conditions = columnFilters.map((filter) => {
const field = this.getField(filter.id);
const operation = field.defaultOperation ?? defaultOperation(field.type);
return {
value: filter.value as string | number,
field: field,
operation,
} satisfies QueryCondition;
});
return {
operator: LogicalOperator.AND,
conditions,
};
});
readonly #mergedCondition = $derived.by<QueryConditionGroup>(() => {
const filters = this.#filtersAsCondition;
const additional = this.additionalFilter;
if (additional) {
return {
operator: LogicalOperator.AND,
conditions: [filters, additional],
};
} else {
return filters;
}
});
readonly #query = queryState({
inputFn: () => ({ condition: this.#mergedCondition, sorting: this.sorting, spatialFilter: this.spatialFilter }),
queryFn: async ({ condition, sorting, spatialFilter, signal }) => {
const sort = sorting.map((s) => ({ field: s.id, order: s.desc ? 'desc' : 'asc' }) as const);
const queryResponse = await this.service.queryWithMetadata({
conditionGroup: condition,
feature: spatialFilter,
sort,
signal,
});
const features = queryResponse.features;
if (queryResponse.maxResultCountReached) {
showToast({
level: 'warning',
message: this.i18n('common.max-results-number-reached', { maxElements: features.length }),
});
}
return features;
},
});
readonly loading = $derived(this.#query.loading);
readonly rowCount = $derived.by(() => this.#query.data?.length ?? 0);
readonly content = $derived.by(() => {
const features = this.#query.data ?? [];
if (this.manualPagination) {
const index = this.pagination.pageIndex;
const size = this.pagination.pageSize;
if (features.length < size) {
return features;
}
return features.slice(index * size, index * size + size);
} else {
return features;
}
});
private getField(key: string) {
return this.service.fields.find((f) => f.key === key)!;
}
public onPaginationChange(state: PaginationState) {
this.pagination = state;
}
public onSortingChange(state: SortingState) {
this.sorting = state;
}
public onFiltersChange(state: ColumnFiltersState) {
this.filters = state;
this.pagination.pageIndex = 0;
}
public onSelectionChange(selection: RowSelectionState) {
this.selection = selection;
}
public resetSelection() {
this.selection = {};
}
public selectAll() {
this.selection = this.content.reduce((result, element) => Object.assign(result, { [element.id]: true }), {});
}
public get featuresSelection() {
return this.content.filter((f) => this.selection[f.id]);
}
public get manualPagination() {
return !this.allowSelection;
}
public reset() {
this.additionalFilter = this.defaultFilter;
this.filters = [];
this.spatialFilter = undefined;
}
public get selectionSize() {
return this.selection ? Object.entries(this.selection).filter(([_, selected]) => selected).length : 0;
}
}

packages/common/src/lib/widgets/service-data-table/service-data-table/service-data-table.model.ts

packages/common/src/lib/widgets/service-data-table/service-data-table/service-data-table.model.ts
import { type ApiFieldDescription, ApiFieldType } from '$lib/api/domain';
import type { ApiFeature } from '$lib/api/feature';
import type { ColumnDef } from '@tanstack/table-core';
import type { I18nManager } from '$lib/api/managers/i18n';
import { renderComponent } from '$lib/components/shadcn/ui/data-table';
import { Checkbox } from '$lib/components/shadcn/ui/checkbox';
import type { ComponentProps } from 'svelte';
import type ServiceDataTableTabContent from '$lib/widgets/service-data-table/ServiceDataTableTabContent.svelte';
import { isMapService, type Queryable } from '$lib/api/utils';
import { RenderComponentConfig } from '$lib/components/shadcn/ui/data-table/render-helpers';
import DisplayAttribute from '$lib/components/display-attribute/DisplayAttribute.svelte';
import type { InMemoryFeaturePageQuery } from './in-memory-feature-page-query.svelte';
import type { ApiMapService } from '$lib/api/mapservices';
import { type ApiSublayer, getTopParent } from '$lib/api/layers';
export type ServiceDataTableTab = Partial<ComponentProps<typeof ServiceDataTableTabContent>> & {
tabName: string;
service: Queryable;
tabId: string;
};
export function getTableId(queryable: ApiMapService | ApiSublayer): string {
if (isMapService(queryable)) {
return queryable.id;
} else {
const parent = getTopParent(queryable);
return `${parent.id}-${queryable.id}`;
}
}
export function mapToColumns(
fields: ApiFieldDescription[],
i18nManager: I18nManager,
allowSelection: boolean,
pageQuery: InMemoryFeaturePageQuery,
): ColumnDef<ApiFeature>[] {
const result: ColumnDef<ApiFeature>[] = [];
if (allowSelection) {
result.push({
id: 'select',
header: ({ table }) =>
renderComponent(Checkbox, {
checked: table.getIsAllPageRowsSelected(),
indeterminate: table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected(),
onCheckedChange: (value) => {
if (value) {
const selection = pageQuery.allResults.reduce(
(acc, current) => {
acc[current.id] = true;
return acc;
},
{} as Record<string, boolean>,
);
table.setRowSelection(() => selection);
} else {
table.setRowSelection(() => ({}));
}
},
'aria-label': 'Select all',
}),
cell: ({ row, table }) =>
renderComponent(Checkbox, {
checked: row.getIsSelected(),
onCheckedChange: (value) => {
table.setRowSelection((old) => {
if (!value) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete old[row.id];
} else {
old[row.id] = true;
}
return old;
});
},
'aria-label': 'Select row',
}),
enableSorting: false,
enableColumnFilter: false,
enableHiding: false,
});
}
for (const field of fields) {
// Skip geometry columns
if (field.type === ApiFieldType.GEOMETRY) {
continue;
}
result.push({
accessorKey: field.key,
accessorFn: (feature) => feature.attributes?.[field.key],
header: field.label,
cell: ({ row }) => {
const value = row.getValue(field.key);
if (value && field.type === ApiFieldType.TIMESTAMP) {
const date = new Date(Number(value));
return i18nManager.datetimeFormatter.format(date);
}
if (value && field.type === ApiFieldType.DATE) {
const date = new Date(Number(value));
return i18nManager.dateFormatter.format(date);
}
if (value && typeof value === 'string') {
return new RenderComponentConfig(DisplayAttribute, { field, value });
}
return value;
},
});
}
return result;
}

packages/common/src/lib/widgets/service-data-table/service-data-table/ServiceDataTable.svelte

packages/common/src/lib/widgets/service-data-table/service-data-table/ServiceDataTable.svelte
<script lang="ts">
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '$lib/components/shadcn/ui/table';
import { mapToColumns } from './service-data-table.model';
import { getMapManager } from '$lib/api/map';
import type { ApiFeature } from '$lib/api/feature';
import { createSvelteTable, FlexRender } from '$lib/components/shadcn/ui/data-table';
import { getCoreRowModel, getFilteredRowModel, getPaginationRowModel } from '@tanstack/table-core';
import Pagination from '$lib/components/pagination/Pagination.svelte';
import TableSortHeader from '$lib/components/api-data-table/TableSortHeader.svelte';
import { getI18n, getI18nManager } from '$lib/api/managers/i18n';
import { ApiSelect } from '$lib/components/api-select';
import DynamicInput from '$lib/components/dynamic-input/DynamicInput.svelte';
import type { InMemoryFeaturePageQuery } from './in-memory-feature-page-query.svelte';
import Loader from '$lib/components/common/Loader.svelte';
import type { ApiFieldDescription } from '$lib/api/domain';
import { cn } from '$lib/components/shadcn/utils';
import type { ServiceDataTableI18n } from '$lib/widgets/service-data-table/service-data-table.config';
interface Props {
fields: ApiFieldDescription[];
pageIndex: number;
pageSize: number;
pageQuery: InMemoryFeaturePageQuery;
i18n: ServiceDataTableI18n;
allowSelection?: boolean;
highlightOnOver?: boolean;
zoomOnHover?: boolean;
zoomOnSelect?: boolean;
onSelected?: (feature: ApiFeature) => void;
class?: string;
}
let {
fields,
pageIndex = $bindable(0),
pageSize = $bindable(10),
pageQuery,
i18n: i18nConfig,
allowSelection = false,
highlightOnOver = true,
zoomOnHover = false,
zoomOnSelect = true,
onSelected,
class: className,
}: Props = $props();
const mapManager = getMapManager();
const highlightManager = mapManager.tools.highlight;
const i18nManager = getI18nManager();
const i18n = getI18n(i18nConfig);
let currentFeature: ApiFeature | undefined;
function highlightFeature(feature: ApiFeature): void {
if (highlightOnOver) {
if (currentFeature) {
unhighlightFeature(currentFeature);
}
currentFeature = feature;
highlightManager.highlightFeature(feature);
if (zoomOnHover) {
mapManager.tools.zoom.zoomToFeature(feature);
}
}
}
function unhighlightFeature(feature: ApiFeature | undefined): void {
if (highlightOnOver) {
const featureToRemove = feature ?? currentFeature;
if (featureToRemove) {
highlightManager.unhighlightFeature(featureToRemove);
currentFeature = undefined;
}
}
}
function selectFeature(feature: ApiFeature): void {
if (zoomOnSelect) {
mapManager.tools.zoom.zoomToFeature(feature);
}
onSelected?.(feature);
}
const columns = $derived(mapToColumns(fields, i18nManager, allowSelection, pageQuery));
const table = createSvelteTable({
get data() {
return pageQuery.content;
},
get columns() {
return columns;
},
state: {
get pagination() {
return pageQuery.pagination;
},
get sorting() {
return pageQuery.sorting;
},
get columnFilters() {
return pageQuery.filters;
},
get rowSelection() {
return pageQuery.selection;
},
},
onSortingChange: (updater) => {
if (typeof updater === 'function') {
pageQuery.onSortingChange(updater(pageQuery.sorting));
} else {
pageQuery.onSortingChange(updater);
}
},
onPaginationChange: (updater) => {
if (typeof updater === 'function') {
pageQuery.onPaginationChange(updater(pageQuery.pagination));
} else {
pageQuery.onPaginationChange(updater);
}
},
manualFiltering: true,
onColumnFiltersChange: (updater) => {
if (typeof updater === 'function') {
pageQuery.onFiltersChange(updater(pageQuery.filters));
} else {
pageQuery.onFiltersChange(updater);
}
},
onRowSelectionChange: (updater) => {
if (typeof updater === 'function') {
pageQuery.onSelectionChange(updater(pageQuery.selection));
} else {
pageQuery.onSelectionChange(updater);
}
},
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: pageQuery.manualPagination,
get rowCount() {
return pageQuery.rowCount;
},
});
const pageState = $derived({
pageIndex: pageIndex,
pageSize: pageSize,
first: pageIndex * pageSize + 1,
last: pageIndex * pageSize + pageSize,
total: pageQuery.rowCount,
});
const selectionState = $derived({
rows: pageQuery.selectionSize,
totalRows: pageQuery.rowCount,
});
const pageSizeOptions = [10, 20, 50].map((v) => ({ label: v.toString(), value: v }));
function getField(key: string) {
return fields.find((f) => f.key === key)!;
}
</script>
<div class={cn('gv-flex gv-flex-col gv-relative', className)}>
{#if pageQuery.loading}
<div
class="gv-absolute gv-w-full gv-h-[calc(100%-1px)] gv-flex gv-justify-center gv-items-center gv-bg-muted/40 gv-z-10"
>
<Loader />
</div>
{/if}
<Table class="gv-flex-1 gv-overflow-auto gv-border-collapse gv-w-full" data-loading={pageQuery.loading}>
<TableHeader>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<TableRow class="gv-sticky gv-top-0 gv-z-[1] gv-bg-muted-600 hover:gv-bg-muted-600 gv-shadow-3lg">
{#each headerGroup.headers as header (header.id)}
<TableHead class="gv-px-0.5 gv-py-0.5 first:gv-pl-3 last:gv-pr-3">
{#if !header.isPlaceholder}
{#if header.column.getCanSort()}
<TableSortHeader
sorted={header.column.getIsSorted()}
onclick={header.column.getToggleSortingHandler()}
class="gv-text-sm gv-justify-start gv-font-bold gv-text-black gv-w-full gv-px-sm gv-py-xs gv-h-fit"
data-test-id={`Service-Datatable-${header.id}-Sort`}
>
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
</TableSortHeader>
{:else}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
{#if header.column.getCanFilter()}
{@const field = getField(header.column.id)}
{@const filterValue = header.column.getFilterValue() ?? ''}
<DynamicInput
{field}
size="sm"
bind:value={
() => filterValue, (v) => header.column.setFilterValue(v?.toString())
}
debounce={500}
class="gv-border-muted-300 focus-visible:gv-ring-0 gv-px-2.5 gv-py-1.5"
data-test-id={`Service-Datatable-${header.id}-Filter`}
/>
{/if}
{/if}
</TableHead>
{/each}
</TableRow>
{/each}
</TableHeader>
<TableBody>
{#each table.getRowModel().rows as row (row.id)}
<TableRow
data-state={row.getIsSelected() && 'selected'}
class="gv-cursor-pointer"
onmouseover={() => highlightFeature(row.original)}
onmouseleave={() => unhighlightFeature(row.original)}
onclick={() => selectFeature(row.original)}
>
{#each row.getVisibleCells() as cell (cell.id)}
<TableCell class="first:gv-pl-3">
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</TableCell>
{/each}
</TableRow>
{:else}
<TableRow>
<TableCell colspan={columns.length} class="gv-h-24 gv-text-center">{i18n('no-results')}</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
<footer
class="gv-w-full gv-bg-muted-400 gv-p-2.5 gv-flex gv-relative gv-items-center gv-justify-between gv-text-sm gv-font-bold"
>
<span>
{i18n('table-page-state', pageState)}
{#if allowSelection}
| {i18n('table-selection-state', selectionState)}
{/if}
</span>
<div class="gv-absolute -gv-translate-x-1/2 gv-start-1/2">
<Pagination
limitButtons
count={pageState.total === 0 ? 1 : pageState.total}
perPage={pageState.pageSize}
bind:page={pageIndex}
onPageChange={() => table.setPageIndex(pageIndex)}
size="sm"
/>
</div>
<div class="gv-flex gv-items-center gv-gap-2">
<span>{i18n('table-page-size')} :</span>
<ApiSelect
bind:value={pageSize}
options={pageSizeOptions}
variant="ghost"
size="sm"
class="gv-w-12 gv-px-1"
itemClass="gv-justify-center gv-px-0"
indicator={false}
/>
</div>
</footer>
</div>

packages/common/src/lib/widgets/service-data-table/ServiceDataTableTabContent.svelte

packages/common/src/lib/widgets/service-data-table/ServiceDataTableTabContent.svelte
<script lang="ts">
import { cn } from '$lib/components/shadcn/utils';
import { getI18n } from '$lib/api/managers/i18n';
import ExportDialog from '$lib/components/common/ExportDialog.svelte';
import { Button } from '$lib/components/shadcn/ui/button';
import SlidersHorizontal from 'lucide-svelte/icons/sliders-horizontal';
import ServiceDataTable from './service-data-table/ServiceDataTable.svelte';
import { debounceState, type Queryable } from '$lib/api/utils';
import AdvancedFiltersDialog from './advanced-filters/AdvancedFiltersDialog.svelte';
import QuickDrawSelector from '$lib/widgets/service-data-table/quick-draw-selector/QuickDrawSelector.svelte';
import { ExternalLink } from 'lucide-svelte';
import type { ApiFeature } from '$lib/api/feature';
import { Checkbox } from '$lib/components/shadcn/ui/checkbox';
import { Label } from '$lib/components/shadcn/ui/label';
import { getMapManager } from '$lib/api/map';
import { onDestroy } from 'svelte';
import type { ApiExtent } from '$lib/api/domain/api-extent.schema';
import MagicalSvelteRenderer from '$lib/components/magical-svelte-renderer/MagicalSvelteRenderer.svelte';
import type { AnyRenderConfig } from '$lib/components/magical-svelte-renderer/magical-svelte-renderer-helpers';
import type { ServiceDataTableI18n } from '$lib/widgets/service-data-table/service-data-table.config';
import type {
ServiceDataTableItemState,
ServiceDataTableState,
} from '$lib/widgets/service-data-table/service-data-table.state.svelte';
import { showToast } from '$lib/components/toast/toast.utils';
interface Props {
name: string;
service: Queryable;
i18n: ServiceDataTableI18n;
itemState: ServiceDataTableItemState;
widgetState: ServiceDataTableState;
allowExport: boolean;
allowAdvancedFilters: boolean;
allowSpatialFilter: boolean;
defaultSpatialFilter?: ApiFeature;
zoomOnSelect: boolean;
highlightOnOver: boolean;
zoomOnHover: boolean;
class?: string | undefined | boolean;
additionalHeaderContent?: AnyRenderConfig[];
allowAdvancedFiltersReset?: boolean;
allowSpatialFilterReset?: boolean;
allowExtentFilter?: boolean;
allowResetAll?: boolean;
disableAllFilters?: boolean;
allowSelection?: boolean;
addDefaultConditionIfEmpty?: boolean;
}
let {
name,
service,
i18n: i18nConfig,
itemState,
widgetState,
allowExport,
allowAdvancedFilters,
allowSpatialFilter,
defaultSpatialFilter,
zoomOnSelect,
highlightOnOver,
zoomOnHover,
class: className,
additionalHeaderContent,
allowAdvancedFiltersReset = true,
allowSpatialFilterReset = true,
allowExtentFilter = true,
allowResetAll = true,
disableAllFilters = false,
allowSelection = false,
addDefaultConditionIfEmpty = true,
}: Props = $props();
const i18n = getI18n(i18nConfig);
const mapManager = getMapManager();
const featureFactory = mapManager.tools.featureFactory;
const pageQuery = itemState.pageQuery;
let quickDrawSelectorFeature = $state<ApiFeature | undefined>();
let exportDialogOpen = $state<boolean>(false);
let filterOnMapExtent = $state<boolean>(false);
let currentMapExtent = $state<ApiExtent>(mapManager.getMapExtent());
const unsub = mapManager.tools.events.watch('EXTENT', (extent) => {
currentMapExtent = extent;
});
const debouncedCurrentMapExtent = debounceState(() => currentMapExtent, 500);
const mapExtentFeature = $derived.by(() => {
const extent = debouncedCurrentMapExtent.debounced;
if (!filterOnMapExtent || !extent) {
return;
}
const coords = [
[extent.xmin, extent.ymin],
[extent.xmax, extent.ymin],
[extent.xmax, extent.ymax],
[extent.xmin, extent.ymax],
[extent.xmin, extent.ymin],
];
return featureFactory.createPolygon({
wkid: extent.wkid,
coords: [coords],
});
});
$effect(() => {
if (defaultSpatialFilter) {
pageQuery.spatialFilter = defaultSpatialFilter;
} else if (quickDrawSelectorFeature) {
pageQuery.spatialFilter = quickDrawSelectorFeature;
} else if (mapExtentFeature) {
pageQuery.spatialFilter = mapExtentFeature;
} else {
pageQuery.spatialFilter = undefined;
}
});
function resetAllFilters() {
pageQuery.reset();
resetSpatialFilter();
filterOnMapExtent = false;
}
function resetSpatialFilter() {
quickDrawSelectorFeature = undefined;
}
function openExportDialog() {
if (pageQuery.allResults.length === 0) {
showToast({ level: 'warning', message: i18n('nothing-to-export') });
return;
}
exportDialogOpen = true;
}
onDestroy(() => {
unsub();
widgetState.remove(itemState);
});
</script>
<div
class={cn(
'gv-p-5 gv-flex-1 gv-overflow-auto gv-flex gv-flex-col gv-gap-5 gv-bg-background gv-pointer-events-auto',
className,
)}
>
<div class="gv-flex gv-justify-between">
<div class="gv-flex gv-items-center gv-gap-2.5">
{#if additionalHeaderContent}
{#each additionalHeaderContent as content}
<MagicalSvelteRenderer {content} />
{/each}
{/if}
{#if allowSelection}
<Button
variant="outline"
class="gv-border-primary/70"
data-test-id="Service-Datatable-Select-All"
onclick={() => pageQuery.selectAll()}
size="sm"
>
{i18n('select-all')}
</Button>
<Button
variant="outline"
class="gv-border-primary/70"
data-test-id="Service-Datatable-Empty-Selection"
onclick={() => pageQuery.resetSelection()}
size="sm"
>
{i18n('reset-selection')}
</Button>
{/if}
</div>
<div class="gv-flex gv-items-center gv-gap-2.5">
{#if !disableAllFilters && allowExtentFilter}
<Checkbox
data-test-id="Service-Datatable-filter-on-map-extent-checkbox"
bind:checked={filterOnMapExtent}
id="filterOnMapExtent"
/>
<Label for="filterOnMapExtent">{i18n('filter-on-map-extent')}</Label>
{/if}
{#if !disableAllFilters && allowSpatialFilter}
<QuickDrawSelector {i18nConfig} bind:feature={quickDrawSelectorFeature} />
{#if allowSpatialFilterReset}
<Button
data-test-id="Service-Datatable-reset-spatial-filter-button"
onclick={() => resetSpatialFilter()}
class="gv-text-foreground"
variant="link"
size="sm"
>
{i18n('reset-spatial-filter')}
</Button>
{/if}
{/if}
{#if !disableAllFilters && allowAdvancedFilters}
<AdvancedFiltersDialog
title={i18n('advanced-filters')}
bind:condition={pageQuery.additionalFilter}
fields={service.fields}
allowGroups
allowReset={allowAdvancedFiltersReset}
{addDefaultConditionIfEmpty}
>
<Button
variant="outline"
class="gv-border-primary/70"
data-test-id="Service-Datatable-open-advanced-filters"
size="sm"
>
{i18n('advanced-filters')}
<SlidersHorizontal class="gv-size-4" />
</Button>
</AdvancedFiltersDialog>
{/if}
{#if !disableAllFilters && allowResetAll}
<Button
data-test-id="Service-Datatable-reset-button"
onclick={() => resetAllFilters()}
class="gv-text-foreground"
variant="link"
size="sm"
>
{i18n('reset-all')}
</Button>
{/if}
{#if allowExport}
<Button
onclick={openExportDialog}
variant="outline"
class="gv-border-primary/70"
data-test-id="Service-Datatable-open-export"
size="sm"
>
{i18n('common.export')}
<ExternalLink class="gv-size-4" />
</Button>
<ExportDialog bind:open={exportDialogOpen} exportFileName={name} features={pageQuery.allResults} />
{/if}
</div>
</div>
<ServiceDataTable
{zoomOnSelect}
{highlightOnOver}
{zoomOnHover}
{pageQuery}
{allowSelection}
bind:pageIndex={pageQuery.pagination.pageIndex}
bind:pageSize={pageQuery.pagination.pageSize}
fields={service.fields}
i18n={i18nConfig}
class="gv-size-full gv-overflow-auto"
/>
</div>

packages/common/src/lib/widgets/service-data-table/ServiceDataTableWidget.svelte

packages/common/src/lib/widgets/service-data-table/ServiceDataTableWidget.svelte
<script lang="ts">
import { getI18n } from '$lib/api/managers/i18n';
import { KeyboardEventKey } from '$lib/api/utils';
import { cn } from '$lib/components/shadcn/utils';
import X from 'lucide-svelte/icons/x';
import { SvelteMap } from 'svelte/reactivity';
import ServiceDataTableTabContent from './ServiceDataTableTabContent.svelte';
import type { ServiceDataTableTab } from './service-data-table/service-data-table.model';
import type { ServiceDataTableProps } from './service-data-table.declaration';
const { fullConfig, reference, state: widgetState }: ServiceDataTableProps = $props();
const { config } = fullConfig;
const {
zoomOnSelect,
highlightOnOver,
zoomOnHover,
allowExport,
allowAdvancedFilters,
allowSpatialFilter,
allowAdvancedFiltersReset,
allowSpatialFilterReset,
} = config;
const i18n = getI18n(fullConfig.i18n);
let activePanel = $state<string>();
const resultsTabs = $state(new SvelteMap<string, ServiceDataTableTab>());
function focusSearch(searchId: string) {
activePanel = searchId;
}
export function closePanel(tabId: string, event?: Event) {
if (event) {
event.stopPropagation();
}
resultsTabs.delete(tabId);
if (resultsTabs.size > 0 && tabId == activePanel) {
const tab = resultsTabs.keys().next().value;
if (tab) {
focusSearch(tab);
}
}
// TODO This is a workaround to close the widget when there are no more results
if (resultsTabs.size == 0) {
reference.deactivate();
}
}
export function closeAllPanelsForServiceId(serviceId: string): void {
Array.from(resultsTabs.keys())
.filter((panelId) => panelId === serviceId || panelId.startsWith(`${serviceId}-`))
.forEach((panelIdToClose) => closePanel(panelIdToClose));
}
export function closeAllPanels() {
for (const key of resultsTabs.keys()) {
resultsTabs.delete(key);
}
}
export function openDataTable(tab: ServiceDataTableTab) {
resultsTabs.set(tab.tabId, tab);
activePanel = tab.tabId;
}
export function openDataTables(tabs: ServiceDataTableTab[]) {
tabs.forEach((tab) => resultsTabs.set(tab.tabId, tab));
activePanel = tabs[0].tabId;
}
function onTabKeydown(event: KeyboardEvent, tabName: string) {
if (event.key === KeyboardEventKey.Space || event.key === KeyboardEventKey.Enter) {
focusSearch(tabName);
}
}
</script>
<div class="gv-size-full gv-overflow-auto gv-flex gv-flex-col">
<ul
class="gv-m-0 gv-flex gv-flex-wrap gv-pl-0 gv-mb-0 gv-list-none gv-border-b gv-border-b-muted gv-absolute gv-top-[-1.60rem]"
>
{#each Array.from(resultsTabs.keys()) as tabId (tabId)}
{@const tabResult = resultsTabs.get(tabId)}
<li class={cn('gv-flex gv-mb-[-1px]')}>
<div
title={tabResult?.tabName}
class={cn(
'gv-border gv-rounded-t-md gv-block gv-p-2 gv-pl-4 gv-cursor-pointer',
'gv-max-w-[200px] gv-whitespace-nowrap gv-overflow-hidden gv-flex gv-items-center gv-justify-between',
tabId === activePanel
? 'gv-text-grey-800 gv-bg-background gv-border-muted gv-border-b-transparent'
: 'gv-border-transparent gv-bg-grey-100 gv-text-grey-500/60 hover:gv-border-grey-200',
)}
role="button"
tabindex="0"
onclick={() => focusSearch(tabId)}
onkeydown={(e) => onTabKeydown(e, tabId)}
data-test-id={`Service-Datatable-tab-${tabId}`}
>
<span class="gv-mr-3 gv-truncate">{tabResult?.tabName}</span>
<button onclick={(evt) => closePanel(tabId, evt)} title={i18n('common.close')}>
<X class="gv-size-4" />
</button>
</div>
</li>
{/each}
</ul>
{#each Array.from(resultsTabs.values()) as tab (tab)}
{@const itemState = widgetState.add(tab)}
<ServiceDataTableTabContent
name={tab.tabName}
i18n={fullConfig.i18n}
{widgetState}
{itemState}
{allowExport}
{allowAdvancedFilters}
{allowSpatialFilter}
{zoomOnSelect}
{highlightOnOver}
{zoomOnHover}
{allowAdvancedFiltersReset}
{allowSpatialFilterReset}
class={activePanel !== tab.tabId && 'gv-hidden'}
{...tab}
/>
{/each}
</div>

Aller plus loin