Source: plugins/ResourceDetails/ResourceDetails.jsx

/*
 * Copyright 2025, 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, { useEffect, useState } from 'react';
import { createPlugin } from '@mapstore/framework/utils/PluginsUtils';
import { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
import PropTypes from 'prop-types';
import { requestResource } from '@js/actions/gnresource';
import controls from '@mapstore/framework/reducers/controls';
import config from '@mapstore/framework/reducers/config';
import gnresource from '@js/reducers/gnresource';
import {
    getResourceData,
    getResourceLoading,
    getResourceDirtyState,
    canEditPermissions
} from '@js/selectors/resource';
import usePluginItems from '@mapstore/framework/hooks/usePluginItems';
import tabComponents from './containers/tabComponents';
import ResourcesPanelWrapper from '@mapstore/framework/plugins/ResourcesCatalog/components/ResourcesPanelWrapper';
import TargetSelectorPortal from '@mapstore/framework/plugins/ResourcesCatalog/components/TargetSelectorPortal';
import useResourcePanelWrapper from '@mapstore/framework/plugins/ResourcesCatalog/hooks/useResourcePanelWrapper';
import { getShowDetails } from '@mapstore/framework/plugins/ResourcesCatalog/selectors/resources';
import { setShowDetails } from '@mapstore/framework/plugins/ResourcesCatalog/actions/resources';
import PendingStatePrompt from '@mapstore/framework/plugins/ResourcesCatalog/containers/PendingStatePrompt';
import DetailsPanel from './containers/DetailsPanel';
import useDetectClickOut from '@js/hooks/useDetectClickOut';

/**
* @module ResourceDetails
*/

/**
 * render a panel for detail information about a resource inside the viewer pages
 * @name ResourceDetails
 * @prop {array} tabs array of tab object representing the structure of the displayed info properties
 * @example
 * {
 *  "name": "DetailViewer",
 *  "cfg": {
 *      "tabs": [
 *          {
 *              "type": "tab",
 *              "id": "info",
 *              "labelId": "gnviewer.info",
 *              "items": [
 *                  {
 *                      "type": "text",
 *                      "labelId": "gnviewer.title",
 *                      "value": "{context.get(state('gnResourceData'), 'title')}"
 *                  },
 *                  {
 *                      "type": "link",
 *                      "labelId": "gnviewer.owner",
 *                      "href": "{'/people/profile/' + context.get(state('gnResourceData'), 'owner.username')}",
 *                      "value": "{context.getUserResourceName(context.get(state('gnResourceData'), 'owner'))}",
 *                      "disableIf": "{!context.get(state('gnResourceData'), 'owner.username')}"
 *                  },
 *                  {
 *                      "type": "date",
 *                      "format": "MMMM Do YYYY",
 *                      "labelId": "gnviewer.published",
 *                      "value": "{context.get(state('gnResourceData'), 'date')}"
 *                  },
 *                  {
 *                      "type": "query",
 *                      "labelId": "gnviewer.resourceType",
 *                      "value": "{context.get(state('gnResourceData'), 'resource_type')}",
 *                      "pathname": "/",
 *                      "query": {
 *                          "f": "{context.get(state('gnResourceData'), 'resource_type')}"
 *                      }
 *                  },
 *                  {
 *                      "type": "html",
 *                      "labelId": "gnviewer.supplementalInformation",
 *                      "value": "{context.get(state('gnResourceData'), 'supplemental_information')}"
 *                  }
 *              ]
 *          }
 *      ]
 *  }
 * }
 */
