@webbycrown/advanced-fields 1.0.0 → 1.0.1
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/admin/jsconfig.json +10 -0
- package/admin/src/components/AdvancedCheckbox/index.jsx +397 -0
- package/admin/src/components/AdvancedInput/index.jsx +163 -0
- package/admin/src/components/AdvancedRadio/index.jsx +344 -0
- package/admin/src/components/Initializer.jsx +18 -0
- package/admin/src/components/PluginIcon.jsx +115 -0
- package/admin/src/index.js +739 -0
- package/admin/src/pages/App.jsx +13 -0
- package/admin/src/pluginId.js +3 -0
- package/admin/src/translations/en.json +86 -0
- package/admin/src/utils/getTranslation.js +5 -0
- package/index.js +202 -0
- package/package.json +5 -1
- package/strapi-server.js +186 -0
- package/strapi-server.mjs +74 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useIntl } from "react-intl";
|
|
4
|
+
import { Field, Box, Flex, Typography } from "@strapi/design-system";
|
|
5
|
+
import { useState, useEffect } from "react";
|
|
6
|
+
|
|
7
|
+
const RadioInput = ({
|
|
8
|
+
attribute = {},
|
|
9
|
+
description = { id: "", defaultMessage: "" },
|
|
10
|
+
disabled,
|
|
11
|
+
error,
|
|
12
|
+
intlLabel = { id: "", defaultMessage: "" },
|
|
13
|
+
labelAction,
|
|
14
|
+
name,
|
|
15
|
+
onChange,
|
|
16
|
+
required,
|
|
17
|
+
value,
|
|
18
|
+
}) => {
|
|
19
|
+
const { formatMessage } = useIntl();
|
|
20
|
+
const [selectedValues, setSelectedValues] = useState([]);
|
|
21
|
+
const [validationError, setValidationError] = useState(null);
|
|
22
|
+
const [hasInteracted, setHasInteracted] = useState(false);
|
|
23
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
selectionType = "single",
|
|
27
|
+
layout = "vertical",
|
|
28
|
+
minChoices = 0,
|
|
29
|
+
maxChoices = 0,
|
|
30
|
+
defaultSelected = "",
|
|
31
|
+
radioOptions = "",
|
|
32
|
+
customErrorMessage = "",
|
|
33
|
+
} = attribute.options || attribute;
|
|
34
|
+
|
|
35
|
+
// Parse radio options
|
|
36
|
+
const options = radioOptions
|
|
37
|
+
.split("\n")
|
|
38
|
+
.filter((opt) => opt.trim())
|
|
39
|
+
.map((opt) => {
|
|
40
|
+
const [value, label] = opt.split("|");
|
|
41
|
+
return { value: value?.trim() || "", label: label?.trim() || value?.trim() || "" };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Initialize selected values
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let initialValues = [];
|
|
47
|
+
|
|
48
|
+
if (value && Array.isArray(value)) {
|
|
49
|
+
initialValues = value;
|
|
50
|
+
} else if (value && typeof value === "string") {
|
|
51
|
+
initialValues = value.split(",").map(v => v.trim()).filter(v => v);
|
|
52
|
+
} else if (defaultSelected) {
|
|
53
|
+
initialValues = defaultSelected.split(",").map(v => v.trim()).filter(v => v);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setSelectedValues(initialValues);
|
|
57
|
+
|
|
58
|
+
// Validate the initial values
|
|
59
|
+
const validationResult = validateSelection(initialValues);
|
|
60
|
+
setValidationError(validationResult);
|
|
61
|
+
|
|
62
|
+
// Only trigger onChange if we have initial values and it's different from current value
|
|
63
|
+
// AND only if the value prop is not already set (to avoid overriding during publish)
|
|
64
|
+
// AND only if this is the first initialization
|
|
65
|
+
if (onChange && initialValues.length > 0 && (!value || (Array.isArray(value) && value.length === 0)) && !isInitialized) {
|
|
66
|
+
// Use setTimeout to ensure this happens after the component is fully mounted
|
|
67
|
+
setTimeout(() => {
|
|
68
|
+
onChange({
|
|
69
|
+
target: {
|
|
70
|
+
value: initialValues,
|
|
71
|
+
name: name,
|
|
72
|
+
id: name
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
setIsInitialized(true);
|
|
76
|
+
}, 0);
|
|
77
|
+
} else if (!isInitialized) {
|
|
78
|
+
setIsInitialized(true);
|
|
79
|
+
}
|
|
80
|
+
}, [value, defaultSelected, onChange, error]);
|
|
81
|
+
|
|
82
|
+
// Validation function - this should match server-side validation
|
|
83
|
+
const validateSelection = (values) => {
|
|
84
|
+
const valArray = Array.isArray(values) ? values : [];
|
|
85
|
+
|
|
86
|
+
// Check required validation first
|
|
87
|
+
if (required && valArray.length === 0) {
|
|
88
|
+
return customErrorMessage || 'This field is required';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// If field is empty and not required, no validation error
|
|
92
|
+
if (valArray.length === 0) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check min/max choices validation (only for multiple mode)
|
|
97
|
+
if (selectionType === "multiple") {
|
|
98
|
+
if (minChoices > 0 && valArray.length < minChoices) {
|
|
99
|
+
return customErrorMessage || `Please select at least ${minChoices} option${minChoices > 1 ? 's' : ''}`;
|
|
100
|
+
}
|
|
101
|
+
if (maxChoices > 0 && valArray.length > maxChoices) {
|
|
102
|
+
return customErrorMessage || `Please select at most ${maxChoices} option${maxChoices > 1 ? 's' : ''}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleRadioChange = (optionValue, isChecked) => {
|
|
110
|
+
let newValues;
|
|
111
|
+
|
|
112
|
+
if (selectionType === "single") {
|
|
113
|
+
// Single selection: replace current selection
|
|
114
|
+
newValues = isChecked ? [optionValue] : [];
|
|
115
|
+
} else {
|
|
116
|
+
// Multiple selection: add/remove from array
|
|
117
|
+
if (isChecked) {
|
|
118
|
+
newValues = [...selectedValues, optionValue];
|
|
119
|
+
} else {
|
|
120
|
+
newValues = selectedValues.filter(val => val !== optionValue);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
setSelectedValues(newValues);
|
|
125
|
+
setHasInteracted(true);
|
|
126
|
+
|
|
127
|
+
// Validate selection only after user interaction
|
|
128
|
+
const error = validateSelection(newValues);
|
|
129
|
+
setValidationError(error);
|
|
130
|
+
|
|
131
|
+
// console.log('AdvancedRadio handleRadioChange:', {
|
|
132
|
+
// optionValue,
|
|
133
|
+
// isChecked,
|
|
134
|
+
// newValues,
|
|
135
|
+
// error,
|
|
136
|
+
// hasInteracted: true
|
|
137
|
+
// });
|
|
138
|
+
|
|
139
|
+
if (onChange) {
|
|
140
|
+
// Create a proper event object with name and id attributes
|
|
141
|
+
const event = {
|
|
142
|
+
target: {
|
|
143
|
+
name: name,
|
|
144
|
+
id: name,
|
|
145
|
+
value: newValues
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
onChange(event);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Show validation error - prioritize Strapi's error, then our validation only after user interaction
|
|
153
|
+
const displayError = error || (hasInteracted && validationError);
|
|
154
|
+
|
|
155
|
+
const renderRadios = () => {
|
|
156
|
+
// Debug logging
|
|
157
|
+
// console.log('AdvancedRadio renderRadios:', {
|
|
158
|
+
// radioOptions,
|
|
159
|
+
// options,
|
|
160
|
+
// attribute,
|
|
161
|
+
// attributeOptions: attribute.options,
|
|
162
|
+
// selectedValues
|
|
163
|
+
// });
|
|
164
|
+
|
|
165
|
+
const radioStyle = {
|
|
166
|
+
display: "flex",
|
|
167
|
+
alignItems: "center",
|
|
168
|
+
gap: "8px",
|
|
169
|
+
padding: "4px 0",
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const radioInputStyle = {
|
|
173
|
+
width: "16px",
|
|
174
|
+
height: "16px",
|
|
175
|
+
accentColor: "#4945ff",
|
|
176
|
+
margin: "0",
|
|
177
|
+
padding: "0",
|
|
178
|
+
opacity: "1",
|
|
179
|
+
visibility: "visible",
|
|
180
|
+
display: "block",
|
|
181
|
+
position: "relative",
|
|
182
|
+
zIndex: "1",
|
|
183
|
+
cursor: "pointer",
|
|
184
|
+
border: "2px solid #dcdce4",
|
|
185
|
+
borderRadius: "50%",
|
|
186
|
+
backgroundColor: "white",
|
|
187
|
+
transition: "all 0.2s ease",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Custom radio button style for multiple selection
|
|
191
|
+
const customRadioStyle = {
|
|
192
|
+
width: "16px",
|
|
193
|
+
height: "16px",
|
|
194
|
+
borderRadius: "50%",
|
|
195
|
+
borderWidth: "1px",
|
|
196
|
+
borderStyle: "solid",
|
|
197
|
+
backgroundColor: "#ffffff",
|
|
198
|
+
cursor: "pointer",
|
|
199
|
+
transition: "all 0.2s ease",
|
|
200
|
+
position: "relative",
|
|
201
|
+
display: "flex",
|
|
202
|
+
alignItems: "center",
|
|
203
|
+
justifyContent: "center",
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const customRadioCheckedStyle = {
|
|
207
|
+
...customRadioStyle,
|
|
208
|
+
backgroundColor: "#ffffff",
|
|
209
|
+
borderColor: "#4945ff",
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const customRadioDotStyle = {
|
|
213
|
+
width: "10px",
|
|
214
|
+
height: "10px",
|
|
215
|
+
borderRadius: "50%",
|
|
216
|
+
backgroundColor: "#4945ff",
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const radioLabelStyle = {
|
|
220
|
+
fontSize: "14px",
|
|
221
|
+
fontFamily: "inherit",
|
|
222
|
+
cursor: "pointer",
|
|
223
|
+
userSelect: "none",
|
|
224
|
+
color: "#32324d",
|
|
225
|
+
fontWeight: "400",
|
|
226
|
+
lineHeight: "1.5",
|
|
227
|
+
marginLeft: "4px",
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// If no options are defined, show a message
|
|
231
|
+
if (!options || options.length === 0) {
|
|
232
|
+
return (
|
|
233
|
+
<div style={{ padding: "8px", color: "#666", fontStyle: "italic" }}>
|
|
234
|
+
{formatMessage({
|
|
235
|
+
id: 'advanced-fields.radio.no-options',
|
|
236
|
+
defaultMessage: 'No options defined. Please configure this field in the content type settings.'
|
|
237
|
+
})}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (layout === "horizontal") {
|
|
243
|
+
return (
|
|
244
|
+
<Flex wrap="wrap" gap={2}>
|
|
245
|
+
{options.map((option) => (
|
|
246
|
+
<div key={option.value} style={radioStyle}>
|
|
247
|
+
{selectionType === "multiple" ? (
|
|
248
|
+
// Custom radio button appearance for multiple selection
|
|
249
|
+
<div
|
|
250
|
+
style={selectedValues.includes(option.value) ? customRadioCheckedStyle : customRadioStyle}
|
|
251
|
+
onClick={() => handleRadioChange(option.value, !selectedValues.includes(option.value))}
|
|
252
|
+
>
|
|
253
|
+
{selectedValues.includes(option.value) && (
|
|
254
|
+
<div style={customRadioDotStyle} />
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
) : (
|
|
258
|
+
// Regular radio button for single selection
|
|
259
|
+
<input
|
|
260
|
+
type="radio"
|
|
261
|
+
id={`${name}-${option.value}`}
|
|
262
|
+
name={name}
|
|
263
|
+
checked={selectedValues.includes(option.value)}
|
|
264
|
+
onChange={(e) => handleRadioChange(option.value, e.target.checked)}
|
|
265
|
+
disabled={disabled}
|
|
266
|
+
style={radioInputStyle}
|
|
267
|
+
/>
|
|
268
|
+
)}
|
|
269
|
+
<label
|
|
270
|
+
htmlFor={selectionType === "single" ? `${name}-${option.value}` : undefined}
|
|
271
|
+
style={radioLabelStyle}
|
|
272
|
+
onClick={selectionType === "multiple" ? () => handleRadioChange(option.value, !selectedValues.includes(option.value)) : undefined}
|
|
273
|
+
>
|
|
274
|
+
{option.label}
|
|
275
|
+
</label>
|
|
276
|
+
</div>
|
|
277
|
+
))}
|
|
278
|
+
</Flex>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div>
|
|
284
|
+
{options.map((option) => (
|
|
285
|
+
<div key={option.value} style={radioStyle}>
|
|
286
|
+
{selectionType === "multiple" ? (
|
|
287
|
+
// Custom radio button appearance for multiple selection
|
|
288
|
+
<div
|
|
289
|
+
style={selectedValues.includes(option.value) ? customRadioCheckedStyle : customRadioStyle}
|
|
290
|
+
onClick={() => handleRadioChange(option.value, !selectedValues.includes(option.value))}
|
|
291
|
+
>
|
|
292
|
+
{selectedValues.includes(option.value) && (
|
|
293
|
+
<div style={customRadioDotStyle} />
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
) : (
|
|
297
|
+
// Regular radio button for single selection
|
|
298
|
+
<input
|
|
299
|
+
type="radio"
|
|
300
|
+
id={`${name}-${option.value}`}
|
|
301
|
+
name={name}
|
|
302
|
+
checked={selectedValues.includes(option.value)}
|
|
303
|
+
onChange={(e) => handleRadioChange(option.value, e.target.checked)}
|
|
304
|
+
disabled={disabled}
|
|
305
|
+
style={radioInputStyle}
|
|
306
|
+
/>
|
|
307
|
+
)}
|
|
308
|
+
<label
|
|
309
|
+
htmlFor={selectionType === "single" ? `${name}-${option.value}` : undefined}
|
|
310
|
+
style={radioLabelStyle}
|
|
311
|
+
onClick={selectionType === "multiple" ? () => handleRadioChange(option.value, !selectedValues.includes(option.value)) : undefined}
|
|
312
|
+
>
|
|
313
|
+
{option.label}
|
|
314
|
+
</label>
|
|
315
|
+
</div>
|
|
316
|
+
))}
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<Box col={6}>
|
|
323
|
+
<Field.Root name={name} error={displayError}>
|
|
324
|
+
<Field.Label>
|
|
325
|
+
{intlLabel.id ? formatMessage(intlLabel) : intlLabel.defaultMessage || name}
|
|
326
|
+
{required && <span style={{ color: "#d02b20", marginLeft: "4px" }}>*</span>}
|
|
327
|
+
</Field.Label>
|
|
328
|
+
{renderRadios()}
|
|
329
|
+
{displayError && (
|
|
330
|
+
<Field.Error>
|
|
331
|
+
{displayError}
|
|
332
|
+
</Field.Error>
|
|
333
|
+
)}
|
|
334
|
+
{description && (description.id || description.defaultMessage) && (
|
|
335
|
+
<Field.Hint>
|
|
336
|
+
{description.id ? formatMessage(description) : description.defaultMessage}
|
|
337
|
+
</Field.Hint>
|
|
338
|
+
)}
|
|
339
|
+
</Field.Root>
|
|
340
|
+
</Box>
|
|
341
|
+
);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export default RadioInput;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { PLUGIN_ID } from '../pluginId';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @type {import('react').FC<{ setPlugin: (id: string) => void }>}
|
|
7
|
+
*/
|
|
8
|
+
const Initializer = ({ setPlugin }) => {
|
|
9
|
+
const ref = useRef(setPlugin);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
ref.current(PLUGIN_ID);
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
return null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export { Initializer };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { PuzzlePiece } from '@strapi/icons';
|
|
2
|
+
import { Box } from '@strapi/design-system';
|
|
3
|
+
|
|
4
|
+
const PluginIcon = (props) => {
|
|
5
|
+
return <PuzzlePiece {...props} />;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// Advanced Input Icon - Clean text input with validation indicator
|
|
9
|
+
const TextFieldIcon = () => (
|
|
10
|
+
<Box
|
|
11
|
+
style={{
|
|
12
|
+
width: '28px',
|
|
13
|
+
height: '28px',
|
|
14
|
+
display: 'flex',
|
|
15
|
+
alignItems: 'center',
|
|
16
|
+
justifyContent: 'center',
|
|
17
|
+
backgroundColor: '#f8f9fa',
|
|
18
|
+
borderRadius: '6px',
|
|
19
|
+
border: '1px solid #e9ecef',
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
22
|
+
<svg
|
|
23
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
24
|
+
viewBox="0 0 24 24"
|
|
25
|
+
width="18"
|
|
26
|
+
height="18"
|
|
27
|
+
fill="none"
|
|
28
|
+
stroke="#495057"
|
|
29
|
+
strokeWidth="2"
|
|
30
|
+
strokeLinecap="round"
|
|
31
|
+
strokeLinejoin="round"
|
|
32
|
+
>
|
|
33
|
+
<rect x="3" y="4" width="18" height="16" rx="2" ry="2"/>
|
|
34
|
+
<line x1="7" y1="8" x2="17" y2="8"/>
|
|
35
|
+
<line x1="7" y1="12" x2="13" y2="12"/>
|
|
36
|
+
<circle cx="18" cy="6" r="2" fill="#28a745"/>
|
|
37
|
+
<path d="m16 5 2 2-2 2" stroke="#fff" strokeWidth="1.5"/>
|
|
38
|
+
</svg>
|
|
39
|
+
</Box>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Advanced Checkbox Icon - Modern checkbox with multiple states
|
|
43
|
+
const CheckIcon = () => (
|
|
44
|
+
<Box
|
|
45
|
+
style={{
|
|
46
|
+
width: '28px',
|
|
47
|
+
height: '28px',
|
|
48
|
+
display: 'flex',
|
|
49
|
+
alignItems: 'center',
|
|
50
|
+
justifyContent: 'center',
|
|
51
|
+
backgroundColor: '#f8f9fa',
|
|
52
|
+
borderRadius: '6px',
|
|
53
|
+
border: '1px solid #e9ecef',
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<svg
|
|
57
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
58
|
+
viewBox="0 0 24 24"
|
|
59
|
+
width="18"
|
|
60
|
+
height="18"
|
|
61
|
+
fill="none"
|
|
62
|
+
stroke="#495057"
|
|
63
|
+
strokeWidth="2"
|
|
64
|
+
strokeLinecap="round"
|
|
65
|
+
strokeLinejoin="round"
|
|
66
|
+
>
|
|
67
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
|
68
|
+
<polyline points="9,12 12,15 22,5" stroke="#28a745" strokeWidth="2.5"/>
|
|
69
|
+
<rect x="3" y="8" width="12" height="2" rx="1" fill="#6c757d" opacity="0.3"/>
|
|
70
|
+
<rect x="3" y="12" width="8" height="2" rx="1" fill="#6c757d" opacity="0.3"/>
|
|
71
|
+
</svg>
|
|
72
|
+
</Box>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Advanced Radio Icon - Clean radio button with selection states
|
|
76
|
+
const MultipleChoiceIcon1 = () => (
|
|
77
|
+
<Box
|
|
78
|
+
style={{
|
|
79
|
+
width: '28px',
|
|
80
|
+
height: '28px',
|
|
81
|
+
display: 'flex',
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
justifyContent: 'center',
|
|
84
|
+
backgroundColor: '#f8f9fa',
|
|
85
|
+
borderRadius: '6px',
|
|
86
|
+
border: '1px solid #e9ecef',
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<svg
|
|
90
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
91
|
+
viewBox="0 0 24 24"
|
|
92
|
+
width="18"
|
|
93
|
+
height="18"
|
|
94
|
+
fill="none"
|
|
95
|
+
stroke="#495057"
|
|
96
|
+
strokeWidth="2"
|
|
97
|
+
strokeLinecap="round"
|
|
98
|
+
strokeLinejoin="round"
|
|
99
|
+
>
|
|
100
|
+
<circle cx="12" cy="12" r="10"/>
|
|
101
|
+
<circle cx="12" cy="12" r="3" fill="#007bff"/>
|
|
102
|
+
<circle cx="12" cy="5" r="2" fill="#6c757d" opacity="0.3"/>
|
|
103
|
+
<circle cx="12" cy="19" r="2" fill="#6c757d" opacity="0.3"/>
|
|
104
|
+
<circle cx="5" cy="12" r="2" fill="#6c757d" opacity="0.3"/>
|
|
105
|
+
<circle cx="19" cy="12" r="2" fill="#6c757d" opacity="0.3"/>
|
|
106
|
+
</svg>
|
|
107
|
+
</Box>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
export {
|
|
111
|
+
PluginIcon,
|
|
112
|
+
CheckIcon,
|
|
113
|
+
TextFieldIcon,
|
|
114
|
+
MultipleChoiceIcon1
|
|
115
|
+
};
|