Repository: geoadmin/web-mapviewer
Pull request #1273: PB-165: Group providers by base URL
Author: login: ismailsunni
Labels: name: feature
Created: 2025-03-20T09:05:42Z | Merged: 2025-04-30T09:19:26Z


[Test link](https://sys-map.dev.bgdi.ch/preview/feat-pb-165-grouping-layers-same-domain/index.html)

Preview:
![image](https://github.com/user-attachments/assets/d4498b05-1f80-49a3-aa59-0567310335cc)
![image](https://github.com/user-attachments/assets/b9b75c6d-466a-4e18-be34-80fae602864b)


[Test link](https://sys-map.dev.bgdi.ch/preview/feat-pb-165-grouping-layers-same-domain/index.html)


Diff:
diff --git a/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue b/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
index 7a28345d5e..1dde84721f 100644
--- a/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
+++ b/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
@@ -1,47 +1,259 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
+import { getLongestCommonPrefix } from '@/utils/utils.js'
 
-const { showProviders, providers } = defineProps({
+/** @enum */
+const NodeType = {
+    GROUP: 'group',
+    SUBGROUP: 'sub-group',
+    URL: 'url',
+}
+
+/**
+ * @typedef {Object} Provider
+ * @property {string} url - The URL of the provider.
+ * @property {string} htmlDisplay - The display name of the provider.
+ * @property {string} emphasize - The emphasized text to highlight in the provider name.
+ */
+
+/**
+ * @typedef {Object} ProviderTreeNode
+ * @property {string} id - The unique identifier for the node.
+ * @property {string} name - The display name of the node.
+ * @property {NodeType} type - The type of the node (e.g. `'group'`, `'url'`).
+ * @property {string} [url] - The URL of the node (only for `'url'` type).
+ * @property {boolean} emphasize - The emphasized text to highlight in the provider name.
+ * @property {boolean} [expanded] - Whether the group node is expanded (only for `'group'` type).
+ * @property {ProviderTreeNode[]} [children] - The child nodes (only for `'group'` type).
+ */
+
+const { showProviders, groupedProviders, filterApplied, filterText } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
+    filterText: {
+        type: String,
+        default: '',
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+/**
+ * Builds a tree node structure from a base URL and an array of providers.
+ *
+ * This function creates a hierarchical structure of nodes. If there is only one provider, it
+ * returns a URL node. If there are multiple providers, it groups them by their common prefix and
+ * creates a group node with sub-group nodes for each unique prefix.
+ *
+ * @param {string} baseUrl - The base URL for the providers.
+ * @param {Provider[]} providers - An array of provider objects.
+ * @returns {ProviderTreeNode} - The constructed tree node.
+ */
+function buildTreeNode(baseUrl, providers) {
+    if (providers.length === 1) {
+        return {
+            id: baseUrl,
+            name: providers[0].htmlDisplay,
+            type: NodeType.URL,
+            url: providers[0].url,
+            emphasize: providers[0].emphasize,
+        }
+    }
+
+    const urls = providers.map((provider) => provider.url)
+    const commonPrefix = getLongestCommonPrefix(urls)
+    const subGroups = {}
+
+    providers.forEach((provider) => {
+        const relativeUrl = provider.htmlDisplay.replace(commonPrefix ?? baseUrl, '')
+        // We group by the first part of the relative URL (good enough for our cases)
+        const subGroupKey = relativeUrl.split('/')[0]
+
+        if (!subGroups[subGroupKey]) {
+            subGroups[subGroupKey] = []
+        }
+        subGroups[subGroupKey].push({
+            ...provider,
+            relativeUrl: relativeUrl.replace(subGroupKey + '/', ''),
+        })
+    })
+
+    return {
+        id: baseUrl,
+        name: commonPrefix ?? baseUrl,
+        type: NodeType.GROUP,
+        expanded: filterApplied,
+        emphasize: filterText,
+        children: Object.entries(subGroups).map(([key, subGroupProviders]) => {
+            if (subGroupProviders.length === 1) {
+                return {
+                    id: subGroupProviders[0].url,
+                    name: `${key}/${subGroupProviders[0].relativeUrl}`,
+                    type: NodeType.URL,
+                    url: subGroupProviders[0].url,
+                    emphasize: subGroupProviders[0].emphasize,
+                }
+            }
+            return {
+                id: `${baseUrl}-${key}`,
+                name: key,
+                type: NodeType.SUBGROUP,
+                expanded: false, // Perhaps we want to expand this if there is a matching filter
+                emphasize: filterText,
+                children: subGroupProviders.map((provider) => ({
+                    id: provider.url,
+                    name: provider.relativeUrl,
+                    type: NodeType.URL,
+                    url: provider.url,
+                    emphasize: provider.emphasize,
+                })),
+            }
+        }),
+    }
+}
+
+/**
+ * Assigns a `tabindex` property to each node in the tree data using a Depth-First Search (DFS)
+ * strategy. This ensures that the tab order matches the visual order of the nodes.
+ *
+ * @param {ProviderTreeNode[]} treeData - The tree data containing nodes to which `tabindex` will be
+ *   assigned.
+ */
+function addTabIndex(treeData) {
+    let currentIndex = 0
+
+    treeData.forEach((node) => {
+        currentIndex = assignTabIndex(node, currentIndex)
+    })
+
+    maxTabIndex.value = currentIndex - 1
+}
+
+/**
+ * Recursively assigns `tabindex` properties to a node and its children.
+ *
+ * @param {ProviderTreeNode} node - The current node to process.
+ * @param {number} startIndex - The starting index for the current node.
+ * @returns {number} The next available index after processing the node and its children.
+ */
+function assignTabIndex(node, startIndex) {
+    node.tabindex = startIndex++
+    if (node.children && node.children.length > 0) {
+        node.children.forEach((child) => {
+            startIndex = assignTabIndex(child, startIndex)
+        })
+    }
+    return startIndex
+}
 
-function goToPrevious(currentKey) {
-    if (currentKey === 0) {
+// Watches
+watch(
+    () => groupedProviders,
+    (newGroupedProviders) => {
+        treeData.length = 0 // Clear the existing treeData
+        Object.entries(newGroupedProviders).forEach(([baseUrl, providers]) => {
+            treeData.push(buildTreeNode(baseUrl, providers))
+        })
+        addTabIndex(treeData)
+    }
+)
+
+// Lifecycle hooks
+onMounted(() => {
+    Object.entries(groupedProviders).forEach(([baseUrl, providers]) => {
+        treeData.push(buildTreeNode(baseUrl, providers))
+    })
+    addTabIndex(treeData)
+})
+
+// Methods
+function toggleNode(node) {
+    if (node.type === NodeType.GROUP || node.type === NodeType.SUBGROUP) {
+        node.expanded = !node.expanded
+    }
+}
+
+function expandNode(node) {
+    if (node.type === NodeType.GROUP || node.type === NodeType.SUBGROUP) {
+        node.expanded = true
+    }
+}
+
+function collapseNode(node) {
+    if (node.type === NodeType.GROUP || node.type === NodeType.SUBGROUP) {
+        node.expanded = false
+    }
+}
+
+function emitProviderSelection(url) {
+    emit('chooseProvider', url)
+}
+
+function goToPrevious(currentTaxIndex) {
+    if (currentTaxIndex === 0) {
         return
     }
-    const key = currentKey - 1
-    providerList.value.querySelector(`[tabindex="${key}"]`).focus()
+    let key = currentTaxIndex - 1
+    while (key >= 0) {
+        const element = providerList.value.querySelector(`[tabindex="${key}"]`)
+        if (element && element.offsetParent !== null) {
+            // Check if the element is visible
+            element.focus()
+            element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) // Ensure the element is visible
+            break
+        }
+        key--
+    }
 }
 
-function goToNext(currentKey) {
-    if (currentKey >= providers.length - 1) {
+function goToNext(currentTabIndex) {
+    if (currentTabIndex >= maxTabIndex.value) {
         return
     }
-    const key = currentKey + 1
-    providerList.value.querySelector(`[tabindex="${key}"]`).focus()
+    let key = currentTabIndex + 1
+    while (key <= maxTabIndex.value) {
+        const element = providerList.value.querySelector(`[tabindex="${key}"]`)
+        if (element && element.offsetParent !== null) {
+            // Check if the element is visible
+            element.focus()
+            element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) // Ensure the element is visible
+            break
+        }
+        key++
+    }
 }
 
 function goToFirst() {
-    providerList.value.querySelector('[tabindex="0"]').focus()
+    const element = providerList.value.querySelector('[tabindex="0"]')
+    if (element) {
+        element.focus()
+        element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) // Ensure the element is visible
+    }
 }
 
 function goToLast() {
-    providerList.value.querySelector(`[tabindex="${providers.length - 1}"]`).focus()
+    // Find the last shown element, it might be not the maxtabindex (e.g. a group)
+    goToPrevious(maxTabIndex.value + 1)
 }
 
 defineExpose({ goToFirst })
@@ -54,30 +266,146 @@ defineExpose({ goToFirst })
     >
         <div
             ref="providerList"
-            class="providers-list"
             data-cy="import-provider-list"
         >
-            <div
-                v-for="(provider, key) in providers"
-                :key="provider"
-                :tabindex="key"
-                class="providers-list-item px-2 py-1 text-nowrap"
-                @keydown.up.prevent="goToPrevious(key)"
-                @keydown.down.prevent="() => goToNext(key)"
-                @keydown.home.prevent="goToFirst"
-                @keydown.end.prevent="goToLast"
-                @keydown.esc.prevent="emit('hide')"
-                @keydown.enter.prevent="emit('chooseProvider', provider.url)"
-                @click="emit('chooseProvider', provider.url)"
+            <template
+                v-for="node in treeData"
+                :key="node.id"
             >
-                <TextSearchMarker
-                    :text="provider.htmlDisplay"
-                    :search="provider.emphasize"
-                />
-            </div>
+                <!-- The first level container  -->
+                <div :data-cy="`import-provider-${node.type}`">
+                    <div
+                        v-if="node.type === NodeType.GROUP"
+                        class="providers-header px-2 py-1 text-nowrap cursor-pointer fw-bold position-sticky top-0 z-2 w-100"
+                        :tabindex="node.tabindex"
+                        @click="toggleNode(node)"
+                        @keydown.right.prevent="expandNode(node)"
+                        @keydown.left.prevent="collapseNode(node)"
+                        @keydown.up.prevent="goToPrevious(node.tabindex)"
+                        @keydown.down.prevent="goToNext(node.tabindex)"
+                        @keydown.home.prevent="goToFirst"
+                        @keydown.end.prevent="goToLast"
+                        @keydown.esc.prevent="emit('hide')"
+                    >
+                        <font-awesome-icon
+                            :icon="['fas', node.expanded ? 'caret-down' : 'caret-right']"
+                        />
+                        <TextSearchMarker
+                            class="ms-1"
+                            :text="node.name"
+                            :search="node.emphasize"
+                        />
+                    </div>
+                    <div
+                        v-if="node.type === NodeType.GROUP && node.expanded"
+                        class="ps-2 ms-3"
+                    >
+                        <template
+                            v-for="child in node.children"
+                            :key="child.id"
+                        >
+                            <!-- The second level container  -->
+                            <div :data-cy="`import-provider-${child.type}`">
+                                <div
+                                    v-if="child.type === NodeType.SUBGROUP"
+                                    class="providers-header px-2 py-1 text-nowrap cursor-pointer fw-bold position-sticky top-3 z-1 w-100"
+                                    :tabindex="child.tabindex"
+                                    @click="toggleNode(child)"
+                                    @keydown.right.prevent="expandNode(child)"
+                                    @keydown.left.prevent="collapseNode(child)"
+                                    @keydown.up.prevent="goToPrevious(child.tabindex)"
+                                    @keydown.down.prevent="goToNext(child.tabindex)"
+                                    @keydown.home.prevent="goToFirst"
+                                    @keydown.end.prevent="goToLast"
+                                    @keydown.esc.prevent="emit('hide')"
+                                >
+                                    <font-awesome-icon
+                                        :icon="[
+                                            'fas',
+                                            child.expanded ? 'caret-down' : 'caret-right',
+                                        ]"
+                                    />
+                                    <TextSearchMarker
+                                        class="ms-1"
+                                        :text="child.name"
+                                        :search="child.emphasize"
+                                    />
+                                </div>
+                                <div
+                                    v-if="child.type === NodeType.SUBGROUP && child.expanded"
+                                    class="ps-2 ms-3 cursor-pointer"
+                                >
+                                    <div
+                                        v-for="grandChild in child.children"
+                                        :key="grandChild.id"
+                                        class="px-2 py-1 text-nowrap providers-item"
+                                        data-cy="import-provider-item"
+                                        :tabindex="grandChild.tabindex"
+                                        @click="emitProviderSelection(grandChild.url)"
+                                        @keydown.enter.prevent="
+                                            emitProviderSelection(grandChild.url)
+                                        "
+                                        @keydown.space.prevent="
+                                            emitProviderSelection(grandChild.url)
+                                        "
+                                        @keydown.up.prevent="goToPrevious(grandChild.tabindex)"
+                                        @keydown.down.prevent="goToNext(grandChild.tabindex)"
+                                        @keydown.home.prevent="goToFirst"
+                                        @keydown.end.prevent="goToLast"
+                                        @keydown.esc.prevent="emit('hide')"
+                                    >
+                                        <TextSearchMarker
+                                            :text="grandChild.name"
+                                            :search="grandChild.emphasize"
+                                        />
+                                    </div>
+                                </div>
+                                <div
+                                    v-else-if="child.type === NodeType.URL"
+                                    class="px-2 py-1 text-nowrap cursor-pointer providers-item"
+                                    data-cy="import-provider-item"
+                                    :tabindex="child.tabindex"
+                                    @click="emitProviderSelection(child.url)"
+                                    @keydown.enter.prevent="emitProviderSelection(child.url)"
+                                    @keydown.space.prevent="emitProviderSelection(child.url)"
+                                    @keydown.up.prevent="goToPrevious(child.tabindex)"
+                                    @keydown.down.prevent="goToNext(child.tabindex)"
+                                    @keydown.home.prevent="goToFirst"
+                                    @keydown.end.prevent="goToLast"
+                                    @keydown.esc.prevent="emit('hide')"
+                                >
+                                    <TextSearchMarker
+                                        :text="child.name"
+                                        :search="child.emphasize"
+                                    />
+                                </div>
+                            </div>
+                        </template>
+                    </div>
+                    <div
+                        v-else-if="node.type === NodeType.URL"
+                        class="px-2 py-1 text-nowrap cursor-pointer providers-item"
+                        data-cy="import-provider-item"
+                        :tabindex="node.tabindex"
+                        @click="emitProviderSelection(node.url)"
+                        @keydown.enter.prevent="emitProviderSelection(node.url)"
+                        @keydown.space.prevent="emitProviderSelection(node.url)"
+                        @keydown.up.prevent="goToPrevious(node.tabindex)"
+                        @keydown.down.prevent="goToNext(node.tabindex)"
+                        @keydown.home.prevent="goToFirst"
+                        @keydown.end.prevent="goToLast"
+                        @keydown.esc.prevent="emit('hide')"
+                    >
+                        <TextSearchMarker
+                            :text="node.name"
+                            :search="node.emphasize"
+                        />
+                    </div>
+                </div>
+            </template>
             <div
-                v-show="providers.length === 0"
-                class="providers-list-empty px-2 py-1"
+                v-show="treeData.length === 0"
+                class="px-2 py-1 user-select-none"
             >
                 <span>-</span>
             </div>
@@ -87,21 +415,20 @@ defineExpose({ goToFirst })
 
 <style lang="scss" scoped>
 @import '@/scss/webmapviewer-bootstrap-theme';
+// Adjust overflow to ensure focus outline is not clipped
 .providers-list-container {
     max-height: 13rem;
+    overflow: visible; // Allow focus outline to be fully visible
 
-    .providers-list {
-        .providers-list-empty {
-            user-select: none;
-        }
+    .providers-header {
+        background-color: $body-bg; // Ensure it matches the background, the bootstrap bg-body does not work with the focus/hover
+    }
 
-        .providers-list-item {
-            cursor: pointer;
-        }
-        .providers-list-item:focus,
-        .providers-list-item:hover {
-            background-color: $list-item-hover-bg-color;
-        }
+    .providers-item:focus,
+    .providers-item:hover,
+    .providers-header:focus,
+    .providers-header:hover {
+        background-color: $list-item-hover-bg-color;
     }
 }
 </style>
diff --git a/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderUrl.vue b/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderUrl.vue
index 2fa2e07953..cafa9f424d 100644
--- a/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderUrl.vue
+++ b/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderUrl.vue
@@ -22,7 +22,8 @@ const providerList = useTemplateRef('providerList')
 const isLoading = ref(false)
 const providerInput = useTemplateRef('providerInput')
 
-const { providers, showProviders, toggleProviders } = useProviders(url)
+const { groupedProviders, showProviders, filterApplied, toggleProviders, filterText } =
+    useProviders(url)
 const { loadCapabilities } = useCapabilities(url)
 
 // Computed properties
@@ -174,7 +175,7 @@ function hideProviders() {
                     v-if="
                         // We use v-if instead of v-show here in order to have bootstrap handling
                         // the rounded corner correctly
-                        !capabilitiesParsed && url.length && isValidUrl(url)
+                        !capabilitiesParsed && url?.length && isValidUrl(url)
                     "
                     id="urlConnectButton"
                     type="button"
@@ -205,8 +206,10 @@ function hideProviders() {
         <ProviderList
             id="urlProvidersList"
             ref="providerList"
-            :providers="providers"
+            :grouped-providers="groupedProviders"
             :show-providers="showProviders"
+            :filter-applied="filterApplied"
+            :filter-text="filterText"
             @choose-provider="chooseProvider"
             @hide="showProviders = false"
         />
diff --git a/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/external-providers.json b/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/external-providers.json
index 490d79a20d..035fb2c483 100644
--- a/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/external-providers.json
+++ b/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/external-providers.json
@@ -21,13 +21,19 @@
     "https://geodienste.ch/db/fruchtfolgeflaechen/deu",
     "https://geodienste.ch/db/fruchtfolgeflaechen/fra",
     "https://geodienste.ch/db/fruchtfolgeflaechen/ita",
-    "https://geodienste.ch/db/gewaesserraum/deu",
-    "https://geodienste.ch/db/gewaesserraum/fra",
-    "https://geodienste.ch/db/gewaesserraum/ita",
     "https://geodienste.ch/db/gefahrenkarten/deu",
     "https://geodienste.ch/db/gefahrenkarten/eng",
     "https://geodienste.ch/db/gefahrenkarten/fra",
     "https://geodienste.ch/db/gefahrenkarten/ita",
+    "https://geodienste.ch/db/gewaesserraum/deu",
+    "https://geodienste.ch/db/gewaesserraum/fra",
+    "https://geodienste.ch/db/gewaesserraum/ita",
+    "https://geodienste.ch/db/grundwasservorkommen/deu",
+    "https://geodienste.ch/db/grundwasservorkommen/fra",
+    "https://geodienste.ch/db/grundwasservorkommen/ita",
+    "https://geodienste.ch/db/inventar_wasserentnahmen/deu",
+    "https://geodienste.ch/db/inventar_wasserentnahmen/fra",
+    "https://geodienste.ch/db/inventar_wasserentnahmen/ita",
     "https://geodienste.ch/db/kantonale_ausnahmetransportrouten/deu",
     "https://geodienste.ch/db/kantonale_ausnahmetransportrouten/eng",
     "https://geodienste.ch/db/kantonale_ausnahmetransportrouten/fra",
@@ -77,12 +83,18 @@
     "https://geodienste.ch/db/planungszonen/deu",
     "https://geodienste.ch/db/planungszonen/fra",
     "https://geodienste.ch/db/planungszonen/ita",
+    "https://geodienste.ch/db/revitalisierung_fliessgewaesser/deu",
+    "https://geodienste.ch/db/revitalisierung_fliessgewaesser/fra",
+    "https://geodienste.ch/db/revitalisierung_fliessgewaesser/ita",
     "https://geodienste.ch/db/revitalisierung_seen/deu",
     "https://geodienste.ch/db/revitalisierung_seen/fra",
     "https://geodienste.ch/db/revitalisierung_seen/ita",
     "https://geodienste.ch/db/richtplanung_erneuerbare_energien/deu",
     "https://geodienste.ch/db/richtplanung_erneuerbare_energien/fra",
     "https://geodienste.ch/db/richtplanung_erneuerbare_energien/ita",
+    "https://geodienste.ch/db/rodungen_und_rodungsersatz/deu",
+    "https://geodienste.ch/db/rodungen_und_rodungsersatz/fra",
+    "https://geodienste.ch/db/rodungen_und_rodungsersatz/ita",
     "https://geodienste.ch/db/sanierung_wasserkraft/deu",
     "https://geodienste.ch/db/sanierung_wasserkraft/fra",
     "https://geodienste.ch/db/sanierung_wasserkraft/ita",
@@ -241,23 +253,14 @@
     "https://wms.geo.bs.ch/",
     "https://wms.geo.gl.ch/",
     "https://wms.geo.gr.ch/admineinteilung",
-    "https://wms.geo.gr.ch/admineinteilung",
-    "https://wms.geo.gr.ch/amtlichevermessung_stand",
     "https://wms.geo.gr.ch/amtlichevermessung_stand",
     "https://wms.geo.gr.ch/bauzonen_graubuenden",
     "https://wms.geo.gr.ch/belastetestandorte",
-    "https://wms.geo.gr.ch/belastetestandorte",
     "https://wms.geo.gr.ch/gewaesserschutz",
-    "https://wms.geo.gr.ch/gewaesserschutz",
-    "https://wms.geo.gr.ch/grundwasser",
     "https://wms.geo.gr.ch/grundwasser",
     "https://wms.geo.gr.ch/historischekarten",
-    "https://wms.geo.gr.ch/historischekarten",
-    "https://wms.geo.gr.ch/naturgefahren_erfassungsbereiche",
     "https://wms.geo.gr.ch/naturgefahren_erfassungsbereiche",
     "https://wms.geo.gr.ch/naturschutz",
-    "https://wms.geo.gr.ch/naturschutz",
-    "https://wms.geo.gr.ch/regionen",
     "https://wms.geo.gr.ch/regionen",
     "https://wms.geo.gr.ch/richtplan",
     "https://wms.geo.gr.ch/rrip_bregaglia",
@@ -271,12 +274,9 @@
     "https://wms.geo.gr.ch/rrip_unterengadin",
     "https://wms.geo.gr.ch/schutzwald",
     "https://wms.geo.gr.ch/seilbahnen",
-    "https://wms.geo.gr.ch/seilbahnen",
     "https://wms.geo.gr.ch/uebersichtsplan",
     "https://wms.geo.gr.ch/waldentwicklungsplan",
     "https://wms.geo.gr.ch/wildruhezonen",
-    "https://wms.geo.gr.ch/wildruhezonen",
-    "https://wms.geo.gr.ch/wildschutzgebiete",
     "https://wms.geo.gr.ch/wildschutzgebiete",
     "https://wms.geo.sh.ch/wms?SERVICE=WMS",
     "https://wms.geo.ti.ch/service",
@@ -345,20 +345,5 @@
     "https://www.geoservice.apps.be.ch/geoservice3/services/a42geo/of_transportation01_de_ms_wms/MapServer/WMSServer",
     "https://www.geoservice.apps.be.ch/geoservice3/services/a42geo/of_transportation02_de_ms_wms/MapServer/WMSServer",
     "https://www.ogd.stadt-zuerich.ch/mapproxy/wmts/1.0.0/WMTSCapabilities.xml",
-    "https://www.opensnowmap.org/OSnM_GetCapabilities.xml",
-    "https://geodienste.ch/db/gewaesserraum/deu",
-    "https://geodienste.ch/db/gewaesserraum/fra",
-    "https://geodienste.ch/db/gewaesserraum/ita",
-    "https://geodienste.ch/db/inventar_wasserentnahmen/deu",
-    "https://geodienste.ch/db/inventar_wasserentnahmen/fra",
-    "https://geodienste.ch/db/inventar_wasserentnahmen/ita",
-    "https://geodienste.ch/db/revitalisierung_fliessgewaesser/deu",
-    "https://geodienste.ch/db/revitalisierung_fliessgewaesser/fra",
-    "https://geodienste.ch/db/revitalisierung_fliessgewaesser/ita",
-    "https://geodienste.ch/db/grundwasservorkommen/deu",
-    "https://geodienste.ch/db/grundwasservorkommen/fra",
-    "https://geodienste.ch/db/grundwasservorkommen/ita",
-    "https://geodienste.ch/db/rodungen_und_rodungsersatz/deu",
-    "https://geodienste.ch/db/rodungen_und_rodungsersatz/ita",
-    "https://geodienste.ch/db/rodungen_und_rodungsersatz/fra"
+    "https://www.opensnowmap.org/OSnM_GetCapabilities.xml"
 ]
diff --git a/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/useProviders.js b/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/useProviders.js
index 2b6ddb3bb0..12caf5c0c3 100644
--- a/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/useProviders.js
+++ b/packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/useProviders.js
@@ -1,3 +1,4 @@
+import log from '@geoadmin/log'
 import { computed, ref } from 'vue'
 
 import providersJson from '@/modules/menu/components/advancedTools/ImportCatalogue/external-providers.json'
@@ -8,6 +9,16 @@ class Provider {
         this.htmlDisplay = url.replace(/access_key=.*/, '')
         this.emphasize = emphasize
     }
+
+    getBaseUrl() {
+        try {
+            const urlObj = new URL(this.url)
+            return urlObj.origin
+        } catch (e) {
+            log.error('Invalid URL:', this.url, e)
+            return null
+        }
+    }
 }
 
 class Providers {
@@ -33,13 +44,31 @@ export function useProviders(newUrl) {
 
     const providers = computed(() => allProviders.value.filter(url.value))
 
+    const groupedProviders = computed(() => {
+        const groups = {}
+        providers.value.forEach((provider) => {
+            const baseUrl = provider.getBaseUrl()
+            if (!groups[baseUrl]) {
+                groups[baseUrl] = []
+            }
+            groups[baseUrl].push(provider)
+        })
+        return groups
+    })
+
+    const filterText = computed(() => url.value)
+    const filterApplied = computed(() => url.value.length > 0)
+
     function toggleProviders() {
         showProviders.value = !showProviders.value
     }
 
     return {
+        groupedProviders,
         showProviders,
         providers,
         toggleProviders,
+        filterApplied,
+        filterText,
     }
 }
diff --git a/packages/mapviewer/src/utils/__tests__/utils.spec.js b/packages/mapviewer/src/utils/__tests__/utils.spec.js
index fb70ab2004..8a81b5a643 100644
--- a/packages/mapviewer/src/utils/__tests__/utils.spec.js
+++ b/packages/mapviewer/src/utils/__tests__/utils.spec.js
@@ -8,6 +8,7 @@ import {
     humanFileSize,
     parseUrlHashQuery,
     transformUrlMapToEmbed,
+    getLongestCommonPrefix,
 } from '@/utils/utils'
 
 describe('utils', () => {
@@ -133,4 +134,31 @@ describe('utils', () => {
             expect(humanFileSize(4.3 * 1024 * 1024)).to.be.equal('4.3 MB')
         })
     })
+
+    describe('getLongestCommonPrefix', () => {
+        it('returns the longest common prefix for a list of URLs', () => {
+            // Empty array
+            expect(getLongestCommonPrefix([])).to.equal('')
+
+            // Single URL
+            let urls = ['https://example.com/path/to/resource']
+            expect(getLongestCommonPrefix(urls)).to.equal('https://example.com/path/to/resource')
+
+            // Multiple URLs with common prefix
+            urls = [
+                'https://example.com/path/to/resource',
+                'https://example.com/path/to/another',
+                'https://example.com/path/to/something-else',
+            ]
+            expect(getLongestCommonPrefix(urls)).to.equal('https://example.com/path/to/')
+
+            // Multiple URLs with no common prefix
+            urls = ['https://example1.com/path', 'https://example2.com/path']
+            expect(getLongestCommonPrefix(urls)).to.equal('')
+
+            // Multiple URLs with one URL is the common prefix
+            urls = ['https://example.com/path', 'https://example.com/path/to']
+            expect(getLongestCommonPrefix(urls)).to.equal('https://example.com/path')
+        })
+    })
 })
diff --git a/packages/mapviewer/src/utils/utils.js b/packages/mapviewer/src/utils/utils.js
index 4fa965f127..683ca7474f 100644
--- a/packages/mapviewer/src/utils/utils.js
+++ b/packages/mapviewer/src/utils/utils.js
@@ -272,3 +272,53 @@ export function humanFileSize(size) {
 export function isInternalUrl(url) {
     return internalDomainRegex.test(url)
 }
+
+/**
+ * Get the longest common prefix of an array of URLs.
+ *
+ * This function finds the longest common prefix shared by all URLs in the array. The resulting
+ * prefix will always end with a `/` if it exists.
+ *
+ * @param {string[]} urls - An array of URLs to find the common prefix for.
+ * @returns {string} The longest common prefix of the URLs. Returns an empty string if no common
+ *   prefix exists.
+ */
+export function getLongestCommonPrefix(urls) {
+    if (!urls.length) {
+        return ''
+    }
+    if (urls.length === 1) {
+        return urls[0]
+    }
+    let prefix = urls[0]
+    for (const url of urls) {
+        while (!url.startsWith(prefix)) {
+            prefix = prefix.slice(0, -1)
+            if (!prefix) {
+                break
+            }
+        }
+    }
+
+    // If the prefix is already in the list of URLs, return it
+    // This assumes that the structure of the URLs is consistent
+    if (urls.includes(prefix)) {
+        return prefix
+    }
+
+    // Ensure the prefix is cut at a slash (not in the middle of text)
+    if (!prefix.endsWith('/')) {
+        const lastSlashIndex = prefix.lastIndexOf('/')
+        if (lastSlashIndex !== -1) {
+            prefix = prefix.slice(0, lastSlashIndex + 1)
+        }
+    }
+
+    // Ensure the prefix is at least the base URL
+    const baseUrl = new URL(urls[0]).origin + '/'
+    if (!prefix.startsWith(baseUrl)) {
+        return ''
+    }
+
+    return prefix
+}
diff --git a/packages/mapviewer/tests/cypress/tests-e2e/importToolMaps.cy.js b/packages/mapviewer/tests/cypress/tests-e2e/importToolMaps.cy.js
index 483bb7ec2f..df0901e182 100644
--- a/packages/mapviewer/tests/cypress/tests-e2e/importToolMaps.cy.js
+++ b/packages/mapviewer/tests/cypress/tests-e2e/importToolMaps.cy.js
@@ -23,10 +23,10 @@ describe('The Import Maps Tool', () => {
         cy.get('[data-cy="menu-tray-tool-section"]').should('be.visible').click()
         cy.get('[data-cy="menu-advanced-tools-import-catalogue"]').should('be.visible').click()
         cy.get('[data-cy="import-catalogue-input"]').should('be.visible').type('Wms.geo.AdmiN')
-        cy.get('[data-cy="import-provider-list"]')
-            .children()
+        // Item with the filtered text should be visible (it means the group is also expanded)
+        cy.get('[data-cy="import-provider-item"]')
             .contains('https://wms.geo.admin.ch')
-            .click()
+            .should('be.visible')
 
         //-----------------------------------------------------------------------------------------
         cy.log('Clear the external layer')
@@ -52,9 +52,13 @@ describe('The Import Maps Tool', () => {
             .should('have.class', 'fa-caret-down')
         cy.get('[data-cy="import-provider-list"]').should('not.be.visible')
         cy.get('[data-cy="import-catalogue-providers-toggle"]').click()
-        cy.get('[data-cy="import-provider-list"]')
-            .children()
+        cy.get('[data-cy="import-provider-item"]')
+            .contains('https://wms.geo.admin.ch')
+            .should('be.visible')
+        // Select the first one
+        cy.get('[data-cy="import-provider-item"]')
             .contains('https://wms.geo.admin.ch')
+            .first()
             .click()
         cy.wait('@wms-get-capabilities')
 
@@ -379,9 +383,17 @@ describe('The Import Maps Tool', () => {
         cy.get('[data-cy="menu-tray-tool-section"]').should('be.visible').click()
         cy.get('[data-cy="menu-advanced-tools-import-catalogue"]').should('be.visible').click()
         cy.get('[data-cy="import-catalogue-input"]').should('be.visible').type('wmts.geo.admin')
-        cy.get('[data-cy="import-provider-list"]')
-            .children()
+        // Item with the filtered text should be visible (it means the group is also expanded)
+        cy.get('[data-cy="import-provider-group"]')
             .contains('https://wmts.geo.admin.ch')
+            .should('be.visible')
+        // Select the first provider under the group
+        cy.get('[data-cy="import-provider-group"]')
+            .contains('https://wmts.geo.admin.ch') // Ensure the group contains the desired text
+            .parents('[data-cy="import-provider-group"]') // Navigate to the parent group first
+            .find('[data-cy="import-provider-item"]') // Find the child items
+            .first()
+            .should('be.visible')
             .click()
         cy.wait('@wmts-get-capabilities')
         cy.get('[data-cy="import-catalog-content"]')
@@ -557,9 +569,17 @@ describe('The Import Maps Tool', () => {
         cy.get('[data-cy="menu-tray-tool-section"]').should('be.visible').click()
         cy.get('[data-cy="menu-advanced-tools-import-catalogue"]').should('be.visible').click()
         cy.get('[data-cy="import-catalogue-input"]').should('be.visible').type('wmts.geo.admin')
-        cy.get('[data-cy="import-provider-list"]')
-            .children()
+        // Item with the filtered text should be visible (it means the group is also expanded)
+        cy.get('[data-cy="import-provider-group"]')
             .contains('https://wmts.geo.admin.ch')
+            .should('be.visible')
+        // Select the first provider under the group
+        cy.get('[data-cy="import-provider-group"]')
+            .contains('https://wmts.geo.admin.ch') // Ensure the group contains the desired text
+            .parents('[data-cy="import-provider-group"]') // Navigate to the parent group first
+            .find('[data-cy="import-provider-item"]') // Find the child items
+            .first()
+            .should('be.visible')
             .click()
         cy.wait('@wmts-get-capabilities-unreachable')
         cy.get('[data-cy="import-catalogue-input"]').should('have.class', 'is-invalid')
@@ -588,9 +608,17 @@ describe('The Import Maps Tool', () => {
 
         cy.get('[data-cy="menu-advanced-tools-import-catalogue"]').should('be.visible').click()
         cy.get('[data-cy="import-catalogue-input"]').should('be.visible').type('wmts.geo.admin')
-        cy.get('[data-cy="import-provider-list"]')
-            .children()
+        // Item with the filtered text should be visible (it means the group is also expanded)
+        cy.get('[data-cy="import-provider-group"]')
             .contains('https://wmts.geo.admin.ch')
+            .should('be.visible')
+        // Select the first provider under the group
+        cy.get('[data-cy="import-provider-group"]')
+            .contains('https://wmts.geo.admin.ch') // Ensure the group contains the desired text
+            .parents('[data-cy="import-provider-group"]') // Navigate to the parent group first
+            .find('[data-cy="import-provider-item"]') // Find the child items
+            .first()
+            .should('be.visible')
             .click()
         cy.wait('@wmts-get-capabilities-unreachable')
         cy.get('[data-cy="import-catalogue-input"]').should('have.class', 'is-invalid')
@@ -618,9 +646,17 @@ describe('The Import Maps Tool', () => {
         ).as('wmts-get-capabilities-unreachable')
         cy.get('[data-cy="menu-advanced-tools-import-catalogue"]').should('be.visible').click()
         cy.get('[data-cy="import-catalogue-input"]').should('be.visible').type('wmts.geo.admin')
-        cy.get('[data-cy="import-provider-list"]')
-            .children()
+        // Item with the filtered text should be visible (it means the group is also expanded)
+        cy.get('[data-cy="import-provider-group"]')
             .contains('https://wmts.geo.admin.ch')
+            .should('be.visible')
+        // Select the first provider under the group
+        cy.get('[data-cy="import-provider-group"]')
+            .contains('https://wmts.geo.admin.ch') // Ensure the group contains the desired text
+            .parents('[data-cy="import-provider-group"]') // Navigate to the parent group first
+            .find('[data-cy="import-provider-item"]') // Find the child items
+            .first()
+            .should('be.visible')
             .click()
         cy.wait('@wmts-get-capabilities-unreachable')
         cy.get('[data-cy="import-catalogue-input"]').should('have.class', 'is-invalid')
@@ -651,9 +687,17 @@ describe('The Import Maps Tool', () => {
         ).as('wmts-get-capabilities-unreachable')
         cy.get('[data-cy="menu-advanced-tools-import-catalogue"]').should('be.visible').click()
         cy.get('[data-cy="import-catalogue-input"]').should('be.visible').type('wmts.geo.admin')
-        cy.get('[data-cy="import-provider-list"]')
-            .children()
+        // Item with the filtered text should be visible (it means the group is also expanded)
+        cy.get('[data-cy="import-provider-group"]')
             .contains('https://wmts.geo.admin.ch')
+            .should('be.visible')
+        // Select the first provider under the group
+        cy.get('[data-cy="import-provider-group"]')
+            .contains('https://wmts.geo.admin.ch') // Ensure the group contains the desired text
+            .parents('[data-cy="import-provider-group"]') // Navigate to the parent group first
+            .find('[data-cy="import-provider-item"]') // Find the child items
+            .first()
+            .should('be.visible')
             .click()
         cy.wait('@wmts-get-capabilities-unreachable')
         cy.get('[data-cy="import-catalogue-input"]').should('have.class', 'is-invalid')


Reviews:
user: login: pakb
body: Might be better not to create a group when there's only one element, it makes it harder to access the link 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T08:39:33Z
user: login: pakb
body: If there are more than one element (meaning we have a group) I'd maybe want to remove the "common" part to all children.
So in your screenshot all `https://geo.so.ch` layers will not start with `https://geo.so.ch` (it's already there as a group title) but only show what's different.

To have a better UI/UX, I'd maybe make each group title "sticky", so that they do not go out of scroll while some children are still visible
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T07:04:25Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR groups map providers by their base URL to enhance the import workflow. Key changes include updating end-to-end tests to select filtered provider items, adding a getBaseUrl() helper in useProviders.js, and refactoring ProviderUrl.vue and ProviderList.vue for grouped provider display with improved keyboard navigation and styling.

### Reviewed Changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.

| File | Description |
| ---- | ----------- |
| packages/mapviewer/tests/cypress/tests-e2e/importToolMaps.cy.js | Updated tests to use new data-cy selectors and assert visibility on filtered provider items |
| packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/useProviders.js | Added getBaseUrl() method and computed grouping logic for providers |
| packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderUrl.vue | Updated prop destructuring and conditional rendering to work with grouped providers |
| packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue | Refactored provider list rendering into a tree structure with enhanced keyboard navigation and adjusted styling for group headers |

<details>
<summary>Files not reviewed (1)</summary>

* **packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/external-providers.json**: Language not supported
</details>



state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T23:34:38Z
user: login: pakb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T09:14:18Z
user: login: ismailsunni
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T23:44:01Z
user: login: ismailsunni
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T03:43:01Z
user: login: ismailsunni
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T06:58:57Z
user: login: ismailsunni
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T06:59:28Z
user: login: pakb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T07:13:18Z
user: login: pakb
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T07:31:10Z

Review comments:
diff_hunk: @@ -1,50 +1,205 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
 const emit = defineEmits(['chooseProvider', 'hide'])
 
 const providerList = useTemplateRef('providerList')
 
-function goToPrevious(currentKey) {
-    if (currentKey === 0) {
+let maxtabindex = 0
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: Copilot
body: Using a mutable variable 'maxtabindex' outside of a reactive context may lead to inconsistent state when treeData updates asynchronously. Consider using a reactive ref to manage this value for improved reliability.
```suggestion
const maxtabindex = ref(0)
```
created_at: 2025-04-23T23:34:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
+    if (!urls.length) return ''
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: no inline if, please use curly braces
created_at: 2025-04-24T08:46:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
+    if (!urls.length) return ''
+    let prefix = urls[0]
+    for (const url of urls) {
+        while (!url.startsWith(prefix)) {
+            prefix = prefix.slice(0, -1)
+            if (!prefix) break
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: same
created_at: 2025-04-24T08:47:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
+    if (!urls.length) return ''
+    let prefix = urls[0]
+    for (const url of urls) {
+        while (!url.startsWith(prefix)) {
+            prefix = prefix.slice(0, -1)
+            if (!prefix) break
+        }
+    }
+    // Ensure the prefix ends with a '/'
+    return prefix.endsWith('/') ? prefix : prefix.slice(0, prefix.lastIndexOf('/') + 1)
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: might be easier to switch that to an `if` statement, such as 
```Javascript
const lastSlashIndex = prefix.lastIndexOf('/');
if (lastSlashIndex !== -1) {
    prefix = prefix.slice(0, lastSlashIndex + 1)
}
return prefix
```
created_at: 2025-04-24T08:50:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
+    if (!urls.length) return ''
+    let prefix = urls[0]
+    for (const url of urls) {
+        while (!url.startsWith(prefix)) {
+            prefix = prefix.slice(0, -1)
+            if (!prefix) break
+        }
+    }
+    // Ensure the prefix ends with a '/'
+    return prefix.endsWith('/') ? prefix : prefix.slice(0, prefix.lastIndexOf('/') + 1)
+}
+
+// Function to build the tree node structure
+// This function takes a base URL and an array of providers and returns a tree node
+// structure. If there is only one provider, it returns a URL node. If there are multiple
+// providers, it groups them by their common prefix and creates a group node with
+// sub-group nodes for each unique prefix.
+//  It also set the name as the text to be shown in the UI
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: You can use the JSDoc block here
```Javascript
/**
 * ...
 */
```
Our IDEs can the leverage the doc you wrote when we hover over the function
created_at: 2025-04-24T08:51:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: Would maybe make more sense to use a JSDoc comment to describe the input param (the name of the function is self-explanatory, only the param type is unknown)
```Javascript
/**
 * @param {String[]} urls
 * @returns {String} common prefix that ends with `/`
 */
```
created_at: 2025-04-24T08:53:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: Might make sense to move that to `src/utils/utils.js` and slap some unit test on it
created_at: 2025-04-24T08:54:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
+    if (!urls.length) return ''
+    let prefix = urls[0]
+    for (const url of urls) {
+        while (!url.startsWith(prefix)) {
+            prefix = prefix.slice(0, -1)
+            if (!prefix) break
+        }
+    }
+    // Ensure the prefix ends with a '/'
+    return prefix.endsWith('/') ? prefix : prefix.slice(0, prefix.lastIndexOf('/') + 1)
+}
+
+// Function to build the tree node structure
+// This function takes a base URL and an array of providers and returns a tree node
+// structure. If there is only one provider, it returns a URL node. If there are multiple
+// providers, it groups them by their common prefix and creates a group node with
+// sub-group nodes for each unique prefix.
+//  It also set the name as the text to be shown in the UI
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: And it also might make sense that you describe what you return here with some type definition (if it doesn't exist yet)
Something like
```Javascript
/**
 * @typedef ExternalLayerTreeNode
 * @property {string} id
 * ...
 */
```
created_at: 2025-04-24T08:56:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
+    if (!urls.length) return ''
+    let prefix = urls[0]
+    for (const url of urls) {
+        while (!url.startsWith(prefix)) {
+            prefix = prefix.slice(0, -1)
+            if (!prefix) break
+        }
+    }
+    // Ensure the prefix ends with a '/'
+    return prefix.endsWith('/') ? prefix : prefix.slice(0, prefix.lastIndexOf('/') + 1)
+}
+
+// Function to build the tree node structure
+// This function takes a base URL and an array of providers and returns a tree node
+// structure. If there is only one provider, it returns a URL node. If there are multiple
+// providers, it groups them by their common prefix and creates a group node with
+// sub-group nodes for each unique prefix.
+//  It also set the name as the text to be shown in the UI
+function buildTreeNode(baseUrl, providers) {
+    if (providers.length === 1) {
+        return {
+            id: baseUrl,
+            name: providers[0].htmlDisplay,
+            type: 'url',
+            url: providers[0].url,
+            emphasize: providers[0].emphasize,
+        }
+    }
+
+    const urls = providers.map((provider) => provider.url)
+    const commonPrefix = getLongestCommonPrefix(urls)
+    const subGroups = {}
+
+    providers.forEach((provider) => {
+        const relativeUrl = provider.htmlDisplay.replace(commonPrefix || baseUrl, '')
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: use `??` instead of `||` if what you want to achieve is `commonPrefix or baseUrl if commonPrefix is null/undefined`
created_at: 2025-04-24T08:57:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
+    if (!urls.length) return ''
+    let prefix = urls[0]
+    for (const url of urls) {
+        while (!url.startsWith(prefix)) {
+            prefix = prefix.slice(0, -1)
+            if (!prefix) break
+        }
+    }
+    // Ensure the prefix ends with a '/'
+    return prefix.endsWith('/') ? prefix : prefix.slice(0, prefix.lastIndexOf('/') + 1)
+}
+
+// Function to build the tree node structure
+// This function takes a base URL and an array of providers and returns a tree node
+// structure. If there is only one provider, it returns a URL node. If there are multiple
+// providers, it groups them by their common prefix and creates a group node with
+// sub-group nodes for each unique prefix.
+//  It also set the name as the text to be shown in the UI
+function buildTreeNode(baseUrl, providers) {
+    if (providers.length === 1) {
+        return {
+            id: baseUrl,
+            name: providers[0].htmlDisplay,
+            type: 'url',
+            url: providers[0].url,
+            emphasize: providers[0].emphasize,
+        }
+    }
+
+    const urls = providers.map((provider) => provider.url)
+    const commonPrefix = getLongestCommonPrefix(urls)
+    const subGroups = {}
+
+    providers.forEach((provider) => {
+        const relativeUrl = provider.htmlDisplay.replace(commonPrefix || baseUrl, '')
+        const subGroupKey = relativeUrl.split('/')[0]
+
+        if (!subGroups[subGroupKey]) {
+            subGroups[subGroupKey] = []
+        }
+        subGroups[subGroupKey].push({
+            ...provider,
+            relativeUrl: relativeUrl.replace(subGroupKey + '/', ''),
+        })
+    })
+
+    return {
+        id: baseUrl,
+        name: commonPrefix || baseUrl,
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: same, would be better to use `??` instead of `||`
created_at: 2025-04-24T08:58:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
+    if (!urls.length) return ''
+    let prefix = urls[0]
+    for (const url of urls) {
+        while (!url.startsWith(prefix)) {
+            prefix = prefix.slice(0, -1)
+            if (!prefix) break
+        }
+    }
+    // Ensure the prefix ends with a '/'
+    return prefix.endsWith('/') ? prefix : prefix.slice(0, prefix.lastIndexOf('/') + 1)
+}
+
+// Function to build the tree node structure
+// This function takes a base URL and an array of providers and returns a tree node
+// structure. If there is only one provider, it returns a URL node. If there are multiple
+// providers, it groups them by their common prefix and creates a group node with
+// sub-group nodes for each unique prefix.
+//  It also set the name as the text to be shown in the UI
+function buildTreeNode(baseUrl, providers) {
+    if (providers.length === 1) {
+        return {
+            id: baseUrl,
+            name: providers[0].htmlDisplay,
+            type: 'url',
+            url: providers[0].url,
+            emphasize: providers[0].emphasize,
+        }
+    }
+
+    const urls = providers.map((provider) => provider.url)
+    const commonPrefix = getLongestCommonPrefix(urls)
+    const subGroups = {}
+
+    providers.forEach((provider) => {
+        const relativeUrl = provider.htmlDisplay.replace(commonPrefix || baseUrl, '')
+        const subGroupKey = relativeUrl.split('/')[0]
+
+        if (!subGroups[subGroupKey]) {
+            subGroups[subGroupKey] = []
+        }
+        subGroups[subGroupKey].push({
+            ...provider,
+            relativeUrl: relativeUrl.replace(subGroupKey + '/', ''),
+        })
+    })
+
+    return {
+        id: baseUrl,
+        name: commonPrefix || baseUrl,
+        type: 'group',
+        expanded: filterApplied,
+        children: Object.entries(subGroups).map(([key, subGroupProviders]) => {
+            if (subGroupProviders.length === 1) {
+                return {
+                    id: subGroupProviders[0].url,
+                    name: key + '/' + subGroupProviders[0].relativeUrl,
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: might make sense to use some `...${variable}...` notation here instead of concat multiple strings
created_at: 2025-04-24T09:00:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
+    if (!urls.length) return ''
+    let prefix = urls[0]
+    for (const url of urls) {
+        while (!url.startsWith(prefix)) {
+            prefix = prefix.slice(0, -1)
+            if (!prefix) break
+        }
+    }
+    // Ensure the prefix ends with a '/'
+    return prefix.endsWith('/') ? prefix : prefix.slice(0, prefix.lastIndexOf('/') + 1)
+}
+
+// Function to build the tree node structure
+// This function takes a base URL and an array of providers and returns a tree node
+// structure. If there is only one provider, it returns a URL node. If there are multiple
+// providers, it groups them by their common prefix and creates a group node with
+// sub-group nodes for each unique prefix.
+//  It also set the name as the text to be shown in the UI
+function buildTreeNode(baseUrl, providers) {
+    if (providers.length === 1) {
+        return {
+            id: baseUrl,
+            name: providers[0].htmlDisplay,
+            type: 'url',
+            url: providers[0].url,
+            emphasize: providers[0].emphasize,
+        }
+    }
+
+    const urls = providers.map((provider) => provider.url)
+    const commonPrefix = getLongestCommonPrefix(urls)
+    const subGroups = {}
+
+    providers.forEach((provider) => {
+        const relativeUrl = provider.htmlDisplay.replace(commonPrefix || baseUrl, '')
+        const subGroupKey = relativeUrl.split('/')[0]
+
+        if (!subGroups[subGroupKey]) {
+            subGroups[subGroupKey] = []
+        }
+        subGroups[subGroupKey].push({
+            ...provider,
+            relativeUrl: relativeUrl.replace(subGroupKey + '/', ''),
+        })
+    })
+
+    return {
+        id: baseUrl,
+        name: commonPrefix || baseUrl,
+        type: 'group',
+        expanded: filterApplied,
+        children: Object.entries(subGroups).map(([key, subGroupProviders]) => {
+            if (subGroupProviders.length === 1) {
+                return {
+                    id: subGroupProviders[0].url,
+                    name: key + '/' + subGroupProviders[0].relativeUrl,
+                    type: 'url',
+                    url: subGroupProviders[0].url,
+                    emphasize: subGroupProviders[0].emphasize,
+                }
+            }
+            return {
+                id: `${baseUrl}-${key}`,
+                name: key,
+                type: 'group',
+                expanded: false,
+                children: subGroupProviders.map((provider) => ({
+                    id: provider.url,
+                    name: provider.relativeUrl,
+                    type: 'url',
+                    url: provider.url,
+                    emphasize: provider.emphasize,
+                })),
+            }
+        }),
+    }
+}
+
+// Set tabindex for each node in the tree with DFS strategy so that the tab
+// index is in correct order visually
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: JSDoc instead of simple comments
created_at: 2025-04-24T09:00:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
+    if (!urls.length) return ''
+    let prefix = urls[0]
+    for (const url of urls) {
+        while (!url.startsWith(prefix)) {
+            prefix = prefix.slice(0, -1)
+            if (!prefix) break
+        }
+    }
+    // Ensure the prefix ends with a '/'
+    return prefix.endsWith('/') ? prefix : prefix.slice(0, prefix.lastIndexOf('/') + 1)
+}
+
+// Function to build the tree node structure
+// This function takes a base URL and an array of providers and returns a tree node
+// structure. If there is only one provider, it returns a URL node. If there are multiple
+// providers, it groups them by their common prefix and creates a group node with
+// sub-group nodes for each unique prefix.
+//  It also set the name as the text to be shown in the UI
+function buildTreeNode(baseUrl, providers) {
+    if (providers.length === 1) {
+        return {
+            id: baseUrl,
+            name: providers[0].htmlDisplay,
+            type: 'url',
+            url: providers[0].url,
+            emphasize: providers[0].emphasize,
+        }
+    }
+
+    const urls = providers.map((provider) => provider.url)
+    const commonPrefix = getLongestCommonPrefix(urls)
+    const subGroups = {}
+
+    providers.forEach((provider) => {
+        const relativeUrl = provider.htmlDisplay.replace(commonPrefix || baseUrl, '')
+        const subGroupKey = relativeUrl.split('/')[0]
+
+        if (!subGroups[subGroupKey]) {
+            subGroups[subGroupKey] = []
+        }
+        subGroups[subGroupKey].push({
+            ...provider,
+            relativeUrl: relativeUrl.replace(subGroupKey + '/', ''),
+        })
+    })
+
+    return {
+        id: baseUrl,
+        name: commonPrefix || baseUrl,
+        type: 'group',
+        expanded: filterApplied,
+        children: Object.entries(subGroups).map(([key, subGroupProviders]) => {
+            if (subGroupProviders.length === 1) {
+                return {
+                    id: subGroupProviders[0].url,
+                    name: key + '/' + subGroupProviders[0].relativeUrl,
+                    type: 'url',
+                    url: subGroupProviders[0].url,
+                    emphasize: subGroupProviders[0].emphasize,
+                }
+            }
+            return {
+                id: `${baseUrl}-${key}`,
+                name: key,
+                type: 'group',
+                expanded: false,
+                children: subGroupProviders.map((provider) => ({
+                    id: provider.url,
+                    name: provider.relativeUrl,
+                    type: 'url',
+                    url: provider.url,
+                    emphasize: provider.emphasize,
+                })),
+            }
+        }),
+    }
+}
+
+// Set tabindex for each node in the tree with DFS strategy so that the tab
+// index is in correct order visually
+function addTabIndex(treeData) {
+    let index = 0
+
+    function dfs(node) {
+        node.tabindex = index++
+        if (node.children && node.children.length > 0) {
+            node.children.forEach((child) => dfs(child))
+        }
+    }
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: why nesting this function? You can maybe give the `index` as a param to the function instead, and return the resulting index instead of mutating it inside the function
created_at: 2025-04-24T09:02:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,49 +1,221 @@
 <script setup>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { reactive, watch, ref, onMounted } from 'vue'
 import { useTemplateRef } from 'vue'
 
 import TextSearchMarker from '@/utils/components/TextSearchMarker.vue'
 
-const { showProviders, providers } = defineProps({
+const { showProviders, groupedProviders, filterApplied } = defineProps({
     showProviders: {
         type: Boolean,
         default: false,
     },
-    providers: {
-        type: Array /* Array of Provider */,
+    groupedProviders: {
+        type: Object /* Object of grouped providers by base URL */,
         default() {
-            return []
+            return {}
         },
     },
+    filterApplied: {
+        type: Boolean,
+        default: false,
+    },
 })
 
+// reactive data
 const emit = defineEmits(['chooseProvider', 'hide'])
-
 const providerList = useTemplateRef('providerList')
+const maxTabIndex = ref(0)
+const treeData = reactive([])
+
+// Function to get the longest common prefix of an array of URLs must end with '/'
+function getLongestCommonPrefix(urls) {
+    if (!urls.length) return ''
+    let prefix = urls[0]
+    for (const url of urls) {
+        while (!url.startsWith(prefix)) {
+            prefix = prefix.slice(0, -1)
+            if (!prefix) break
+        }
+    }
+    // Ensure the prefix ends with a '/'
+    return prefix.endsWith('/') ? prefix : prefix.slice(0, prefix.lastIndexOf('/') + 1)
+}
+
+// Function to build the tree node structure
+// This function takes a base URL and an array of providers and returns a tree node
+// structure. If there is only one provider, it returns a URL node. If there are multiple
+// providers, it groups them by their common prefix and creates a group node with
+// sub-group nodes for each unique prefix.
+//  It also set the name as the text to be shown in the UI
+function buildTreeNode(baseUrl, providers) {
+    if (providers.length === 1) {
+        return {
+            id: baseUrl,
+            name: providers[0].htmlDisplay,
+            type: 'url',
+            url: providers[0].url,
+            emphasize: providers[0].emphasize,
+        }
+    }
+
+    const urls = providers.map((provider) => provider.url)
+    const commonPrefix = getLongestCommonPrefix(urls)
+    const subGroups = {}
+
+    providers.forEach((provider) => {
+        const relativeUrl = provider.htmlDisplay.replace(commonPrefix || baseUrl, '')
+        const subGroupKey = relativeUrl.split('/')[0]
+
+        if (!subGroups[subGroupKey]) {
+            subGroups[subGroupKey] = []
+        }
+        subGroups[subGroupKey].push({
+            ...provider,
+            relativeUrl: relativeUrl.replace(subGroupKey + '/', ''),
+        })
+    })
+
+    return {
+        id: baseUrl,
+        name: commonPrefix || baseUrl,
+        type: 'group',
+        expanded: filterApplied,
+        children: Object.entries(subGroups).map(([key, subGroupProviders]) => {
+            if (subGroupProviders.length === 1) {
+                return {
+                    id: subGroupProviders[0].url,
+                    name: key + '/' + subGroupProviders[0].relativeUrl,
+                    type: 'url',
+                    url: subGroupProviders[0].url,
+                    emphasize: subGroupProviders[0].emphasize,
+                }
+            }
+            return {
+                id: `${baseUrl}-${key}`,
+                name: key,
+                type: 'group',
+                expanded: false,
+                children: subGroupProviders.map((provider) => ({
+                    id: provider.url,
+                    name: provider.relativeUrl,
+                    type: 'url',
+                    url: provider.url,
+                    emphasize: provider.emphasize,
+                })),
+            }
+        }),
+    }
+}
+
+// Set tabindex for each node in the tree with DFS strategy so that the tab
+// index is in correct order visually
+function addTabIndex(treeData) {
+    let index = 0
+
+    function dfs(node) {
+        node.tabindex = index++
+        if (node.children && node.children.length > 0) {
+            node.children.forEach((child) => dfs(child))
+        }
+    }
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: Would also make sense to add a little JSDoc that explains what this function does, `dfs` isn't a self-explanatory function name
created_at: 2025-04-24T09:02:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,26 +229,125 @@ defineExpose({ goToFirst })
             class="providers-list"
             data-cy="import-provider-list"
         >
+            <template v-for="node in treeData" :key="node.id">
+                <component
+                    :is="node.type === 'group' ? 'div' : 'div'"
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: that might be some left-over, but is not relevant anymore. This will create a `<div>` regardless of the state, so you could remove the whole `<component>` thing and replace it with a `<div>`
created_at: 2025-04-24T09:05:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,26 +229,125 @@ defineExpose({ goToFirst })
             class="providers-list"
             data-cy="import-provider-list"
         >
+            <template v-for="node in treeData" :key="node.id">
+                <component
+                    :is="node.type === 'group' ? 'div' : 'div'"
+                    :class="node.type === 'group' ? 'providers-group' : 'providers-list-item'"
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: could using something like
```
:class="`providers-${node.type}"
```
also work? that looks simpler to understand than a ternary operator in this context
created_at: 2025-04-24T09:06:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,26 +229,125 @@ defineExpose({ goToFirst })
             class="providers-list"
             data-cy="import-provider-list"
         >
+            <template v-for="node in treeData" :key="node.id">
+                <component
+                    :is="node.type === 'group' ? 'div' : 'div'"
+                    :class="node.type === 'group' ? 'providers-group' : 'providers-list-item'"
+                    :data-cy="node.type === 'group' ? 'import-provider-group' : 'import-provider-item'"
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: same thing here
created_at: 2025-04-24T09:06:34Z
author_association: CONTRIBUTOR
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: You might want to add a prettier run after each save on your IDE, if I run prettier locally I have some changes here
created_at: 2025-04-24T09:08:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -102,6 +375,42 @@ defineExpose({ goToFirst })
         .providers-list-item:hover {
             background-color: $list-item-hover-bg-color;
         }
+
+        .providers-group-header {
+            cursor: pointer;
+            font-weight: bold;
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: might be better to use a `<strong>` tag instead of relying on CSS to bolden your text
created_at: 2025-04-24T09:09:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -102,6 +375,42 @@ defineExpose({ goToFirst })
         .providers-list-item:hover {
             background-color: $list-item-hover-bg-color;
         }
+
+        .providers-group-header {
+            cursor: pointer;
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: there are some bootstrap utils that can achieve that too, the more you are using bootstrap classes, the less work it will be when we switch to tailwind

So if you could do a little pass of all the rules you have below, and check if some bootstrap class would do the same, it would be nice
created_at: 2025-04-24T09:10:41Z
author_association: CONTRIBUTOR
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/external-providers.json
user: login: pakb
body: We should really be sorting this alphabetically to detect duplicates... Do you have a tool that could achieve that? (CoPilot could maybe do it)
created_at: 2025-04-24T09:12:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -8,6 +9,16 @@ class Provider {
         this.htmlDisplay = url.replace(/access_key=.*/, '')
         this.emphasize = emphasize
     }
+
+    getBaseUrl() {
+        try {
+            const urlObj = new URL(this.url)
+            return urlObj.origin
+        } catch (e) {
+            log.error('Invalid URL:', this.url, e)
+            return ''
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/useProviders.js
user: login: pakb
body: might make more sense to return `null` or `undefined` and then manage the fact that it can be "falsy" in `useProviders` function bellow
created_at: 2025-04-24T09:14:01Z
author_association: CONTRIBUTOR
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/external-providers.json
user: login: ismailsunni
body: Ah, I removing the duplicates with a script, but forget to sort it. *I thought I did
created_at: 2025-04-28T23:44:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -102,6 +375,42 @@ defineExpose({ goToFirst })
         .providers-list-item:hover {
             background-color: $list-item-hover-bg-color;
         }
+
+        .providers-group-header {
+            cursor: pointer;
+            font-weight: bold;
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: ismailsunni
body: I use it for styling `<TextSearchMarker>`, I don't think it's possible to use `<strong>` tag to bold the text inside `<TextSearchMarker>`. The alternative would be create  a new prop to enable this `<strong>` tag. Is that what do you suggest?
created_at: 2025-04-30T03:43:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -102,6 +375,42 @@ defineExpose({ goToFirst })
         .providers-list-item:hover {
             background-color: $list-item-hover-bg-color;
         }
+
+        .providers-group-header {
+            cursor: pointer;
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: ismailsunni
body: now all is using bootstrap classes except `bg-body` that for some reason it doesn't work with `focus` and `hover`
created_at: 2025-04-30T06:58:57Z
author_association: CONTRIBUTOR
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: ismailsunni
body: I already did actually, the `format on save`, right?
created_at: 2025-04-30T06:59:28Z
author_association: CONTRIBUTOR
path: packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/ProviderList.vue
user: login: pakb
body: should be enough yes, weird that I had some changes when running prettier on my end...
created_at: 2025-04-30T07:13:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: cypress[bot]
created_at: 2025-03-20T09:18:21Z
body: 
<h3>
  <a href="https://cloud.cypress.io/projects/fj2ezv?utm_source=github&utm_medium=PASSED&utm_campaign=project">web-mapviewer</a>&nbsp;
  <img src="https://assets.cypress.io/github-pr-comment-icons/chevron.svg" alt="" height="14" width="14" />&nbsp;
  <a href="https://cloud.cypress.io/projects/fj2ezv/runs/5173/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">Run #5173</a>
</h3>

<details>
  <summary>
    <strong>Run Properties:</strong>&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/fj2ezv/runs/5173/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#5173</a></code> &nbsp;&bull;&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/geoadmin/web-mapviewer/commit/e60b37563087165af39b9276dd78d7dd4f5c6a92">e60b375630</a>: Merge pull request #1273 from geoadmin/feat-pb-165-grouping-layers-same-domain</code>
  </summary><br />

  <table width="744">
    <tbody>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Project</strong>
        </td>
        <td width="520">
          <code><a href="https://cloud.cypress.io/projects/fj2ezv?utm_source=github&utm_medium=PASSED&utm_campaign=project">web-mapviewer</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Branch Review</strong>
        </th>
        <td>
          <code><a href="https://cloud.cypress.io/projects/fj2ezv/branches/develop/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=1273">develop</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run status</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/fj2ezv/runs/5173/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#5173</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run duration</strong></td>
        <td><code>01m 23s</code></td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Commit</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/geoadmin/web-mapviewer/commit/e60b37563087165af39b9276dd78d7dd4f5c6a92">e60b375630</a>: Merge pull request #1273 from geoadmin/feat-pb-165-grouping-layers-same-domain</code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Committer</strong></td>
        <td><code>Ismail Sunni</code></td>
      </tr>
    </tbody>
    <tfoot>
      <tr />
      <tr height="48">
        <td colspan="2">
          <a href="https://cloud.cypress.io/projects/fj2ezv/runs/5173/properties?utm_source=github&utm_medium=PASSED&utm_campaign=properties">View all properties for this run ↗︎</a>
        </td>
      </tr>
    </tfoot>
  </table>
  <hr />
</details>

<table width="744">
  <thead>
    <tr />
    <tr height="48">
      <th colspan="2" align="left" width="744">Test results</th>
    </tr>
  </thead>
  <tbody>
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/failed.svg" alt="Tests that failed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/fj2ezv/runs/5173/overview?reviewViewBy=FAILED&utm_source=github&utm_medium=PASSED&utm_campaign=failed%20tests">
          <strong>Failures</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/flaky-v3.svg" alt="Tests that were flaky" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/fj2ezv/runs/5173/overview?reviewViewBy=FLAKY&utm_source=github&utm_medium=PASSED&utm_campaign=flaky%20tests">
          <strong>Flaky</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/pending.svg" alt="Tests that did not run due to a developer annotating a test with .skip" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/fj2ezv/runs/5173/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=pending%20tests&statuses=%5B%7B%22value%22%3A%22PENDING%22%2C%22label%22%3A%22PENDING%22%7D%5D">
          <strong>Pending</strong>
        </a>
      </td>
      <td>
        <code><strong>1</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/skipped-v3.svg" alt="Tests that did not run due to a failure in a mocha hook" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/fj2ezv/runs/5173/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=skipped%20tests&statuses=%5B%7B%22value%22%3A%22SKIPPED%22%2C%22label%22%3A%22SKIPPED%22%7D%5D">
          <strong>Skipped</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="Tests that passed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/fj2ezv/runs/5173/overview?utm_source=github&utm_medium=PASSED&utm_campaign=passed%20tests">
          <strong>Passing</strong>
        </a>
      </td>
      <td>
        <code><strong>48</strong></code>
      </td>
    </tr><tfoot>
    <tr height="48"><td colspan="2"><a href="https://cloud.cypress.io/projects/fj2ezv/branches/develop/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=1273">View all changes introduced in this branch ↗︎</a></td></tr></tfoot>
  </tbody>
</table>






author_association: NONE
user: login: ismailsunni
created_at: 2025-03-25T09:57:43Z
body: @pakb so, the one-element group should be changed to one-element (i.e. no caret)? 
author_association: CONTRIBUTOR
user: login: ismailsunni
created_at: 2025-03-25T22:31:55Z
body: @pakb new appearance with no-group for one URL

![image](https://github.com/user-attachments/assets/a3208e7f-e356-4147-bf41-c78562db9cdd)

author_association: CONTRIBUTOR
user: login: ismailsunni
created_at: 2025-04-16T07:44:49Z
body: @pakb something like this?
![image](https://github.com/user-attachments/assets/dc622709-0851-450f-9145-583855d0bc79)

author_association: CONTRIBUTOR
user: login: ismailsunni
created_at: 2025-04-24T07:33:31Z
body: Update screenshot:

![image](https://github.com/user-attachments/assets/436a1160-f9ef-4d74-816c-a75077c44952)

author_association: CONTRIBUTOR