@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 +10 -0
- package/jest-addon.config.js +3 -1
- package/package.json +1 -1
- package/src/components/Blocks/CclFamiliesCardContainerBlock/CclFamiliesCardContainerEdit.jsx +101 -0
- package/src/components/Blocks/CclFamiliesCardContainerBlock/CclFamiliesCardContainerEdit.test.jsx +209 -0
- package/src/components/Blocks/CclFamiliesCardContainerBlock/CclFamiliesCardContainerView.jsx +59 -0
- package/src/components/Blocks/CclFamiliesCardContainerBlock/CclFamiliesCardContainerView.test.jsx +128 -0
- package/src/components/Blocks/CclFamiliesCardContainerBlock/FamilyCard.jsx +61 -0
- package/src/components/Blocks/CclFamiliesCardContainerBlock/FamilyCard.test.jsx +186 -0
- package/src/components/Blocks/CclFamiliesCardContainerBlock/schema.js +53 -0
- package/src/components/Blocks/CustomTemplates/VoltoTabsBlock/CclProductTabsWithSubtabsView.jsx +209 -0
- package/src/components/Blocks/CustomTemplates/VoltoTabsBlock/CclProductTabsWithSubtabsView.test.jsx +169 -0
- package/src/components/Blocks/CustomTemplates/VoltoTabsBlock/index.js +2 -0
- package/src/components/Blocks/customBlocks.js +25 -0
- package/theme/site/extras/cards.less +42 -0
- package/theme/site/extras/ccl-tabs.less +8 -0
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
|
package/jest-addon.config.js
CHANGED
|
@@ -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
|
@@ -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;
|
package/src/components/Blocks/CclFamiliesCardContainerBlock/CclFamiliesCardContainerEdit.test.jsx
ADDED
|
@@ -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;
|
package/src/components/Blocks/CclFamiliesCardContainerBlock/CclFamiliesCardContainerView.test.jsx
ADDED
|
@@ -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
|
+
};
|
package/src/components/Blocks/CustomTemplates/VoltoTabsBlock/CclProductTabsWithSubtabsView.jsx
ADDED
|
@@ -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);
|
package/src/components/Blocks/CustomTemplates/VoltoTabsBlock/CclProductTabsWithSubtabsView.test.jsx
ADDED
|
@@ -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
|
+
}
|