diff --git a/bims/api_urls.py b/bims/api_urls.py index 7ea5d7bcb..e3c21e8c8 100644 --- a/bims/api_urls.py +++ b/bims/api_urls.py @@ -64,7 +64,7 @@ from bims.api_views.hide_popup_info_user import HidePopupInfoUser from bims.api_views.send_notification_to_validator import \ SendNotificationValidation -from bims.views.context_layers import ContextLayerGroup, CloudNativeLayerAutoCompleteAPI +from bims.views.context_layers import ContextLayerGroup, CloudNativeLayerAutoCompleteAPI, ContextFilter from bims.views.locate import filter_farm_ids_view, get_farm_view from bims.api_views.user_boundary import ( UserBoundaryList, @@ -389,6 +389,9 @@ path('context-layer-group/', ContextLayerGroup.as_view(), name='context-layers-group'), + path('context-filter/', + ContextFilter.as_view(), + name='context-filter'), path('context-layer-group//', ContextLayerGroup.as_view(),), path('cloud-native-layer-autocomplete/', diff --git a/bims/package-lock.json b/bims/package-lock.json index f4223382a..9ebe2a3fa 100644 --- a/bims/package-lock.json +++ b/bims/package-lock.json @@ -15,11 +15,13 @@ "@babel/preset-env": "^7.0.0-beta.54", "@babel/preset-react": "^7.0.0-beta.54", "@babel/runtime": "^7.4.5", + "array-move": "^4.0.0", "async": "^2.6.4", "axios": "^1.7.4", "babel-core": "^7.0.0-bridge.0", "babel-jest": "^27.4.6", "babel-loader": "^8.3.0", + "bootstrap-icons": "^1.11.3", "braces": "^3.0.3", "clean-webpack-plugin": "^4.0.0-alpha.0", "clipboard": "^1.7.1", @@ -43,6 +45,7 @@ "react-bootstrap": "^1.6.1", "react-confirm-alert": "^2.7.0", "react-dom": "^18.3.1", + "react-easy-sort": "^1.6.0", "react-rnd": "^10.3.4", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", @@ -4604,6 +4607,17 @@ "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", "integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=" }, + "node_modules/array-move": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/array-move/-/array-move-4.0.0.tgz", + "integrity": "sha512-+RY54S8OuVvg94THpneQvFRmqWdAHeqtMzgMW6JNurHxe8rsS07cHQdfGkXnTUXiBcyZ0j3SiDIxxj0RPiqCkQ==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/array-union": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", @@ -4981,6 +4995,21 @@ "multicast-dns": "^7.2.5" } }, + "node_modules/bootstrap-icons": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", + "integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ] + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -10854,6 +10883,33 @@ "prop-types": "^15.6.0" } }, + "node_modules/react-easy-sort": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/react-easy-sort/-/react-easy-sort-1.6.0.tgz", + "integrity": "sha512-zd9Nn90wVlZPEwJrpqElN87sf9GZnFR1StfjgNQVbSpR5QTSzCHjEYK6REuwq49Ip+76KOMSln9tg/ST2KLelg==", + "dependencies": { + "array-move": "^3.0.1", + "tslib": "2.0.1" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, + "node_modules/react-easy-sort/node_modules/array-move": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/array-move/-/array-move-3.0.1.tgz", + "integrity": "sha512-H3Of6NIn2nNU1gsVDqDnYKY/LCdWvCMMOWifNGhKcVQgiZ6nOek39aESOvro6zmueP07exSl93YLvkN4fZOkSg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -12093,9 +12149,9 @@ "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" }, "node_modules/tiny-invariant": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", - "integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", + "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==" }, "node_modules/tiny-lr": { "version": "1.1.1", @@ -12155,6 +12211,11 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", + "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==" + }, "node_modules/tween.js": { "version": "16.6.0", "resolved": "https://registry.npmjs.org/tween.js/-/tween.js-16.6.0.tgz", diff --git a/bims/package.json b/bims/package.json index 42e2a58f5..568ce0bb5 100644 --- a/bims/package.json +++ b/bims/package.json @@ -27,11 +27,13 @@ "@babel/preset-env": "^7.0.0-beta.54", "@babel/preset-react": "^7.0.0-beta.54", "@babel/runtime": "^7.4.5", + "array-move": "^4.0.0", "async": "^2.6.4", "axios": "^1.7.4", "babel-core": "^7.0.0-bridge.0", "babel-jest": "^27.4.6", "babel-loader": "^8.3.0", + "bootstrap-icons": "^1.11.3", "braces": "^3.0.3", "clean-webpack-plugin": "^4.0.0-alpha.0", "clipboard": "^1.7.1", @@ -55,6 +57,7 @@ "react-bootstrap": "^1.6.1", "react-confirm-alert": "^2.7.0", "react-dom": "^18.3.1", + "react-easy-sort": "^1.6.0", "react-rnd": "^10.3.4", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", diff --git a/bims/static/react/css/ContextLayers.scss b/bims/static/react/css/ContextLayers.scss index 6a5a7dffa..5f532e7d1 100644 --- a/bims/static/react/css/ContextLayers.scss +++ b/bims/static/react/css/ContextLayers.scss @@ -20,3 +20,26 @@ .autocomplete-item:hover { background-color: #f0f0f0; } + +.context-filter-header { + cursor: pointer; + font-weight: bold; + padding: 10px; + border: 1px solid #ddd; + border-radius: 5px; + background-color: #f9f9f9; +} + +.context-group-box { + margin-left: 20px; + border-left: 2px solid #ddd; + margin-bottom: 10px; + background-color: white; +} + +.context-group-item { + margin-bottom: 5px; + border-bottom: 1px solid #ddd; + border-right: 1px solid #ddd; + padding: 10px; +} \ No newline at end of file diff --git a/bims/static/react/js/ContextLayersView.jsx b/bims/static/react/js/ContextLayersView.jsx index 63aa6103d..a66439b7e 100644 --- a/bims/static/react/js/ContextLayersView.jsx +++ b/bims/static/react/js/ContextLayersView.jsx @@ -1,132 +1,13 @@ import React, { useState, useEffect } from 'react'; -import axios from "axios"; import '../css/ContextLayers.scss'; import {createRoot} from "react-dom/client"; -import {ContextGroupDetailModal} from "./components/ContextGroupDetailModal"; -import {ContextGroupCard} from "./components/ContextGroupCard"; -import {Button} from "reactstrap"; +import ContextGroupView from "./components/ContextGroupView"; +import ContextFilterView from "./components/ContextFilterView"; const ContextLayersView = (props) => { - const [loading, setLoading] = useState(true) - const [contextGroups, setContextGroups] = useState([]) - const [error, setError] = useState(null) - const [selectedGroup, setSelectedGroup] = useState(null) - const [showModal, setShowModal] = useState(false) - const [filterText, setFilterText] = useState('') - const [showOnlyActive, setShowOnlyActive] = useState(false) - - const fetchContextGroups = async () => { - setContextGroups([]) - try { - setLoading(true) - const response = await axios.get('/api/context-layer-group'); - setContextGroups(response.data); - } catch (error) { - console.error("Error fetching context groups:", error); - setError("Failed to load context groups"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchContextGroups(); - }, []); - - const handleCardClick = (group) => { - setSelectedGroup(group); - setShowModal(true); - }; - - const handleCloseModal = () => { - setShowModal(false); - setSelectedGroup(null); - }; - - const handleSaveGroup = async (groupData) => { - setShowModal(false) - let url = `/api/context-layer-group/${groupData.id}/`; - - const response = await fetch(url, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': props.csrfToken - }, - body: JSON.stringify(groupData), - }); - - if (response.ok) { - const data = await response.json(); - fetchContextGroups() - } else { - console.error('Error saving group:', response.statusText); - } - } - - const handleFilterChange = (e) => { - setFilterText(e.target.value); - }; - - const handleShowOnlyActiveChange = (e) => { - setShowOnlyActive(e.target.checked); - }; - - const filteredGroups = contextGroups.filter(group => { - const matchesName = group.name.toLowerCase().includes(filterText.toLowerCase()); - const matchesActive = showOnlyActive ? group.active : true; - return matchesName && matchesActive; - }); - - if (loading) { - return
Loading...
; - } - - if (error) { - return
{error}
; - } - return ( -
-
- -
-
- - -
- -
-
-
- {filteredGroups.map((group) => ( -
- -
- ))} -
- -
+ ) } diff --git a/bims/static/react/js/components/ContextFilterView.jsx b/bims/static/react/js/components/ContextFilterView.jsx new file mode 100644 index 000000000..4f6dc3218 --- /dev/null +++ b/bims/static/react/js/components/ContextFilterView.jsx @@ -0,0 +1,163 @@ +import React, { useState, useEffect } from 'react'; +import axios from "axios"; +import SortableList, {SortableItem, SortableKnob} from "react-easy-sort"; +import { arrayMoveImmutable } from "array-move"; +import "bootstrap-icons/font/bootstrap-icons.css"; + + +const ContextFilterView = (props) => { + const [loading, setLoading] = useState(true); + const [contextFilters, setContextFilters] = useState([]); + const [error, setError] = useState(null); + const [filterText, setFilterText] = useState(''); + const [expandedGroups, setExpandedGroups] = useState({}); + const [childContextGroups, setChildContextGroups] = useState({}); + + const contextLayerGroupAPI = '/api/context-filter/'; + + const updateOrder = async (updatedFilters, updatedGroups) => { + try { + const data = { + filters: updatedFilters, + groups: updatedGroups + }; + await axios.put(contextLayerGroupAPI, data, { + headers: { + 'X-CSRFToken': props.csrfToken, + 'Content-Type': 'application/json' + } + }); + console.log("Order updated successfully."); + } catch (error) { + console.error("Failed to update order:", error); + } + }; + + const fetchContextFilters = async () => { + setContextFilters([]); + try { + setLoading(true); + const response = await axios.get(contextLayerGroupAPI); + setContextFilters(response.data); + + // Default to expanded for all groups + const defaultExpanded = {}; + const defaultChildGroups = {}; + response.data.forEach(group => { + defaultExpanded[group.id] = true; + defaultChildGroups[group.id] = group.location_context_groups; + }); + setExpandedGroups(defaultExpanded); + setChildContextGroups(defaultChildGroups); + } catch (error) { + console.error("Error fetching context filters:", error); + setError("Failed to load context filters"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchContextFilters(); + }, []); + + const toggleGroup = (groupId) => { + setExpandedGroups(prevState => ({ + ...prevState, + [groupId]: !prevState[groupId] + })); + }; + + const onSortParent = (oldIndex, newIndex) => { + const updatedFilters = arrayMoveImmutable(contextFilters, oldIndex, newIndex).map((filter, index) => ({ + id: filter.id, + display_order: index + 1 + })); + + updateOrder(updatedFilters, childContextGroups) + setContextFilters(arrayMoveImmutable(contextFilters, oldIndex, newIndex)) + }; + + const onSortChild = (parentId, oldIndex, newIndex) => { + const updatedGroups = { + [parentId]: arrayMoveImmutable(childContextGroups[parentId], oldIndex, newIndex).map((group, index) => ({ + id: group.group.id, + group_display_order: index + 1 + })) + }; + updateOrder(contextFilters, updatedGroups); + setChildContextGroups(prevState => ({ + ...prevState, + [parentId]: arrayMoveImmutable(prevState[parentId], oldIndex, newIndex) + })); + }; + + const handleFilterChange = (e) => { + setFilterText(e.target.value); + }; + + const filteredContextFilters = contextFilters.filter(group => + group.title.toLowerCase().includes(filterText.toLowerCase()) + ); + + if (loading) { + return
Loading...
; + } + + if (error) { + return
{error}
; + } + + return ( +
+
+ +
+
+ + {filteredContextFilters.map((contextFilter) => ( + +
+
toggleGroup(contextFilter.id)} + className="context-filter-header" + > + + + + {contextFilter.title} +
+ {expandedGroups[contextFilter.id] && ( + onSortChild(contextFilter.id, oldIndex, newIndex)} draggedItemClassName={'dragged'}> +
+ {childContextGroups[contextFilter.id]?.map((contextGroup) => ( + +
+ + + + {contextGroup.group.name} +
+
+ ))} +
+
+ )} +
+
+ ))} +
+
+
+ ); +} + +export default ContextFilterView; diff --git a/bims/static/react/js/components/ContextGroupView.jsx b/bims/static/react/js/components/ContextGroupView.jsx new file mode 100644 index 000000000..f5513de43 --- /dev/null +++ b/bims/static/react/js/components/ContextGroupView.jsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect } from 'react'; +import axios from "axios"; +import {ContextGroupDetailModal} from "./ContextGroupDetailModal"; +import {ContextGroupCard} from "./ContextGroupCard"; +import {Button} from "reactstrap"; + + +const ContextGroupView = (props) => { + const [loading, setLoading] = useState(true) + const [contextGroups, setContextGroups] = useState([]) + const [error, setError] = useState(null) + const [selectedGroup, setSelectedGroup] = useState(null) + const [showModal, setShowModal] = useState(false) + const [filterText, setFilterText] = useState('') + const [showOnlyActive, setShowOnlyActive] = useState(false) + + const contextLayerGroupAPI = '/api/context-layer-group' + + const fetchContextGroups = async () => { + setContextGroups([]) + try { + setLoading(true) + const response = await axios.get(contextLayerGroupAPI); + setContextGroups(response.data); + } catch (error) { + console.error("Error fetching context groups:", error); + setError("Failed to load context groups"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchContextGroups(); + }, []); + + const handleCardClick = (group) => { + setSelectedGroup(group); + setShowModal(true); + }; + + const handleCloseModal = () => { + setShowModal(false); + setSelectedGroup(null); + }; + + const handleSaveGroup = async (groupData) => { + setShowModal(false) + let url = `/api/context-layer-group/${groupData.id}/`; + + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': props.csrfToken + }, + body: JSON.stringify(groupData), + }); + + if (response.ok) { + const data = await response.json(); + fetchContextGroups() + } else { + console.error('Error saving group:', response.statusText); + } + } + + const handleFilterChange = (e) => { + setFilterText(e.target.value); + }; + + const handleShowOnlyActiveChange = (e) => { + setShowOnlyActive(e.target.checked); + }; + + const filteredGroups = contextGroups.filter(group => { + const matchesName = group.name.toLowerCase().includes(filterText.toLowerCase()); + const matchesActive = showOnlyActive ? group.active : true; + return matchesName && matchesActive; + }); + + if (loading) { + return
Loading...
; + } + + if (error) { + return
{error}
; + } + + return ( +
+
+ +
+
+ + +
+ +
+
+
+ {filteredGroups.map((group) => ( +
+ +
+ ))} +
+ +
+ ) +} + +export default ContextGroupView; diff --git a/bims/static/react/js/components/ContextLayerFilters.jsx b/bims/static/react/js/components/ContextLayerFilters.jsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/bims/templates/navigation_bar.html b/bims/templates/navigation_bar.html index 2ed619f19..a4e530cc6 100644 --- a/bims/templates/navigation_bar.html +++ b/bims/templates/navigation_bar.html @@ -117,6 +117,8 @@ {% if not fada_site %} Validate Records {% endif %} + Upload Spatial Layers + Context Layers Admin Page {% if not fada_site %} diff --git a/bims/views/context_layers.py b/bims/views/context_layers.py index 3bd10fbd9..58c75415b 100644 --- a/bims/views/context_layers.py +++ b/bims/views/context_layers.py @@ -1,5 +1,6 @@ from braces.views import SuperuserRequiredMixin from django.views.generic import TemplateView +from django_tenants.utils import get_tenant from rest_framework import serializers, status from rest_framework.response import Response from rest_framework.views import APIView @@ -7,7 +8,14 @@ from bims.models.location_context_group import ( LocationContextGroup ) +from bims.models.location_context_filter import ( + LocationContextFilter +) +from bims.models.location_context_filter_group_order import ( + LocationContextFilterGroupOrder +) from bims.utils.uuid import is_uuid +from bims.tasks.location_context import generate_spatial_scale_filter from cloud_native_gis.models import Layer from cloud_native_gis.serializer.layer import LayerSerializer @@ -39,6 +47,33 @@ class Meta: fields = '__all__' +class LocationFilterGroupSerializer(serializers.ModelSerializer): + group = LocationContextGroupSerializer(many=False) + + class Meta: + model = LocationContextFilterGroupOrder + fields = '__all__' + + +class LocationFilterSerializer(serializers.ModelSerializer): + location_context_groups = serializers.SerializerMethodField() + + def get_location_context_groups(self, obj): + filter_group_orders = ( + LocationContextFilterGroupOrder.objects.filter(filter=obj) + ) + return ( + LocationFilterGroupSerializer( + filter_group_orders, + many=True).data + ) + + class Meta: + model = LocationContextFilter + fields = '__all__' + + + class ContextLayersView(SuperuserRequiredMixin, TemplateView): template_name = 'context_layers.html' @@ -84,6 +119,45 @@ def get(self, request, *args): return Response(context_group_data.data) +class ContextFilter(SuperuserRequiredMixin, APIView): + def get(self, request, *args): + context_filters = ( + LocationContextFilter.objects.all().order_by( + 'display_order') + ) + context_filter_data = LocationFilterSerializer( + context_filters, many=True + ) + return Response(context_filter_data.data) + + def put(self, request, *args): + """Update the order of context filters and groups.""" + data = request.data + filters_data = data.get('filters', []) + groups_data = data.get('groups', {}) + + # Update filter order + for filter_data in filters_data: + filter_id = filter_data.get('id') + new_order = filter_data.get('display_order') + LocationContextFilter.objects.filter(id=filter_id).update(display_order=new_order) + + # Update group order within each filter + for filter_id, groups in groups_data.items(): + for group_data in groups: + group_id = group_data.get('id') + new_group_order = group_data.get('group_display_order') + LocationContextFilterGroupOrder.objects.filter( + filter_id=filter_id, + group_id=group_id + ).update(group_display_order=new_group_order) + + tenant_id = get_tenant(request).id + generate_spatial_scale_filter.delay(tenant_id) + + return Response({"message": "Order updated successfully."}, status=200) + + class CloudNativeLayerAutoCompleteAPI(APIView): def get(self, request, *args): query = request.query_params.get('q', '')