@eeacms/volto-bise-policy 1.3.4 → 1.3.6
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 +39 -13
- package/package.json +2 -1
- package/src/components/Widgets/EUNISObjectListWidget.jsx +8 -6
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerEdit.jsx +22 -1
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.jsx +17 -12
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyExplorerView.test.jsx +1 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyFilters.jsx +59 -13
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.jsx +28 -16
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyListing.test.jsx +54 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.jsx +68 -35
- package/src/components/manage/Blocks/CaseStudyExplorer/CaseStudyMap.test.jsx +37 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.jsx +41 -39
- package/src/components/manage/Blocks/CaseStudyExplorer/FeatureInteraction.test.jsx +39 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/index.js +2 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/schema.js +19 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/styles.less +33 -0
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.js +171 -93
- package/src/components/manage/Blocks/CaseStudyExplorer/utils.test.js +96 -1
- package/theme/globals/eunismarinehabitat.less +26 -10
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
export const STATUS_COLORS = {
|
|
2
|
+
planned: '#006bb8',
|
|
3
|
+
ongoing: '#ff9933',
|
|
4
|
+
completed: '#00a390',
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const FALLBACK_STATUS_COLOR = '#6B7280';
|
|
8
|
+
export const CLUSTER_COLOR = '#007B6C';
|
|
9
|
+
|
|
10
|
+
const FIELD_ALIASES = {
|
|
11
|
+
habitat_ecosystem_type: ['habitat_ecosystem_type', 'ecosystem_typology'],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function getCaseProperty(properties, fieldName) {
|
|
15
|
+
if (properties?.[fieldName] !== undefined) return properties[fieldName];
|
|
16
|
+
return (FIELD_ALIASES[fieldName] || [])
|
|
17
|
+
.map((field) => properties?.[field])
|
|
18
|
+
.find((value) => value !== undefined);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function asValues(value) {
|
|
22
|
+
if (value === undefined || value === null || value === '') return [];
|
|
23
|
+
return Array.isArray(value) ? value : [value];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function valueCode(value) {
|
|
27
|
+
if (value && typeof value === 'object') {
|
|
28
|
+
return String(
|
|
29
|
+
value.id ?? value.value ?? value.token ?? value.title ?? value,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return String(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function valueLabel(value) {
|
|
36
|
+
if (value && typeof value === 'object') {
|
|
37
|
+
return String(
|
|
38
|
+
value.title ?? value.name ?? value.label ?? value.value ?? value.id,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return String(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getStatusColor(status) {
|
|
45
|
+
return STATUS_COLORS[status] || FALLBACK_STATUS_COLOR;
|
|
46
|
+
}
|
|
47
|
+
|
|
1
48
|
export function centerAndResetMapZoom({ map, ol }) {
|
|
2
49
|
map.getView().animate({
|
|
3
50
|
zoom: 4,
|
|
@@ -8,9 +55,17 @@ export function centerAndResetMapZoom({ map, ol }) {
|
|
|
8
55
|
|
|
9
56
|
export function scrollToElement(elementId) {
|
|
10
57
|
const element = document.getElementById(elementId);
|
|
11
|
-
element
|
|
12
|
-
|
|
13
|
-
|
|
58
|
+
element?.scrollIntoView({ behavior: 'smooth' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getSelectInteraction(map) {
|
|
62
|
+
const interactions = map?.getInteractions?.();
|
|
63
|
+
const interactionArray =
|
|
64
|
+
interactions?.getArray?.() || interactions?.array_ || [];
|
|
65
|
+
|
|
66
|
+
return interactionArray.find(
|
|
67
|
+
(interaction) => typeof interaction?.getFeatures === 'function',
|
|
68
|
+
);
|
|
14
69
|
}
|
|
15
70
|
|
|
16
71
|
export function getExtentOfFeatures({ features, ol }) {
|
|
@@ -31,24 +86,30 @@ export function getFeatures({ cases, ol }) {
|
|
|
31
86
|
const Feature = ol.ol.Feature;
|
|
32
87
|
|
|
33
88
|
return cases.map((c, index) => {
|
|
34
|
-
const {
|
|
35
|
-
geometry: { coordinates },
|
|
36
|
-
} = c;
|
|
37
89
|
const point = new Feature(
|
|
38
|
-
new ol.geom.Point(ol.proj.fromLonLat(coordinates)),
|
|
90
|
+
new ol.geom.Point(ol.proj.fromLonLat(c.geometry.coordinates)),
|
|
39
91
|
);
|
|
92
|
+
const properties = c.properties || {};
|
|
93
|
+
const status = properties.current_status;
|
|
40
94
|
point.setId(index);
|
|
41
95
|
point.setProperties(
|
|
42
96
|
{
|
|
43
|
-
title:
|
|
44
|
-
image:
|
|
45
|
-
nwrm_type:
|
|
46
|
-
measures_implemented:
|
|
47
|
-
typology_of_measures:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
97
|
+
title: properties.title,
|
|
98
|
+
image: properties.image,
|
|
99
|
+
nwrm_type: properties.nwrm_type,
|
|
100
|
+
measures_implemented: properties.measures,
|
|
101
|
+
typology_of_measures: properties.typology_of_measures,
|
|
102
|
+
current_status: status,
|
|
103
|
+
habitat_ecosystem_type: getCaseProperty(
|
|
104
|
+
properties,
|
|
105
|
+
'habitat_ecosystem_type',
|
|
106
|
+
),
|
|
107
|
+
nrr_article: properties.nrr_article,
|
|
108
|
+
scale_of_planning: properties.scale_of_planning,
|
|
109
|
+
description: properties.description,
|
|
110
|
+
index,
|
|
111
|
+
path: properties.path,
|
|
112
|
+
color: getStatusColor(status),
|
|
52
113
|
},
|
|
53
114
|
false,
|
|
54
115
|
);
|
|
@@ -56,89 +117,106 @@ export function getFeatures({ cases, ol }) {
|
|
|
56
117
|
});
|
|
57
118
|
}
|
|
58
119
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
? caseStudiesIds.includes(_case.properties.url.split('/').pop())
|
|
66
|
-
: true;
|
|
67
|
-
|
|
68
|
-
if (!searchInput) {
|
|
69
|
-
flag_searchInput = true;
|
|
70
|
-
} else {
|
|
71
|
-
if (_case.properties.title.toLowerCase().match(searchInput)) {
|
|
72
|
-
flag_searchInput = true;
|
|
73
|
-
} else if (
|
|
74
|
-
_case.properties.description.toLowerCase().match(searchInput)
|
|
75
|
-
) {
|
|
76
|
-
flag_searchInput = true;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (!activeFilters.measures_implemented.length) {
|
|
81
|
-
flag_implemented = true;
|
|
82
|
-
} else {
|
|
83
|
-
let measures_implemented = _case.properties.measures?.map((item) => {
|
|
84
|
-
return item['id'].toString();
|
|
85
|
-
});
|
|
120
|
+
// Escape a string so it can be embedded in a RegExp pattern as a literal.
|
|
121
|
+
// Prevents user-provided input from being interpreted as regex syntax
|
|
122
|
+
// (avoids ReDoS via crafted patterns and SyntaxError from malformed ones).
|
|
123
|
+
export function escapeRegExp(str) {
|
|
124
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
125
|
+
}
|
|
86
126
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return flag_case &&
|
|
108
|
-
flag_implemented &&
|
|
109
|
-
flag_typology_of_measures &&
|
|
110
|
-
flag_searchInput
|
|
111
|
-
? _case
|
|
112
|
-
: false;
|
|
113
|
-
});
|
|
127
|
+
const WORD_CHAR = /[A-Za-z0-9_]/;
|
|
128
|
+
|
|
129
|
+
// Case-insensitive whole-word substring match implemented without regular
|
|
130
|
+
// expressions, so the needle is always treated as literal text. Equivalent
|
|
131
|
+
// to /\bneedle\b/i but runs in linear time (indexOf) and is immune to
|
|
132
|
+
// catastrophic backtracking.
|
|
133
|
+
function hasWholeWord(text, needle) {
|
|
134
|
+
let index = text.indexOf(needle);
|
|
135
|
+
while (index !== -1) {
|
|
136
|
+
const before = index === 0 ? '' : text[index - 1];
|
|
137
|
+
const after =
|
|
138
|
+
index + needle.length >= text.length ? '' : text[index + needle.length];
|
|
139
|
+
if (!WORD_CHAR.test(before) && !WORD_CHAR.test(after)) return true;
|
|
140
|
+
index = text.indexOf(needle, index + 1);
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
114
144
|
|
|
115
|
-
|
|
145
|
+
const FILTER_FIELDS = [
|
|
146
|
+
'measures_implemented',
|
|
147
|
+
'typology_of_measures',
|
|
148
|
+
'current_status',
|
|
149
|
+
'habitat_ecosystem_type',
|
|
150
|
+
'nrr_article',
|
|
151
|
+
'scale_of_planning',
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
export function filterCasesByPath(cases, filterPath) {
|
|
155
|
+
const path = filterPath?.trim();
|
|
156
|
+
if (!path) return cases;
|
|
157
|
+
|
|
158
|
+
return cases.filter((_case) =>
|
|
159
|
+
String(_case.properties?.path || '').includes(path),
|
|
160
|
+
);
|
|
116
161
|
}
|
|
117
162
|
|
|
118
|
-
export function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
163
|
+
export function filterCases(
|
|
164
|
+
cases,
|
|
165
|
+
activeFilters,
|
|
166
|
+
caseStudiesIds,
|
|
167
|
+
searchInput,
|
|
168
|
+
filterPath,
|
|
169
|
+
) {
|
|
170
|
+
return filterCasesByPath(cases, filterPath).filter((_case) => {
|
|
171
|
+
const properties = _case.properties || {};
|
|
172
|
+
const flagCase = caseStudiesIds
|
|
173
|
+
? caseStudiesIds.includes(properties.url?.split('/').pop())
|
|
174
|
+
: true;
|
|
175
|
+
const searchable = `${properties.title || ''} ${
|
|
176
|
+
properties.description || ''
|
|
177
|
+
}`;
|
|
178
|
+
const flagSearch =
|
|
179
|
+
!searchInput ||
|
|
180
|
+
hasWholeWord(searchable.toLowerCase(), searchInput.toLowerCase());
|
|
181
|
+
|
|
182
|
+
const matchesFilters = FILTER_FIELDS.every((filterName) => {
|
|
183
|
+
const selected = activeFilters[filterName] || [];
|
|
184
|
+
if (!selected.length) return true;
|
|
185
|
+
const values =
|
|
186
|
+
filterName === 'measures_implemented'
|
|
187
|
+
? asValues(properties.measures).map((item) => valueCode(item))
|
|
188
|
+
: filterName === 'typology_of_measures'
|
|
189
|
+
? asValues(properties.typology_of_measures).map((item) =>
|
|
190
|
+
valueCode(item),
|
|
191
|
+
)
|
|
192
|
+
: asValues(getCaseProperty(properties, filterName)).map((item) =>
|
|
193
|
+
valueCode(item),
|
|
194
|
+
);
|
|
195
|
+
return selected.some((filter) => values.includes(String(filter)));
|
|
132
196
|
});
|
|
133
197
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
198
|
+
return flagCase && flagSearch && matchesFilters;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function getFilters(cases) {
|
|
203
|
+
const filters = Object.fromEntries(FILTER_FIELDS.map((field) => [field, {}]));
|
|
204
|
+
|
|
205
|
+
Object.values(cases || {}).forEach((_case) => {
|
|
206
|
+
const properties = _case.properties || {};
|
|
207
|
+
FILTER_FIELDS.forEach((field) => {
|
|
208
|
+
const source =
|
|
209
|
+
field === 'measures_implemented'
|
|
210
|
+
? properties.measures
|
|
211
|
+
: field === 'typology_of_measures'
|
|
212
|
+
? properties.typology_of_measures
|
|
213
|
+
: getCaseProperty(properties, field);
|
|
214
|
+
asValues(source).forEach((item) => {
|
|
215
|
+
const code = valueCode(item);
|
|
216
|
+
if (!filters[field][code]) filters[field][code] = valueLabel(item);
|
|
217
|
+
});
|
|
140
218
|
});
|
|
141
|
-
}
|
|
219
|
+
});
|
|
142
220
|
|
|
143
|
-
return
|
|
221
|
+
return filters;
|
|
144
222
|
}
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import './mockJsdom';
|
|
2
2
|
import '@testing-library/jest-dom';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
FALLBACK_STATUS_COLOR,
|
|
5
|
+
getFeatures,
|
|
6
|
+
getStatusColor,
|
|
7
|
+
filterCases,
|
|
8
|
+
getFilters,
|
|
9
|
+
} from './utils';
|
|
4
10
|
|
|
5
11
|
describe('utils.js', () => {
|
|
12
|
+
test('maps exact statuses and unknown statuses to colors', () => {
|
|
13
|
+
expect(getStatusColor('planned')).toBe('#006bb8');
|
|
14
|
+
expect(getStatusColor('ongoing')).toBe('#ff9933');
|
|
15
|
+
expect(getStatusColor('completed')).toBe('#00a390');
|
|
16
|
+
expect(getStatusColor('Planned')).toBe(FALLBACK_STATUS_COLOR);
|
|
17
|
+
expect(getStatusColor('future')).toBe(FALLBACK_STATUS_COLOR);
|
|
18
|
+
});
|
|
19
|
+
|
|
6
20
|
const mockCases = [
|
|
7
21
|
{
|
|
8
22
|
geometry: { coordinates: [0, 0] },
|
|
@@ -70,6 +84,83 @@ describe('utils.js', () => {
|
|
|
70
84
|
expect(mockCasesFiltered).toStrictEqual([]);
|
|
71
85
|
});
|
|
72
86
|
|
|
87
|
+
test('filterCases treats search input as literal text (ReDoS-safe)', () => {
|
|
88
|
+
const cases = [
|
|
89
|
+
{
|
|
90
|
+
geometry: { coordinates: [0, 0] },
|
|
91
|
+
properties: {
|
|
92
|
+
title: 'the river project (R1)',
|
|
93
|
+
description: 'a aaa aaaa aaaaa b',
|
|
94
|
+
url: '/the-river-project-r1',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
const noFilters = {
|
|
99
|
+
measures_implemented: [],
|
|
100
|
+
typology_of_measures: [],
|
|
101
|
+
current_status: [],
|
|
102
|
+
habitat_ecosystem_type: [],
|
|
103
|
+
nrr_article: [],
|
|
104
|
+
scale_of_planning: [],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Malformed regex input must not throw (previously: SyntaxError)
|
|
108
|
+
expect(() => filterCases(cases, noFilters, null, '(')).not.toThrow();
|
|
109
|
+
// Regex metacharacters are matched literally, not interpreted
|
|
110
|
+
expect(() => filterCases(cases, noFilters, null, '(a+)+$')).not.toThrow();
|
|
111
|
+
expect(filterCases(cases, noFilters, null, '(a+)+$')).toHaveLength(0);
|
|
112
|
+
expect(filterCases(cases, noFilters, null, '(r1')).toHaveLength(1);
|
|
113
|
+
// Whole-word semantics are preserved
|
|
114
|
+
expect(filterCases(cases, noFilters, null, 'rive')).toHaveLength(0);
|
|
115
|
+
expect(filterCases(cases, noFilters, null, 'river')).toHaveLength(1);
|
|
116
|
+
// Case-insensitive
|
|
117
|
+
expect(filterCases(cases, noFilters, null, 'R1')).toHaveLength(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('filters new scalar and multi-value fields', () => {
|
|
121
|
+
const cases = [
|
|
122
|
+
{
|
|
123
|
+
geometry: { coordinates: [0, 0] },
|
|
124
|
+
properties: {
|
|
125
|
+
title: 'planned river case',
|
|
126
|
+
description: '',
|
|
127
|
+
url: '/planned-river-case',
|
|
128
|
+
current_status: 'planned',
|
|
129
|
+
ecosystem_typology: ['River', 'Wetland'],
|
|
130
|
+
nrr_article: [{ id: 'article-1', title: 'Article 1' }],
|
|
131
|
+
scale_of_planning: 'Regional',
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
geometry: { coordinates: [0, 0] },
|
|
136
|
+
properties: {
|
|
137
|
+
title: 'future case',
|
|
138
|
+
description: '',
|
|
139
|
+
url: '/future-case',
|
|
140
|
+
current_status: 'Future',
|
|
141
|
+
ecosystem_typology: ['Forest'],
|
|
142
|
+
nrr_article: ['article-2'],
|
|
143
|
+
scale_of_planning: 'National',
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
expect(
|
|
149
|
+
filterCases(cases, {
|
|
150
|
+
measures_implemented: [],
|
|
151
|
+
typology_of_measures: [],
|
|
152
|
+
current_status: ['planned'],
|
|
153
|
+
habitat_ecosystem_type: ['River'],
|
|
154
|
+
nrr_article: ['article-1'],
|
|
155
|
+
scale_of_planning: ['Regional'],
|
|
156
|
+
}),
|
|
157
|
+
).toHaveLength(1);
|
|
158
|
+
expect(getFilters(cases).nrr_article).toEqual({
|
|
159
|
+
'article-1': 'Article 1',
|
|
160
|
+
'article-2': 'article-2',
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
73
164
|
test('getFilters', () => {
|
|
74
165
|
const mockCasesObject = mockCases.reduce((acc, item, index) => {
|
|
75
166
|
acc[index] = item;
|
|
@@ -83,6 +174,10 @@ describe('utils.js', () => {
|
|
|
83
174
|
typology_of_measures: {
|
|
84
175
|
testsector: 'testsector',
|
|
85
176
|
},
|
|
177
|
+
current_status: {},
|
|
178
|
+
habitat_ecosystem_type: {},
|
|
179
|
+
nrr_article: {},
|
|
180
|
+
scale_of_planning: {},
|
|
86
181
|
});
|
|
87
182
|
});
|
|
88
183
|
});
|
|
@@ -22,23 +22,40 @@
|
|
|
22
22
|
box-shadow: none;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
.contenttype-marine_eunis_habitat
|
|
26
|
-
|
|
25
|
+
.is-adding-contenttype-marine_eunis_habitat {
|
|
26
|
+
.slate_wysiwyg_box {
|
|
27
|
+
background-color: white;
|
|
28
|
+
padding: 5px;
|
|
29
|
+
border: 1px solid #c7d5d8;
|
|
30
|
+
}
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
.contenttype-marine_eunis_habitat
|
|
30
|
-
|
|
33
|
+
.contenttype-marine_eunis_habitat,
|
|
34
|
+
.is-adding-contenttype-marine_eunis_habitat {
|
|
35
|
+
.help.slate_wysiwyg p.help {
|
|
36
|
+
white-space: break-spaces;
|
|
37
|
+
}
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
.contenttype-marine_eunis_habitat
|
|
34
|
-
|
|
40
|
+
.is-adding-contenttype-marine_eunis_habitat,
|
|
41
|
+
.contenttype-marine_eunis_habitat {
|
|
42
|
+
.bise-objectlist-widget p.help {
|
|
43
|
+
white-space: break-spaces;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.is-adding-contenttype-marine_eunis_habitat,
|
|
48
|
+
.contenttype-marine_eunis_habitat {
|
|
49
|
+
.eea.banner .gradient {
|
|
50
|
+
background: none;
|
|
51
|
+
}
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
.contenttype-nrr_measure .eea.banner .gradient {
|
|
38
55
|
background: none;
|
|
39
56
|
}
|
|
40
57
|
|
|
41
|
-
.bise-objectlist-widget .inline.field
|
|
58
|
+
.bise-objectlist-widget .inline.field>.ui.grid {
|
|
42
59
|
margin: 0;
|
|
43
60
|
}
|
|
44
61
|
|
|
@@ -46,7 +63,6 @@
|
|
|
46
63
|
color: var(--text-color-secondary, #289588);
|
|
47
64
|
}
|
|
48
65
|
|
|
49
|
-
.contenttype-nrr_measure
|
|
50
|
-
.top.aligned.four.wide.computer.twelve.wide.mobile.four.wide.column {
|
|
66
|
+
.contenttype-nrr_measure .top.aligned.four.wide.computer.twelve.wide.mobile.four.wide.column {
|
|
51
67
|
background-color: #f9f9f9;
|
|
52
|
-
}
|
|
68
|
+
}
|