@eeacms/volto-clms-theme 1.1.203 → 1.1.205

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ ### [1.1.205](https://github.com/eea/volto-clms-theme/compare/1.1.204...1.1.205) - 18 December 2024
8
+
9
+ #### :rocket: New Features
10
+
11
+ - feat: add a drag-and-drop field in the dataset to arrange the technical documents in the accordion - refs #271669 [ana-oprea - [`8aebc43`](https://github.com/eea/volto-clms-theme/commit/8aebc43986f30b0092a0ac77f3e653cfc94a179d)]
12
+
13
+ ### [1.1.204](https://github.com/eea/volto-clms-theme/compare/1.1.203...1.1.204) - 9 December 2024
14
+
15
+ #### :bug: Bug Fixes
16
+
17
+ - fix: In downloads page, sometimes the dataset name is not loaded - refs #279962 [ana-oprea - [`48a85bf`](https://github.com/eea/volto-clms-theme/commit/48a85bfb55262082e7d8e26716ac47ef3159ae4e)]
18
+
7
19
  ### [1.1.203](https://github.com/eea/volto-clms-theme/compare/1.1.202...1.1.203) - 3 December 2024
8
20
 
9
21
  ### [1.1.202](https://github.com/eea/volto-clms-theme/compare/1.1.201...1.1.202) - 20 November 2024
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-clms-theme",
3
- "version": "1.1.203",
3
+ "version": "1.1.205",
4
4
  "description": "volto-clms-theme: Volto theme for CLMS site",
5
5
  "main": "src/index.js",
6
6
  "author": "CodeSyntax for the European Environment Agency",
@@ -116,12 +116,30 @@ const CclRelatedListingView = (props) => {
116
116
  }, [data, id, uid, dispatch]);
117
117
 
118
118
  React.useEffect(() => {
119
- if (sLoaded) {
120
- p_functions.setOriginalDataList([...searchSubrequests.items]);
121
- p_functions.setDataList([...searchSubrequests.items]);
119
+ if (sLoaded && properties?.technical_documents_order) {
120
+ const orderMap = new Map(
121
+ properties?.technical_documents_order.items?.map((item, index) => [
122
+ item.id,
123
+ index,
124
+ ]) || [],
125
+ );
126
+
127
+ // Sort libraries based on the technical_documents_order
128
+ const sorted = [...libraries].sort(
129
+ (a, b) =>
130
+ (orderMap.get(a['@id']) ?? Infinity) -
131
+ (orderMap.get(b['@id']) ?? Infinity),
132
+ );
133
+
134
+ p_functions.setOriginalDataList([...sorted]);
135
+ p_functions.setDataList([...sorted]);
136
+ }
137
+ if (sLoaded && !properties?.technical_documents_order) {
138
+ p_functions.setOriginalDataList([...libraries]);
139
+ p_functions.setDataList([...libraries]);
122
140
  }
123
141
  // eslint-disable-next-line react-hooks/exhaustive-deps
124
- }, [sLoaded]);
142
+ }, [sLoaded, properties.technical_documents_order, libraries]);
125
143
 
