@tunghtml/strapi-plugin-composite-field 1.2.7 → 1.2.9
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/README.md +14 -0
- package/admin/src/components/CompositeInput.jsx +75 -79
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -141,6 +141,7 @@ Enable **Auto-generate** in Advanced settings to automatically update the compos
|
|
|
141
141
|
- If you manually edit the composite field, it will preserve your changes
|
|
142
142
|
- When any source field changes, auto-generation resumes with the new values
|
|
143
143
|
- This prevents accidental overwrites while maintaining automatic updates
|
|
144
|
+
- Opening an existing entry keeps its saved value: the field is only regenerated after a source field changes, so the entry is not marked as modified on load
|
|
144
145
|
|
|
145
146
|
### Space-Only Separator
|
|
146
147
|
|
|
@@ -175,6 +176,19 @@ Result: Panel Discussion - John Doe
|
|
|
175
176
|
|
|
176
177
|
## Changelog
|
|
177
178
|
|
|
179
|
+
### v1.2.9 (2026-10-19)
|
|
180
|
+
|
|
181
|
+
**🔧 Technical:**
|
|
182
|
+
|
|
183
|
+
- Value formatting and joining moved out of the component, regexes use `RegExp.exec`
|
|
184
|
+
|
|
185
|
+
### v1.2.8 (2026-10-19)
|
|
186
|
+
|
|
187
|
+
**🐛 Bug Fixes:**
|
|
188
|
+
|
|
189
|
+
- Opening an existing entry no longer regenerates the composite value, which marked the form as modified and rewrote the stored value on the next save
|
|
190
|
+
- Loading another entry or resetting the form takes the loaded value as the new starting point
|
|
191
|
+
|
|
178
192
|
### v1.2.4 (2026-02-05)
|
|
179
193
|
|
|
180
194
|
**🎯 Improvements:**
|
|
@@ -5,6 +5,57 @@ import get from 'lodash/get';
|
|
|
5
5
|
import React from 'react';
|
|
6
6
|
import { useIntl } from 'react-intl';
|
|
7
7
|
|
|
8
|
+
const ISO_DATE_REGEX = /^(\d{4})-(\d{2})-(\d{2})(T[\d:.]+Z?)?$/;
|
|
9
|
+
const TIME_REGEX = /^(\d{2}):(\d{2}):(\d{2})(\.\d{3})?$/;
|
|
10
|
+
|
|
11
|
+
// Format a source value for display (especially time/date)
|
|
12
|
+
const formatFieldValue = (val) => {
|
|
13
|
+
if (val === null || val === undefined) return '';
|
|
14
|
+
|
|
15
|
+
// If it's a Date object
|
|
16
|
+
if (val instanceof Date) {
|
|
17
|
+
return val.toLocaleTimeString([], {
|
|
18
|
+
hour: '2-digit',
|
|
19
|
+
minute: '2-digit',
|
|
20
|
+
hour12: false,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const stringVal = String(val);
|
|
25
|
+
|
|
26
|
+
// ISO date (YYYY-MM-DDTHH:MM:SS.sssZ or YYYY-MM-DD) => MM/DD/YYYY
|
|
27
|
+
const dateMatch = ISO_DATE_REGEX.exec(stringVal);
|
|
28
|
+
if (dateMatch) {
|
|
29
|
+
const [, year, month, day] = dateMatch;
|
|
30
|
+
return `${month}/${day}/${year}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Time (HH:MM:SS.mmm or HH:MM:SS) => HH:MM
|
|
34
|
+
const timeMatch = TIME_REGEX.exec(stringVal);
|
|
35
|
+
if (timeMatch) {
|
|
36
|
+
return `${timeMatch[1]}:${timeMatch[2]}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return stringVal;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Text of one source value: relations/objects use their name, title or label
|
|
43
|
+
const toDisplayText = (rawValue) => {
|
|
44
|
+
if (rawValue === null || rawValue === undefined) return '';
|
|
45
|
+
if (typeof rawValue === 'object' && !(rawValue instanceof Date)) {
|
|
46
|
+
return String(rawValue.name || rawValue.title || rawValue.label || '');
|
|
47
|
+
}
|
|
48
|
+
return formatFieldValue(rawValue);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// A space-only separator joins with single spaces, other separators get spaces around them
|
|
52
|
+
const joinParts = (parts, separator) => {
|
|
53
|
+
const cleanSeparator = separator.trim();
|
|
54
|
+
return cleanSeparator === ''
|
|
55
|
+
? parts.join(' ').trim()
|
|
56
|
+
: parts.join(` ${cleanSeparator} `).trim();
|
|
57
|
+
};
|
|
58
|
+
|
|
8
59
|
const CompositeInput = (props) => {
|
|
9
60
|
const {
|
|
10
61
|
attribute,
|
|
@@ -28,10 +79,15 @@ const CompositeInput = (props) => {
|
|
|
28
79
|
const [isManuallyEdited, setIsManuallyEdited] = React.useState(false);
|
|
29
80
|
|
|
30
81
|
const lastGeneratedVal = React.useRef(value || '');
|
|
31
|
-
|
|
82
|
+
// Source values the field was last generated from; null until the form has loaded
|
|
83
|
+
const lastWatchedValues = React.useRef(null);
|
|
32
84
|
|
|
33
85
|
React.useEffect(() => {
|
|
34
86
|
const validValue = value || '';
|
|
87
|
+
// A value this field did not produce (another entry loaded, form reset) is a new baseline
|
|
88
|
+
if (validValue !== lastGeneratedVal.current) {
|
|
89
|
+
lastWatchedValues.current = null;
|
|
90
|
+
}
|
|
35
91
|
setLocalValue(validValue);
|
|
36
92
|
lastGeneratedVal.current = validValue;
|
|
37
93
|
// Reset manual edit flag when value changes from outside (e.g., form reset)
|
|
@@ -64,43 +120,6 @@ const CompositeInput = (props) => {
|
|
|
64
120
|
}
|
|
65
121
|
};
|
|
66
122
|
|
|
67
|
-
// Helper function to format values (especially time/date)
|
|
68
|
-
const formatFieldValue = React.useCallback((val) => {
|
|
69
|
-
if (val === null || val === undefined) return '';
|
|
70
|
-
|
|
71
|
-
// If it's a Date object
|
|
72
|
-
if (val instanceof Date) {
|
|
73
|
-
return val.toLocaleTimeString([], {
|
|
74
|
-
hour: '2-digit',
|
|
75
|
-
minute: '2-digit',
|
|
76
|
-
hour12: false,
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const stringVal = String(val);
|
|
81
|
-
|
|
82
|
-
// Check if value is ISO date format (YYYY-MM-DDTHH:MM:SS.sssZ or YYYY-MM-DD)
|
|
83
|
-
const isoDateRegex = /^(\d{4})-(\d{2})-(\d{2})(T[\d:.]+Z?)?$/;
|
|
84
|
-
const dateMatch = stringVal.match(isoDateRegex);
|
|
85
|
-
|
|
86
|
-
if (dateMatch) {
|
|
87
|
-
// Return in MM/DD/YYYY format
|
|
88
|
-
const [, year, month, day] = dateMatch;
|
|
89
|
-
return `${month}/${day}/${year}`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Check if value is time format (HH:MM:SS.mmm or HH:MM:SS)
|
|
93
|
-
const timeRegex = /^(\d{2}):(\d{2}):(\d{2})(\.\d{3})?$/;
|
|
94
|
-
const timeMatch = stringVal.match(timeRegex);
|
|
95
|
-
|
|
96
|
-
if (timeMatch) {
|
|
97
|
-
// Return only HH:MM
|
|
98
|
-
return `${timeMatch[1]}:${timeMatch[2]}`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return stringVal;
|
|
102
|
-
}, []);
|
|
103
|
-
|
|
104
123
|
const handleGenerate = React.useCallback(() => {
|
|
105
124
|
// Calculate parent path to resolve relative field names
|
|
106
125
|
const parts = name.split('.');
|
|
@@ -108,44 +127,15 @@ const CompositeInput = (props) => {
|
|
|
108
127
|
parts.pop(); // Remove current field name
|
|
109
128
|
const parentPath = parts.join('.');
|
|
110
129
|
|
|
111
|
-
const generatedParts =
|
|
130
|
+
const generatedParts = fields
|
|
131
|
+
.map((fieldName) => {
|
|
132
|
+
// Resolve full path for the target field
|
|
133
|
+
const fullPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
|
134
|
+
return toDisplayText(get(values, fullPath));
|
|
135
|
+
})
|
|
136
|
+
.filter(Boolean);
|
|
112
137
|
|
|
113
|
-
|
|
114
|
-
// Resolve full path for the target field
|
|
115
|
-
const fullPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
|
116
|
-
|
|
117
|
-
// Get raw value from form values
|
|
118
|
-
let rawValue = get(values, fullPath);
|
|
119
|
-
|
|
120
|
-
// Handle different value types
|
|
121
|
-
if (rawValue !== null && rawValue !== undefined) {
|
|
122
|
-
let stringValue = '';
|
|
123
|
-
|
|
124
|
-
// Handle relations/objects
|
|
125
|
-
if (typeof rawValue === 'object' && !(rawValue instanceof Date)) {
|
|
126
|
-
if (rawValue.name) stringValue = String(rawValue.name);
|
|
127
|
-
else if (rawValue.title) stringValue = String(rawValue.title);
|
|
128
|
-
else if (rawValue.label) stringValue = String(rawValue.label);
|
|
129
|
-
} else {
|
|
130
|
-
stringValue = formatFieldValue(rawValue);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (stringValue) {
|
|
134
|
-
generatedParts.push(stringValue);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
// Handle separator logic - if separator is just a space, don't add extra spaces
|
|
140
|
-
const cleanSeparator = separator.trim();
|
|
141
|
-
let result;
|
|
142
|
-
if (cleanSeparator === '') {
|
|
143
|
-
// If separator is just spaces, use single space
|
|
144
|
-
result = generatedParts.join(' ').trim();
|
|
145
|
-
} else {
|
|
146
|
-
// For other separators, add spaces around them
|
|
147
|
-
result = generatedParts.join(` ${cleanSeparator} `).trim();
|
|
148
|
-
}
|
|
138
|
+
const result = joinParts(generatedParts, separator);
|
|
149
139
|
|
|
150
140
|
if (result !== lastGeneratedVal.current) {
|
|
151
141
|
setLocalValue(result);
|
|
@@ -157,7 +147,7 @@ const CompositeInput = (props) => {
|
|
|
157
147
|
onChange({ target: { name, value: result, type: 'text' } });
|
|
158
148
|
}
|
|
159
149
|
}
|
|
160
|
-
}, [fields, separator, onChange, name, values
|
|
150
|
+
}, [fields, separator, onChange, name, values]);
|
|
161
151
|
|
|
162
152
|
// Watch only the relevant field values to trigger auto-generation
|
|
163
153
|
const watchedValues = React.useMemo(() => {
|
|
@@ -177,13 +167,19 @@ const CompositeInput = (props) => {
|
|
|
177
167
|
|
|
178
168
|
// Check if watched values actually changed
|
|
179
169
|
const currentWatchedValues = JSON.stringify(watchedValues);
|
|
180
|
-
|
|
170
|
+
|
|
171
|
+
// Keep the saved value when an entry opens: regenerating it there marks the form as
|
|
172
|
+
// modified and overwrites values written by the server on the next save
|
|
173
|
+
if (lastWatchedValues.current === null) {
|
|
174
|
+
lastWatchedValues.current = currentWatchedValues;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
181
177
|
|
|
182
178
|
// Only auto-generate if:
|
|
183
179
|
// 1. Values actually changed
|
|
184
180
|
// 2. User hasn't manually edited the field
|
|
185
|
-
if (currentWatchedValues !==
|
|
186
|
-
lastWatchedValues.current =
|
|
181
|
+
if (currentWatchedValues !== lastWatchedValues.current) {
|
|
182
|
+
lastWatchedValues.current = currentWatchedValues;
|
|
187
183
|
|
|
188
184
|
if (!isManuallyEdited) {
|
|
189
185
|
const timeoutId = setTimeout(() => {
|
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.9",
|
|
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",
|