strapi-plugin-tags-custom-field 1.0.0

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,336 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Field, Flex, Box, Tag, TextInput, Typography } from "@strapi/design-system";
4
+ import { Cross } from "@strapi/icons";
5
+ import { useIntl } from "react-intl";
6
+ const DEFAULT_SEPARATOR = ",";
7
+ const DEFAULT_MAX_TAGS = 20;
8
+ const DEFAULT_MAX_TAG_LENGTH = 40;
9
+ const parseTagsValue = (value) => {
10
+ if (Array.isArray(value)) {
11
+ return value.map((item) => String(item).trim()).filter((item) => item.length > 0);
12
+ }
13
+ if (typeof value !== "string") {
14
+ return [];
15
+ }
16
+ const normalized = value.trim();
17
+ if (!normalized) {
18
+ return [];
19
+ }
20
+ try {
21
+ const parsed = JSON.parse(normalized);
22
+ if (Array.isArray(parsed)) {
23
+ return parsed.map((item) => String(item).trim()).filter((item) => item.length > 0);
24
+ }
25
+ } catch {
26
+ }
27
+ return [normalized];
28
+ };
29
+ const serializeTagsValue = (tags) => tags;
30
+ const normalizeCaseValue = (rawValue) => {
31
+ if (rawValue === "lowercase" || rawValue === "uppercase") {
32
+ return rawValue;
33
+ }
34
+ return "none";
35
+ };
36
+ const normalizeTag = (value, normalizeCase) => {
37
+ const cleanedValue = value.trim();
38
+ if (!cleanedValue) {
39
+ return "";
40
+ }
41
+ if (normalizeCase === "lowercase") {
42
+ return cleanedValue.toLowerCase();
43
+ }
44
+ if (normalizeCase === "uppercase") {
45
+ return cleanedValue.toUpperCase();
46
+ }
47
+ return cleanedValue;
48
+ };
49
+ const parseBoolean = (value, defaultValue = false) => {
50
+ if (typeof value === "boolean") {
51
+ return value;
52
+ }
53
+ if (typeof value === "string") {
54
+ return value.toLowerCase() === "true";
55
+ }
56
+ return defaultValue;
57
+ };
58
+ const parsePositiveInt = (value, fallbackValue) => {
59
+ const parsedValue = Number(value);
60
+ if (Number.isFinite(parsedValue) && parsedValue > 0) {
61
+ return Math.floor(parsedValue);
62
+ }
63
+ return fallbackValue;
64
+ };
65
+ const getSplitRegex = (separator) => {
66
+ const characters = Array.from(/* @__PURE__ */ new Set([separator, "\n", "\r"])).filter(
67
+ (character) => character.length > 0
68
+ );
69
+ const escapedCharacters = characters.map((character) => character.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("");
70
+ return new RegExp(`[${escapedCharacters}]+`);
71
+ };
72
+ const parseRawTags = (rawValue, separator, normalizeCase) => rawValue.split(getSplitRegex(separator)).map((tag) => normalizeTag(tag, normalizeCase)).filter((tag) => tag.length > 0);
73
+ const hasSplitCharacters = (value, separator) => [separator, "\n", "\r"].some(
74
+ (character) => character.length > 0 && value.includes(character)
75
+ );
76
+ const TagsInput = React.forwardRef(
77
+ ({
78
+ attribute,
79
+ disabled = false,
80
+ error,
81
+ hint,
82
+ intlLabel,
83
+ name,
84
+ onChange,
85
+ placeholder,
86
+ required = false,
87
+ value
88
+ }, ref) => {
89
+ const { formatMessage } = useIntl();
90
+ const [tags, setTags] = React.useState(() => parseTagsValue(value));
91
+ const [draft, setDraft] = React.useState("");
92
+ const [localError, setLocalError] = React.useState();
93
+ React.useEffect(() => {
94
+ setTags(parseTagsValue(value));
95
+ }, [value]);
96
+ const fieldType = attribute?.type ?? "json";
97
+ const options = attribute?.options ?? {};
98
+ const separator = typeof options.separator === "string" && options.separator.trim().length > 0 ? options.separator.trim().charAt(0) : DEFAULT_SEPARATOR;
99
+ const maxTags = parsePositiveInt(options.maxTags, DEFAULT_MAX_TAGS);
100
+ const maxTagLength = parsePositiveInt(
101
+ options.maxTagLength,
102
+ DEFAULT_MAX_TAG_LENGTH
103
+ );
104
+ const allowDuplicates = parseBoolean(options.allowDuplicates, false);
105
+ 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({
111
+ id: "tags-input.hint",
112
+ defaultMessage: "Press Enter or type the separator to add tags. Paste multiple tags at once."
113
+ });
114
+ const shownError = error || localError;
115
+ const emitChange = React.useCallback(
116
+ (nextTags) => {
117
+ onChange({
118
+ target: {
119
+ name,
120
+ type: fieldType,
121
+ value: serializeTagsValue(nextTags)
122
+ }
123
+ });
124
+ },
125
+ [fieldType, name, onChange]
126
+ );
127
+ const addTags = React.useCallback(
128
+ (rawTags) => {
129
+ if (rawTags.length === 0) {
130
+ return false;
131
+ }
132
+ 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()
162
+ );
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;
174
+ }
175
+ if (didChange) {
176
+ setLocalError(void 0);
177
+ emitChange(nextTags);
178
+ }
179
+ return didChange ? nextTags : currentTags;
180
+ });
181
+ return didChange;
182
+ },
183
+ [allowDuplicates, emitChange, formatMessage, maxTagLength, maxTags]
184
+ );
185
+ const commitDraft = React.useCallback(() => {
186
+ const parsedTags = parseRawTags(draft, separator, normalizeCase);
187
+ const added = addTags(parsedTags);
188
+ if (added) {
189
+ setDraft("");
190
+ }
191
+ }, [addTags, draft, normalizeCase, separator]);
192
+ const removeTag = React.useCallback(
193
+ (index) => {
194
+ setTags((currentTags) => {
195
+ const nextTags = currentTags.filter((_, currentIndex) => currentIndex !== index);
196
+ emitChange(nextTags);
197
+ return nextTags;
198
+ });
199
+ },
200
+ [emitChange]
201
+ );
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
+ const onKeyDown = (event) => {
214
+ if (event.key === "Enter" || event.key === separator) {
215
+ event.preventDefault();
216
+ commitDraft();
217
+ }
218
+ if (event.key === "Backspace" && draft.length === 0 && !disabled) {
219
+ event.preventDefault();
220
+ removeLastTag();
221
+ }
222
+ };
223
+ const onPaste = (event) => {
224
+ const pastedText = event.clipboardData.getData("text");
225
+ if (!pastedText || !hasSplitCharacters(pastedText, separator)) {
226
+ return;
227
+ }
228
+ event.preventDefault();
229
+ const parsedTags = parseRawTags(pastedText, separator, normalizeCase);
230
+ addTags(parsedTags);
231
+ };
232
+ return /* @__PURE__ */ jsx(
233
+ Field.Root,
234
+ {
235
+ id: name,
236
+ name,
237
+ required,
238
+ hint: hintMessage,
239
+ error: shownError,
240
+ children: /* @__PURE__ */ jsxs(Flex, { direction: "column", alignItems: "stretch", gap: 2, children: [
241
+ /* @__PURE__ */ jsx(Field.Label, { children: label }),
242
+ tags.length > 0 ? /* @__PURE__ */ jsx(
243
+ Box,
244
+ {
245
+ borderColor: "neutral150",
246
+ borderStyle: "solid",
247
+ borderWidth: "1px",
248
+ borderRadius: "4px",
249
+ padding: 2,
250
+ background: "neutral0",
251
+ children: /* @__PURE__ */ jsx(Flex, { wrap: "wrap", gap: 2, children: tags.map((tag, index) => /* @__PURE__ */ jsx(
252
+ Tag,
253
+ {
254
+ icon: /* @__PURE__ */ jsx(Cross, {}),
255
+ disabled,
256
+ onClick: !disabled ? () => removeTag(index) : void 0,
257
+ label: `Remove ${tag}`,
258
+ children: tag
259
+ },
260
+ `${tag}-${index}`
261
+ )) })
262
+ }
263
+ ) : null,
264
+ /* @__PURE__ */ jsx(
265
+ TextInput,
266
+ {
267
+ id: name,
268
+ ref,
269
+ disabled,
270
+ required: required && tags.length === 0,
271
+ value: draft,
272
+ onChange: (event) => {
273
+ const nextDraft = event.currentTarget.value;
274
+ if (nextDraft.length <= maxTagLength) {
275
+ setDraft(nextDraft);
276
+ if (localError) {
277
+ setLocalError(void 0);
278
+ }
279
+ } else {
280
+ setLocalError(
281
+ formatMessage(
282
+ {
283
+ id: "tags-input.error.max-length",
284
+ defaultMessage: "Each tag must be at most {maxLength} characters."
285
+ },
286
+ { maxLength: maxTagLength }
287
+ )
288
+ );
289
+ }
290
+ },
291
+ onBlur: commitDraft,
292
+ onKeyDown,
293
+ onPaste,
294
+ placeholder: placeholder ?? formatMessage(
295
+ {
296
+ id: "tags-input.placeholder",
297
+ defaultMessage: "Type a tag and press Enter or {separator} to add it"
298
+ },
299
+ { separator }
300
+ )
301
+ }
302
+ ),
303
+ /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", gap: 2, children: [
304
+ /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: formatMessage(
305
+ {
306
+ id: "tags-input.counter.tags",
307
+ defaultMessage: "{count}/{maxTags} tags"
308
+ },
309
+ { count: tags.length, maxTags }
310
+ ) }),
311
+ /* @__PURE__ */ jsx(
312
+ Typography,
313
+ {
314
+ variant: "pi",
315
+ textColor: draft.length >= maxTagLength ? "danger600" : "neutral600",
316
+ children: formatMessage(
317
+ {
318
+ id: "tags-input.counter.characters",
319
+ defaultMessage: "{count}/{maxLength} characters"
320
+ },
321
+ { count: draft.length, maxLength: maxTagLength }
322
+ )
323
+ }
324
+ )
325
+ ] }),
326
+ /* @__PURE__ */ jsx(Field.Hint, {}),
327
+ /* @__PURE__ */ jsx(Field.Error, {})
328
+ ] })
329
+ }
330
+ );
331
+ }
332
+ );
333
+ TagsInput.displayName = "TagsInput";
334
+ export {
335
+ TagsInput
336
+ };
@@ -0,0 +1,23 @@
1
+ const en = {
2
+ "plugin.name": "Tags Input",
3
+ "field.tags.label": "Tags",
4
+ "field.tags.description": "Store tags as a JSON array",
5
+ "field.tags.options.behavior.section": "Tag behavior",
6
+ "field.tags.options.maxTags.label": "Maximum tags",
7
+ "field.tags.options.maxTags.description": "Maximum number of tags allowed",
8
+ "field.tags.options.maxTagLength.label": "Maximum tag length",
9
+ "field.tags.options.maxTagLength.description": "Maximum number of characters per tag",
10
+ "field.tags.options.allowDuplicates.label": "Allow duplicates",
11
+ "field.tags.options.allowDuplicates.description": "Allow repeated tags in the same value",
12
+ "field.tags.options.input.section": "Input parsing",
13
+ "field.tags.options.separator.label": "Separator",
14
+ "field.tags.options.separator.description": "Character used to split typed and pasted values",
15
+ "field.tags.options.normalizeCase.label": "Normalize case",
16
+ "field.tags.options.normalizeCase.description": "Transform tag casing before saving",
17
+ "field.tags.options.normalizeCase.none": "None",
18
+ "field.tags.options.normalizeCase.lowercase": "lowercase",
19
+ "field.tags.options.normalizeCase.uppercase": "UPPERCASE"
20
+ };
21
+ export {
22
+ en as default
23
+ };
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const en = {
4
+ "plugin.name": "Tags Input",
5
+ "field.tags.label": "Tags",
6
+ "field.tags.description": "Store tags as a JSON array",
7
+ "field.tags.options.behavior.section": "Tag behavior",
8
+ "field.tags.options.maxTags.label": "Maximum tags",
9
+ "field.tags.options.maxTags.description": "Maximum number of tags allowed",
10
+ "field.tags.options.maxTagLength.label": "Maximum tag length",
11
+ "field.tags.options.maxTagLength.description": "Maximum number of characters per tag",
12
+ "field.tags.options.allowDuplicates.label": "Allow duplicates",
13
+ "field.tags.options.allowDuplicates.description": "Allow repeated tags in the same value",
14
+ "field.tags.options.input.section": "Input parsing",
15
+ "field.tags.options.separator.label": "Separator",
16
+ "field.tags.options.separator.description": "Character used to split typed and pasted values",
17
+ "field.tags.options.normalizeCase.label": "Normalize case",
18
+ "field.tags.options.normalizeCase.description": "Transform tag casing before saving",
19
+ "field.tags.options.normalizeCase.none": "None",
20
+ "field.tags.options.normalizeCase.lowercase": "lowercase",
21
+ "field.tags.options.normalizeCase.uppercase": "UPPERCASE"
22
+ };
23
+ exports.default = en;
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
+ const jsxRuntime = require("react/jsx-runtime");
4
+ const __variableDynamicImportRuntimeHelper = (glob, path, segs) => {
5
+ const v = glob[path];
6
+ if (v) {
7
+ return typeof v === "function" ? v() : Promise.resolve(v);
8
+ }
9
+ return new Promise((_, reject) => {
10
+ (typeof queueMicrotask === "function" ? queueMicrotask : setTimeout)(
11
+ reject.bind(
12
+ null,
13
+ new Error(
14
+ "Unknown variable dynamic import: " + path + (path.split("/").length !== segs ? ". Note that variables only represent file names one level deep." : "")
15
+ )
16
+ )
17
+ );
18
+ });
19
+ };
20
+ const PLUGIN_ID = "tags-input";
21
+ const PluginIcon = () => /* @__PURE__ */ jsxRuntime.jsxs(
22
+ "svg",
23
+ {
24
+ width: "16",
25
+ height: "16",
26
+ viewBox: "0 0 24 24",
27
+ fill: "none",
28
+ xmlns: "http://www.w3.org/2000/svg",
29
+ "aria-hidden": "true",
30
+ focusable: "false",
31
+ children: [
32
+ /* @__PURE__ */ jsxRuntime.jsx(
33
+ "path",
34
+ {
35
+ d: "M7 8h10M7 12h7M7 16h10",
36
+ stroke: "currentColor",
37
+ strokeWidth: "2",
38
+ strokeLinecap: "round"
39
+ }
40
+ ),
41
+ /* @__PURE__ */ jsxRuntime.jsx(
42
+ "path",
43
+ {
44
+ d: "M4 5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5Z",
45
+ stroke: "currentColor",
46
+ strokeWidth: "2"
47
+ }
48
+ )
49
+ ]
50
+ }
51
+ );
52
+ const getTranslation = (id) => `${PLUGIN_ID}.${id}`;
53
+ const index = {
54
+ register(app) {
55
+ app.customFields.register({
56
+ name: "tags",
57
+ pluginId: PLUGIN_ID,
58
+ type: "json",
59
+ intlLabel: {
60
+ id: getTranslation("field.tags.label"),
61
+ defaultMessage: "Tags"
62
+ },
63
+ intlDescription: {
64
+ id: getTranslation("field.tags.description"),
65
+ defaultMessage: "Edit tags as an array of strings"
66
+ },
67
+ icon: PluginIcon,
68
+ components: {
69
+ Input: async () => Promise.resolve().then(() => require("./TagsInput-BGUydL2t.js")).then((module2) => ({
70
+ default: module2.TagsInput
71
+ }))
72
+ },
73
+ options: {
74
+ base: [
75
+ {
76
+ sectionTitle: {
77
+ id: getTranslation("field.tags.options.behavior.section"),
78
+ defaultMessage: "Tag behavior"
79
+ },
80
+ items: [
81
+ {
82
+ intlLabel: {
83
+ id: getTranslation("field.tags.options.maxTags.label"),
84
+ defaultMessage: "Maximum tags"
85
+ },
86
+ description: {
87
+ id: getTranslation("field.tags.options.maxTags.description"),
88
+ defaultMessage: "Maximum number of tags allowed"
89
+ },
90
+ name: "options.maxTags",
91
+ type: "number",
92
+ value: 20
93
+ },
94
+ {
95
+ intlLabel: {
96
+ id: getTranslation("field.tags.options.maxTagLength.label"),
97
+ defaultMessage: "Maximum tag length"
98
+ },
99
+ description: {
100
+ id: getTranslation(
101
+ "field.tags.options.maxTagLength.description"
102
+ ),
103
+ defaultMessage: "Maximum number of characters per tag"
104
+ },
105
+ name: "options.maxTagLength",
106
+ type: "number",
107
+ value: 40
108
+ },
109
+ {
110
+ intlLabel: {
111
+ id: getTranslation("field.tags.options.allowDuplicates.label"),
112
+ defaultMessage: "Allow duplicates"
113
+ },
114
+ description: {
115
+ id: getTranslation(
116
+ "field.tags.options.allowDuplicates.description"
117
+ ),
118
+ defaultMessage: "Allow repeated tags in the same value"
119
+ },
120
+ name: "options.allowDuplicates",
121
+ type: "checkbox",
122
+ value: false
123
+ }
124
+ ]
125
+ },
126
+ {
127
+ sectionTitle: {
128
+ id: getTranslation("field.tags.options.input.section"),
129
+ defaultMessage: "Input parsing"
130
+ },
131
+ items: [
132
+ {
133
+ intlLabel: {
134
+ id: getTranslation("field.tags.options.separator.label"),
135
+ defaultMessage: "Separator"
136
+ },
137
+ description: {
138
+ id: getTranslation("field.tags.options.separator.description"),
139
+ defaultMessage: "Character used to split typed and pasted values"
140
+ },
141
+ name: "options.separator",
142
+ type: "text",
143
+ value: ","
144
+ },
145
+ {
146
+ intlLabel: {
147
+ id: getTranslation("field.tags.options.normalizeCase.label"),
148
+ defaultMessage: "Normalize case"
149
+ },
150
+ description: {
151
+ id: getTranslation(
152
+ "field.tags.options.normalizeCase.description"
153
+ ),
154
+ defaultMessage: "Transform tag casing before saving"
155
+ },
156
+ name: "options.normalizeCase",
157
+ type: "select",
158
+ value: "none",
159
+ options: [
160
+ {
161
+ key: "none",
162
+ value: "none",
163
+ metadatas: {
164
+ intlLabel: {
165
+ id: getTranslation(
166
+ "field.tags.options.normalizeCase.none"
167
+ ),
168
+ defaultMessage: "None"
169
+ }
170
+ }
171
+ },
172
+ {
173
+ key: "lowercase",
174
+ value: "lowercase",
175
+ metadatas: {
176
+ intlLabel: {
177
+ id: getTranslation(
178
+ "field.tags.options.normalizeCase.lowercase"
179
+ ),
180
+ defaultMessage: "lowercase"
181
+ }
182
+ }
183
+ },
184
+ {
185
+ key: "uppercase",
186
+ value: "uppercase",
187
+ metadatas: {
188
+ intlLabel: {
189
+ id: getTranslation(
190
+ "field.tags.options.normalizeCase.uppercase"
191
+ ),
192
+ defaultMessage: "UPPERCASE"
193
+ }
194
+ }
195
+ }
196
+ ]
197
+ }
198
+ ]
199
+ }
200
+ ]
201
+ }
202
+ });
203
+ app.registerPlugin({
204
+ id: PLUGIN_ID,
205
+ name: "Tags Input"
206
+ });
207
+ },
208
+ async registerTrads({ locales }) {
209
+ return Promise.all(
210
+ locales.map(async (locale) => {
211
+ try {
212
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-C_FVtF2u.js")) }), `./translations/${locale}.json`, 3);
213
+ return { data, locale };
214
+ } catch {
215
+ return { data: {}, locale };
216
+ }
217
+ })
218
+ );
219
+ }
220
+ };
221
+ exports.default = index;