function ResourceDetailsPanel({
    tabs = [
        {
            "type": "tab",
            "id": "info",
            "labelId": "gnviewer.info",
            "items": [
                {
                    "type": "text",
                    "labelId": "gnviewer.title",
                    "value": "{context.get(state('gnResourceData'), 'title')}"
                },
                {
                    "type": "link",
                    "labelId": "gnviewer.owner",
                    "href": "{'/people/profile/' + context.get(state('gnResourceData'), 'owner.username')}",
                    "value": "{context.getUserResourceName(context.get(state('gnResourceData'), 'owner'))}",
                    "disableIf": "{!context.get(state('gnResourceData'), 'owner.username')}"
                },
                {
                    "type": "date",
                    "format": "YYYY-MM-DD HH:mm",
                    "labelId": "{'gnviewer.'+context.get(state('gnResourceData'), 'date_type')}",
                    "value": "{context.get(state('gnResourceData'), 'date')}"
                },
                {
                    "type": "date",
                    "format": "YYYY-MM-DD HH:mm",
                    "labelId": "gnviewer.created",
                    "value": "{context.get(state('gnResourceData'), 'created')}"
                },
                {
                    "type": "date",
                    "format": "YYYY-MM-DD HH:mm",
                    "labelId": "gnviewer.lastModified",
                    "value": "{context.get(state('gnResourceData'), 'last_updated')}"
                },
                {
                    "type": "query",
                    "labelId": "gnviewer.resourceType",
                    "value": "{context.get(state('gnResourceData'), 'resource_type')}",
                    "pathname": "/",
                    "query": {
                        "f": "{context.get(state('gnResourceData'), 'resource_type')}"
                    }
                },
                {
                    "type": "{context.isDocumentExternalSource(state('gnResourceData')) ? 'link' : 'text'}",
                    "labelId": "gnviewer.sourceType",
                    "value": "{context.get(state('gnResourceData'), 'sourcetype', '').toLowerCase()}",
                    "href": "{context.get(state('gnResourceData'), 'href')}"
                },
                {
                    "type": "query",
                    "labelId": "gnviewer.category",
                    "value": "{context.get(state('gnResourceData'), 'category.gn_description')}",
                    "pathname": "/",
                    "query": {
                        "filter{category.identifier.in}": "{context.get(state('gnResourceData'), 'category.identifier')}"
                    }
                },
                {
                    "type": "link",
                    "labelId": "gnviewer.pointOfContact",
                    "value": "{context.getUserResourceNames(context.get(state('gnResourceData'), 'poc'))}",
                    "disableIf": "{!context.get(state('gnResourceData'), 'poc')}"
                },
                {
                    "type": "query",
                    "labelId": "gnviewer.keywords",
                    "value": "{context.get(state('gnResourceData'), 'keywords')}",
                    "valueKey": "name",
                    "pathname": "/",
                    "queryTemplate": {
                        "filter{keywords.slug.in}": "${slug}"
                    }
                },
                {
                    "type": "query",
                    "labelId": "gnviewer.regions",
                    "value": "{context.get(state('gnResourceData'), 'regions')}",
                    "valueKey": "name",
                    "pathname": "/",
                    "queryTemplate": {
                        "filter{regions.code.in}": "${code}"
                    }
                },
                {
                    "type": "text",
                    "labelId": "gnviewer.attribution",
                    "value": "{context.get(state('gnResourceData'), 'attribution')}"
                },
                {
                    "type": "text",
                    "labelId": "gnviewer.language",
                    "value": "{context.get(state('gnResourceData'), 'language')}"
                },
                {
                    "type": "html",
                    "labelId": "gnviewer.supplementalInformation",
                    "value": "{context.get(state('gnResourceData'), 'supplemental_information')}"
                },
                {
                    "type": "date",
                    "format": "YYYY-MM-DD HH:mm",
                    "labelId": "gnviewer.temporalExtent",
                    "value": {
                        "start": "{context.get(state('gnResourceData'), 'temporal_extent_start')}",
                        "end": "{context.get(state('gnResourceData'), 'temporal_extent_end')}"
                    }
                },
                {
                    "type": "link",
                    "style": "label",
                    "labelId": "gnviewer.viewFullMetadata",
                    "href": "{context.getMetadataDetailUrl(state('gnResourceData'))}",
                    "disableIf": "{!context.getMetadataDetailUrl(state('gnResourceData'))}"
                }
            ]
        },
        {
            "type": "permissions",
            "id": "permissions",
            "labelId": "gnviewer.permissions",
            "disableIf": "{!context.canAccessPermissions(state('gnResourceData'))}",
            "items": [true]
        },
        {
            "type": "locations",
            "id": "locations",
            "labelId": "gnviewer.locations",
            "items": "{({extent: context.get(state('gnResourceData'), 'extent')})}"
        },
        {
            "type": "attribute-table",
            "id": "attributes",
            "labelId": "gnviewer.attributes",
            "disableIf": "{context.get(state('gnResourceData'), 'resource_type') !== 'dataset'}",
            "items": "{context.get(state('gnResourceData'), 'attribute_set')}"
        },
        {
            "type": "linked-resources",
            "id": "related",
            "labelId": "gnviewer.linkedResources.label",
            "items": "{context.get(state('gnResourceData'), 'linkedResources')}"
        },
        {
            "type": "assets",
            "id": "assets",
            "labelId": "gnviewer.assets",
            "items": "{context.get(state('gnResourceData'), 'assets')}"
        },
        {
            "type": "settings",
            "id": "settings",
            "labelId": "gnviewer.management",
            "disableIf": "{!context.canManageResourceSettings(state('gnResourceData'))}",
            "items": [
                true
            ]
        }
    ],
    items,
    editable = true,
    canEdit,
    targetSelector,
    headerNodeSelector = '#gn-brand-navbar',
    navbarNodeSelector = '#ms-action-navbar',
    footerNodeSelector = '.gn-footer',
    width,
    height,
    show,
    onShow,
    enableFilters,
    resource,
    resourcesGridId,
    loading,
    pendingChanges,
    enablePreview,
    editingOverlay,
    closeOnClickOut
}, context) {

    const [confirmModal, setConfirmModal] = useState(false);
    const editing = canEdit && editable;

    const {
        stickyTop,
        stickyBottom
    } = useResourcePanelWrapper({
        headerNodeSelector,
        navbarNodeSelector,
        footerNodeSelector,
        width,
        height,
        active: true
    });

    function handleConfirm() {
        onShow(false);
    }

    function handleClose() {
        if (pendingChanges) {
            setConfirmModal(true);
        } else {
            handleConfirm();
        }
    }

    useEffect(() => {
        return () => {
            // close when unmount
            handleClose();
        };
    }, []);

    const node = useDetectClickOut({
        disabled: !closeOnClickOut || !show,
        onClickOut: () => {
            handleClose();
        }
    });


    const { loadedPlugins } = context;
    const configuredItems = usePluginItems({ items, loadedPlugins }, [resource?.pk]);
    const toolbarItems = [
        ...configuredItems.filter(item => item.target === "toolbar")
    ].sort((a, b) => a.position - b.position);

    return (
        <TargetSelectorPortal targetSelector={targetSelector}>
            <ResourcesPanelWrapper
                className="ms-resource-detail shadow-md"
                top={stickyTop}
                bottom={stickyBottom}
                show={show}
                enabled={show}
                editing={editingOverlay && pendingChanges}
            >
                <DetailsPanel
                    panelRef={node}
                    resource={resource}
                    loading={loading}
                    toolbarItems={toolbarItems}
                    onClose={handleClose}
                    editing={editing}
                    enableFilters={enableFilters}
                    enablePreview={enablePreview}
                    tabs={tabs}
                    tabComponents={tabComponents}
                    resourcesGridId={resourcesGridId}
                />
            </ResourcesPanelWrapper>
            <PendingStatePrompt
                show={!!confirmModal}
                onCancel={() => setConfirmModal(false)}
                onConfirm={handleConfirm}
                pendingState={!!pendingChanges}
                titleId="resourcesCatalog.detailsPendingChangesTitle"
                descriptionId="resourcesCatalog.detailsPendingChangesDescription"
                cancelId="resourcesCatalog.detailsPendingChangesCancel"
                confirmId="resourcesCatalog.detailsPendingChangesConfirm"
                variant="danger"
            />
        </TargetSelectorPortal>
    );
}

