@tunghtml/strapi-plugin-composite-field 1.2.1 → 1.2.3

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.1",
3
+ "version": "1.2.3",
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"
@@ -0,0 +1,5 @@
1
+ const register = require("./register");
2
+
3
+ module.exports = {
4
+ register,
5
+ };
@@ -0,0 +1,7 @@
1
+ module.exports = ({ strapi }) => {
2
+ strapi.customFields.register({
3
+ name: "composite",
4
+ plugin: "composite-field",
5
+ type: "string",
6
+ });
7
+ };
package/strapi-admin.js CHANGED
@@ -37,7 +37,7 @@ export default {
37
37
  defaultMessage: "Combine multiple fields into one display value",
38
38
  },
39
39
  components: {
40
- Input: async () => import("./components/CompositeInput"),
40
+ Input: async () => import("./admin/src/components/CompositeInput"),
41
41
  },
42
42
  options: {
43
43
  base: [