diff --git a/web/client/api/GeoNode.js b/web/client/api/GeoNode.js index 9207540912..612b96cc78 100644 --- a/web/client/api/GeoNode.js +++ b/web/client/api/GeoNode.js @@ -20,6 +20,9 @@ import { getConfigProp } from '../utils/ConfigUtils'; import { resolveApiPresetParams, paramsSerializer, mergePresetParams } from '../utils/GeoNodeUtils'; export const GEONODE_RESOURCE_TYPE_FILTER = 'filter{resource_type.in}'; +// default sort applied to the catalog resources request when the caller does not provide one +// (matches the "Most recent" default shown in the catalog toolbar) +export const GEONODE_DEFAULT_SORT = '-date'; export const RESOURCES = 'resources'; @@ -211,7 +214,7 @@ export const getRecords = (url, startPosition, maxRecords, text, options) => { baseUrl: url, ...(resourceTypes.length && { [GEONODE_RESOURCE_TYPE_FILTER]: resourceTypes }), ...options?.options?.filters, - sort: options?.options?.sort, + sort: options?.options?.sort ?? service?.defaultSort ?? GEONODE_DEFAULT_SORT, ...(service?.apiPresetKey && { apiPresetKey: service.apiPresetKey }) }); }; diff --git a/web/client/api/__tests__/GeoNode-test.js b/web/client/api/__tests__/GeoNode-test.js index 13e9b5ad37..619fbc485c 100644 --- a/web/client/api/__tests__/GeoNode-test.js +++ b/web/client/api/__tests__/GeoNode-test.js @@ -193,4 +193,32 @@ describe('Test correctness of the GeoNode APIs (mock axios)', () => { } }); }); + + it('getRecords applies the default sort when none is provided', (done) => { + mockAxios.onGet().reply((config) => { + try { + expect(config.params.sort).toEqual(['-date']); + } catch (e) { + done(e); + } + return [200, { resources: [], total: 0, page: 1, page_size: 4, links: {} }]; + }); + + API.getRecords('https://example.com', 1, 4, '', {}).then(() => done()).catch(done); + }); + + it('getRecords prefers the service defaultSort over the global default', (done) => { + mockAxios.onGet().reply((config) => { + try { + expect(config.params.sort).toEqual(['-popular_count']); + } catch (e) { + done(e); + } + return [200, { resources: [], total: 0, page: 1, page_size: 4, links: {} }]; + }); + + API.getRecords('https://example.com', 1, 4, '', { + options: { service: { defaultSort: '-popular_count' } } + }).then(() => done()).catch(done); + }); }); diff --git a/web/client/api/catalog/GeoNode.js b/web/client/api/catalog/GeoNode.js index d7f6288109..f3fa3f5947 100644 --- a/web/client/api/catalog/GeoNode.js +++ b/web/client/api/catalog/GeoNode.js @@ -7,13 +7,19 @@ */ -import { textSearch as geonodeTextSearch, getDatasetByPk, getResourceByPk } from '../GeoNode'; +import { v4 as uuid } from 'uuid'; +import isEmpty from 'lodash/isEmpty'; +import turfCenter from '@turf/center'; +import { textSearch as geonodeTextSearch, getDatasetByPk, getResourceByPk, getDocumentByPk } from '../GeoNode'; import { getLayerTitleTranslations } from '../../utils/LayersUtils'; import { resourceToLayerConfig, isDefaultDatasetSubtype, - getTagConfig + getTagConfig, + ResourceTypes, + GEONODE_DOCUMENTS_ROW_VIEWER } from '../../utils/GeoNodeUtils'; +import { getPolygonFromExtent } from '../../utils/CoordinatesUtils'; import { getConfigProp } from '../../utils/ConfigUtils'; import { preprocess as commonPreprocess, @@ -92,6 +98,7 @@ export const getCatalogRecords = (records, options) => { tagFilterType, creator: record.owner?.username, identifier: record?.pk, + icon: record.resource_type === ResourceTypes.DOCUMENT ? { glyph: 'document' } : undefined, isValid: true }; }); @@ -112,20 +119,157 @@ export const getLayerFromRecord = (record, options, asPromise = false) => { .then((resource) => resourceToLayerConfig({ ...record, ...resource }, options)); }; -export const getCapabilities = () => { +const calculateBbox = (coordinates) => { + const validCoords = (coordinates || []).filter(coord => coord && coord.length === 2); + if (validCoords.length === 0) { + return null; + } + const lons = validCoords.map(coord => coord[0]); + const lats = validCoords.map(coord => coord[1]); + return { + bounds: { + minx: Math.min(...lons), + miny: Math.min(...lats), + maxx: Math.max(...lons), + maxy: Math.max(...lats) + }, + crs: 'EPSG:4326' + }; +}; + +const documentMarkerSymbolizer = (glyph) => [{ + kind: 'Icon', + size: 46, + image: { args: [{ color: 'blue', glyph, shape: 'circle' }], name: 'msMarkerIcon' }, + anchor: 'bottom', + rotate: 0, + opacity: 1, + symbolizerId: '01', + msBringToFront: false, + msHeightReference: 'none' +}]; + +const DOCUMENTS_STYLE = { + format: 'geostyler', + metadata: { editorType: 'visual' }, + body: { + rules: [ + { name: 'Videos', ruleId: '01', mandatory: false, filter: ['&&', ['==', 'subtype', 'video']], symbolizers: documentMarkerSymbolizer('video-camera') }, + { name: 'Images', ruleId: '02', mandatory: false, filter: ['&&', ['==', 'subtype', 'image']], symbolizers: documentMarkerSymbolizer('camera') }, + { name: 'Files', ruleId: '03', mandatory: false, filter: ['&&', ['!=', 'subtype', 'image'], ['!=', 'subtype', 'video']], symbolizers: documentMarkerSymbolizer('file') } + ] + } +}; + +/** + * Build a single MapStore vector layer that collects the given GeoNode documents + * as point features (located at the center of each document extent). Documents + * without an extent are skipped. + */ +export const documentsToLayerConfig = (documents = [], options = {}) => { + const baseURL = options?.service?.url; + // resilient per document: a failed fetch is skipped, not fatal to the whole layer + return Promise.all(documents.map(doc => getDocumentByPk(baseURL, doc.pk).catch(() => null))) + .then((fullDocs) => { + const features = fullDocs + .map((doc) => { + const extent = doc?.extent?.coords; + const polygon = !isEmpty(extent) ? getPolygonFromExtent(extent) : null; + const center = polygon ? turfCenter(polygon) : null; + if (!center) { + return null; + } + return { + type: 'Feature', + properties: doc, + geometry: { + type: 'Point', + coordinates: center.geometry.coordinates + }, + id: doc.pk + }; + }) + .filter(Boolean); + const bbox = calculateBbox(features.map(feature => feature.geometry.coordinates)); + return { + id: uuid(), + type: 'vector', + visibility: true, + name: 'Documents', + title: 'Documents', + ...(bbox && { bbox }), + features, + style: DOCUMENTS_STYLE, + rowViewer: GEONODE_DOCUMENTS_ROW_VIEWER + }; + }); +}; + +/** + * Process the whole selected record set into map content (N records -> M layers). + * GeoNode documents collapse into a single vector layer; every other record type + * is converted through getLayerFromRecord. Always resolves with `{ layers, groups }`. + */ +export const processRecords = (records = [], options = {}) => { + const protectedId = options?.service?.protectedId; + const applySecurity = (layer) => layer && protectedId + ? { ...layer, security: { type: 'basic', sourceId: protectedId } } + : layer; + const documents = records.filter(record => record.resource_type === ResourceTypes.DOCUMENT); + const others = records.filter(record => record.resource_type !== ResourceTypes.DOCUMENT); + const otherLayersPromise = Promise.all( + // resilient per record: a failed conversion is skipped, not fatal to the batch + others.map(record => getLayerFromRecord(record, options, true).then(applySecurity).catch(() => null)) + ); + const documentsLayerPromise = documents.length + ? documentsToLayerConfig(documents, options).catch(() => null) + : Promise.resolve(null); + return Promise.all([otherLayersPromise, documentsLayerPromise]) + .then(([otherLayers, documentsLayer]) => ({ + layers: [...otherLayers, documentsLayer].filter(Boolean), + groups: [] + })); +}; + +export const getCapabilities = ({ service } = {}) => { + + const subtypes = [ + ...(service?.resourceTypes?.includes('dataset') ? [ + 'vector', + 'raster', + 'vector_time', + '3dtiles', + 'tabular' + ] : []), + ...(service?.resourceTypes?.includes('document') ? [ + 'image', + 'video', + 'audio', + 'text', + 'archive', + 'presentation' + ] : []) + ]; return { filterSupport: true, orderBySupport: true, - getTagFilterKey: (service) => { - const tagFilterType = resolveTagFilterType(service); + getTagFilterKey: (_service) => { + const tagFilterType = resolveTagFilterType(_service); return getTagConfig(tagFilterType).filterKey; }, filterFormFields: [ - { id: 'category', type: 'select', order: 5, facet: 'category', label: 'Category', key: 'filter{category.identifier.in}' }, - { id: 'keyword', type: 'select', order: 6, facet: 'keyword', label: 'Keyword', key: 'filter{keywords.slug.in}' }, - { id: 'region', type: 'select', order: 7, facet: 'place', label: 'Region', key: 'filter{regions.code.in}' }, + ...(subtypes?.length ? [{ + id: 'subtype', + labelId: 'catalog.subtypes.label', + type: 'select', + order: 4, + options: subtypes.map((value) => ({ value, labelId: `catalog.subtypes.${value}` })) + }] : []), + { id: 'category', type: 'select', order: 5, facet: 'category', labelId: 'catalog.filterFields.category', key: 'filter{category.identifier.in}' }, + { id: 'keyword', type: 'select', order: 6, facet: 'keyword', labelId: 'catalog.filterFields.keyword', key: 'filter{keywords.slug.in}' }, + { id: 'region', type: 'select', order: 7, facet: 'place', labelId: 'catalog.filterFields.region', key: 'filter{regions.code.in}' }, { type: 'date-range', filterKey: 'date', labelId: 'resourcesCatalog.creationFilter' }, - { labelId: 'Extent Filter', type: 'extent' } + { labelId: 'catalog.filterFields.extent', type: 'extent' } ] }; }; diff --git a/web/client/api/catalog/__tests__/GeoNode-test.js b/web/client/api/catalog/__tests__/GeoNode-test.js index 5edd92518d..9ea6bca22d 100644 --- a/web/client/api/catalog/__tests__/GeoNode-test.js +++ b/web/client/api/catalog/__tests__/GeoNode-test.js @@ -7,9 +7,13 @@ */ import expect from 'expect'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '../../../libs/ajax'; import { getCatalogRecords, - getLayerFromRecord + getLayerFromRecord, + documentsToLayerConfig, + processRecords } from '../GeoNode'; describe('Test correctness of the GeoNode catalog APIs', () => { @@ -120,3 +124,145 @@ describe('Test correctness of the GeoNode catalog APIs', () => { }); }); }); + +describe('GeoNode catalog processRecords / documents', () => { + let mockAxios; + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + afterEach(() => { + mockAxios.restore(); + }); + + const mockDocuments = () => { + mockAxios.onGet().reply((config) => { + if (config.url.indexOf('/documents/10') !== -1) { + return [200, { document: { pk: 10, title: 'Doc 10', subtype: 'image', detail_url: '/documents/10', extent: { coords: [0, 0, 10, 10] } } }]; + } + if (config.url.indexOf('/documents/11') !== -1) { + return [200, { document: { pk: 11, title: 'Doc 11', subtype: 'document', detail_url: '/documents/11' } }]; + } + return [404]; + }); + }; + + const datasetRecord = { + resource_type: 'dataset', + alternate: 'geonode:layer', + links: [{ link_type: 'OGC:WMS', url: 'http://sample?name=layer1' }] + }; + + it('documentsToLayerConfig builds a single vector layer of point features', (done) => { + mockDocuments(); + documentsToLayerConfig([{ pk: 10 }, { pk: 11 }], { service: { url: 'http://gn' } }) + .then((layer) => { + try { + expect(layer.type).toBe('vector'); + expect(layer.name).toBe('Documents'); + expect(layer.rowViewer).toBe('GEONODE_DOCUMENTS_ROW_VIEWER'); + // doc 11 has no extent -> skipped + expect(layer.features.length).toBe(1); + expect(layer.features[0].id).toBe(10); + expect(layer.features[0].geometry.type).toBe('Point'); + expect(layer.features[0].geometry.coordinates).toEqual([5, 5]); + expect(layer.bbox).toExist(); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('processRecords collapses documents and converts other records to layers', (done) => { + mockDocuments(); + const records = [datasetRecord, { resource_type: 'document', pk: 10 }]; + processRecords(records, { service: { url: 'http://gn' } }) + .then(({ layers, groups }) => { + try { + expect(groups).toEqual([]); + expect(layers.length).toBe(2); + const vector = layers.find(layer => layer.type === 'vector'); + const wms = layers.find(layer => layer.type === 'wms'); + expect(vector).toExist(); + expect(wms).toExist(); + expect(vector.features.length).toBe(1); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('processRecords applies protectedId security to dataset layers but not documents', (done) => { + mockDocuments(); + const records = [datasetRecord, { resource_type: 'document', pk: 10 }]; + processRecords(records, { service: { url: 'http://gn', protectedId: 'svc-1' } }) + .then(({ layers }) => { + try { + const vector = layers.find(layer => layer.type === 'vector'); + const wms = layers.find(layer => layer.type === 'wms'); + expect(wms.security).toEqual({ type: 'basic', sourceId: 'svc-1' }); + expect(vector.security).toBe(undefined); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('processRecords returns empty layers for an empty selection', (done) => { + processRecords([], { service: { url: 'http://gn' } }) + .then(({ layers, groups }) => { + try { + expect(layers).toEqual([]); + expect(groups).toEqual([]); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('documentsToLayerConfig skips documents whose fetch fails', (done) => { + mockAxios.onGet().reply((config) => { + if (config.url.indexOf('/documents/10') !== -1) { + return [200, { document: { pk: 10, title: 'Doc 10', subtype: 'image', extent: { coords: [0, 0, 10, 10] } } }]; + } + return [500]; + }); + documentsToLayerConfig([{ pk: 10 }, { pk: 12 }], { service: { url: 'http://gn' } }) + .then((layer) => { + try { + expect(layer.features.length).toBe(1); + expect(layer.features[0].id).toBe(10); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('processRecords skips records that fail and keeps the rest', (done) => { + mockAxios.onGet().reply((config) => { + if (config.url.indexOf('/documents/10') !== -1) { + return [200, { document: { pk: 10, subtype: 'image', extent: { coords: [0, 0, 10, 10] } } }]; + } + return [500]; + }); + const records = [ + { resource_type: 'dataset', pk: 99, subtype: 'vector', alternate: 'geonode:layer', links: [{ link_type: 'OGC:WMS', url: 'http://sample?name=layer1' }] }, + { resource_type: 'document', pk: 10 } + ]; + processRecords(records, { service: { url: 'http://gn' } }) + .then(({ layers }) => { + try { + // dataset fetch (datasets/99) fails -> skipped; documents layer survives + expect(layers.find(layer => layer.type === 'vector')).toExist(); + expect(layers.find(layer => layer.type === 'wms')).toNotExist(); + done(); + } catch (e) { + done(e); + } + }); + }); +}); diff --git a/web/client/api/catalog/index.js b/web/client/api/catalog/index.js index 6f29005c49..3e36a0546c 100644 --- a/web/client/api/catalog/index.js +++ b/web/client/api/catalog/index.js @@ -36,6 +36,7 @@ import * as flatgeobuf from './FlatGeobuf'; * ``` * - `getCatalogRecords` (data, options) => function that returns an array of catalogs records * - `getLayerFromRecord` (record, options, asPromise) => function that returns a promise/object that resolve with a mapstore layer configuration object given a catalog record + * - `processRecords` (records, options) => (optional) function that returns a promise resolving with `{ layers, groups }`. When present, the catalog processes the whole selected record set through this instead of calling `getLayerFromRecord` per record (e.g. to collapse GeoNode documents into a single vector layer, or import map resources as groups + layers) * - `preprocess` return an Observable that performs actions on service object prior to its save * - `validate`: function that gets the service object and returns an Observable. The stream emit an exception if the service validation fails. Otherwise it emits the `service` object and complete. * - `testService` function that gets the service object and returns an Observable. The stream emit an exception if the service do not respond. Otherwise it emits the `service` object and complete. diff --git a/web/client/components/catalog/datasets/Catalog.jsx b/web/client/components/catalog/datasets/Catalog.jsx index f543d78e6a..0b73cc946d 100644 --- a/web/client/components/catalog/datasets/Catalog.jsx +++ b/web/client/components/catalog/datasets/Catalog.jsx @@ -153,7 +153,7 @@ const Catalog = ({ const [sort, setSort] = useState(currentSearchOptions.sort || '-date'); const [selectedServiceInitialized, setSelectedServiceInitialized] = useState(false); const servicesEffectInitialized = useRef(false); - const serviceCapabilities = API[selectedFormat]?.getCapabilities?.() || { + const serviceCapabilities = API[selectedFormat]?.getCapabilities?.({ service: services[selectedService] }) || { filterSupport: false, orderBySupport: false }; @@ -260,7 +260,6 @@ const Catalog = ({ return accumulator; }, {}); setFilters(updatedFilters); - clearSelection?.(); search({ searchText, filters: updatedFilters, sort }); }; @@ -281,7 +280,6 @@ const Catalog = ({ const onSortChange = (newSort) => { setSort(newSort); - clearSelection?.(); search({ searchText, filters, sort: newSort, start: searchOptions?.startPosition }); }; @@ -291,7 +289,6 @@ const Catalog = ({ { - clearSelection?.(); onChangeText(text, opts); }} enableFilters={serviceCapabilities.filterSupport} diff --git a/web/client/components/catalog/datasets/CatalogCard.jsx b/web/client/components/catalog/datasets/CatalogCard.jsx index 493315666c..1d65bc6048 100644 --- a/web/client/components/catalog/datasets/CatalogCard.jsx +++ b/web/client/components/catalog/datasets/CatalogCard.jsx @@ -200,7 +200,7 @@ const CatalogCard = ({ '@extras': { info: { thumbnailUrl: record?.thumbnail_url || record?.thumbnail, - icon: { glyph: 'dataset' }, + icon: record?.icon ?? { glyph: 'dataset' }, title: getTitle(record?.title), // creator: record.metadata?.creator || record?.creator || 'Unknown', description: record?.description || record?.raw_abstract, diff --git a/web/client/components/catalog/datasets/hoc/__tests__/catalogRequestsWorkflow-test.js b/web/client/components/catalog/datasets/hoc/__tests__/catalogRequestsWorkflow-test.js new file mode 100644 index 0000000000..fee44446a8 --- /dev/null +++ b/web/client/components/catalog/datasets/hoc/__tests__/catalogRequestsWorkflow-test.js @@ -0,0 +1,96 @@ +/* + * Copyright 2026, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import expect from 'expect'; + +import withCatalogRequests from '../catalogRequestsWorkflow'; +import API from '../../../../../api/catalog'; + +describe('catalogRequestsWorkflow HOC', () => { + let originalGeonode; + let capturedArgs; + + const mockGeoNode = () => { + originalGeonode = API.geonode; + capturedArgs = null; + API.geonode = { + ...originalGeonode, + textSearch: (...args) => { + capturedArgs = args; + return Promise.resolve({ records: [] }); + } + }; + }; + + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + if (originalGeonode) { + API.geonode = originalGeonode; + originalGeonode = null; + } + ReactDOM.unmountComponentAtNode(document.getElementById('container')); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('constrains the search to the provided resourceTypes', (done) => { + mockGeoNode(); + let receivedServices; + const Probe = (props) => { + receivedServices = props.services; + return
; + }; + const Wrapped = withCatalogRequests(Probe); + ReactDOM.render( + , + document.getElementById('container') + ); + setTimeout(() => { + try { + expect(capturedArgs).toExist(); + // 5th textSearch arg is { options: { service } } + expect(capturedArgs[4].options.service.resourceTypes).toEqual(['dataset']); + // the child receives the constrained services as well (used by getCapabilities / search) + expect(receivedServices.gn.resourceTypes).toEqual(['dataset']); + done(); + } catch (e) { + done(e); + } + }, 0); + }); + + it('does not constrain the service when resourceTypes is not provided', (done) => { + mockGeoNode(); + const Wrapped = withCatalogRequests(() =>
); + ReactDOM.render( + , + document.getElementById('container') + ); + setTimeout(() => { + try { + expect(capturedArgs).toExist(); + expect(capturedArgs[4].options.service.resourceTypes).toEqual(['dataset', 'document']); + done(); + } catch (e) { + done(e); + } + }, 0); + }); +}); diff --git a/web/client/components/catalog/datasets/hoc/catalogRequestsWorkflow.js b/web/client/components/catalog/datasets/hoc/catalogRequestsWorkflow.js index 3f67997a8b..40871dd758 100644 --- a/web/client/components/catalog/datasets/hoc/catalogRequestsWorkflow.js +++ b/web/client/components/catalog/datasets/hoc/catalogRequestsWorkflow.js @@ -7,6 +7,7 @@ const withCatalogRequests = (Component) => { pageSize = 12, locales = 'en-US', layerOptions = {}, + resourceTypes, services, selectedService, selected, @@ -21,8 +22,18 @@ const withCatalogRequests = (Component) => { ...props }) { - const service = services?.[selectedService] || {}; + const baseService = useMemo(() => services?.[selectedService] || {}, [services, selectedService]); + // constrain the GeoNode resource types when the host restricts them (e.g. widget builder -> datasets only) + // memoized so the derived object keeps a stable identity (it feeds the search effect dependencies) + const service = useMemo( + () => (resourceTypes?.length ? { ...baseService, resourceTypes } : baseService), + [baseService, resourceTypes] + ); const selectedFormat = service?.type; + const searchServices = useMemo( + () => (resourceTypes?.length && selectedService ? { ...services, [selectedService]: service } : services), + [services, selectedService, service, resourceTypes] + ); const [searchText, setSearchText] = useState(''); const [result, setResult] = useState(''); @@ -122,7 +133,7 @@ const withCatalogRequests = (Component) => { multiSelect={multiSelect} onChangeCatalogMode={onChangeCatalogMode} title={title} - services={services} + services={searchServices} selectedService={selectedService} selected={selectedRecords} isAllSelected={false} diff --git a/web/client/components/catalog/editor/AdvancedSettings/GeoNodeAdvancedSettings.jsx b/web/client/components/catalog/editor/AdvancedSettings/GeoNodeAdvancedSettings.jsx index 6b42d1721c..0a58b09597 100644 --- a/web/client/components/catalog/editor/AdvancedSettings/GeoNodeAdvancedSettings.jsx +++ b/web/client/components/catalog/editor/AdvancedSettings/GeoNodeAdvancedSettings.jsx @@ -22,9 +22,16 @@ const TAG_FILTER_TYPE_OPTIONS = [ { value: 'keyword', label: } ]; +const RESOURCE_TYPE_OPTIONS = [ + { value: 'dataset', label: }, + { value: 'document', label: } +]; + /** * Advanced settings form for GeoNode catalog services. * + * - resourceTypes: the GeoNode resource types included in the catalog search. At least one + * type must remain selected. Defaults to ['dataset'] when not set. * - tagFilterType: Selects whether the card tags (used for filtering and display) come from * 'category' (default) or from 'keyword'. The default can be overridden via * `initialState.defaultState.catalog.default.tagFilterType` in localConfig. @@ -38,8 +45,31 @@ export default ({ const currentValue = !isNil(service?.tagFilterType) ? service.tagFilterType : globalDefault; const selectedOption = TAG_FILTER_TYPE_OPTIONS.find(o => o.value === currentValue) || TAG_FILTER_TYPE_OPTIONS[0]; + const currentResourceTypes = service?.resourceTypes?.length ? service.resourceTypes : ['dataset']; + const selectedResourceTypes = RESOURCE_TYPE_OPTIONS.filter(o => currentResourceTypes.includes(o.value)); + return ( + + + + +