@trackunit/custom-field-components 2.23.13 → 2.23.14

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/index.cjs.js CHANGED
@@ -6,22 +6,17 @@ var reactFormComponents = require('@trackunit/react-form-components');
6
6
  var sharedUtils = require('@trackunit/shared-utils');
7
7
  var reactHookForm = require('react-hook-form');
8
8
  var react = require('react');
9
- var reactComponents = require('@trackunit/react-components');
10
- var reactCoreHooks = require('@trackunit/react-core-hooks');
11
- var reactModal = require('@trackunit/react-modal');
12
9
 
13
10
  var defaultTranslations = {
14
11
  "customfield.input.email": "Email",
15
12
  "customfield.input.webaddress": "Web Address",
16
- "customfield.stringlistfield.add": "Add",
17
- "customfield.stringlistfield.addField.Heading": "Add {{label}}",
18
- "customfield.stringlistfield.addField.InputPlaceholder": "Enter {{label}}",
19
- "customfield.stringlistfield.deleteField.Heading": "Delete {{label}}",
20
- "customfield.stringlistfield.deleteField.Message": "Are you sure you want to delete this {{label}}?",
21
- "customfield.stringlistfield.deleteField.PrimaryActionLabel": "Delete",
22
- "customfield.stringlistfield.deleteField.SecondaryActionLabel": "Cancel",
23
- "customfield.stringlistfield.discard": "Discard",
24
- "customfield.stringlistfield.noValuesAddedYet": "No values added yet",
13
+ "customfield.stringlistfield.counter": "{{count}}/{{maximumItems}}",
14
+ "customfield.stringlistfield.duplicate": "This value has already been added",
15
+ "customfield.stringlistfield.itemMaximumLength": "Each value can be at most {{itemMaximumLength}} characters",
16
+ "customfield.stringlistfield.itemMinimumLength": "Each value must be at least {{itemMinimumLength}} characters",
17
+ "customfield.stringlistfield.maximumItems": "You can add at most {{maximumItems}} values",
18
+ "customfield.stringlistfield.pattern": "Value is not in the expected format",
19
+ "customfield.stringlistfield.placeholder": "Add {{label}}",
25
20
  "customfield.unit.ACRE": "acre",
26
21
  "customfield.unit.AMPERE": "A",
27
22
  "customfield.unit.AMPERE_HOUR": "Ah",
@@ -252,73 +247,183 @@ const DropdownCustomField = ({ defaultValue, "data-testid": dataTestId, onChange
252
247
  };
253
248
 
254
249
  /**
255
- * Modal for adding a new string list field
250
+ * A labeled multi-value field for string-list custom fields.
256
251
  */
257
- const AddStringListField = ({ id, label, isEditing, setIsEditing, onAdd, }) => {
258
- const modal = reactModal.useModal({
259
- isOpen: isEditing,
260
- onClose: () => {
261
- setInputValue("");
262
- setIsEditing(false);
263
- },
264
- });
252
+ const StringListField = ({ defaultValue = [], label, description, helpText, errorMessage, disabled = false, validationRules, id, control, "data-testid": dataTestId, itemMinimumLength, itemMaximumLength, maximumItems, pattern, className, }) => {
253
+ const [t] = useTranslation();
254
+ const normalizeStringListItem = react.useCallback((raw) => raw.trim().replace(/\s+/g, " "), []);
265
255
  const [inputValue, setInputValue] = react.useState("");
266
- const { t } = useTranslation();
267
- const handleAdd = () => {
268
- if (inputValue.trim()) {
269
- onAdd(inputValue.trim());
270
- setInputValue("");
271
- setIsEditing(false);
256
+ const [reportItemMinimumLength, setReportItemMinimumLength] = react.useState(false);
257
+ const inputValueRef = react.useRef("");
258
+ const selectTestId = dataTestId ?? id;
259
+ const itemPattern = react.useMemo(() => {
260
+ if (!pattern) {
261
+ return null;
272
262
  }
273
- };
274
- return (jsxRuntime.jsxs(reactModal.Modal, { ...modal, "data-testid": `${id}-edit-modal`, size: "medium", children: [jsxRuntime.jsx(reactModal.ModalHeader, { heading: t("customfield.stringlistfield.addField.Heading", { label: label }), onClickClose: modal.close }), jsxRuntime.jsx(reactModal.ModalBody, { children: jsxRuntime.jsx(reactFormComponents.TextBaseInput, { "data-testid": `${id}-edit-modal-input`, onChange: e => setInputValue(e.target.value), placeholder: t("customfield.stringlistfield.addField.InputPlaceholder", { label: label }), value: inputValue }) }), jsxRuntime.jsx(reactModal.ModalFooter, { cancelLabel: t("customfield.stringlistfield.discard"), "data-testid": `${id}-edit-modal-footer`, onCancel: modal.close, primaryAction: handleAdd, primaryLabel: t("customfield.stringlistfield.add") })] }));
275
- };
276
-
277
- const StringListInput = ({ value: initialValue, onSave, onClickDelete, disabled, id, index }) => {
278
- const [t] = useTranslation();
279
- const [localValue, setLocalValue] = react.useState(initialValue);
280
- react.useEffect(() => {
281
- setLocalValue(initialValue);
282
- }, [initialValue]);
283
- const handleBlur = react.useCallback(() => {
284
- if (localValue !== initialValue) {
285
- onSave(localValue);
286
- }
287
- }, [localValue, initialValue, onSave]);
288
- return (jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [jsxRuntime.jsx(reactFormComponents.TextBaseInput, { "data-testid": `${id}-${index}-input`, disabled: disabled, onBlur: handleBlur, onChange: e => setLocalValue(e.target.value), value: localValue }), jsxRuntime.jsx(reactComponents.IconButton, { disabled: disabled, icon: jsxRuntime.jsx(reactComponents.Icon, { name: "Trash", size: "small" }), onClick: onClickDelete, title: t("customfield.stringlistfield.deleteField.PrimaryActionLabel"), variant: "secondary-danger" })] }));
289
- };
290
- /**
291
- * A custom field that displays a list of strings.
292
- */
293
- const StringListField = ({ defaultValue = [], label, description, disabled = false, validationRules, id, control, register, setValue, "data-testid": dataTestId, ...props }) => {
294
- const [isEditing, setIsEditing] = react.useState(false);
295
- const { confirm } = reactCoreHooks.useConfirmationDialog();
296
- const { t } = useTranslation();
263
+ try {
264
+ return new RegExp(pattern);
265
+ }
266
+ catch {
267
+ return null;
268
+ }
269
+ }, [pattern]);
297
270
  return (jsxRuntime.jsx(reactHookForm.Controller, { control: control, defaultValue: defaultValue, name: id, render: ({ field }) => {
298
- const handleRemoveValue = (index) => {
299
- const newValues = [...field.value].filter((_, i) => i !== index);
300
- field.onChange(newValues);
271
+ const values = Array.isArray(field.value) ? field.value : [];
272
+ const atCap = typeof maximumItems === "number" && values.length >= maximumItems;
273
+ const rejection = (() => {
274
+ const item = normalizeStringListItem(inputValue);
275
+ if (item === "") {
276
+ return null;
277
+ }
278
+ if (typeof itemMaximumLength === "number" && item.length > itemMaximumLength) {
279
+ return "itemMaximumLength";
280
+ }
281
+ if (reportItemMinimumLength && typeof itemMinimumLength === "number" && item.length < itemMinimumLength) {
282
+ return "itemMinimumLength";
283
+ }
284
+ if (itemPattern && !itemPattern.test(item)) {
285
+ return "pattern";
286
+ }
287
+ if (atCap) {
288
+ return "maximumItems";
289
+ }
290
+ if (values.some(existing => existing.toLowerCase() === item.toLowerCase())) {
291
+ return "duplicate";
292
+ }
293
+ return null;
294
+ })();
295
+ const standingHelpText = (() => {
296
+ if (!disabled && atCap && typeof maximumItems === "number") {
297
+ return t("customfield.stringlistfield.maximumItems", { maximumItems });
298
+ }
299
+ return helpText ?? description;
300
+ })();
301
+ const resolvedHelpText = (() => {
302
+ switch (rejection) {
303
+ case "duplicate":
304
+ return t("customfield.stringlistfield.duplicate");
305
+ case "maximumItems":
306
+ return t("customfield.stringlistfield.maximumItems", { maximumItems });
307
+ case "itemMaximumLength":
308
+ return t("customfield.stringlistfield.itemMaximumLength", { itemMaximumLength });
309
+ case "itemMinimumLength":
310
+ return t("customfield.stringlistfield.itemMinimumLength", { itemMinimumLength });
311
+ case "pattern":
312
+ return t("customfield.stringlistfield.pattern");
313
+ case null:
314
+ return errorMessage ?? standingHelpText;
315
+ default:
316
+ return sharedUtils.exhaustiveCheck(rejection);
317
+ }
318
+ })();
319
+ const selectedOptions = values.map(item => ({
320
+ label: item,
321
+ value: item,
322
+ }));
323
+ const tryAddItem = (raw) => {
324
+ if (disabled) {
325
+ return;
326
+ }
327
+ const item = normalizeStringListItem(raw);
328
+ if (item === "") {
329
+ return;
330
+ }
331
+ if (typeof itemMaximumLength === "number" && item.length > itemMaximumLength) {
332
+ return;
333
+ }
334
+ if (typeof itemMinimumLength === "number" && item.length < itemMinimumLength) {
335
+ return;
336
+ }
337
+ if (itemPattern && !itemPattern.test(item)) {
338
+ return;
339
+ }
340
+ if (typeof maximumItems === "number" && values.length >= maximumItems) {
341
+ return;
342
+ }
343
+ if (values.some(existing => existing.toLowerCase() === item.toLowerCase())) {
344
+ return;
345
+ }
346
+ inputValueRef.current = "";
347
+ setInputValue("");
348
+ field.onChange([...values, item]);
301
349
  };
302
- const handleFieldChange = (index, value) => {
303
- const newValues = [...field.value];
304
- newValues[index] = value;
305
- field.onChange(newValues);
350
+ const onCreateOption = (raw) => {
351
+ tryAddItem(raw);
306
352
  };
307
- const handleRemoveValueConfirmation = async (index) => {
308
- const confirmResult = await confirm({
309
- title: t("customfield.stringlistfield.deleteField.Heading", { label: label }),
310
- message: t("customfield.stringlistfield.deleteField.Message", { label: label }),
311
- primaryActionLabel: t("customfield.stringlistfield.deleteField.PrimaryActionLabel"),
312
- secondaryActionLabel: t("customfield.stringlistfield.deleteField.SecondaryActionLabel"),
313
- primaryActionType: "primary-danger",
314
- });
315
- if (confirmResult === "PRIMARY") {
316
- handleRemoveValue(index);
353
+ const onSelectChange = (selected, actionMeta) => {
354
+ if (disabled || actionMeta.action === "create-option") {
355
+ return;
356
+ }
357
+ const incoming = selected.map(option => option.value);
358
+ const kept = values.filter(existing => incoming.includes(existing));
359
+ const added = incoming.filter(next => !values.some(existing => existing.toLowerCase() === next.toLowerCase()));
360
+ if (added.length > 0 && typeof maximumItems === "number" && kept.length + added.length > maximumItems) {
361
+ return;
317
362
  }
363
+ field.onChange([...kept, ...added]);
318
364
  };
319
- return (jsxRuntime.jsxs(reactComponents.Card, { ...props, className: "w-full", "data-testid": dataTestId, children: [jsxRuntime.jsx(reactComponents.CardHeader, { accessories: jsxRuntime.jsx(reactComponents.Tooltip, { label: description }), heading: label, headingVariant: "secondary" }), jsxRuntime.jsx(reactComponents.CardBody, { children: field.value.length === 0 ? (jsxRuntime.jsx(reactComponents.Text, { children: t("customfield.stringlistfield.noValuesAddedYet") })) : (jsxRuntime.jsx("div", { className: "flex flex-wrap gap-2", children: field.value.map((value, index) => (jsxRuntime.jsx(StringListInput, { disabled: disabled, id: id, index: index, onClickDelete: () => handleRemoveValueConfirmation(index), onSave: newValue => handleFieldChange(index, newValue), value: value }, `${id}-${index}`))) })) }), !disabled ? (jsxRuntime.jsx(reactComponents.CardFooter, { children: jsxRuntime.jsx(reactComponents.Button, { disabled: disabled, onClick: () => setIsEditing(true), variant: "secondary", children: t("customfield.stringlistfield.add", { label: label }) }) })) : null, jsxRuntime.jsx(AddStringListField, { id: id, isEditing: isEditing, label: label, onAdd: (newItem) => {
320
- field.onChange([...field.value, newItem]);
321
- }, setIsEditing: setIsEditing })] }));
365
+ const onKeyDown = (event) => {
366
+ const raw = inputValueRef.current;
367
+ const item = normalizeStringListItem(raw);
368
+ if (event.key === " " && item === "") {
369
+ event.preventDefault();
370
+ const next = `${raw} `;
371
+ inputValueRef.current = next;
372
+ setInputValue(next);
373
+ return;
374
+ }
375
+ if (event.key !== "Enter") {
376
+ return;
377
+ }
378
+ if (item === "") {
379
+ if (raw !== "") {
380
+ event.preventDefault();
381
+ inputValueRef.current = "";
382
+ setInputValue("");
383
+ }
384
+ return;
385
+ }
386
+ event.preventDefault();
387
+ if (typeof itemMinimumLength === "number" && item.length < itemMinimumLength) {
388
+ setReportItemMinimumLength(true);
389
+ }
390
+ tryAddItem(raw);
391
+ };
392
+ const onInputChange = (nextValue, meta) => {
393
+ if (meta.action === "input-change") {
394
+ setReportItemMinimumLength(false);
395
+ inputValueRef.current = nextValue;
396
+ setInputValue(nextValue);
397
+ return nextValue;
398
+ }
399
+ const draft = inputValueRef.current;
400
+ const item = normalizeStringListItem(draft);
401
+ if (meta.action === "input-blur" &&
402
+ item !== "" &&
403
+ typeof itemMinimumLength === "number" &&
404
+ item.length < itemMinimumLength) {
405
+ setReportItemMinimumLength(true);
406
+ }
407
+ const isRejectedDraft = item !== "" &&
408
+ ((typeof itemMaximumLength === "number" && item.length > itemMaximumLength) ||
409
+ (typeof itemMinimumLength === "number" && item.length < itemMinimumLength) ||
410
+ (itemPattern !== null && !itemPattern.test(item)) ||
411
+ (typeof maximumItems === "number" && values.length >= maximumItems) ||
412
+ values.some(existing => existing.toLowerCase() === item.toLowerCase()));
413
+ if (isRejectedDraft) {
414
+ return draft;
415
+ }
416
+ if (meta.action === "input-blur" && item !== "") {
417
+ tryAddItem(draft);
418
+ return "";
419
+ }
420
+ inputValueRef.current = nextValue;
421
+ setInputValue(nextValue);
422
+ return nextValue;
423
+ };
424
+ return (jsxRuntime.jsx(reactFormComponents.FormGroup, { className: className, "data-testid": dataTestId ? `${dataTestId}-field` : undefined, helpAddon: typeof maximumItems === "number"
425
+ ? t("customfield.stringlistfield.counter", { count: values.length, maximumItems })
426
+ : undefined, helpText: resolvedHelpText, htmlFor: id, isInvalid: rejection !== null || Boolean(errorMessage), label: label, children: jsxRuntime.jsx(reactFormComponents.CreatableSelect, { "data-testid": selectTestId, disabled: disabled, hasError: rejection !== null || Boolean(errorMessage), hideSelectedOptions: false, inputId: id, inputValue: inputValue, isMulti: true, label: label, onChange: onSelectChange, onCreateOption: onCreateOption, onInputChange: onInputChange, onKeyDown: onKeyDown, options: selectedOptions, placeholder: t("customfield.stringlistfield.placeholder", { label }), value: selectedOptions }) }));
322
427
  }, rules: validationRules }));
323
428
  };
324
429
 
@@ -447,44 +552,11 @@ const getDropdownValidationRules = (_definition) => {
447
552
  return getDefaultValidationRules();
448
553
  };
449
554
  /**
450
- * Generates validation rules for a string list field.
451
- *
452
- * @param {GetStringListValidationRulesDefinition} definition - The definition of the string list field.
453
- * @returns {ValidationRules} The generated validation rules.
555
+ * String-list item limits are enforced when a value is added. React Hook Form's
556
+ * minLength, maxLength, and pattern rules apply to the array itself, so they are not used here.
454
557
  */
455
- const getStringListValidationRules = (definition) => {
456
- const defaultRules = {};
457
- const itemMinLength = definition.itemMinimumLength !== null && definition.itemMinimumLength !== undefined
458
- ? {
459
- minLength: {
460
- value: definition.itemMinimumLength,
461
- message: `minimum length for each item is ${definition.itemMinimumLength}`,
462
- },
463
- }
464
- : undefined;
465
- const itemMaxLength = definition.itemMaximumLength !== null && definition.itemMaximumLength !== undefined
466
- ? {
467
- maxLength: {
468
- value: definition.itemMaximumLength,
469
- message: `maximum length for each item is ${definition.itemMaximumLength}`,
470
- },
471
- }
472
- : undefined;
473
- const pattern = definition.pattern
474
- ? {
475
- pattern: {
476
- value: RegExp(definition.pattern),
477
- message: `Must match pattern: ${definition.pattern}`,
478
- },
479
- }
480
- : undefined;
481
- return definition.uiEditable
482
- ? {
483
- ...itemMaxLength,
484
- ...itemMinLength,
485
- ...pattern,
486
- }
487
- : defaultRules;
558
+ const getStringListValidationRules = (_definition) => {
559
+ return {};
488
560
  };
489
561
  /**
490
562
  * Generates validation rules for a date field.
@@ -609,8 +681,8 @@ const useCustomFieldResolver = ({ field, definition, register, setValue, formSta
609
681
  return (jsxRuntime.jsx(DateCustomField, { "data-testid": `${key}`, defaultValue: value, disabled: !isEditableCombined, errorMessage: errorMessage, helpText: description, id: uniqueIdentifier, label: title || "", readOnly: !isEditableCombined, register: validation.register, setValue: validation.setValue, title: title || "", validationRules: rules }));
610
682
  }
611
683
  case "StringListFieldDefinition": {
612
- const rules = getStringListValidationRules(def);
613
- return (jsxRuntime.jsx(StringListField, { control: control, "data-testid": key, defaultValue: value ?? undefined, description: description ?? "", disabled: !isEditableCombined, errorMessage: errorMessage, id: uniqueIdentifier, label: title || "", register: validation.register, setValue: validation.setValue, validationRules: rules }));
684
+ const rules = getStringListValidationRules();
685
+ return (jsxRuntime.jsx(StringListField, { control: control, "data-testid": key, defaultValue: value ?? undefined, description: description ?? "", disabled: !isEditableCombined, errorMessage: errorMessage, id: uniqueIdentifier, itemMaximumLength: def.itemMaximumLength, itemMinimumLength: def.itemMinimumLength, label: title || "", maximumItems: def.maximumItems, pattern: def.pattern, validationRules: rules }));
614
686
  }
615
687
  case "JsonFieldDefinition":
616
688
  case "FileFieldDefinition": {
package/index.esm.js CHANGED
@@ -1,25 +1,20 @@
1
- import { jsx, jsxs } from 'react/jsx-runtime';
1
+ import { jsx } from 'react/jsx-runtime';
2
2
  import { registerTranslations, useNamespaceTranslation } from '@trackunit/i18n-library-translation';
3
- import { FormGroup, Checkbox, toISODateStringUTC, DateField, MultiSelectField, SelectField, TextBaseInput, useGetPhoneValidationRules, NumberField, TextField, PhoneField, ActionButton } from '@trackunit/react-form-components';
3
+ import { FormGroup, Checkbox, toISODateStringUTC, DateField, MultiSelectField, SelectField, CreatableSelect, useGetPhoneValidationRules, NumberField, TextField, PhoneField, ActionButton } from '@trackunit/react-form-components';
4
4
  import { uuidv4, exhaustiveCheck } from '@trackunit/shared-utils';
5
5
  import { Controller, useWatch } from 'react-hook-form';
6
- import { useState, useEffect, useCallback, createElement, useMemo } from 'react';
7
- import { Card, CardHeader, Tooltip, CardBody, Text, CardFooter, Button, IconButton, Icon } from '@trackunit/react-components';
8
- import { useConfirmationDialog } from '@trackunit/react-core-hooks';
9
- import { useModal, Modal, ModalHeader, ModalBody, ModalFooter } from '@trackunit/react-modal';
6
+ import { useState, useEffect, useCallback, createElement, useMemo, useRef } from 'react';
10
7
 
11
8
  var defaultTranslations = {
12
9
  "customfield.input.email": "Email",
13
10
  "customfield.input.webaddress": "Web Address",
14
- "customfield.stringlistfield.add": "Add",
15
- "customfield.stringlistfield.addField.Heading": "Add {{label}}",
16
- "customfield.stringlistfield.addField.InputPlaceholder": "Enter {{label}}",
17
- "customfield.stringlistfield.deleteField.Heading": "Delete {{label}}",
18
- "customfield.stringlistfield.deleteField.Message": "Are you sure you want to delete this {{label}}?",
19
- "customfield.stringlistfield.deleteField.PrimaryActionLabel": "Delete",
20
- "customfield.stringlistfield.deleteField.SecondaryActionLabel": "Cancel",
21
- "customfield.stringlistfield.discard": "Discard",
22
- "customfield.stringlistfield.noValuesAddedYet": "No values added yet",
11
+ "customfield.stringlistfield.counter": "{{count}}/{{maximumItems}}",
12
+ "customfield.stringlistfield.duplicate": "This value has already been added",
13
+ "customfield.stringlistfield.itemMaximumLength": "Each value can be at most {{itemMaximumLength}} characters",
14
+ "customfield.stringlistfield.itemMinimumLength": "Each value must be at least {{itemMinimumLength}} characters",
15
+ "customfield.stringlistfield.maximumItems": "You can add at most {{maximumItems}} values",
16
+ "customfield.stringlistfield.pattern": "Value is not in the expected format",
17
+ "customfield.stringlistfield.placeholder": "Add {{label}}",
23
18
  "customfield.unit.ACRE": "acre",
24
19
  "customfield.unit.AMPERE": "A",
25
20
  "customfield.unit.AMPERE_HOUR": "Ah",
@@ -250,73 +245,183 @@ const DropdownCustomField = ({ defaultValue, "data-testid": dataTestId, onChange
250
245
  };
251
246
 
252
247
  /**
253
- * Modal for adding a new string list field
248
+ * A labeled multi-value field for string-list custom fields.
254
249
  */
255
- const AddStringListField = ({ id, label, isEditing, setIsEditing, onAdd, }) => {
256
- const modal = useModal({
257
- isOpen: isEditing,
258
- onClose: () => {
259
- setInputValue("");
260
- setIsEditing(false);
261
- },
262
- });
250
+ const StringListField = ({ defaultValue = [], label, description, helpText, errorMessage, disabled = false, validationRules, id, control, "data-testid": dataTestId, itemMinimumLength, itemMaximumLength, maximumItems, pattern, className, }) => {
251
+ const [t] = useTranslation();
252
+ const normalizeStringListItem = useCallback((raw) => raw.trim().replace(/\s+/g, " "), []);
263
253
  const [inputValue, setInputValue] = useState("");
264
- const { t } = useTranslation();
265
- const handleAdd = () => {
266
- if (inputValue.trim()) {
267
- onAdd(inputValue.trim());
268
- setInputValue("");
269
- setIsEditing(false);
254
+ const [reportItemMinimumLength, setReportItemMinimumLength] = useState(false);
255
+ const inputValueRef = useRef("");
256
+ const selectTestId = dataTestId ?? id;
257
+ const itemPattern = useMemo(() => {
258
+ if (!pattern) {
259
+ return null;
270
260
  }
271
- };
272
- return (jsxs(Modal, { ...modal, "data-testid": `${id}-edit-modal`, size: "medium", children: [jsx(ModalHeader, { heading: t("customfield.stringlistfield.addField.Heading", { label: label }), onClickClose: modal.close }), jsx(ModalBody, { children: jsx(TextBaseInput, { "data-testid": `${id}-edit-modal-input`, onChange: e => setInputValue(e.target.value), placeholder: t("customfield.stringlistfield.addField.InputPlaceholder", { label: label }), value: inputValue }) }), jsx(ModalFooter, { cancelLabel: t("customfield.stringlistfield.discard"), "data-testid": `${id}-edit-modal-footer`, onCancel: modal.close, primaryAction: handleAdd, primaryLabel: t("customfield.stringlistfield.add") })] }));
273
- };
274
-
275
- const StringListInput = ({ value: initialValue, onSave, onClickDelete, disabled, id, index }) => {
276
- const [t] = useTranslation();
277
- const [localValue, setLocalValue] = useState(initialValue);
278
- useEffect(() => {
279
- setLocalValue(initialValue);
280
- }, [initialValue]);
281
- const handleBlur = useCallback(() => {
282
- if (localValue !== initialValue) {
283
- onSave(localValue);
284
- }
285
- }, [localValue, initialValue, onSave]);
286
- return (jsxs("div", { className: "flex items-center gap-2", children: [jsx(TextBaseInput, { "data-testid": `${id}-${index}-input`, disabled: disabled, onBlur: handleBlur, onChange: e => setLocalValue(e.target.value), value: localValue }), jsx(IconButton, { disabled: disabled, icon: jsx(Icon, { name: "Trash", size: "small" }), onClick: onClickDelete, title: t("customfield.stringlistfield.deleteField.PrimaryActionLabel"), variant: "secondary-danger" })] }));
287
- };
288
- /**
289
- * A custom field that displays a list of strings.
290
- */
291
- const StringListField = ({ defaultValue = [], label, description, disabled = false, validationRules, id, control, register, setValue, "data-testid": dataTestId, ...props }) => {
292
- const [isEditing, setIsEditing] = useState(false);
293
- const { confirm } = useConfirmationDialog();
294
- const { t } = useTranslation();
261
+ try {
262
+ return new RegExp(pattern);
263
+ }
264
+ catch {
265
+ return null;
266
+ }
267
+ }, [pattern]);
295
268
  return (jsx(Controller, { control: control, defaultValue: defaultValue, name: id, render: ({ field }) => {
296
- const handleRemoveValue = (index) => {
297
- const newValues = [...field.value].filter((_, i) => i !== index);
298
- field.onChange(newValues);
269
+ const values = Array.isArray(field.value) ? field.value : [];
270
+ const atCap = typeof maximumItems === "number" && values.length >= maximumItems;
271
+ const rejection = (() => {
272
+ const item = normalizeStringListItem(inputValue);
273
+ if (item === "") {
274
+ return null;
275
+ }
276
+ if (typeof itemMaximumLength === "number" && item.length > itemMaximumLength) {
277
+ return "itemMaximumLength";
278
+ }
279
+ if (reportItemMinimumLength && typeof itemMinimumLength === "number" && item.length < itemMinimumLength) {
280
+ return "itemMinimumLength";
281
+ }
282
+ if (itemPattern && !itemPattern.test(item)) {
283
+ return "pattern";
284
+ }
285
+ if (atCap) {
286
+ return "maximumItems";
287
+ }
288
+ if (values.some(existing => existing.toLowerCase() === item.toLowerCase())) {
289
+ return "duplicate";
290
+ }
291
+ return null;
292
+ })();
293
+ const standingHelpText = (() => {
294
+ if (!disabled && atCap && typeof maximumItems === "number") {
295
+ return t("customfield.stringlistfield.maximumItems", { maximumItems });
296
+ }
297
+ return helpText ?? description;
298
+ })();
299
+ const resolvedHelpText = (() => {
300
+ switch (rejection) {
301
+ case "duplicate":
302
+ return t("customfield.stringlistfield.duplicate");
303
+ case "maximumItems":
304
+ return t("customfield.stringlistfield.maximumItems", { maximumItems });
305
+ case "itemMaximumLength":
306
+ return t("customfield.stringlistfield.itemMaximumLength", { itemMaximumLength });
307
+ case "itemMinimumLength":
308
+ return t("customfield.stringlistfield.itemMinimumLength", { itemMinimumLength });
309
+ case "pattern":
310
+ return t("customfield.stringlistfield.pattern");
311
+ case null:
312
+ return errorMessage ?? standingHelpText;
313
+ default:
314
+ return exhaustiveCheck(rejection);
315
+ }
316
+ })();
317
+ const selectedOptions = values.map(item => ({
318
+ label: item,
319
+ value: item,
320
+ }));
321
+ const tryAddItem = (raw) => {
322
+ if (disabled) {
323
+ return;
324
+ }
325
+ const item = normalizeStringListItem(raw);
326
+ if (item === "") {
327
+ return;
328
+ }
329
+ if (typeof itemMaximumLength === "number" && item.length > itemMaximumLength) {
330
+ return;
331
+ }
332
+ if (typeof itemMinimumLength === "number" && item.length < itemMinimumLength) {
333
+ return;
334
+ }
335
+ if (itemPattern && !itemPattern.test(item)) {
336
+ return;
337
+ }
338
+ if (typeof maximumItems === "number" && values.length >= maximumItems) {
339
+ return;
340
+ }
341
+ if (values.some(existing => existing.toLowerCase() === item.toLowerCase())) {
342
+ return;
343
+ }
344
+ inputValueRef.current = "";
345
+ setInputValue("");
346
+ field.onChange([...values, item]);
299
347
  };
300
- const handleFieldChange = (index, value) => {
301
- const newValues = [...field.value];
302
- newValues[index] = value;
303
- field.onChange(newValues);
348
+ const onCreateOption = (raw) => {
349
+ tryAddItem(raw);
304
350
  };
305
- const handleRemoveValueConfirmation = async (index) => {
306
- const confirmResult = await confirm({
307
- title: t("customfield.stringlistfield.deleteField.Heading", { label: label }),
308
- message: t("customfield.stringlistfield.deleteField.Message", { label: label }),
309
- primaryActionLabel: t("customfield.stringlistfield.deleteField.PrimaryActionLabel"),
310
- secondaryActionLabel: t("customfield.stringlistfield.deleteField.SecondaryActionLabel"),
311
- primaryActionType: "primary-danger",
312
- });
313
- if (confirmResult === "PRIMARY") {
314
- handleRemoveValue(index);
351
+ const onSelectChange = (selected, actionMeta) => {
352
+ if (disabled || actionMeta.action === "create-option") {
353
+ return;
354
+ }
355
+ const incoming = selected.map(option => option.value);
356
+ const kept = values.filter(existing => incoming.includes(existing));
357
+ const added = incoming.filter(next => !values.some(existing => existing.toLowerCase() === next.toLowerCase()));
358
+ if (added.length > 0 && typeof maximumItems === "number" && kept.length + added.length > maximumItems) {
359
+ return;
315
360
  }
361
+ field.onChange([...kept, ...added]);
316
362
  };
317
- return (jsxs(Card, { ...props, className: "w-full", "data-testid": dataTestId, children: [jsx(CardHeader, { accessories: jsx(Tooltip, { label: description }), heading: label, headingVariant: "secondary" }), jsx(CardBody, { children: field.value.length === 0 ? (jsx(Text, { children: t("customfield.stringlistfield.noValuesAddedYet") })) : (jsx("div", { className: "flex flex-wrap gap-2", children: field.value.map((value, index) => (jsx(StringListInput, { disabled: disabled, id: id, index: index, onClickDelete: () => handleRemoveValueConfirmation(index), onSave: newValue => handleFieldChange(index, newValue), value: value }, `${id}-${index}`))) })) }), !disabled ? (jsx(CardFooter, { children: jsx(Button, { disabled: disabled, onClick: () => setIsEditing(true), variant: "secondary", children: t("customfield.stringlistfield.add", { label: label }) }) })) : null, jsx(AddStringListField, { id: id, isEditing: isEditing, label: label, onAdd: (newItem) => {
318
- field.onChange([...field.value, newItem]);
319
- }, setIsEditing: setIsEditing })] }));
363
+ const onKeyDown = (event) => {
364
+ const raw = inputValueRef.current;
365
+ const item = normalizeStringListItem(raw);
366
+ if (event.key === " " && item === "") {
367
+ event.preventDefault();
368
+ const next = `${raw} `;
369
+ inputValueRef.current = next;
370
+ setInputValue(next);
371
+ return;
372
+ }
373
+ if (event.key !== "Enter") {
374
+ return;
375
+ }
376
+ if (item === "") {
377
+ if (raw !== "") {
378
+ event.preventDefault();
379
+ inputValueRef.current = "";
380
+ setInputValue("");
381
+ }
382
+ return;
383
+ }
384
+ event.preventDefault();
385
+ if (typeof itemMinimumLength === "number" && item.length < itemMinimumLength) {
386
+ setReportItemMinimumLength(true);
387
+ }
388
+ tryAddItem(raw);
389
+ };
390
+ const onInputChange = (nextValue, meta) => {
391
+ if (meta.action === "input-change") {
392
+ setReportItemMinimumLength(false);
393
+ inputValueRef.current = nextValue;
394
+ setInputValue(nextValue);
395
+ return nextValue;
396
+ }
397
+ const draft = inputValueRef.current;
398
+ const item = normalizeStringListItem(draft);
399
+ if (meta.action === "input-blur" &&
400
+ item !== "" &&
401
+ typeof itemMinimumLength === "number" &&
402
+ item.length < itemMinimumLength) {
403
+ setReportItemMinimumLength(true);
404
+ }
405
+ const isRejectedDraft = item !== "" &&
406
+ ((typeof itemMaximumLength === "number" && item.length > itemMaximumLength) ||
407
+ (typeof itemMinimumLength === "number" && item.length < itemMinimumLength) ||
408
+ (itemPattern !== null && !itemPattern.test(item)) ||
409
+ (typeof maximumItems === "number" && values.length >= maximumItems) ||
410
+ values.some(existing => existing.toLowerCase() === item.toLowerCase()));
411
+ if (isRejectedDraft) {
412
+ return draft;
413
+ }
414
+ if (meta.action === "input-blur" && item !== "") {
415
+ tryAddItem(draft);
416
+ return "";
417
+ }
418
+ inputValueRef.current = nextValue;
419
+ setInputValue(nextValue);
420
+ return nextValue;
421
+ };
422
+ return (jsx(FormGroup, { className: className, "data-testid": dataTestId ? `${dataTestId}-field` : undefined, helpAddon: typeof maximumItems === "number"
423
+ ? t("customfield.stringlistfield.counter", { count: values.length, maximumItems })
424
+ : undefined, helpText: resolvedHelpText, htmlFor: id, isInvalid: rejection !== null || Boolean(errorMessage), label: label, children: jsx(CreatableSelect, { "data-testid": selectTestId, disabled: disabled, hasError: rejection !== null || Boolean(errorMessage), hideSelectedOptions: false, inputId: id, inputValue: inputValue, isMulti: true, label: label, onChange: onSelectChange, onCreateOption: onCreateOption, onInputChange: onInputChange, onKeyDown: onKeyDown, options: selectedOptions, placeholder: t("customfield.stringlistfield.placeholder", { label }), value: selectedOptions }) }));
320
425
  }, rules: validationRules }));
321
426
  };
322
427
 
@@ -445,44 +550,11 @@ const getDropdownValidationRules = (_definition) => {
445
550
  return getDefaultValidationRules();
446
551
  };
447
552
  /**
448
- * Generates validation rules for a string list field.
449
- *
450
- * @param {GetStringListValidationRulesDefinition} definition - The definition of the string list field.
451
- * @returns {ValidationRules} The generated validation rules.
553
+ * String-list item limits are enforced when a value is added. React Hook Form's
554
+ * minLength, maxLength, and pattern rules apply to the array itself, so they are not used here.
452
555
  */
453
- const getStringListValidationRules = (definition) => {
454
- const defaultRules = {};
455
- const itemMinLength = definition.itemMinimumLength !== null && definition.itemMinimumLength !== undefined
456
- ? {
457
- minLength: {
458
- value: definition.itemMinimumLength,
459
- message: `minimum length for each item is ${definition.itemMinimumLength}`,
460
- },
461
- }
462
- : undefined;
463
- const itemMaxLength = definition.itemMaximumLength !== null && definition.itemMaximumLength !== undefined
464
- ? {
465
- maxLength: {
466
- value: definition.itemMaximumLength,
467
- message: `maximum length for each item is ${definition.itemMaximumLength}`,
468
- },
469
- }
470
- : undefined;
471
- const pattern = definition.pattern
472
- ? {
473
- pattern: {
474
- value: RegExp(definition.pattern),
475
- message: `Must match pattern: ${definition.pattern}`,
476
- },
477
- }
478
- : undefined;
479
- return definition.uiEditable
480
- ? {
481
- ...itemMaxLength,
482
- ...itemMinLength,
483
- ...pattern,
484
- }
485
- : defaultRules;
556
+ const getStringListValidationRules = (_definition) => {
557
+ return {};
486
558
  };
487
559
  /**
488
560
  * Generates validation rules for a date field.
@@ -607,8 +679,8 @@ const useCustomFieldResolver = ({ field, definition, register, setValue, formSta
607
679
  return (jsx(DateCustomField, { "data-testid": `${key}`, defaultValue: value, disabled: !isEditableCombined, errorMessage: errorMessage, helpText: description, id: uniqueIdentifier, label: title || "", readOnly: !isEditableCombined, register: validation.register, setValue: validation.setValue, title: title || "", validationRules: rules }));
608
680
  }
609
681
  case "StringListFieldDefinition": {
610
- const rules = getStringListValidationRules(def);
611
- return (jsx(StringListField, { control: control, "data-testid": key, defaultValue: value ?? undefined, description: description ?? "", disabled: !isEditableCombined, errorMessage: errorMessage, id: uniqueIdentifier, label: title || "", register: validation.register, setValue: validation.setValue, validationRules: rules }));
682
+ const rules = getStringListValidationRules();
683
+ return (jsx(StringListField, { control: control, "data-testid": key, defaultValue: value ?? undefined, description: description ?? "", disabled: !isEditableCombined, errorMessage: errorMessage, id: uniqueIdentifier, itemMaximumLength: def.itemMaximumLength, itemMinimumLength: def.itemMinimumLength, label: title || "", maximumItems: def.maximumItems, pattern: def.pattern, validationRules: rules }));
612
684
  }
613
685
  case "JsonFieldDefinition":
614
686
  case "FileFieldDefinition": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/custom-field-components",
3
- "version": "2.23.13",
3
+ "version": "2.23.14",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -8,12 +8,10 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "react-select": "^5.10.2",
11
- "@trackunit/react-form-components": "2.10.7",
11
+ "@trackunit/react-form-components": "2.10.8",
12
12
  "@trackunit/shared-utils": "1.19.4",
13
- "@trackunit/custom-field-api": "2.24.13",
13
+ "@trackunit/custom-field-api": "2.24.14",
14
14
  "@trackunit/react-components": "3.8.1",
15
- "@trackunit/react-modal": "2.10.7",
16
- "@trackunit/react-core-hooks": "1.25.4",
17
15
  "@trackunit/i18n-library-translation": "2.8.4",
18
16
  "@trackunit/iris-app-runtime-core-api": "1.21.4"
19
17
  },
@@ -1,20 +1,22 @@
1
1
  import { CommonProps } from "@trackunit/react-components";
2
2
  import { ReactElement } from "react";
3
- import { Control, FieldValues, RegisterOptions, UseFormRegister, UseFormSetValue } from "react-hook-form";
3
+ import { Control, FieldValues, RegisterOptions } from "react-hook-form";
4
4
  export interface StringListFieldProps extends CommonProps {
5
5
  defaultValue?: Array<string>;
6
6
  label: string;
7
7
  helpText?: string;
8
8
  errorMessage?: string;
9
- register: UseFormRegister<FieldValues>;
10
- setValue: UseFormSetValue<FieldValues>;
11
9
  description?: string;
12
10
  disabled?: boolean;
13
11
  validationRules: RegisterOptions;
14
12
  id: string;
15
13
  control: Control<FieldValues>;
14
+ itemMinimumLength?: number | null;
15
+ itemMaximumLength?: number | null;
16
+ maximumItems?: number | null;
17
+ pattern?: string | null;
16
18
  }
17
19
  /**
18
- * A custom field that displays a list of strings.
20
+ * A labeled multi-value field for string-list custom fields.
19
21
  */
20
- export declare const StringListField: ({ defaultValue, label, description, disabled, validationRules, id, control, register, setValue, "data-testid": dataTestId, ...props }: StringListFieldProps) => ReactElement;
22
+ export declare const StringListField: ({ defaultValue, label, description, helpText, errorMessage, disabled, validationRules, id, control, "data-testid": dataTestId, itemMinimumLength, itemMaximumLength, maximumItems, pattern, className, }: StringListFieldProps) => ReactElement;
@@ -58,12 +58,10 @@ interface GetStringListValidationRulesDefinition extends CommonValidationRulesDe
58
58
  maximumItems?: number | null;
59
59
  }
60
60
  /**
61
- * Generates validation rules for a string list field.
62
- *
63
- * @param {GetStringListValidationRulesDefinition} definition - The definition of the string list field.
64
- * @returns {ValidationRules} The generated validation rules.
61
+ * String-list item limits are enforced when a value is added. React Hook Form's
62
+ * minLength, maxLength, and pattern rules apply to the array itself, so they are not used here.
65
63
  */
66
- export declare const getStringListValidationRules: (definition: GetStringListValidationRulesDefinition) => ValidationRules;
64
+ export declare const getStringListValidationRules: (_definition: GetStringListValidationRulesDefinition) => ValidationRules;
67
65
  /**
68
66
  * Generates validation rules for a date field.
69
67
  *
@@ -1,13 +0,0 @@
1
- import { ReactElement } from "react";
2
- interface AddStringListFieldProps {
3
- id: string;
4
- label: string;
5
- isEditing: boolean;
6
- setIsEditing: (isEditing: boolean) => void;
7
- onAdd: (newItem: string) => void;
8
- }
9
- /**
10
- * Modal for adding a new string list field
11
- */
12
- export declare const AddStringListField: ({ id, label, isEditing, setIsEditing, onAdd, }: AddStringListFieldProps) => ReactElement;
13
- export {};