126
144
  return (
127
145
  <>
@@ -35,10 +35,10 @@ const DataSetInfoContent = (props) => {
35
35
  if (UID) {
36
36
  dispatch(
37
37
  searchContent(
38
- '',
38
+ 'en/technical-library',
39
39
  {
40
40
  portal_type: 'TechnicalLibrary',
41
- path: '/',
41
+ path: 'en/technical-library',
42
42
  associated_datasets: UID,
43
43
  b_size: 99999,
44
44
  },
@@ -41,7 +41,7 @@ const DatasetNaming = (props) => {
41
41
  const { dataset } = props;
42
42
  return (
43
43
  <>
44
- {`${dataset['name'] ? dataset['name'] : 'loading...'} -
44
+ {`${dataset['name'] || dataset['DatasetTitle'] || 'loading...'} -
45
45
  ${
46
46
  dataset?.OutputFormat
47
47
  ? dataset?.OutputFormat
@@ -0,0 +1,174 @@
1
+ import React, { useEffect, useCallback, useMemo } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { useLocation } from 'react-router-dom';
4
+ import { useDispatch, useSelector } from 'react-redux';
5
+ import { searchContent } from '@plone/volto/actions';
6
+ import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
7
+ import { FormFieldWrapper, Icon } from '@plone/volto/components';
8
+ import { Button } from 'semantic-ui-react';
9
+ import dragSVG from '@plone/volto/icons/drag.svg';
10
+
11
+ const reorder = (list, startIndex, endIndex) => {
12
+ const result = Array.from(list);
13
+ const [removed] = result.splice(startIndex, 1);
14
+ result.splice(endIndex, 0, removed);
15
+ return result;
16
+ };
17
+
18
+ const DocumentItem = React.memo(({ item, provided }) => (
19
+ <div
20
+ ref={provided.innerRef}
21
+ {...provided.draggableProps}
22
+ {...provided.dragHandleProps}
23
+ className="document-item"
24
+ role="listitem"
25
+ aria-label={`Draggable item: ${item.title}`}
26
+ >
27
+ <div style={{ display: 'inline-block' }}>
28
+ <Button icon basic aria-label="Drag handle">
29
+ <Icon
30
+ name={dragSVG}
31
+ size="20px"
32
+ color="#878f93"
33
+ className="content drag handle"
34
+ />
35
+ </Button>
36
+ </div>
37
+ <div>{item.title}</div>
38
+ </div>
39
+ ));
40
+
41
+ const OrderDocumentsWidget = (props) => {
42
+ const { id, formData, onChange, value } = props;
43
+ const UID = formData?.UID;
44
+ const location = useLocation();
45
+ const dispatch = useDispatch();
46
+ const searchSubrequests = useSelector((state) => state.search.subrequests);
47
+ const [documentsList, setDocumentsList] = React.useState([]);
48
+ const [isLoading, setIsLoading] = React.useState(true);
49
+
50
+ const fetchDocuments = useCallback(() => {
51
+ if (UID) {
52
+ const sort_on = ['documentation_sorting', 'sortable_title'];
53
+ const sort_order = ['ascending', 'ascending'];
54
+
55
+ setIsLoading(true);
56
+ dispatch(
57
+ searchContent(
58
+ 'en/technical-library',
59
+ {
60
+ portal_type: 'TechnicalLibrary',
61
+ path: 'en/technical-library',
62
+ associated_datasets: UID,
63
+ sort_on: sort_on,
64
+ sort_order: sort_order,
65
+ b_size: 99999,
66
+ },
67
+ id,
68
+ ),
69
+ ).finally(() => setIsLoading(false));
70
+ }
71
+ }, [id, UID, dispatch]);
72
+
73
+ const handleDragEnd = useCallback(
74
+ (result) => {
75
+ if (!result.destination) return;
76
+
77
+ const reorderedList = reorder(
78
+ documentsList,
79
+ result.source.index,
80
+ result.destination.index,
81
+ );
82
+
83
+ onChange(id, { items: reorderedList });
84
+ setDocumentsList(reorderedList);
85
+ },
86
+ [documentsList, onChange, id],
87
+ );
88
+
89
+ const memoizedList = useMemo(
90
+ () => (
91
+ <DragDropContext onDragEnd={handleDragEnd}>
92
+ <Droppable droppableId="documents">
93
+ {(provided) => (
94
+ <div
95
+ ref={provided.innerRef}
96
+ {...provided.droppableProps}
97
+ className="documents-list"
98
+ role="list"
99
+ aria-label="Draggable documents list"
100
+ >
101
+ {documentsList.map((item, index) => (
102
+ <Draggable key={item.id} draggableId={item.id} index={index}>
103
+ {(provided) => (
104
+ <DocumentItem item={item} provided={provided} />
105
+ )}
106
+ </Draggable>
107
+ ))}
108
+ {provided.placeholder}
109
+ </div>
110
+ )}
111
+ </Droppable>
112
+ </DragDropContext>
113
+ ),
114
+ [documentsList, handleDragEnd],
115
+ );
116
+
117
+ useEffect(() => {
118
+ fetchDocuments();
119
+ }, [fetchDocuments, location]);
120
+
121
+ useEffect(() => {
122
+ if (searchSubrequests?.[id]?.items) {
123
+ const libraries = searchSubrequests[id].items;
124
+ const receivedDocumentsList = libraries.map((item) => ({
125
+ id: item['@id'],
126
+ title: item.title,
127
+ }));
128
+
129
+ if (value?.items?.length < receivedDocumentsList.length) {
130
+ const newItems = receivedDocumentsList.filter(
131
+ (item) => !value.items.some((item2) => item2.id === item.id),
132
+ );
133
+ setDocumentsList([...value.items, ...newItems]);
134
+ } else {
135
+ setDocumentsList(value.items);
136
+ }
137
+ }
138
+ }, [searchSubrequests, id, value]);
139
+
140
+ return (
141
+ <FormFieldWrapper
142
+ {...props}
143
+ draggable={true}
144
+ className="drag-drop-list-widget"
145
+ >
146
+ <div className="order-documents-area">
147
+ <div className="documents-list-header">
148
+ <div>Technical Document Title</div>
149
+ </div>
150
+ {isLoading ? (
151
+ <div className="loading-documents">Loading documents...</div>
152
+ ) : (
153
+ memoizedList
154
+ )}
155
+ </div>
156
+ </FormFieldWrapper>
157
+ );
158
+ };
159
+
160
+ OrderDocumentsWidget.propTypes = {
161
+ id: PropTypes.string.isRequired,
162
+ formData: PropTypes.object,
163
+ onChange: PropTypes.func.isRequired,
164
+ value: PropTypes.shape({
165
+ items: PropTypes.arrayOf(
166
+ PropTypes.shape({
167
+ id: PropTypes.string,
168
+ title: PropTypes.string,
169
+ }),
170
+ ),
171
+ }),
172
+ };
173
+
174
+ export default React.memo(OrderDocumentsWidget);
@@ -0,0 +1,209 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
+ import { Provider } from 'react-intl-redux';
4
+ import configureStore from 'redux-mock-store';
5
+ import OrderDocumentsWidget from './OrderDocumentsWidget';
6
+ import { MemoryRouter } from 'react-router-dom';
7
+ import '@testing-library/jest-dom/extend-expect';
8
+
9
+ const mockStore = configureStore([]);
10
+
11
+ jest.mock('@plone/volto/actions', () => ({
12
+ searchContent: jest.fn(() => ({
13
+ type: 'SEARCH_CONTENT',
14
+ path: 'en/technical-library',
15
+ subrequest: 'test-widget',
16
+ })),
17
+ }));
18
+
19
+ describe('OrderDocumentsWidget', () => {
20
+ const SPACE = { keyCode: 32 };
21
+ const ARROW_UP = { keyCode: 38 };
22
+ const ARROW_DOWN = { keyCode: 40 };
23
+
24
+ let store;
25
+ let mockOnChange;
26
+ let initialState;
27
+
28
+ beforeEach(() => {
29
+ initialState = {
30
+ intl: {
31
+ locale: 'en',
32
+ messages: {},
33
+ },
34
+ search: {
35
+ subrequests: {
36
+ testId: {
37
+ items: [
38
+ { '@id': 'doc1', title: 'Document 1' },
39
+ { '@id': 'doc2', title: 'Document 2' },
40
+ { '@id': 'doc3', title: 'Document 3' },
41
+ ],
42
+ },
43
+ },
44
+ },
45
+ };
46
+ store = mockStore(initialState);
47
+ mockOnChange = jest.fn();
48
+ store.dispatch = jest.fn(() => Promise.resolve());
49
+ });
50
+
51
+ it('renders the widget with documents', async () => {
52
+ act(() => {
53
+ render(
54
+ <Provider store={store}>
55
+ <MemoryRouter>
56
+ <OrderDocumentsWidget
57
+ id="testId"
58
+ formData={{ UID: 'someUID' }}
59
+ onChange={mockOnChange}
60
+ value={{ items: [] }}
61
+ />
62
+ </MemoryRouter>
63
+ </Provider>,
64
+ );
65
+ });
66
+ await Promise.resolve();
67
+
68
+ expect(screen.getByText('Technical Document Title')).toBeInTheDocument();
69
+ expect(screen.getByText('Document 1')).toBeInTheDocument();
70
+ expect(screen.getByText('Document 2')).toBeInTheDocument();
71
+ expect(screen.getByText('Document 3')).toBeInTheDocument();
72
+ });
73
+
74
+ it('shows loading state when fetching documents', async () => {
75
+ act(() => {
76
+ render(
77
+ <Provider store={store}>
78
+ <MemoryRouter>
79
+ <OrderDocumentsWidget
80
+ id="testId"
81
+ formData={{ UID: 'someUID' }}
82
+ onChange={mockOnChange}
83
+ value={{ items: [] }}
84
+ />
85
+ </MemoryRouter>
86
+ </Provider>,
87
+ );
88
+ });
89
+
90
+ expect(screen.getByText('Loading documents...')).toBeInTheDocument();
91
+ });
92
+
93
+ it('calls onChange with reordered documents after drag-and-drop', async () => {
94
+ const { container } = render(
95
+ <Provider store={store}>
96
+ <MemoryRouter>
97
+ <OrderDocumentsWidget
98
+ id="testId"
99
+ formData={{ UID: 'someUID' }}
100
+ onChange={mockOnChange}
101
+ value={{
102
+ items: [
103
+ { id: 'doc1', title: 'Document 1' },
104
+ { id: 'doc2', title: 'Document 2' },
105
+ { id: 'doc3', title: 'Document 3' },
106
+ ],
107
+ }}
108
+ />
109
+ </MemoryRouter>
110
+ </Provider>,
111
+ );
112
+
113
+ await Promise.resolve();
114
+
115
+ const dragItem = container.querySelector(
116
+ 'div[aria-label="Draggable item: Document 1"]',
117
+ );
118
+
119
+ fireEvent.keyDown(dragItem, SPACE);
120
+ fireEvent.keyDown(dragItem, ARROW_DOWN);
121
+ fireEvent.keyDown(dragItem, SPACE);
122
+
123
+ expect(mockOnChange).toHaveBeenCalledWith('testId', {
124
+ items: [
125
+ { id: 'doc2', title: 'Document 2' },
126
+ { id: 'doc1', title: 'Document 1' },
127
+ { id: 'doc3', title: 'Document 3' },
128
+ ],
129
+ });
130
+
131
+ const dragItem2 = container.querySelector(
132
+ 'div[aria-label="Draggable item: Document 3"]',
133
+ );
134
+
135
+ fireEvent.keyDown(dragItem2, SPACE);
136
+ fireEvent.keyDown(dragItem2, ARROW_UP);
137
+ fireEvent.keyDown(dragItem2, SPACE);
138
+
139
+ expect(mockOnChange).toHaveBeenCalledWith('testId', {
140
+ items: [
141
+ { id: 'doc2', title: 'Document 2' },
142
+ { id: 'doc3', title: 'Document 3' },
143
+ { id: 'doc1', title: 'Document 1' },
144
+ ],
145
+ });
146
+ });
147
+
148
+ it('updates state when new documents are fetched from Redux', async () => {
149
+ const { rerender } = render(
150
+ <Provider store={store}>
151
+ <MemoryRouter>
152
+ <OrderDocumentsWidget
153
+ id="testId"
154
+ formData={{ UID: 'someUID' }}
155
+ onChange={mockOnChange}
156
+ value={{ items: [] }}
157
+ />
158
+ </MemoryRouter>
159
+ </Provider>,
160
+ );
161
+
162
+ await Promise.resolve();
163
+
164
+ const updatedState = {
165
+ intl: {
166
+ locale: 'en',
167
+ messages: {},
168
+ },
169
+ search: {
170
+ subrequests: {
171
+ testId: {
172
+ items: [
173
+ { '@id': 'doc1', title: 'Document 1' },
174
+ { '@id': 'doc2', title: 'Document 2' },
175
+ { '@id': 'doc3', title: 'Document 3' },
176
+ { '@id': 'doc4', title: 'Document 4' },
177
+ ],
178
+ },
179
+ },
180
+ },
181
+ };
182
+
183
+ const updatedStore = mockStore(updatedState);
184
+ mockOnChange = jest.fn();
185
+ updatedStore.dispatch = jest.fn(() => Promise.resolve());
186
+
187
+ act(() => {
188
+ rerender(
189
+ <Provider store={updatedStore}>
190
+ <MemoryRouter>
191
+ <OrderDocumentsWidget
192
+ id="testId"
193
+ formData={{ UID: 'someUID' }}
194
+ onChange={mockOnChange}
195
+ value={{ items: [] }}
196
+ />
197
+ </MemoryRouter>
198
+ </Provider>,
199
+ );
200
+ });
201
+
202
+ await Promise.resolve();
203
+
204
+ expect(screen.getByText('Document 1')).toBeInTheDocument();
205
+ expect(screen.getByText('Document 2')).toBeInTheDocument();
206
+ expect(screen.getByText('Document 3')).toBeInTheDocument();
207
+ expect(screen.getByText('Document 4')).toBeInTheDocument();
208
+ });
209
+ });
package/src/index.js CHANGED
@@ -30,6 +30,7 @@ import CLMSTechnicalLibraryView from './components/CLMSTechnicalLibraryView/CLMS
30
30
  import CLMSProductionUpdatesView from './components/CLMSProductionUpdatesView/CLMSProductionUpdatesView';
31
31
 
32
32
  // WIDGET
33
+ import OrderDocumentsWidget from './components/Widgets/OrderDocumentsWidget';
33
34
  import BoundingWidget from './components/Widgets/BoundingWidget';
34
35
  import ContactWidget from './components/Widgets/ContactWidget';
35
36
  import DatasetDownloadInformationWidget from './components/Widgets/DatasetDownloadInformationWidget';
@@ -98,6 +99,7 @@ const applyConfig = (config) => {
98
99
  config.widgets.type.tabs = TabsWidget;
99
100
  config.widgets.widget = {
100
101
  ...config.widgets.widget,
102
+ order_docs_widget: OrderDocumentsWidget,
101
103
  bounding_widget: BoundingWidget,
102
104
  layer_widget: MapLayersWidget,
103
105
  downloadable_files_widget: DownloadableFilesTableWidget,
@@ -22,4 +22,5 @@
22
22
  @import 'ccl-tabs';
23
23
  @import 'meeting';
24
24
  @import 'downloadable-files-table-widget';
25
- @import 'video';
25
+ @import 'video';
26
+ @import 'order-documents-widget';
@@ -0,0 +1,67 @@
1
+ .order-documents-area {
2
+ width: 100%;
3
+ max-width: 800px;
4
+ margin: 16px auto;
5
+ border: 1px solid #e0e0e0;
6
+ border-radius: 8px;
7
+ overflow: hidden;
8
+ background-color: #ffffff;
9
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
10
+
11
+ [data-rbd-draggable-context-id] {
12
+ margin-bottom: 0.5rem;
13
+ }
14
+
15
+ /* Table header */
16
+ .documents-list-header {
17
+ display: flex;
18
+ background-color: #f5f5f5;
19
+ padding: 12px 16px;
20
+ font-weight: bold;
21
+ border-bottom: 1px solid #e0e0e0;
22
+ }
23
+
24
+ .documents-list-header div {
25
+ text-align: left;
26
+ }
27
+
28
+ /* List container */
29
+ .documents-list {
30
+ display: flex;
31
+ flex-direction: column;
32
+ color: @clmsGreen;
33
+ }
34
+
35
+ /* Individual document items (rows) */
36
+ .document-item {
37
+ display: flex;
38
+ align-items: center;
39
+ padding: 12px 16px;
40
+ border-bottom: 1px solid #e0e0e0;
41
+ background-color: #ffffff;
42
+ transition: background-color 0.2s ease;
43
+ cursor: grab;
44
+ }
45
+
46
+ .document-item:last-child {
47
+ border-bottom: none;
48
+ }
49
+
50
+ .document-item:hover {
51
+ background-color: #f9f9f9;
52
+ }
53
+
54
+ /* Columns within a row */
55
+ .document-item div {
56
+ text-align: left;
57
+ overflow: hidden;
58
+ text-overflow: ellipsis;
59
+ white-space: nowrap;
60
+ }
61
+
62
+ /* Dragging styles */
63
+ .document-item[aria-grabbed='true'] {
64
+ background-color: #f0f8ff;
65
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
66
+ }
67
+ }