strapi-plugin-tags-custom-field 1.0.0 → 1.0.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.
package/README.md CHANGED
@@ -1,103 +1,59 @@
1
- # strapi-plugin-tags-custom-field
1
+ # strapi-plugin-tags-custom-field
2
2
 
3
- Strapi 5 plugin that adds a `tags` custom field to edit a list of tags (`array of strings`) and store it as a native JSON array.
3
+ NPM package: https://www.npmjs.com/package/strapi-plugin-tags-custom-field
4
4
 
5
- ## What this plugin does
5
+ Custom field plugin for Strapi 5 to manage tags as a native JSON array (`string[]`).
6
6
 
7
- - Registers the `tags` custom field on the server (`type: json`).
8
- - Registers the custom field in the admin panel with a tags input component.
9
- - Saves tags as JSON array values (e.g. `["news","featured","tech"]`).
10
- - Uses Strapi Design System components for native admin look and feel.
11
- - Supports keyboard and clipboard workflows for faster data entry.
12
-
13
- ## Requirements
14
-
15
- - Node.js 18+ (recommended: Node.js 20)
16
- - Strapi 5
17
-
18
- ## Local plugin development
19
-
20
- ```bash
21
- npm install
22
- npm run build
23
- npm run test:ts
24
- npm run verify
25
- ```
26
-
27
- ## Install from npm (recommended)
7
+ ## Installation
28
8
 
29
9
  ```bash
30
10
  npm install strapi-plugin-tags-custom-field
31
11
  ```
32
12
 
33
- Then restart your Strapi server and add the field in Content-Type Builder:
13
+ Restart your Strapi server after installation.
34
14
 
35
- - Add a new field.
36
- - Open the Custom fields category.
37
- - Select `Tags`.
38
- - Configure the custom field options if needed (see below).
15
+ ## Usage in Strapi
39
16
 
40
- ## Link locally during development
17
+ 1. Open Content-Type Builder.
18
+ 2. Add a new field.
19
+ 3. Open **Custom fields**.
20
+ 4. Select **Tags**.
21
+ 5. Configure field options if needed.
41
22
 
42
- 1. In the plugin project:
23
+ ## Field options
43
24
 
44
- ```bash
45
- npm install
46
- npm run watch:link
47
- ```
25
+ - `maxTags` (default: `20`): maximum number of tags.
26
+ - `maxTagLength` (default: `40`): maximum characters per tag.
27
+ - `allowDuplicates` (default: `false`): allow repeated tags.
28
+ - `separator` (default: `,`): character used to split typed/pasted values.
29
+ - `normalizeCase` (default: `none`): `none`, `lowercase`, or `UPPERCASE`.
48
30
 
49
- 2. In the Strapi project (in another terminal):
31
+ ## Input behavior
50
32
 
51
- ```bash
52
- npx yalc add --link strapi-plugin-tags-custom-field
53
- npm install
54
- npm run develop
55
- ```
33
+ - `Enter` adds the current tag.
34
+ - The configured separator also adds the current tag.
35
+ - Paste supports multiple tags (newline or configured separator).
56
36
 
57
- 3. In the Strapi Content-Type Builder, add the `Tags` custom field.
37
+ ## Data format
58
38
 
59
- ## Input behavior and UX
39
+ The value is stored as native JSON array and returned as array by Strapi APIs.
60
40
 
61
- - Press `Enter` to add the current tag.
62
- - Press the configured separator (default: `,`) to add the current tag.
63
- - Paste multiple tags at once (separated by newline or the configured separator).
64
- - Press `Backspace` on an empty input to remove the last tag.
65
- - Each draft tag has a configurable character limit with a live counter in the UI.
41
+ Example:
66
42
 
67
- ## Custom field options (Content-Type Builder)
68
-
69
- - `maxTags` (number, default: `20`): maximum number of tags allowed.
70
- - `maxTagLength` (number, default: `40`): maximum number of characters per tag.
71
- - `allowDuplicates` (boolean, default: `false`): allows repeated tags.
72
- - `separator` (text, default: `,`): character used for splitting input/paste.
73
- - `normalizeCase` (select, default: `none`): `none`, `lowercase`, or `UPPERCASE`.
74
-
75
- ## Database value format
76
-
77
- The value is stored as a native JSON array. Examples:
78
-
79
- - No tags: `[]`
80
- - With tags: `["javascript","strapi","cms"]`
81
-
82
- ## Main structure
43
+ ```json
44
+ {
45
+ "tags": ["javascript", "strapi", "cms"]
46
+ }
47
+ ```
83
48
 
