@eeacms/volto-clms-theme 1.1.211 → 1.1.212

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,16 @@ 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.212](https://github.com/eea/volto-clms-theme/compare/1.1.211...1.1.212) - 19 February 2025
8
+
9
+ #### :rocket: New Features
10
+
11
+ - feat: Implementation of the dataset listing enhancement prototype within the product -refs #280654 [ana-oprea - [`5c24fec`](https://github.com/eea/volto-clms-theme/commit/5c24fec680e7721bc092f1d842c351ecaa2d3ce9)]
12
+
13
+ #### :bug: Bug Fixes
14
+
15
+ - fix: lint errors [ana-oprea - [`6b61c4d`](https://github.com/eea/volto-clms-theme/commit/6b61c4d1091cb68424dd545df0a669d277c5e873)]
16
+
7
17
  ### [1.1.211](https://github.com/eea/volto-clms-theme/compare/1.1.210...1.1.211) - 13 February 2025
8
18
 
9
19
  ### [1.1.210](https://github.com/eea/volto-clms-theme/compare/1.1.209...1.1.210) - 10 February 2025
@@ -15,6 +15,8 @@ module.exports = {
15
15
  '<rootDir>/src/addons/volto-clms-theme/src/$1',
16
16
  '@eeacms/volto-clms-utils/(.*)$':
17
17
  '<rootDir>/node_modules/@eeacms/volto-clms-utils/src/$1',
18
+ '@eeacms/volto-tabs-block/(.*)$':
19
+ '<rootDir>/node_modules/@eeacms/volto-tabs-block/src/$1',
18
20
  '@kitconcept/volto-blocks-grid/(.*)$':
19
21
  '<rootDir>/node_modules/@kitconcept/volto-blocks-grid/src/$1',
20
22
  '@plone/volto-slate':
@@ -31,7 +33,7 @@ module.exports = {
31
33
  '^.+\\.(svg)$': './node_modules/@plone/volto/jest-svgsystem-transform.js',
32
34
  },
33
35
  transformIgnorePatterns: [
34
- 'node_modules/(?!(@eeacms/volto-clms-utils/|@plone/volto/|slick-carousel|react-input-range))',
36
+ 'node_modules/(?!(@eeacms/volto-clms-utils/|@eeacms/volto-tabs-block/|@plone/volto/|slick-carousel|react-input-range))',
35
37
  ],
36
38
  coverageThreshold: {
37
39
  global: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-clms-theme",
3
- "version": "1.1.211",
3
+ "version": "1.1.212",
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",
@@ -0,0 +1,101 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { SidebarPortal, BlockDataForm } from '@plone/volto/components';
3
+ import { CardBlockSchema, CardContainerSchema } from './schema';
4
+ import { isEmpty } from 'lodash';
5
+ import { emptyCard, getPanels } from '../utils';
6
+ import FamilyCard from './FamilyCard';
7
+
8
+ const CclFamiliesListingEdit = (props) => {
9
+ const { block, data, onChangeBlock, selected, setSidebarTab } = props;
10
+ const [selectedCardBlock, setSelectedCardBlock] = useState(-1);
11
+
12
+ let schema = CardContainerSchema();
13
+ const properties = isEmpty(data?.customCards?.blocks)
14
+ ? emptyCard(2)
15
+ : data.customCards;
16
+
17
+ const panelData = properties;
18
+ const panels = getPanels(panelData);
19
+ useEffect(() => {
20
+ if (isEmpty(data?.customCards)) {
21
+ onChangeBlock(block, {
22
+ ...data,
23
+ customCards: {
24
+ ...properties,
25
+ },
26
+ });
27
+ }
28
+ /* eslint-disable-next-line */
29
+ }, []);
30
+
31
+ return (
32
+ <>
33
+ <div
34
+ className="cardContainer-header"
35
+ onClick={() => {
36
+ setSidebarTab(1);
37
+ setSelectedCardBlock(-1);
38
+ }}
39
+ aria-hidden="true"
40
+ >
41
+ {data.title || 'Family card container'}
42
+ </div>
43
+ <div className={'card-container'}>
44
+ <>
45
+ {panels.map(([uid, panel], index) => (
46
+ <FamilyCard
47
+ key={index}
48
+ card={panel}
49
+ onClickImage={() => {
50
+ setSelectedCardBlock(uid);
51
+ }}
52
+ isCustomCard={true}
53
+ isEditMode
54
+ />
55
+ ))}
56
+ </>
57
+ </div>
58
+ <SidebarPortal selected={selected && selectedCardBlock === -1}>
59
+ <BlockDataForm
60
+ schema={schema}
61
+ title="Card container block"
62
+ onChangeField={(id, value) => {
63
+ onChangeBlock(block, {
64
+ ...data,
65
+ [id]: value,
66
+ });
67
+ }}
68
+ formData={data}
69
+ />
70
+ </SidebarPortal>
71
+ <SidebarPortal
72
+ selected={
73
+ selected && selectedCardBlock !== -1 && data.customCards?.blocks
74
+ }
75
+ >
76
+ <BlockDataForm
77
+ schema={CardBlockSchema()}
78
+ title="Card block"
79
+ onChangeField={(id, value) => {
80
+ onChangeBlock(block, {
81
+ ...data,
82
+ customCards: {
83
+ ...data.customCards,
84
+ blocks: {
85
+ ...data.customCards.blocks,
86
+ [selectedCardBlock]: {
87
+ ...data.customCards.blocks[selectedCardBlock],
88
+ [id]: value,
89
+ },
90
+ },
91
+ },
92
+ });
93
+ }}
94
+ formData={data.customCards?.blocks[selectedCardBlock]}
95
+ />
96
+ </SidebarPortal>
97
+ </>
98
+ );
99
+ };
100
+
101
+ export default CclFamiliesListingEdit;
@@ -0,0 +1,209 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import CclFamiliesCardContainerEdit from './CclFamiliesCardContainerEdit';
5
+ import { Provider } from 'react-intl-redux';
6
+ import configureStore from 'redux-mock-store';
7
+ import '@testing-library/jest-dom/extend-expect';
8
+
9
+ const mockStore = configureStore([]);
10
+
11
+ const initialState = {
12
+ intl: {
13
+ locale: 'en',
14
+ messages: {},
15
+ },
16
+ };
17
+ const store = mockStore(initialState);
18
+
19
+ jest.mock('../utils', () => ({
20
+ emptyCard: jest.fn(() => ({
21
+ blocks: {
22
+ '123': { '@type': 'card', title: 'Card 1' },
23
+ '456': { '@type': 'card', title: 'Card 2' },
24
+ },
25
+ })),
26
+ getPanels: jest.fn((data) => Object.entries(data.blocks || {})),
27
+ }));
28
+
29
+ jest.mock('./FamilyCard', () => {
30
+ return function MockFamilyCard({ onClickImage }) {
31
+ return (
32
+ <div
33
+ data-testid="family-card"
34
+ onClick={onClickImage}
35
+ onKeyDown={(e) => {
36
+ if (e.key === 'Enter') {
37
+ onClickImage(e);
38
+ }
39
+ }}
40
+ role="button"
41
+ tabIndex={0}
42
+ aria-label="Family Card"
43
+ >
44
+ Family Card
45
+ </div>
46
+ );
47
+ };
48
+ });
49
+
50
+ describe('CclFamiliesCardContainerEdit', () => {
51
+ const defaultProps = {
52
+ block: 'block-123',
53
+ data: {},
54
+ onChangeBlock: jest.fn(),
55
+ selected: true,
56
+ setSidebarTab: jest.fn(),
57
+ intl: { formatMessage: jest.fn() },
58
+ };
59
+
60
+ beforeEach(() => {
61
+ jest.clearAllMocks();
62
+ });
63
+
64
+ it('initializes with empty cards when no customCards data exists', () => {
65
+ render(
66
+ <Provider store={store}>
67
+ <CclFamiliesCardContainerEdit {...defaultProps} />
68
+ </Provider>,
69
+ );
70
+
71
+ expect(defaultProps.onChangeBlock).toHaveBeenCalledWith(
72
+ 'block-123',
73
+ expect.objectContaining({
74
+ customCards: expect.any(Object),
75
+ }),
76
+ );
77
+ });
78
+
79
+ it('displays the container title', () => {
80
+ const props = {
81
+ ...defaultProps,
82
+ data: { title: 'Test Container' },
83
+ };
84
+
85
+ render(
86
+ <Provider store={store}>
87
+ <CclFamiliesCardContainerEdit {...props} />
88
+ </Provider>,
89
+ );
90
+ expect(screen.getByText('Test Container')).toBeInTheDocument();
91
+ });
92
+
93
+ it('displays default title when no title is provided', () => {
94
+ render(
95
+ <Provider store={store}>
96
+ <CclFamiliesCardContainerEdit {...defaultProps} />
97
+ </Provider>,
98
+ );
99
+ expect(screen.getByText('Family card container')).toBeInTheDocument();
100
+ });
101
+
102
+ it('clicking container header sets sidebar tab and resets selected card', () => {
103
+ act(() => {
104
+ render(
105
+ <Provider store={store}>
106
+ <CclFamiliesCardContainerEdit {...defaultProps} />
107
+ </Provider>,
108
+ );
109
+ fireEvent.click(screen.getByText('Family card container'));
110
+ });
111
+
112
+ expect(defaultProps.setSidebarTab).toHaveBeenCalledWith(1);
113
+ expect(screen.getByRole('form')).toBeInTheDocument();
114
+ });
115
+
116
+ it('renders family cards for each panel', () => {
117
+ const props = {
118
+ ...defaultProps,
119
+ data: {
120
+ customCards: {
121
+ blocks: {
122
+ '123': { title: 'Card 1' },
123
+ '456': { title: 'Card 2' },
124
+ },
125
+ },
126
+ },
127
+ };
128
+
129
+ render(
130
+ <Provider store={store}>
131
+ <CclFamiliesCardContainerEdit {...props} />
132
+ </Provider>,
133
+ );
134
+ const cards = screen.getAllByTestId('family-card');
135
+ expect(cards).toHaveLength(2);
136
+ });
137
+
138
+ it('shows correct sidebar portal based on card selection', () => {
139
+ const props = {
140
+ ...defaultProps,
141
+ data: {
142
+ customCards: {
143
+ blocks: {
144
+ '123': { title: 'Card 1' },
145
+ },
146
+ },
147
+ },
148
+ };
149
+
150
+ render(
151
+ <Provider store={store}>
152
+ <CclFamiliesCardContainerEdit {...props} />
153
+ </Provider>,
154
+ );
155
+
156
+ expect(screen.getByText('Card container block')).toBeInTheDocument();
157
+
158
+ // Click on a card
159
+ fireEvent.click(screen.getByTestId('family-card'));
160
+
161
+ // Should now show card block form
162
+ expect(screen.getByText('Card block')).toBeInTheDocument();
163
+ });
164
+
165
+ it('clicking header resets card selection and shows container form', () => {
166
+ const props = {
167
+ ...defaultProps,
168
+ data: {
169
+ customCards: {
170
+ blocks: {
171
+ '123': { title: 'Card 1' },
172
+ },
173
+ },
174
+ },
175
+ };
176
+
177
+ render(
178
+ <Provider store={store}>
179
+ <CclFamiliesCardContainerEdit {...props} />
180
+ </Provider>,
181
+ );
182
+
183
+ // First click a card to select it
184
+ fireEvent.click(screen.getByTestId('family-card'));
185
+ expect(screen.getByText('Card block')).toBeInTheDocument();
186
+
187
+ // Then click the header
188
+ fireEvent.click(screen.getByText('Family card container'));
189
+
190
+ // Should show container form again
191
+ expect(screen.getByText('Card container block')).toBeInTheDocument();
192
+ expect(props.setSidebarTab).toHaveBeenCalledWith(1);
193
+ });
194
+
195
+ it('initializes with empty cards when no customCards data exists', () => {
196
+ render(
197
+ <Provider store={store}>
198
+ <CclFamiliesCardContainerEdit {...defaultProps} />
199
+ </Provider>,
200
+ );
201
+
202
+ expect(defaultProps.onChangeBlock).toHaveBeenCalledWith(
203
+ 'block-123',
204
+ expect.objectContaining({
205
+ customCards: expect.any(Object),
206
+ }),
207
+ );
208
+ });
209
+ });
@@ -0,0 +1,59 @@
1
+ import React, { useEffect } from 'react';
2
+ import { isEmpty } from 'lodash';
3
+ import { getPanels } from '../utils';
4
+ import FamilyCard from './FamilyCard';
5
+
6
+ const CclFamiliesListingView = (props) => {
7
+ const {
8
+ block,
9
+ data,
10
+ onChangeBlock,
11
+ setSidebarTab,
12
+ setSelectedCardBlock,
13
+ } = props;
14
+
15
+ const properties = data?.customCards;
16
+
17
+ const panelData = properties;
18
+ const panels = getPanels(panelData);
19
+ useEffect(() => {
20
+ if (isEmpty(data?.customCards)) {
21
+ onChangeBlock(block, {
22
+ ...data,
23
+ customCards: {
24
+ ...properties,
25
+ },
26
+ });
27
+ }
28
+ /* eslint-disable-next-line */
29
+ }, []);
30
+
31
+ return (
32
+ <>
33
+ <div
34
+ className="cardContainer-header"
35
+ onClick={() => {
36
+ setSidebarTab(1);
37
+ setSelectedCardBlock(-1);
38
+ }}
39
+ aria-hidden="true"
40
+ >
41
+ {data?.title && data.title}
42
+ </div>
43
+ <div className={'card-container'}>
44
+ <>
45
+ {panels.map(([uid, panel], index) => (
46
+ <FamilyCard
47
+ key={index}
48
+ card={panel}
49
+ isCustomCard={true}
50
+ isEditMode={false}
51
+ />
52
+ ))}
53
+ </>
54
+ </div>
55
+ </>
56
+ );
57
+ };
58
+
59
+ export default CclFamiliesListingView;
@@ -0,0 +1,128 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import CclFamiliesCardContainerView from './CclFamiliesCardContainerView';
5
+ import { getPanels } from '../utils';
6
+
7
+ jest.mock('../utils', () => ({
8
+ getPanels: jest.fn(),
9
+ }));
10
+
11
+ jest.mock('./FamilyCard', () => {
12
+ return function MockFamilyCard({ card, isCustomCard, isEditMode }) {
13
+ return <div data-testid="family-card">{card.title || 'Family Card'}</div>;
14
+ };
15
+ });
16
+
17
+ describe('CclFamiliesCardContainerView', () => {
18
+ const defaultProps = {
19
+ block: 'block-123',
20
+ data: {},
21
+ onChangeBlock: jest.fn(),
22
+ setSidebarTab: jest.fn(),
23
+ setSelectedCardBlock: jest.fn(),
24
+ };
25
+
26
+ beforeEach(() => {
27
+ jest.clearAllMocks();
28
+ getPanels.mockImplementation((data) => [
29
+ ['card1', { title: 'Card 1' }],
30
+ ['card2', { title: 'Card 2' }],
31
+ ]);
32
+ });
33
+
34
+ it('renders without crashing', () => {
35
+ render(<CclFamiliesCardContainerView {...defaultProps} />);
36
+ expect(screen.getAllByTestId('family-card')).toHaveLength(2);
37
+ });
38
+
39
+ it('initializes with empty cards when no customCards data exists', () => {
40
+ render(<CclFamiliesCardContainerView {...defaultProps} />);
41
+
42
+ expect(defaultProps.onChangeBlock).toHaveBeenCalledWith(
43
+ 'block-123',
44
+ expect.objectContaining({
45
+ customCards: expect.any(Object),
46
+ }),
47
+ );
48
+ });
49
+
50
+ it('does not call onChangeBlock when customCards exist', () => {
51
+ const propsWithCards = {
52
+ ...defaultProps,
53
+ data: {
54
+ customCards: {
55
+ blocks: {
56
+ card1: { title: 'Card 1' },
57
+ card2: { title: 'Card 2' },
58
+ },
59
+ },
60
+ },
61
+ };
62
+
63
+ render(<CclFamiliesCardContainerView {...propsWithCards} />);
64
+ expect(defaultProps.onChangeBlock).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('renders title when provided', () => {
68
+ const propsWithTitle = {
69
+ ...defaultProps,
70
+ data: {
71
+ title: 'Test Title',
72
+ customCards: {},
73
+ },
74
+ };
75
+
76
+ render(<CclFamiliesCardContainerView {...propsWithTitle} />);
77
+ expect(screen.getByText('Test Title')).toBeInTheDocument();
78
+ });
79
+
80
+ it('renders correct number of FamilyCard components', () => {
81
+ const propsWithCards = {
82
+ ...defaultProps,
83
+ data: {
84
+ customCards: {
85
+ blocks: {
86
+ card1: { title: 'Card 1' },
87
+ card2: { title: 'Card 2' },
88
+ },
89
+ },
90
+ },
91
+ };
92
+
93
+ render(<CclFamiliesCardContainerView {...propsWithCards} />);
94
+ const cards = screen.getAllByTestId('family-card');
95
+ expect(cards).toHaveLength(2);
96
+ });
97
+
98
+ it('passes correct props to FamilyCard components', () => {
99
+ getPanels.mockImplementation((data) => [['card1', { title: 'Test Card' }]]);
100
+
101
+ const propsWithCards = {
102
+ ...defaultProps,
103
+ data: {
104
+ customCards: {
105
+ blocks: {
106
+ card1: { title: 'Test Card' },
107
+ },
108
+ },
109
+ },
110
+ };
111
+
112
+ render(<CclFamiliesCardContainerView {...propsWithCards} />);
113
+
114
+ const card = screen.getByText('Test Card');
115
+ expect(card).toBeInTheDocument();
116
+ });
117
+
118
+ it('handles undefined data', () => {
119
+ const propsWithUndefined = {
120
+ ...defaultProps,
121
+ data: undefined,
122
+ };
123
+
124
+ expect(() => {
125
+ render(<CclFamiliesCardContainerView {...propsWithUndefined} />);
126
+ }).not.toThrow();
127
+ });
128
+ });
@@ -0,0 +1,61 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { FontAwesomeIcon } from '@eeacms/volto-clms-utils/components';
3
+ import { Link } from 'react-router-dom';
4
+ import { flattenToAppURL, isInternalURL } from '@plone/volto/helpers';
5
+
6
+ const FamilyCard = (props) => {
7
+ const {
8
+ children,
9
+ card,
10
+ onClickImage = () => {
11
+ return '';
12
+ },
13
+ isEditMode,
14
+ } = props;
15
+
16
+ const [hasLink, setHasLink] = useState(false);
17
+ const href = card.href;
18
+
19
+ useEffect(() => {
20
+ if (isEditMode) {
21
+ setHasLink(false);
22
+ } else {
23
+ if (card.href) {
24
+ if (card.href && card.href.length > 0) {
25
+ setHasLink(true);
26
+ }
27
+ if (card.href.length === 0) {
28
+ setHasLink(false);
29
+ }
30
+ }
31
+ }
32
+ }, [isEditMode, card.href]);
33
+
34
+ const url = hasLink && isInternalURL(href) ? flattenToAppURL(href) : href;
35
+ const As = hasLink && isInternalURL(url) ? Link : 'a';
36
+
37
+ return (
38
+ <As
39
+ href={hasLink ? url : null}
40
+ to={hasLink ? url : null}
41
+ className={'card-product-family'}
42
+ onClick={() => onClickImage()}
43
+ onKeyDown={() => onClickImage()}
44
+ >
45
+ <div className="card-text">
46
+ <div className="card-product-familiy-title">{card?.title}</div>
47
+ <div>
48
+ <div className="card-product-familiy-description">
49
+ {card?.description}
50
+ </div>
51
+ </div>
52
+ {children}
53
+ </div>
54
+ <div className="card-icon">
55
+ <FontAwesomeIcon icon={['fas', 'chevron-right']} />
56
+ </div>
57
+ </As>
58
+ );
59
+ };
60
+
61
+ export default FamilyCard;
@@ -0,0 +1,186 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import { BrowserRouter } from 'react-router-dom';
5
+ import FamilyCard from './FamilyCard';
6
+ import { isInternalURL } from '@plone/volto/helpers';
7
+
8
+ jest.mock('@plone/volto/helpers', () => ({
9
+ isInternalURL: jest.fn(),
10
+ flattenToAppURL: jest.fn((url) => url),
11
+ }));
12
+
13
+ jest.mock('@eeacms/volto-clms-utils/components', () => ({
14
+ FontAwesomeIcon: () => <span data-testid="chevron-icon">Icon</span>,
15
+ }));
16
+
17
+ const renderWithRouter = (ui) => {
18
+ return render(<BrowserRouter>{ui}</BrowserRouter>);
19
+ };
20
+
21
+ describe('FamilyCard', () => {
22
+ const defaultProps = {
23
+ card: {
24
+ title: 'Test Card',
25
+ description: 'Test Description',
26
+ },
27
+ onClickImage: jest.fn(),
28
+ isEditMode: false,
29
+ };
30
+
31
+ beforeEach(() => {
32
+ jest.clearAllMocks();
33
+ isInternalURL.mockImplementation(() => false);
34
+ });
35
+
36
+ it('renders basic card content correctly', () => {
37
+ renderWithRouter(<FamilyCard {...defaultProps} />);
38
+
39
+ expect(screen.getByText('Test Card')).toBeInTheDocument();
40
+ expect(screen.getByText('Test Description')).toBeInTheDocument();
41
+ expect(screen.getByTestId('chevron-icon')).toBeInTheDocument();
42
+ });
43
+
44
+ it('handles click events', () => {
45
+ renderWithRouter(<FamilyCard {...defaultProps} />);
46
+
47
+ fireEvent.click(screen.getByText('Test Card'));
48
+ expect(defaultProps.onClickImage).toHaveBeenCalled();
49
+ });
50
+
51
+ it('handles keyDown events', () => {
52
+ renderWithRouter(<FamilyCard {...defaultProps} />);
53
+
54
+ fireEvent.keyDown(screen.getByText('Test Card'));
55
+ expect(defaultProps.onClickImage).toHaveBeenCalled();
56
+ });
57
+
58
+ it('renders children when provided', () => {
59
+ renderWithRouter(
60
+ <FamilyCard {...defaultProps}>
61
+ <div data-testid="child-content">Child Content</div>
62
+ </FamilyCard>,
63
+ );
64
+
65
+ expect(screen.getByTestId('child-content')).toBeInTheDocument();
66
+ });
67
+
68
+ it('does not render link in edit mode', () => {
69
+ const propsWithLink = {
70
+ ...defaultProps,
71
+ isEditMode: true,
72
+ card: {
73
+ ...defaultProps.card,
74
+ href: 'http://example.com',
75
+ },
76
+ };
77
+
78
+ renderWithRouter(<FamilyCard {...propsWithLink} />);
79
+
80
+ const element = screen.getByText('Test Card').closest('a, Link');
81
+ expect(element).not.toHaveAttribute('href', 'http://example.com');
82
+ });
83
+
84
+ it('renders internal link correctly', () => {
85
+ isInternalURL.mockImplementation(() => true);
86
+
87
+ const propsWithInternalLink = {
88
+ ...defaultProps,
89
+ card: {
90
+ ...defaultProps.card,
91
+ href: '/internal/page',
92
+ },
93
+ };
94
+
95
+ renderWithRouter(<FamilyCard {...propsWithInternalLink} />);
96
+
97
+ expect(screen.getByText('Test Card').closest('a')).toHaveAttribute(
98
+ 'href',
99
+ '/internal/page',
100
+ );
101
+ });
102
+
103
+ it('renders external link correctly', () => {
104
+ const propsWithExternalLink = {
105
+ ...defaultProps,
106
+ card: {
107
+ ...defaultProps.card,
108
+ href: 'https://example.com',
109
+ },
110
+ };
111
+
112
+ renderWithRouter(<FamilyCard {...propsWithExternalLink} />);
113
+
114
+ expect(screen.getByText('Test Card').closest('a')).toHaveAttribute(
115
+ 'href',
116
+ 'https://example.com',
117
+ );
118
+ });
119
+
120
+ it('handles empty href array', () => {
121
+ const propsWithEmptyHref = {
122
+ ...defaultProps,
123
+ card: {
124
+ ...defaultProps.card,
125
+ href: '',
126
+ },
127
+ };
128
+
129
+ renderWithRouter(<FamilyCard {...propsWithEmptyHref} />);
130
+
131
+ const element = screen.getByText('Test Card').closest('a');
132
+ expect(element).not.toHaveAttribute('href');
133
+ });
134
+
135
+ it('handles direct href string', () => {
136
+ const propsWithDirectHref = {
137
+ ...defaultProps,
138
+ card: {
139
+ ...defaultProps.card,
140
+ href: 'https://example.com',
141
+ },
142
+ };
143
+
144
+ renderWithRouter(<FamilyCard {...propsWithDirectHref} />);
145
+
146
+ expect(screen.getByText('Test Card').closest('a')).toHaveAttribute(
147
+ 'href',
148
+ 'https://example.com',
149
+ );
150
+ });
151
+
152
+ it('updates hasLink state when href changes', async () => {
153
+ const { rerender } = renderWithRouter(<FamilyCard {...defaultProps} />);
154
+
155
+ // Initial render without href
156
+ let element = screen.getByText('Test Card').closest('a');
157
+ expect(element).not.toHaveAttribute('href');
158
+
159
+ // Update props with href
160
+ const propsWithHref = {
161
+ ...defaultProps,
162
+ card: {
163
+ ...defaultProps.card,
164
+ href: 'https://example.com',
165
+ },
166
+ };
167
+
168
+ await act(async () => {
169
+ rerender(<FamilyCard {...propsWithHref} />);
170
+ });
171
+
172
+ element = screen.getByText('Test Card').closest('a');
173
+ expect(element).toHaveAttribute('href', 'https://example.com');
174
+ });
175
+
176
+ it('handles undefined card properties', () => {
177
+ const propsWithUndefined = {
178
+ ...defaultProps,
179
+ card: {},
180
+ };
181
+
182
+ renderWithRouter(<FamilyCard {...propsWithUndefined} />);
183
+
184
+ expect(screen.getByTestId('chevron-icon')).toBeInTheDocument();
185
+ });
186
+ });
@@ -0,0 +1,53 @@
1
+ export const CardContainerSchema = () => ({
2
+ title: 'Card container',
3
+ fieldsets: [
4
+ {
5
+ id: 'default',
6
+ title: 'Default',
7
+ fields: ['title', 'customCards'],
8
+ },
9
+ ],
10
+ properties: {
11
+ title: {
12
+ title: 'Title',
13
+ description: 'Card container block friendly name',
14
+ type: 'string',
15
+ },
16
+ customCards: {
17
+ title: 'Custom cards',
18
+ type: 'panels',
19
+ schema: CardBlockSchema,
20
+ },
21
+ },
22
+ required: ['cardOrigin'],
23
+ });
24
+
25
+ export const CardBlockSchema = () => {
26
+ return {
27
+ title: 'Card block',
28
+ fieldsets: [
29
+ {
30
+ id: 'default',
31
+ title: 'Default',
32
+ fields: ['title', 'description', 'href'],
33
+ },
34
+ ],
35
+ properties: {
36
+ title: {
37
+ title: 'Title',
38
+ description: 'Card title',
39
+ type: 'string',
40
+ placeholder: 'Card title here',
41
+ },
42
+ description: {
43
+ title: 'Description',
44
+ type: 'string',
45
+ },
46
+ href: {
47
+ title: 'Link',
48
+ type: 'string',
49
+ },
50
+ },
51
+ required: [],
52
+ };
53
+ };
@@ -0,0 +1,209 @@
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import { NavLink, useHistory, useLocation } from 'react-router-dom';
3
+ import cx from 'classnames';
4
+ import { RenderBlocks } from '@plone/volto/components';
5
+ import { slugify } from '../../utils';
6
+ import './fontawesome';
7
+ import { connect } from 'react-redux';
8
+ import { compose } from 'redux';
9
+
10
+ import { withScrollToTarget } from '@eeacms/volto-tabs-block/hocs';
11
+
12
+ const TabsComponent = ({
13
+ tabsList = [],
14
+ tabs = {},
15
+ ExtraComponent = () => null,
16
+ metadata = {},
17
+ }) => {
18
+ const location = useLocation();
19
+ const history = useHistory();
20
+ // Local state for the active tab and which main tab (if any) is expanded.
21
+ const [activeTab, setActiveTab] = useState(null);
22
+ const [expandedTab, setExpandedTab] = useState(null);
23
+
24
+ const groupedTabs = useMemo(() => {
25
+ const groups = [];
26
+ let currentGroup = null;
27
+ tabsList.forEach((tabId) => {
28
+ const isSubTab = Boolean(tabs[tabId]?.subTab?.subtab);
29
+ if (!isSubTab) {
30
+ currentGroup = { main: tabId, subs: [] };
31
+ groups.push(currentGroup);
32
+ } else if (currentGroup) {
33
+ currentGroup.subs.push(tabId);
34
+ } else {
35
+ currentGroup = { main: tabId, subs: [] };
36
+ groups.push(currentGroup);
37
+ }
38
+ });
39
+ return groups;
40
+ }, [tabsList, tabs]);
41
+
42
+ // Helper: update URL and activeTab.
43
+ const updateTab = (tabId, tabHash) => {
44
+ setActiveTab(tabId);
45
+ history.push(tabHash);
46
+ };
47
+
48
+ // When clicking a main tab:
49
+ const handleMainTabClick = (e, tabId, tabHash, subs) => {
50
+ e.preventDefault();
51
+ // If there are subtabs for this main tab:
52
+ if (subs && subs.length > 0) {
53
+ if (expandedTab !== tabId) {
54
+ setExpandedTab(tabId);
55
+ // If the current active tab is not one of the subtabs, switch to the first subtab.
56
+ if (!subs.includes(activeTab)) {
57
+ const firstSub = subs[0];
58
+ const subTitle = tabs[firstSub]?.title || 'Subtab';
59
+ updateTab(firstSub, getTabHash(subTitle));
60
+ } else {
61
+ // Otherwise, update the URL hash.
62
+ updateTab(tabId, tabHash);
63
+ }
64
+ } else {
65
+ // Collapse the subtabs and select the first main tab.
66
+ setExpandedTab(null);
67
+ updateTab(
68
+ tabsList[0] || tabId,
69
+ tabs[tabsList[0]]?.title
70
+ ? getTabHash(tabs[tabsList[0]]?.title || '')
71
+ : tabId,
72
+ );
73
+ }
74
+ } else {
75
+ // No subtabs—simply update the active tab.
76
+ updateTab(tabId, tabHash);
77
+ }
78
+ };
79
+
80
+ // When clicking a subtab.
81
+ const handleSubTabClick = (e, tabId, tabHash) => {
82
+ e.preventDefault();
83
+ updateTab(tabId, tabHash);
84
+ };
85
+
86
+ const getTabHash = (title) => `#tab=${slugify(title)}`;
87
+
88
+ // On mount or when location.hash changes, update activeTab.
89
+ useEffect(() => {
90
+ const hash = location.hash;
91
+ if (hash) {
92
+ const foundTab = tabsList.find((tabId) => {
93
+ const title = tabs[tabId]?.title || '';
94
+ return hash === getTabHash(title);
95
+ });
96
+ if (foundTab) {
97
+ setActiveTab(foundTab);
98
+ return;
99
+ }
100
+ }
101
+ // Fallback: if no hash is present or no match was found, select the first tab.
102
+ if (tabsList.length > 0) {
103
+ setActiveTab(tabsList[0]);
104
+ }
105
+ }, [location.hash, tabsList, tabs]);
106
+
107
+ useEffect(() => {
108
+ if (activeTab) {
109
+ const group = groupedTabs.find((group) => group.subs.includes(activeTab));
110
+ if (group && expandedTab !== group.main) {
111
+ setExpandedTab(group.main);
112
+ }
113
+ }
114
+ }, [activeTab, expandedTab, groupedTabs]);
115
+
116
+ return (
117
+ <>
118
+ <div className="left-content cont-w-25">
119
+ <ExtraComponent />
120
+ <nav className="left-menu">
121
+ {groupedTabs.map((group, idx) => {
122
+ const mainTab = group.main;
123
+ const mainTitle = tabs[mainTab]?.title || `Tab ${idx + 1}`;
124
+ const mainTabHash = getTabHash(mainTitle);
125
+ return (
126
+ <React.Fragment key={mainTab}>
127
+ <div
128
+ id={mainTabHash}
129
+ className={cx(
130
+ 'card',
131
+ activeTab === mainTab && 'active',
132
+ group.subs && group.subs.length > 0 && 'has-subtabs',
133
+ )}
134
+ >
135
+ <NavLink
136
+ to={mainTabHash}
137
+ onClick={(e) =>
138
+ handleMainTabClick(e, mainTab, mainTabHash, group.subs)
139
+ }
140
+ >
141
+ {mainTitle}
142
+ </NavLink>
143
+ </div>
144
+ {/* Render subtabs only if the main tab is expanded */}
145
+ {group.subs.length > 0 && expandedTab === mainTab && (
146
+ <div className="subtabs">
147
+ {group.subs.map((subTabId) => {
148
+ const subTitle = tabs[subTabId]?.title || 'Subtab';
149
+ const subTabHash = getTabHash(subTitle);
150
+ return (
151
+ <div
152
+ key={subTabId}
153
+ id={subTabHash}
154
+ className={cx(
155
+ 'card',
156
+ activeTab === subTabId && 'active',
157
+ 'subcard',
158
+ )}
159
+ >
160
+ <NavLink
161
+ to={subTabHash}
162
+ onClick={(e) =>
163
+ handleSubTabClick(e, subTabId, subTabHash)
164
+ }
165
+ >
166
+ {subTitle}
167
+ </NavLink>
168
+ </div>
169
+ );
170
+ })}
171
+ </div>
172
+ )}
173
+ </React.Fragment>
174
+ );
175
+ })}
176
+ </nav>
177
+ </div>
178
+ <div className="right-content cont-w-75">
179
+ {activeTab && tabs[activeTab] ? (
180
+ <div
181
+ key={activeTab} // Using activeTab as a key forces a re‑mount on change.
182
+ className={cx('panel', 'panel-selected')}
183
+ role="button"
184
+ aria-hidden="false"
185
+ >
186
+ <RenderBlocks metadata={metadata} content={tabs[activeTab]} />
187
+ </div>
188
+ ) : (
189
+ <div>No content available.</div>
190
+ )}
191
+ </div>
192
+ </>
193
+ );
194
+ };
195
+
196
+ const CclProductTabsWithSubtabsView = (props) => {
197
+ return (
198
+ <div className="ccl-container ccl-container-flex tab-container">
199
+ <TabsComponent {...props} />
200
+ </div>
201
+ );
202
+ };
203
+
204
+ export default compose(
205
+ connect((state) => ({
206
+ hashlink: state.hashlink,
207
+ })),
208
+ withScrollToTarget,
209
+ )(CclProductTabsWithSubtabsView);
@@ -0,0 +1,169 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
+ import { Router } from 'react-router-dom';
4
+ import { createMemoryHistory } from 'history';
5
+ import { Provider } from 'react-intl-redux';
6
+ import configureStore from 'redux-mock-store';
7
+ import '@testing-library/jest-dom';
8
+ import CclProductTabsWithSubtabsView from './CclProductTabsWithSubtabsView';
9
+
10
+ const mockStore = configureStore([]);
11
+
12
+ const initialState = {
13
+ intl: {
14
+ locale: 'en',
15
+ messages: {},
16
+ },
17
+ };
18
+ const store = mockStore(initialState);
19
+
20
+ describe('CclProductTabsWithSubtabsView', () => {
21
+ const history = createMemoryHistory();
22
+ const defaultProps = {
23
+ tabsList: ['tab1', 'tab2', 'tab3', 'tab4'],
24
+ tabs: {
25
+ tab1: { title: 'First Tab', content: 'Content 1' },
26
+ tab2: { title: 'Second Tab', content: 'Content 2' },
27
+ tab3: { title: 'Third Tab', content: 'Content 3' },
28
+ tab4: {
29
+ title: 'Fourth Tab',
30
+ content: 'Content 4',
31
+ subTab: { subtab: true },
32
+ },
33
+ },
34
+ metadata: {},
35
+ };
36
+
37
+ const renderWithRouter = (ui, { route = '/' } = {}) => {
38
+ history.push(route);
39
+ return render(
40
+ <Provider store={store}>
41
+ <Router history={history}>{ui}</Router>
42
+ </Provider>,
43
+ );
44
+ };
45
+
46
+ beforeEach(() => {
47
+ history.push('/');
48
+ });
49
+
50
+ it('renders all main tabs', () => {
51
+ renderWithRouter(<CclProductTabsWithSubtabsView {...defaultProps} />);
52
+
53
+ expect(screen.getByText('First Tab')).toBeInTheDocument();
54
+ expect(screen.getByText('Second Tab')).toBeInTheDocument();
55
+ expect(screen.getByText('Third Tab')).toBeInTheDocument();
56
+ expect(screen.queryByText('Fourth Tab')).not.toBeInTheDocument(); // Fourth tab is a subtab
57
+ });
58
+
59
+ it('selects first tab by default', () => {
60
+ renderWithRouter(<CclProductTabsWithSubtabsView {...defaultProps} />);
61
+
62
+ const firstTab = screen.getByText('First Tab').closest('.card');
63
+ expect(firstTab).toHaveClass('active');
64
+ });
65
+
66
+ it('changes active tab on click', () => {
67
+ renderWithRouter(<CclProductTabsWithSubtabsView {...defaultProps} />);
68
+
69
+ fireEvent.click(screen.getByText('Second Tab'));
70
+
71
+ const secondTab = screen.getByText('Second Tab').closest('.card');
72
+ expect(secondTab).toHaveClass('active');
73
+ });
74
+
75
+ it('handles subtabs correctly', () => {
76
+ const propsWithSubtabs = {
77
+ ...defaultProps,
78
+ tabsList: ['main1', 'main2', 'sub1', 'sub2'],
79
+ tabs: {
80
+ main1: { title: 'Main Tab' },
81
+ main2: { title: 'Main Tab 2' },
82
+ sub1: { title: 'Subtab 1', subTab: { subtab: true } },
83
+ sub2: { title: 'Subtab 2', subTab: { subtab: true } },
84
+ },
85
+ };
86
+
87
+ renderWithRouter(<CclProductTabsWithSubtabsView {...propsWithSubtabs} />);
88
+
89
+ const firstTab = screen.getByText('Main Tab').closest('.card');
90
+ expect(firstTab).toHaveClass('active');
91
+ // Click main tab to expand subtabs
92
+ fireEvent.click(screen.getByText('Main Tab 2'));
93
+
94
+ // Check if subtabs are visible
95
+ expect(screen.getByText('Subtab 1')).toBeInTheDocument();
96
+ expect(screen.getByText('Subtab 2')).toBeInTheDocument();
97
+ const firstSubTab = screen.getByText('Subtab 1').closest('.card');
98
+ expect(firstSubTab).toHaveClass('active');
99
+ });
100
+
101
+ it('updates URL hash when changing tabs', () => {
102
+ renderWithRouter(<CclProductTabsWithSubtabsView {...defaultProps} />);
103
+
104
+ fireEvent.click(screen.getByText('Second Tab'));
105
+
106
+ expect(history.location.hash).toBe('#tab=second_tab');
107
+ });
108
+
109
+ it('selects correct tab based on URL hash', () => {
110
+ act(() => {
111
+ renderWithRouter(<CclProductTabsWithSubtabsView {...defaultProps} />, {
112
+ route: '/#tab=second_tab',
113
+ });
114
+ });
115
+
116
+ const secondTab = screen.getByText('Second Tab').closest('.card');
117
+ expect(secondTab).toHaveClass('active');
118
+ });
119
+
120
+ it('collapses expanded subtabs when clicking different main tab', () => {
121
+ const propsWithSubtabs = {
122
+ ...defaultProps,
123
+ tabsList: ['main1', 'main2', 'sub1', 'sub2'],
124
+ tabs: {
125
+ main1: { title: 'Main Tab 1' },
126
+ main2: { title: 'Main Tab 2' },
127
+ sub1: { title: 'Subtab 1', subTab: { subtab: true } },
128
+ sub2: { title: 'Subtab 2', subTab: { subtab: true } },
129
+ },
130
+ };
131
+
132
+ renderWithRouter(<CclProductTabsWithSubtabsView {...propsWithSubtabs} />);
133
+
134
+ const firstTab = screen.getByText('Main Tab 1').closest('.card');
135
+ expect(firstTab).toHaveClass('active');
136
+
137
+ // Expand second main tab's subtabs
138
+ fireEvent.click(screen.getByText('Main Tab 2'));
139
+
140
+ // Click second main tab
141
+ fireEvent.click(screen.getByText('Main Tab 2'));
142
+
143
+ // Verify subtabs are no longer visible
144
+ expect(screen.queryByText('Subtab 1')).not.toBeInTheDocument();
145
+ });
146
+
147
+ it('renders ExtraComponent when provided', () => {
148
+ const ExtraComponent = () => (
149
+ <div data-testid="extra-component">Extra Content</div>
150
+ );
151
+
152
+ renderWithRouter(
153
+ <CclProductTabsWithSubtabsView
154
+ {...defaultProps}
155
+ ExtraComponent={ExtraComponent}
156
+ />,
157
+ );
158
+
159
+ expect(screen.getByTestId('extra-component')).toBeInTheDocument();
160
+ });
161
+
162
+ it('handles empty tabsList', () => {
163
+ renderWithRouter(
164
+ <CclProductTabsWithSubtabsView {...defaultProps} tabsList={[]} />,
165
+ );
166
+
167
+ expect(screen.getByText('No content available.')).toBeInTheDocument();
168
+ });
169
+ });
@@ -5,6 +5,7 @@ import CclCarouselView from './CclCarouselView';
5
5
  import RoutingHOC from './RoutingHOC';
6
6
  import CclProductTabsView from './CclProductTabsView';
7
7
  import FixTemplates from './FixTemplates';
8
+ import CclProductTabsWithSubtabsView from './CclProductTabsWithSubtabsView';
8
9
 
9
10
  export {
10
11
  CclTabsView,
@@ -13,5 +14,6 @@ export {
13
14
  CclCarouselView,
14
15
  RoutingHOC,
15
16
  CclProductTabsView,
17
+ CclProductTabsWithSubtabsView,
16
18
  FixTemplates,
17
19
  };
@@ -12,6 +12,7 @@ import { SelectFacetFilterListEntry } from '@plone/volto/components/manage/Block
12
12
  import {
13
13
  CclCarouselView,
14
14
  CclProductTabsView,
15
+ CclProductTabsWithSubtabsView,
15
16
  CclTabsView,
16
17
  CclVerticalFaqTabsView,
17
18
  CclVerticalTabsView,
@@ -52,6 +53,8 @@ import CclHomeUsersBlockView from '@eeacms/volto-clms-theme/components/Blocks/Cc
52
53
  import CclMapMenu from '@eeacms/volto-clms-theme/components/Blocks/CustomTemplates/VoltoArcgisBlock/CclMapMenu';
53
54
  import CclRelatedListingEdit from '@eeacms/volto-clms-theme/components/Blocks/CclRelatedListingBlock/CclRelatedListingEdit';
54
55
  import CclRelatedListingView from '@eeacms/volto-clms-theme/components/Blocks/CclRelatedListingBlock/CclRelatedListingView';
56
+ import CclFamiliesCardContainerView from '@eeacms/volto-clms-theme/components/Blocks/CclFamiliesCardContainerBlock/CclFamiliesCardContainerView';
57
+ import CclFamiliesCardContainerEdit from '@eeacms/volto-clms-theme/components/Blocks/CclFamiliesCardContainerBlock/CclFamiliesCardContainerEdit';
55
58
  import CclUseCaseListEdit from '@eeacms/volto-clms-theme/components/Blocks/CclUseCaseList/CclUseCaseListEdit';
56
59
  import CclUseCaseListView from '@eeacms/volto-clms-theme/components/Blocks/CclUseCaseList/CclUseCaseListView';
57
60
  import CclWhiteBgView from '@eeacms/volto-clms-theme/components/Blocks/CclHomeBgImageBlock/CclWhiteBgView';
@@ -246,6 +249,12 @@ const customBlocks = (config) => ({
246
249
  view: RoutingHOC(CclProductTabsView),
247
250
  schema: defaultSchema,
248
251
  },
252
+ CCLProductTabsWithSubtabs: {
253
+ title: 'Vertical Product Tabs with Subtabs',
254
+ edit: DefaultEdit,
255
+ view: RoutingHOC(CclProductTabsWithSubtabsView),
256
+ schema: defaultSchema,
257
+ },
249
258
  CCLVerticalFaqTabs: {
250
259
  title: 'Vertical FAQ Tabs',
251
260
  edit: DefaultEdit,
@@ -323,6 +332,22 @@ const customBlocks = (config) => ({
323
332
  view: [], // Future proof (not implemented yet) view user role(s)
324
333
  },
325
334
  },
335
+ familiesCardContainer: {
336
+ id: 'familiesCardContainer', // The name (id) of the block
337
+ title: 'Families card container', // The display name of the block
338
+ icon: containerSVG, // The icon used in the block chooser
339
+ group: 'ccl_blocks', // The group (blocks can be grouped, displayed in the chooser)
340
+ view: CclFamiliesCardContainerView, // The view mode component
341
+ edit: CclFamiliesCardContainerEdit, // The edit mode component
342
+ restricted: false, // If the block is restricted, it won't show in the chooser
343
+ mostUsed: false, // A meta group `most used`, appearing at the top of the chooser
344
+ blockHasOwnFocusManagement: false, // Set this to true if the block manages its own focus
345
+ sidebarTab: 1, // The sidebar tab you want to be selected when selecting the block
346
+ security: {
347
+ addPermission: [], // Future proof (not implemented yet) add user permission role(s)
348
+ view: [], // Future proof (not implemented yet) view user role(s)
349
+ },
350
+ },
326
351
  relatedListing: {
327
352
  id: 'relatedListing', // The name (id) of the block
328
353
  title: 'Related items listing', // The display name of the block
@@ -438,3 +438,45 @@
438
438
  .card-work-line h2 {
439
439
  margin-top: 0 !important;
440
440
  }
441
+
442
+ .card-product-family {
443
+ position: relative;
444
+ display: flex;
445
+ width: 100%;
446
+ padding: 1rem;
447
+ border: 1px solid transparent;
448
+
449
+ .card-icon {
450
+ width: auto;
451
+ color: black;
452
+ font-size: 0.7rem;
453
+ font-weight: bold;
454
+ line-height: 1;
455
+ vertical-align: middle;
456
+ }
457
+
458
+ &:not(:last-of-type) {
459
+ border-bottom: solid 1px fade(@clmsGreen, 20%);
460
+ }
461
+ }
462
+
463
+ .card-product-family:hover {
464
+ background-color: fade(@clmsGreen, 5%);
465
+ border-color: @clmsGreen;
466
+
467
+ .card-product-familiy-title {
468
+ color: @clmsGreen;
469
+ }
470
+ }
471
+
472
+ .card-product-familiy-title {
473
+ margin-bottom: 0.5rem;
474
+ color: #404040;
475
+ font-weight: bold;
476
+ font-size: 15px;
477
+ }
478
+
479
+ .card-product-familiy-description {
480
+ color: #404040;
481
+ font-size: 14px;
482
+ }
@@ -23,3 +23,11 @@
23
23
  .deactivate-content {
24
24
  display: none;
25
25
  }
26
+
27
+ .block-editor-tabs_block
28
+ .block.tabs_block
29
+ .tabs-block.edit
30
+ .default.tabs
31
+ .ui.left.menu {
32
+ flex-wrap: wrap;
33
+ }