Skip to content

Commit 2d063da

Browse files
committed
TagManagement: Add support for defining max field size constraints
Change-type: minor See: https://balena.fibery.io/Work/Project/re-pitching-API-Limit-size-of-large-fields-audit-the-outliers-975
1 parent 7dbbf39 commit 2d063da

File tree

4 files changed

+91
-13
lines changed

4 files changed

+91
-13
lines changed

src/components/RJST/Actions/Tags.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,20 @@ export const Tags = <T extends RJSTBaseResource<T>>({
119119
return null;
120120
}
121121

122+
const tagSchema = schema.properties?.[rjstContext.tagField];
123+
122124
return (
123125
<Spinner show={isPending} sx={{ width: '100%', height: '100%' }}>
124126
<TagManagementDialog<T>
125127
items={items}
126128
itemType={rjstContext.resource}
127129
titleField={getItemName ?? (rjstContext.nameField as keyof T)}
128130
tagField={rjstContext.tagField as keyof T}
131+
tagSchema={
132+
tagSchema != null && typeof tagSchema === 'object'
133+
? tagSchema
134+
: undefined
135+
}
129136
done={async (tagSubmitInfo) => {
130137
await changeTags(tagSubmitInfo);
131138
onDone();

src/components/TagManagementDialog/AddTagForm.tsx

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import find from 'lodash/find';
44
import startsWith from 'lodash/startsWith';
55
import isEmpty from 'lodash/isEmpty';
66
import { Button, Stack, TextField, Typography } from '@mui/material';
7+
import type { JSONSchema7 as JSONSchema } from 'json-schema';
78
import { Callout } from '../Callout';
8-
import type { TFunction } from '../../hooks/useTranslations';
99
import { useTranslation } from '../../hooks/useTranslations';
1010
import {
1111
stopKeyDownEvent,
@@ -21,21 +21,41 @@ const RESERVED_NAMESPACES = ['io.resin.', 'io.balena.'];
2121

2222
const newTagValidationRules = <T extends object>(
2323
t: ReturnType<typeof useTranslation>['t'],
24-
key: string,
24+
schema: JSONSchema | undefined,
2525
existingTags: Array<ResourceTagInfo<T>>,
26-
) => {
26+
key: string,
27+
value: string,
28+
): Array<{
29+
test: () => boolean;
30+
field: 'tag_key' | 'value';
31+
message: string;
32+
}> => {
33+
const tagKeySchema = schema?.properties?.tag_key;
34+
const tagValueSchema = schema?.properties?.value;
35+
const tagKeyMaxLength =
36+
tagKeySchema != null && typeof tagKeySchema === 'object'
37+
? tagKeySchema.maxLength
38+
: null;
39+
const tagValueMaxLength =
40+
tagValueSchema != null && typeof tagValueSchema === 'object'
41+
? tagValueSchema.maxLength
42+
: null;
43+
2744
return [
2845
{
2946
test: () => !key || isEmpty(key),
47+
field: 'tag_key',
3048
message: t('fields_errors.tag_name_cannot_be_empty'),
3149
},
3250
{
3351
test: () => /\s/.test(key),
52+
field: 'tag_key',
3453
message: t('fields_errors.tag_names_cannot_contain_whitespace'),
3554
},
3655
{
3756
test: () =>
3857
RESERVED_NAMESPACES.some((reserved) => startsWith(key, reserved)),
58+
field: 'tag_key',
3959
message: t(`fields_errors.some_tag_keys_are_reserved`, {
4060
namespace: RESERVED_NAMESPACES.join(', '),
4161
}),
@@ -45,30 +65,61 @@ const newTagValidationRules = <T extends object>(
4565
existingTags.some(
4666
(tag) => tag.state !== 'deleted' && tag.tag_key === key,
4767
),
68+
field: 'tag_key',
4869
message: t('fields_errors.tag_with_same_name_exists'),
4970
},
71+
...(tagKeyMaxLength != null
72+
? ([
73+
{
74+
test: () => key.length > tagKeyMaxLength,
75+
field: 'tag_key',
76+
message: t(
77+
'fields_errors.tag_name_cannot_longer_than_maximum_characters',
78+
{ maximum: tagKeyMaxLength },
79+
),
80+
},
81+
] satisfies ReturnType<typeof newTagValidationRules>)
82+
: []),
83+
...(tagValueMaxLength != null
84+
? ([
85+
{
86+
test: () => value.length > tagValueMaxLength,
87+
field: 'value',
88+
message: t(
89+
'fields_errors.tag_value_cannot_longer_than_maximum_characters',
90+
{ maximum: tagValueMaxLength },
91+
),
92+
},
93+
] satisfies ReturnType<typeof newTagValidationRules>)
94+
: []),
5095
];
5196
};
5297

5398
interface AddTagFormProps<T> {
54-
t: TFunction;
5599
itemType: string;
100+
/**
101+
* This is atm only used for constraint validation,
102+
* but in the future it would be great if this becomes mandatory
103+
* and we use an autogenerated form.
104+
*/
105+
schema?: JSONSchema;
56106
existingTags: Array<ResourceTagInfo<T>>;
57107
overridableTags?: Array<ResourceTagInfo<T>>;
58108
addTag: (tag: ResourceTagInfo<T>) => void;
59109
}
60110

61111
export const AddTagForm = <T extends object>({
62112
itemType,
113+
schema,
63114
existingTags,
64115
overridableTags = [],
65116
addTag,
66117
}: AddTagFormProps<T>) => {
67118
const { t } = useTranslation();
68119
const [tagKey, setTagKey] = React.useState('');
69120
const [value, setValue] = React.useState('');
70-
const [tagKeyIsInvalid, setTagKeyIsInvalid] = React.useState(false);
71-
const [error, setError] = React.useState<{ message: string }>();
121+
const [error, setError] =
122+
React.useState<ReturnType<typeof newTagValidationRules>[number]>();
72123
const [canSubmit, setCanSubmit] = React.useState(false);
73124
const [confirmationDialogOptions, setConfirmationDialogOptions] =
74125
React.useState<SimpleConfirmationDialogProps>();
@@ -79,13 +130,16 @@ export const AddTagForm = <T extends object>({
79130
const formUuid = `add-tag-form-${formId}`;
80131

81132
const checkNewTagValidity = (key: string) => {
82-
const failedRule = newTagValidationRules<T>(t, key, existingTags).find(
83-
(rule) => rule.test(),
84-
);
133+
const failedRule = newTagValidationRules<T>(
134+
t,
135+
schema,
136+
existingTags,
137+
key,
138+
value,
139+
).find((rule) => rule.test());
85140

86141
const hasErrors = !!failedRule;
87142

88-
setTagKeyIsInvalid(hasErrors);
89143
setError(failedRule);
90144
setCanSubmit(!hasErrors);
91145
return hasErrors;
@@ -143,7 +197,6 @@ export const AddTagForm = <T extends object>({
143197

144198
setTagKey('');
145199
setValue('');
146-
setTagKeyIsInvalid(false);
147200
setError(undefined);
148201
setCanSubmit(false);
149202

@@ -180,7 +233,7 @@ export const AddTagForm = <T extends object>({
180233
checkNewTagValidity(e.target.value);
181234
}}
182235
value={tagKey}
183-
error={tagKeyIsInvalid}
236+
error={error?.field === 'tag_key'}
184237
placeholder={t('labels.tag_name')}
185238
/>
186239

@@ -196,6 +249,7 @@ export const AddTagForm = <T extends object>({
196249
setValue(e.target.value);
197250
}}
198251
value={value}
252+
error={error?.field === 'value'}
199253
placeholder={t('labels.value')}
200254
/>
201255

src/components/TagManagementDialog/index.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
33
import { faUndo } from '@fortawesome/free-solid-svg-icons/faUndo';
44
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5+
import type { JSONSchema7 as JSONSchema } from 'json-schema';
56
import { AddTagForm } from './AddTagForm';
67
import type {
78
ResourceTagInfo,
@@ -141,6 +142,8 @@ export interface TagManagementDialogProps<T> {
141142
titleField: keyof T | ((item: T) => string);
142143
/** Tags property in the selected item */
143144
tagField: keyof T;
145+
/** The schema of the tag resource */
146+
tagSchema?: JSONSchema;
144147
/** On cancel press event */
145148
cancel: () => void;
146149
/** On done press event */
@@ -154,6 +157,7 @@ export const TagManagementDialog = <T extends TaggedResource>({
154157
itemType,
155158
titleField,
156159
tagField,
160+
tagSchema,
157161
cancel,
158162
done,
159163
}: TagManagementDialogProps<T>) => {
@@ -163,6 +167,12 @@ export const TagManagementDialog = <T extends TaggedResource>({
163167
const [partialTags, setPartialTags] =
164168
React.useState<Array<ResourceTagInfo<T>>>();
165169

170+
const tagValueSchema = tagSchema?.properties?.value;
171+
const tagValueMaxLength =
172+
tagValueSchema != null && typeof tagValueSchema === 'object'
173+
? tagValueSchema.maxLength
174+
: null;
175+
166176
const tagDiffs = React.useMemo(
167177
() => getResourceTagSubmitInfo(tags ?? []),
168178
[tags],
@@ -295,10 +305,10 @@ export const TagManagementDialog = <T extends TaggedResource>({
295305
<DialogContent>
296306
<AddTagForm<T>
297307
itemType={itemType}
308+
schema={tagSchema}
298309
existingTags={tags}
299310
overridableTags={partialTags}
300311
addTag={addTag}
301-
t={t}
302312
/>
303313
<Table>
304314
<TableHead>
@@ -394,6 +404,11 @@ export const TagManagementDialog = <T extends TaggedResource>({
394404
}}
395405
value={editingTag.value}
396406
placeholder={t('labels.tag_value')}
407+
inputProps={
408+
tagValueMaxLength != null
409+
? { maxLength: tagValueMaxLength }
410+
: undefined
411+
}
397412
/>
398413
)}
399414
</TableCell>

src/hooks/useTranslations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ const translationMap = {
4949
'fields_errors.tag_name_cannot_be_empty': "The tag name can't be empty.",
5050
'fields_errors.tag_names_cannot_contain_whitespace':
5151
'Tag names cannot contain whitespace',
52+
'fields_errors.tag_name_cannot_longer_than_maximum_characters': `The tag name can't be longer than {{maximum}} characters.`,
53+
'fields_errors.tag_value_cannot_longer_than_maximum_characters': `The tag value can't be longer than {{maximum}} characters.`,
5254
'fields_errors.some_tag_keys_are_reserved':
5355
'Tag names beginning with {{namespace}} are reserved',
5456
'fields_errors.tag_with_same_name_exists':

0 commit comments

Comments
 (0)