84
- - `server/src/register.ts`: backend custom field registration.
85
- - `admin/src/index.ts`: admin custom field registration.
86
- - `admin/src/components/TagsInput.tsx`: visual input with add/remove tag behavior.
49
+ ## Compatibility
87
50
 
88
- ## Release checklist
51
+ - Strapi: `v5`
52
+ - Node.js: `>=18`
89
53
 
90
- 1. Update version in `package.json`.
91
- 2. Run:
54
+ ## Local development (plugin repo)
92
55
 
93
56
  ```bash
94
- npm ci
57
+ npm install
95
58
  npm run release:check
96
- ```
97
-
98
- 3. Publish to npm:
99
-
100
- ```bash
101
- npm publish
102
- ```
103
-
59
+ ```
@@ -96,10 +96,12 @@ const hasSplitCharacters = (value, separator) => [separator, "\n", "\r"].some(
96
96
  const TagsInput = React__namespace.forwardRef(
97
97
  ({
98
98
  attribute,
99
+ description,
99
100
  disabled = false,
100
101
  error,
101
102
  hint,
102
103
  intlLabel,
104
+ label,
103
105
  name,
104
106
  onChange,
105
107
  placeholder,
@@ -123,11 +125,22 @@ const TagsInput = React__namespace.forwardRef(
123
125
  );
124
126
  const allowDuplicates = parseBoolean(options.allowDuplicates, false);
125
127
  const normalizeCase = normalizeCaseValue(options.normalizeCase);
126
- const label = intlLabel?.id || intlLabel?.defaultMessage ? formatMessage({
127
- id: intlLabel?.id ?? `${name}.label`,
128
- defaultMessage: intlLabel?.defaultMessage ?? "Tags"
129
- }) : "Tags";
130
- const hintMessage = hint ?? formatMessage({
128
+ const formatIntlMessage = React__namespace.useCallback(
129
+ (message) => {
130
+ if (!message?.id && !message?.defaultMessage) {
131
+ return void 0;
132
+ }
133
+ return formatMessage({
134
+ id: message.id ?? `${name}.label`,
135
+ defaultMessage: message.defaultMessage ?? "Tags"
136
+ });
137
+ },
138
+ [formatMessage, name]
139
+ );
140
+ const labelText = typeof label === "string" ? label : formatIntlMessage(label) ?? formatIntlMessage(intlLabel);
141
+ const labelMessage = labelText && labelText.trim().length > 0 ? labelText.trim() : "Tags";
142
+ const descriptionText = typeof description === "string" ? description : formatIntlMessage(description);
143
+ const hintMessage = descriptionText && descriptionText.trim().length > 0 ? descriptionText.trim() : hint ?? formatMessage({
131
144
  id: "tags-input.hint",
132
145
  defaultMessage: "Press Enter or type the separator to add tags. Paste multiple tags at once."
133
146
  });
@@ -149,58 +162,53 @@ const TagsInput = React__namespace.forwardRef(
149
162
  if (rawTags.length === 0) {
150
163
  return false;
151
164
  }
165
+ const nextTags = [...tags];
152
166
  let didChange = false;
153
- setTags((currentTags) => {
154
- const nextTags = [...currentTags];
155
- for (const rawTag of rawTags) {
156
- if (nextTags.length >= maxTags) {
157
- setLocalError(
158
- formatMessage(
159
- {
160
- id: "tags-input.error.max-tags",
161
- defaultMessage: "You can only add up to {maxTags} tags."
162
- },
163
- { maxTags }
164
- )
165
- );
166
- break;
167
- }
168
- if (rawTag.length > maxTagLength) {
169
- setLocalError(
170
- formatMessage(
171
- {
172
- id: "tags-input.error.max-length",
173
- defaultMessage: "Each tag must be at most {maxLength} characters."
174
- },
175
- { maxLength: maxTagLength }
176
- )
177
- );
178
- continue;
179
- }
180
- const duplicateIndex = nextTags.findIndex(
181
- (tag) => tag.toLowerCase() === rawTag.toLowerCase()
167
+ let nextError;
168
+ for (const rawTag of rawTags) {
169
+ if (nextTags.length >= maxTags) {
170
+ nextError = formatMessage(
171
+ {
172
+ id: "tags-input.error.max-tags",
173
+ defaultMessage: "You can only add up to {maxTags} tags."
174
+ },
175
+ { maxTags }
182
176
  );
183
- if (duplicateIndex !== -1 && !allowDuplicates) {
184
- setLocalError(
185
- formatMessage({
186
- id: "tags-input.error.duplicate",
187
- defaultMessage: "Duplicate tags are not allowed."
188
- })
189
- );
190
- continue;
191
- }
192
- nextTags.push(rawTag);
193
- didChange = true;
177
+ break;
194
178
  }
195
- if (didChange) {
196
- setLocalError(void 0);
197
- emitChange(nextTags);
179
+ if (rawTag.length > maxTagLength) {
180
+ nextError = formatMessage(
181
+ {
182
+ id: "tags-input.error.max-length",
183
+ defaultMessage: "Each tag must be at most {maxLength} characters."
184
+ },
185
+ { maxLength: maxTagLength }
186
+ );
187
+ continue;
198
188
  }
199
- return didChange ? nextTags : currentTags;
200
- });
189
+ const duplicateIndex = nextTags.findIndex(
190
+ (tag) => tag.toLowerCase() === rawTag.toLowerCase()
191
+ );
192
+ if (duplicateIndex !== -1 && !allowDuplicates) {
193
+ nextError = formatMessage({
194
+ id: "tags-input.error.duplicate",
195
+ defaultMessage: "Duplicate tags are not allowed."
196
+ });
197
+ continue;
198
+ }
199
+ nextTags.push(rawTag);
200
+ didChange = true;
201
+ }
202
+ if (didChange) {
203
+ setTags(nextTags);
204
+ setLocalError(void 0);
205
+ emitChange(nextTags);
206
+ } else if (nextError) {
207
+ setLocalError(nextError);
208
+ }
201
209
  return didChange;
202
210
  },
203
- [allowDuplicates, emitChange, formatMessage, maxTagLength, maxTags]
211
+ [allowDuplicates, emitChange, formatMessage, maxTagLength, maxTags, tags]
204
212
  );
205
213
  const commitDraft = React__namespace.useCallback(() => {
206
214
  const parsedTags = parseRawTags(draft, separator, normalizeCase);
@@ -219,26 +227,11 @@ const TagsInput = React__namespace.forwardRef(
219
227
  },
220
228
  [emitChange]
221
229
  );
222
- const removeLastTag = React__namespace.useCallback(() => {
223
- setTags((currentTags) => {
224
- if (currentTags.length === 0) {
225
- return currentTags;
226
- }
227
- const nextTags = currentTags.slice(0, currentTags.length - 1);
228
- emitChange(nextTags);
229
- setLocalError(void 0);
230
- return nextTags;
231
- });
232
- }, [emitChange]);
233
230
  const onKeyDown = (event) => {
234
231
  if (event.key === "Enter" || event.key === separator) {
235
232
  event.preventDefault();
236
233
  commitDraft();
237
234
  }
238
- if (event.key === "Backspace" && draft.length === 0 && !disabled) {
239
- event.preventDefault();
240
- removeLastTag();
241
- }
242
235
  };
243
236
  const onPaste = (event) => {
244
237
  const pastedText = event.clipboardData.getData("text");
@@ -247,7 +240,10 @@ const TagsInput = React__namespace.forwardRef(
247
240
  }
248
241
  event.preventDefault();
249
242
  const parsedTags = parseRawTags(pastedText, separator, normalizeCase);
250
- addTags(parsedTags);
243
+ const added = addTags(parsedTags);
244
+ if (added) {
245
+ setDraft("");
246
+ }
251
247
  };
252
248
  return /* @__PURE__ */ jsxRuntime.jsx(
253
249
  designSystem.Field.Root,
@@ -258,7 +254,7 @@ const TagsInput = React__namespace.forwardRef(
258
254
  hint: hintMessage,
259
255
  error: shownError,
260
256
  children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { direction: "column", alignItems: "stretch", gap: 2, children: [
261
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { children: label }),
257
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { children: labelMessage }),
262
258
  tags.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(
263
259
  designSystem.Box,
264
260
  {
@@ -76,10 +76,12 @@ const hasSplitCharacters = (value, separator) => [separator, "\n", "\r"].some(
76
76
  const TagsInput = React.forwardRef(
77
77
  ({
78
78
  attribute,
79
+ description,
79
80
  disabled = false,
80
81
  error,
81
82
  hint,
82
83
  intlLabel,
84
+ label,
83
85
  name,
84
86
  onChange,
85
87
  placeholder,
@@ -103,11 +105,22 @@ const TagsInput = React.forwardRef(
103
105
  );
104
106
  const allowDuplicates = parseBoolean(options.allowDuplicates, false);
105
107
  const normalizeCase = normalizeCaseValue(options.normalizeCase);
106
- const label = intlLabel?.id || intlLabel?.defaultMessage ? formatMessage({
107
- id: intlLabel?.id ?? `${name}.label`,
108
- defaultMessage: intlLabel?.defaultMessage ?? "Tags"
109
- }) : "Tags";
110
- const hintMessage = hint ?? formatMessage({
108
+ const formatIntlMessage = React.useCallback(
109
+ (message) => {
110
+ if (!message?.id && !message?.defaultMessage) {
111
+ return void 0;
112
+ }
113
+ return formatMessage({
114
+ id: message.id ?? `${name}.label`,
115
+ defaultMessage: message.defaultMessage ?? "Tags"
116
+ });
117
+ },
118
+ [formatMessage, name]
119
+ );
120
+ const labelText = typeof label === "string" ? label : formatIntlMessage(label) ?? formatIntlMessage(intlLabel);
121
+ const labelMessage = labelText && labelText.trim().length > 0 ? labelText.trim() : "Tags";
122
+ const descriptionText = typeof description === "string" ? description : formatIntlMessage(description);
123
+ const hintMessage = descriptionText && descriptionText.trim().length > 0 ? descriptionText.trim() : hint ?? formatMessage({
111
124
  id: "tags-input.hint",
112
125
  defaultMessage: "Press Enter or type the separator to add tags. Paste multiple tags at once."
113
126
  });
@@ -129,58 +142,53 @@ const TagsInput = React.forwardRef(
129
142
  if (rawTags.length === 0) {
130
143
  return false;
131
144
  }
145
+ const nextTags = [...tags];
132
146
  let didChange = false;
133
- setTags((currentTags) => {
134
- const nextTags = [...currentTags];
135
- for (const rawTag of rawTags) {
136
- if (nextTags.length >= maxTags) {
137
- setLocalError(
138
- formatMessage(
139
- {
140
- id: "tags-input.error.max-tags",
141
- defaultMessage: "You can only add up to {maxTags} tags."
142
- },
143
- { maxTags }
144
- )
145
- );
146
- break;
147
- }
148
- if (rawTag.length > maxTagLength) {
149
- setLocalError(
150
- formatMessage(
151
- {
152
- id: "tags-input.error.max-length",
153
- defaultMessage: "Each tag must be at most {maxLength} characters."
154
- },
155
- { maxLength: maxTagLength }
156
- )
157
- );
158
- continue;
159
- }
160
- const duplicateIndex = nextTags.findIndex(
161
- (tag) => tag.toLowerCase() === rawTag.toLowerCase()
147
+ let nextError;
148
+ for (const rawTag of rawTags) {
149
+ if (nextTags.length >= maxTags) {
150
+ nextError = formatMessage(
151
+ {
152
+ id: "tags-input.error.max-tags",
153
+ defaultMessage: "You can only add up to {maxTags} tags."
154
+ },
155
+ { maxTags }
162
156
  );
163
- if (duplicateIndex !== -1 && !allowDuplicates) {
164
- setLocalError(
165
- formatMessage({
166
- id: "tags-input.error.duplicate",
167
- defaultMessage: "Duplicate tags are not allowed."
168
- })
169
- );
170
- continue;
171
- }
172
- nextTags.push(rawTag);
173
- didChange = true;
157
+ break;
174
158
  }
175
- if (didChange) {
176
- setLocalError(void 0);
177
- emitChange(nextTags);
159
+ if (rawTag.length > maxTagLength) {
160
+ nextError = formatMessage(
161
+ {
162
+ id: "tags-input.error.max-length",
163
+ defaultMessage: "Each tag must be at most {maxLength} characters."
164
+ },
165
+ { maxLength: maxTagLength }
166
+ );
167
+ continue;
178
168
  }
179
- return didChange ? nextTags : currentTags;
180
- });
169
+ const duplicateIndex = nextTags.findIndex(
170
+ (tag) => tag.toLowerCase() === rawTag.toLowerCase()
171
+ );
172
+ if (duplicateIndex !== -1 && !allowDuplicates) {
173
+ nextError = formatMessage({
174
+ id: "tags-input.error.duplicate",
175
+ defaultMessage: "Duplicate tags are not allowed."
176
+ });
177
+ continue;
178
+ }
179
+ nextTags.push(rawTag);
180
+ didChange = true;
181
+ }
182
+ if (didChange) {
183
+ setTags(nextTags);
184
+ setLocalError(void 0);
185
+ emitChange(nextTags);
186
+ } else if (nextError) {
187
+ setLocalError(nextError);
188
+ }
181
189
  return didChange;
182
190
  },
183
- [allowDuplicates, emitChange, formatMessage, maxTagLength, maxTags]
191
+ [allowDuplicates, emitChange, formatMessage, maxTagLength, maxTags, tags]
184
192
  );
185
193
  const commitDraft = React.useCallback(() => {
186
194
  const parsedTags = parseRawTags(draft, separator, normalizeCase);
@@ -199,26 +207,11 @@ const TagsInput = React.forwardRef(
199
207
  },
200
208
  [emitChange]
201
209
  );
202
- const removeLastTag = React.useCallback(() => {
203
- setTags((currentTags) => {
204
- if (currentTags.length === 0) {
205
- return currentTags;
206
- }
207
- const nextTags = currentTags.slice(0, currentTags.length - 1);
208
- emitChange(nextTags);
209
- setLocalError(void 0);
210
- return nextTags;
211
- });
212
- }, [emitChange]);
213
210
  const onKeyDown = (event) => {
214
211
  if (event.key === "Enter" || event.key === separator) {
215
212
  event.preventDefault();
216
213
  commitDraft();
217
214
  }
218
- if (event.key === "Backspace" && draft.length === 0 && !disabled) {
219
- event.preventDefault();
220
- removeLastTag();
221
- }
222
215
  };
223
216
  const onPaste = (event) => {
224
217
  const pastedText = event.clipboardData.getData("text");
@@ -227,7 +220,10 @@ const TagsInput = React.forwardRef(
227
220
  }
228
221
  event.preventDefault();
229
222
  const parsedTags = parseRawTags(pastedText, separator, normalizeCase);
230
- addTags(parsedTags);
223
+ const added = addTags(parsedTags);
224
+ if (added) {
225
+ setDraft("");
226
+ }
231
227
  };
232
228
  return /* @__PURE__ */ jsx(
233
229
  Field.Root,
@@ -238,7 +234,7 @@ const TagsInput = React.forwardRef(
238
234
  hint: hintMessage,
239
235
  error: shownError,
240
236
  children: /* @__PURE__ */ jsxs(Flex, { direction: "column", alignItems: "stretch", gap: 2, children: [
241
- /* @__PURE__ */ jsx(Field.Label, { children: label }),
237
+ /* @__PURE__ */ jsx(Field.Label, { children: labelMessage }),
242
238
  tags.length > 0 ? /* @__PURE__ */ jsx(
243
239
  Box,
244
240
  {
@@ -66,7 +66,7 @@ const index = {
66
66
  },
67
67
  icon: PluginIcon,
68
68
  components: {
69
- Input: async () => Promise.resolve().then(() => require("./TagsInput-BGUydL2t.js")).then((module2) => ({
69
+ Input: async () => Promise.resolve().then(() => require("./TagsInput-BfjqO_MO.js")).then((module2) => ({
70
70
  default: module2.TagsInput
71
71
  }))
72
72
  },
@@ -64,7 +64,7 @@ const index = {
64
64
  },
65
65
  icon: PluginIcon,
66
66
  components: {
67
- Input: async () => import("./TagsInput-CCehyU5Y.mjs").then((module) => ({
67
+ Input: async () => import("./TagsInput-CzXiRuEv.mjs").then((module) => ({
68
68
  default: module.TagsInput
69
69
  }))
70
70
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-tags-custom-field",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Custom field plugin for Strapi 5 to manage tags (array of strings) stored as JSON array.",
5
5
  "keywords": [
6
6
  "strapi",