ResourceDetailsPanel.contextTypes = {
    loadedPlugins: PropTypes.object,
    plugins: PropTypes.object
};

const ResourceDetails = ({ defaultOpen, ...props }) => {
    useEffect(() => {
        if (props?.resource?.pk && defaultOpen) {
            props.onShow(true);
        }
    }, [props?.resource?.pk, defaultOpen]);
    return props?.resource?.pk && props.show ? <ResourceDetailsPanel {...props}/> : null;
};

const ResourceDetailsPlugin = connect(
    createStructuredSelector({
        resource: getResourceData,
        show: getShowDetails,
        loading: getResourceLoading,
        canEdit: canEditPermissions,
        pendingChanges: getResourceDirtyState
    }),
    {
        onShow: setShowDetails
    }
)(ResourceDetails);

export default createPlugin('ResourceDetails', {
    component: ResourceDetailsPlugin,
    containers: {
        ActionNavbar: {
            name: 'ResourceDetailsButton',
            Component: connect(() => ({}), { onShow: setShowDetails })(({ component, resourcesGridId, onShow }) => {
                const Component = component;
                function handleClick() {
                    onShow(true, resourcesGridId);
                }
                return Component ? (
                    <Component
                        onClick={handleClick}
                        glyph="details"
                        iconType="glyphicon"
                        square
                        labelId="resourcesCatalog.viewResourceProperties"
                    />
                ) : null;
            }),
            priority: 1,
            doNotHide: true
        },
        ResourcesGrid: {
            priority: 2,
            target: 'card-buttons',
            position: 2,
            Component: connect(
                createStructuredSelector({
                    selectedResource: getResourceData
                }),
                {
                    onSelect: requestResource,
                    onShow: setShowDetails
                }
            )(({ resourcesGridId, resource, onSelect, component, selectedResource, onShow }) => {
                const Component = component;
                function handleClick() {
                    if (!selectedResource['@ms-detail'] || selectedResource?.pk !== resource?.pk) {
                        onSelect(resource, resourcesGridId);
                    }
                    onShow(true, resourcesGridId);
                }
                return (
                    <Component
                        onClick={handleClick}
                        glyph="details"
                        iconType="glyphicon"
                        square
                        labelId="resourcesCatalog.viewResourceProperties"
                    />
                );
            }),
            doNotHide: true
        }
    },
    reducers: {
        gnresource,
        controls,
        config
    }
});