@tunghtml/strapi-plugin-composite-field 1.2.1 → 1.2.2
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.
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { Button, Field, Flex, Typography } from '@strapi/design-system';
|
|
2
|
+
import { Play } from '@strapi/icons';
|
|
3
|
+
import { unstable_useContentManagerContext as useContentManagerContext } from '@strapi/strapi/admin';
|
|
4
|
+
import get from 'lodash/get';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { useIntl } from 'react-intl';
|
|
7
|
+
|
|
8
|
+
const CompositeInput = (props) => {
|
|
9
|
+
const {
|
|
10
|
+
attribute,
|
|
11
|
+
name,
|
|
12
|
+
value,
|
|
13
|
+
onChange,
|
|
14
|
+
disabled,
|
|
15
|
+
required,
|
|
16
|
+
error,
|
|
17
|
+
description,
|
|
18
|
+
labelAction,
|
|
19
|
+
label,
|
|
20
|
+
intlLabel,
|
|
21
|
+
} = props;
|
|
22
|
+
|
|
23
|
+
const { formatMessage } = useIntl();
|
|
24
|
+
const { form } = useContentManagerContext();
|
|
25
|
+
const { values } = form;
|
|
26
|
+
|
|
27
|
+
const [localValue, setLocalValue] = React.useState(value || '');
|
|
28
|
+
|
|
29
|
+
const lastGeneratedVal = React.useRef(value || '');
|
|
30
|
+
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
const validValue = value || '';
|
|
33
|
+
setLocalValue(validValue);
|
|
34
|
+
lastGeneratedVal.current = validValue;
|
|
35
|
+
}, [value]);
|
|
36
|
+
|
|
37
|
+
const fieldsConfig = attribute?.options?.fields || '';
|
|
38
|
+
const separator = attribute?.options?.separator || ' - ';
|
|
39
|
+
const editable = attribute?.options?.editable !== false;
|
|
40
|
+
const autoGenerate = attribute?.options?.autoGenerate === true;
|
|
41
|
+
|
|
42
|
+
// Parse fields
|
|
43
|
+
const fields = React.useMemo(() => {
|
|
44
|
+
if (typeof fieldsConfig === 'string') {
|
|
45
|
+
return fieldsConfig
|
|
46
|
+
.split('\n')
|
|
47
|
+
.map((f) => f.trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
return [];
|
|
51
|
+
}, [fieldsConfig]);
|
|
52
|
+
|
|
53
|
+
const handleChange = (e) => {
|
|
54
|
+
const newValue = e.target.value;
|
|
55
|
+
setLocalValue(newValue);
|
|
56
|
+
lastGeneratedVal.current = newValue;
|
|
57
|
+
if (onChange) {
|
|
58
|
+
onChange({ target: { name, value: newValue, type: 'text' } });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Helper function to format values (especially time/date)
|
|
63
|
+
const formatFieldValue = React.useCallback((val) => {
|
|
64
|
+
if (val === null || val === undefined) return '';
|
|
65
|
+
|
|
66
|
+
// If it's a Date object
|
|
67
|
+
if (val instanceof Date) {
|
|
68
|
+
return val.toLocaleTimeString([], {
|
|
69
|
+
hour: '2-digit',
|
|
70
|
+
minute: '2-digit',
|
|
71
|
+
hour12: false,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const stringVal = String(val);
|
|
76
|
+
|
|
77
|
+
// Check if value is ISO date format (YYYY-MM-DDTHH:MM:SS.sssZ or YYYY-MM-DD)
|
|
78
|
+
const isoDateRegex = /^(\d{4})-(\d{2})-(\d{2})(T[\d:.]+Z?)?$/;
|
|
79
|
+
const dateMatch = stringVal.match(isoDateRegex);
|
|
80
|
+
|
|
81
|
+
if (dateMatch) {
|
|
82
|
+
// Return in MM/DD/YYYY format
|
|
83
|
+
const [, year, month, day] = dateMatch;
|
|
84
|
+
return `${month}/${day}/${year}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check if value is time format (HH:MM:SS.mmm or HH:MM:SS)
|
|
88
|
+
const timeRegex = /^(\d{2}):(\d{2}):(\d{2})(\.\d{3})?$/;
|
|
89
|
+
const timeMatch = stringVal.match(timeRegex);
|
|
90
|
+
|
|
91
|
+
if (timeMatch) {
|
|
92
|
+
// Return only HH:MM
|
|
93
|
+
return `${timeMatch[1]}:${timeMatch[2]}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return stringVal;
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
const handleGenerate = React.useCallback(() => {
|
|
100
|
+
// Calculate parent path to resolve relative field names
|
|
101
|
+
const parts = name.split('.');
|
|
102
|
+
|
|
103
|
+
parts.pop(); // Remove current field name
|
|
104
|
+
const parentPath = parts.join('.');
|
|
105
|
+
|
|
106
|
+
const generatedParts = [];
|
|
107
|
+
|
|
108
|
+
fields.forEach((fieldName) => {
|
|
109
|
+
// Resolve full path for the target field
|
|
110
|
+
const fullPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
|
111
|
+
|
|
112
|
+
// Get raw value from form values
|
|
113
|
+
let rawValue = get(values, fullPath);
|
|
114
|
+
|
|
115
|
+
// Handle different value types
|
|
116
|
+
if (rawValue !== null && rawValue !== undefined) {
|
|
117
|
+
let stringValue = '';
|
|
118
|
+
|
|
119
|
+
// Handle relations/objects
|
|
120
|
+
if (typeof rawValue === 'object' && !(rawValue instanceof Date)) {
|
|
121
|
+
if (rawValue.name) stringValue = String(rawValue.name);
|
|
122
|
+
else if (rawValue.title) stringValue = String(rawValue.title);
|
|
123
|
+
else if (rawValue.label) stringValue = String(rawValue.label);
|
|
124
|
+
} else {
|
|
125
|
+
stringValue = formatFieldValue(rawValue);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (stringValue) {
|
|
129
|
+
generatedParts.push(stringValue);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Ensure separator has spaces around it
|
|
135
|
+
const cleanSeparator = separator.trim();
|
|
136
|
+
const result = generatedParts.join(` ${cleanSeparator} `).trim();
|
|
137
|
+
|
|
138
|
+
if (result !== lastGeneratedVal.current) {
|
|
139
|
+
setLocalValue(result);
|
|
140
|
+
lastGeneratedVal.current = result;
|
|
141
|
+
|
|
142
|
+
if (onChange) {
|
|
143
|
+
onChange({ target: { name, value: result, type: 'text' } });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}, [fields, separator, onChange, name, values, formatFieldValue]);
|
|
147
|
+
|
|
148
|
+
// Watch only the relevant field values to trigger auto-generation
|
|
149
|
+
const watchedValues = React.useMemo(() => {
|
|
150
|
+
const parts = name.split('.');
|
|
151
|
+
parts.pop();
|
|
152
|
+
const parentPath = parts.join('.');
|
|
153
|
+
|
|
154
|
+
return fields.map((fieldName) => {
|
|
155
|
+
const fullPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
|
156
|
+
return get(values, fullPath);
|
|
157
|
+
});
|
|
158
|
+
}, [fields, name, values]);
|
|
159
|
+
|
|
160
|
+
// Auto-generate when watched values change
|
|
161
|
+
React.useEffect(() => {
|
|
162
|
+
if (!autoGenerate || fields.length === 0) return;
|
|
163
|
+
|
|
164
|
+
const timeoutId = setTimeout(() => {
|
|
165
|
+
handleGenerate();
|
|
166
|
+
}, 300); // Debounce
|
|
167
|
+
|
|
168
|
+
return () => clearTimeout(timeoutId);
|
|
169
|
+
}, [autoGenerate, fields, handleGenerate, watchedValues, name]);
|
|
170
|
+
|
|
171
|
+
if (!props) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Field.Root
|
|
177
|
+
name={name}
|
|
178
|
+
id={name}
|
|
179
|
+
error={error}
|
|
180
|
+
hint={description?.id ? formatMessage(description) : description}
|
|
181
|
+
required={required}
|
|
182
|
+
>
|
|
183
|
+
<Flex direction="column" alignItems="stretch" gap={1}>
|
|
184
|
+
<Flex>
|
|
185
|
+
<Field.Label action={labelAction}>
|
|
186
|
+
{label || intlLabel?.defaultMessage || name}
|
|
187
|
+
</Field.Label>
|
|
188
|
+
</Flex>
|
|
189
|
+
|
|
190
|
+
<div style={{ position: 'relative' }}>
|
|
191
|
+
<Field.Input
|
|
192
|
+
type="text"
|
|
193
|
+
value={localValue}
|
|
194
|
+
onChange={handleChange}
|
|
195
|
+
disabled={disabled || !editable}
|
|
196
|
+
placeholder={
|
|
197
|
+
autoGenerate
|
|
198
|
+
? 'Auto-generated from fields'
|
|
199
|
+
: 'Click button to generate'
|
|
200
|
+
}
|
|
201
|
+
style={{ paddingRight: '40px' }}
|
|
202
|
+
/>
|
|
203
|
+
{!autoGenerate && (
|
|
204
|
+
<div
|
|
205
|
+
style={{
|
|
206
|
+
position: 'absolute',
|
|
207
|
+
right: '8px',
|
|
208
|
+
top: '50%',
|
|
209
|
+
transform: 'translateY(-50%)',
|
|
210
|
+
display: 'flex',
|
|
211
|
+
alignItems: 'center',
|
|
212
|
+
justifyContent: 'center',
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<Button
|
|
216
|
+
type="button"
|
|
217
|
+
onClick={handleGenerate}
|
|
218
|
+
disabled={disabled || fields.length === 0}
|
|
219
|
+
variant="tertiary"
|
|
220
|
+
aria-label="Generate composite value"
|
|
221
|
+
style={{
|
|
222
|
+
width: '28px',
|
|
223
|
+
height: '28px',
|
|
224
|
+
padding: '0',
|
|
225
|
+
minWidth: 'auto',
|
|
226
|
+
border: 'none',
|
|
227
|
+
background: 'transparent',
|
|
228
|
+
display: 'flex',
|
|
229
|
+
alignItems: 'center',
|
|
230
|
+
justifyContent: 'center',
|
|
231
|
+
color: '#8e8ea9',
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
<Play style={{ width: '14px', height: '14px' }} />
|
|
235
|
+
</Button>
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
{fields.length > 0 && (
|
|
241
|
+
<Field.Hint>
|
|
242
|
+
<Typography variant="pi" textColor="neutral600">
|
|
243
|
+
Combines: {fields.join(', ')}
|
|
244
|
+
{autoGenerate && ' (auto-generated)'}
|
|
245
|
+
{!editable && ' (read-only)'}
|
|
246
|
+
</Typography>
|
|
247
|
+
</Field.Hint>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
<Field.Error />
|
|
251
|
+
</Flex>
|
|
252
|
+
</Field.Root>
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export default CompositeInput;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as CompositeInput } from "./CompositeInput";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tunghtml/strapi-plugin-composite-field",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "A Strapi v5 plugin to create composite display fields by combining multiple text, string, and enum fields with auto-generate support",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"strapi",
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
},
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"author": "Tung Le <tunghtml@gmail.com>",
|
|
22
|
+
"files": [
|
|
23
|
+
"admin",
|
|
24
|
+
"server",
|
|
25
|
+
"strapi-admin.js",
|
|
26
|
+
"strapi-server.js"
|
|
27
|
+
],
|
|
22
28
|
"dependencies": {
|
|
23
29
|
"@strapi/design-system": "^2.0.0-rc.11",
|
|
24
30
|
"@strapi/icons": "^2.0.0-rc.11"
|