diff --git a/src/components/RJST/Actions/Tags.tsx b/src/components/RJST/Actions/Tags.tsx index c6d18c7d..b08c241a 100644 --- a/src/components/RJST/Actions/Tags.tsx +++ b/src/components/RJST/Actions/Tags.tsx @@ -119,6 +119,8 @@ export const Tags = >({ return null; } + const tagSchema = schema.properties?.[rjstContext.tagField]; + return ( @@ -126,6 +128,11 @@ export const Tags = >({ itemType={rjstContext.resource} titleField={getItemName ?? (rjstContext.nameField as keyof T)} tagField={rjstContext.tagField as keyof T} + tagSchema={ + tagSchema != null && typeof tagSchema === 'object' + ? tagSchema + : undefined + } done={async (tagSubmitInfo) => { await changeTags(tagSubmitInfo); onDone(); diff --git a/src/components/TagManagementDialog/AddTagForm.tsx b/src/components/TagManagementDialog/AddTagForm.tsx index c9f0086e..8585c7bf 100644 --- a/src/components/TagManagementDialog/AddTagForm.tsx +++ b/src/components/TagManagementDialog/AddTagForm.tsx @@ -4,8 +4,8 @@ import find from 'lodash/find'; import startsWith from 'lodash/startsWith'; import isEmpty from 'lodash/isEmpty'; import { Button, Stack, TextField, Typography } from '@mui/material'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; import { Callout } from '../Callout'; -import type { TFunction } from '../../hooks/useTranslations'; import { useTranslation } from '../../hooks/useTranslations'; import { stopKeyDownEvent, @@ -21,21 +21,41 @@ const RESERVED_NAMESPACES = ['io.resin.', 'io.balena.']; const newTagValidationRules = ( t: ReturnType['t'], - key: string, + schema: JSONSchema | undefined, existingTags: Array>, -) => { + key: string, + value: string, +): Array<{ + test: () => boolean; + field: 'tag_key' | 'value'; + message: string; +}> => { + const tagKeySchema = schema?.properties?.tag_key; + const tagValueSchema = schema?.properties?.value; + const tagKeyMaxLength = + tagKeySchema != null && typeof tagKeySchema === 'object' + ? tagKeySchema.maxLength + : null; + const tagValueMaxLength = + tagValueSchema != null && typeof tagValueSchema === 'object' + ? tagValueSchema.maxLength + : null; + return [ { test: () => !key || isEmpty(key), + field: 'tag_key', message: t('fields_errors.tag_name_cannot_be_empty'), }, { test: () => /\s/.test(key), + field: 'tag_key', message: t('fields_errors.tag_names_cannot_contain_whitespace'), }, { test: () => RESERVED_NAMESPACES.some((reserved) => startsWith(key, reserved)), + field: 'tag_key', message: t(`fields_errors.some_tag_keys_are_reserved`, { namespace: RESERVED_NAMESPACES.join(', '), }), @@ -45,14 +65,44 @@ const newTagValidationRules = ( existingTags.some( (tag) => tag.state !== 'deleted' && tag.tag_key === key, ), + field: 'tag_key', message: t('fields_errors.tag_with_same_name_exists'), }, + ...(tagKeyMaxLength != null + ? ([ + { + test: () => key.length > tagKeyMaxLength, + field: 'tag_key', + message: t( + 'fields_errors.tag_name_cannot_longer_than_maximum_characters', + { maximum: tagKeyMaxLength }, + ), + }, + ] satisfies ReturnType) + : []), + ...(tagValueMaxLength != null + ? ([ + { + test: () => value.length > tagValueMaxLength, + field: 'value', + message: t( + 'fields_errors.tag_value_cannot_longer_than_maximum_characters', + { maximum: tagValueMaxLength }, + ), + }, + ] satisfies ReturnType) + : []), ]; }; interface AddTagFormProps { - t: TFunction; itemType: string; + /** + * This is atm only used for constraint validation, + * but in the future it would be great if this becomes mandatory + * and we use an autogenerated form. + */ + schema?: JSONSchema; existingTags: Array>; overridableTags?: Array>; addTag: (tag: ResourceTagInfo) => void; @@ -60,6 +110,7 @@ interface AddTagFormProps { export const AddTagForm = ({ itemType, + schema, existingTags, overridableTags = [], addTag, @@ -67,8 +118,8 @@ export const AddTagForm = ({ const { t } = useTranslation(); const [tagKey, setTagKey] = React.useState(''); const [value, setValue] = React.useState(''); - const [tagKeyIsInvalid, setTagKeyIsInvalid] = React.useState(false); - const [error, setError] = React.useState<{ message: string }>(); + const [error, setError] = + React.useState[number]>(); const [canSubmit, setCanSubmit] = React.useState(false); const [confirmationDialogOptions, setConfirmationDialogOptions] = React.useState(); @@ -79,13 +130,16 @@ export const AddTagForm = ({ const formUuid = `add-tag-form-${formId}`; const checkNewTagValidity = (key: string) => { - const failedRule = newTagValidationRules(t, key, existingTags).find( - (rule) => rule.test(), - ); + const failedRule = newTagValidationRules( + t, + schema, + existingTags, + key, + value, + ).find((rule) => rule.test()); const hasErrors = !!failedRule; - setTagKeyIsInvalid(hasErrors); setError(failedRule); setCanSubmit(!hasErrors); return hasErrors; @@ -143,7 +197,6 @@ export const AddTagForm = ({ setTagKey(''); setValue(''); - setTagKeyIsInvalid(false); setError(undefined); setCanSubmit(false); @@ -180,7 +233,7 @@ export const AddTagForm = ({ checkNewTagValidity(e.target.value); }} value={tagKey} - error={tagKeyIsInvalid} + error={error?.field === 'tag_key'} placeholder={t('labels.tag_name')} /> @@ -196,6 +249,7 @@ export const AddTagForm = ({ setValue(e.target.value); }} value={value} + error={error?.field === 'value'} placeholder={t('labels.value')} /> diff --git a/src/components/TagManagementDialog/index.tsx b/src/components/TagManagementDialog/index.tsx index 4ed867f7..a9ca93c0 100644 --- a/src/components/TagManagementDialog/index.tsx +++ b/src/components/TagManagementDialog/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt'; import { faUndo } from '@fortawesome/free-solid-svg-icons/faUndo'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import type { JSONSchema7 as JSONSchema } from 'json-schema'; import { AddTagForm } from './AddTagForm'; import type { ResourceTagInfo, @@ -141,6 +142,8 @@ export interface TagManagementDialogProps { titleField: keyof T | ((item: T) => string); /** Tags property in the selected item */ tagField: keyof T; + /** The schema of the tag resource */ + tagSchema?: JSONSchema; /** On cancel press event */ cancel: () => void; /** On done press event */ @@ -154,6 +157,7 @@ export const TagManagementDialog = ({ itemType, titleField, tagField, + tagSchema, cancel, done, }: TagManagementDialogProps) => { @@ -163,6 +167,12 @@ export const TagManagementDialog = ({ const [partialTags, setPartialTags] = React.useState>>(); + const tagValueSchema = tagSchema?.properties?.value; + const tagValueMaxLength = + tagValueSchema != null && typeof tagValueSchema === 'object' + ? tagValueSchema.maxLength + : null; + const tagDiffs = React.useMemo( () => getResourceTagSubmitInfo(tags ?? []), [tags], @@ -295,10 +305,10 @@ export const TagManagementDialog = ({ itemType={itemType} + schema={tagSchema} existingTags={tags} overridableTags={partialTags} addTag={addTag} - t={t} /> @@ -394,6 +404,11 @@ export const TagManagementDialog = ({ }} value={editingTag.value} placeholder={t('labels.tag_value')} + inputProps={ + tagValueMaxLength != null + ? { maxLength: tagValueMaxLength } + : undefined + } /> )} diff --git a/src/hooks/useTranslations.ts b/src/hooks/useTranslations.ts index 96ffbbda..e2e980cf 100644 --- a/src/hooks/useTranslations.ts +++ b/src/hooks/useTranslations.ts @@ -49,6 +49,8 @@ const translationMap = { 'fields_errors.tag_name_cannot_be_empty': "The tag name can't be empty.", 'fields_errors.tag_names_cannot_contain_whitespace': 'Tag names cannot contain whitespace', + 'fields_errors.tag_name_cannot_longer_than_maximum_characters': `The tag name can't be longer than {{maximum}} characters.`, + 'fields_errors.tag_value_cannot_longer_than_maximum_characters': `The tag value can't be longer than {{maximum}} characters.`, 'fields_errors.some_tag_keys_are_reserved': 'Tag names beginning with {{namespace}} are reserved', 'fields_errors.tag_with_same_name_exists':