Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/components/RJST/Actions/Tags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,20 @@ export const Tags = <T extends RJSTBaseResource<T>>({
return null;
}

const tagSchema = schema.properties?.[rjstContext.tagField];

return (
<Spinner show={isPending} sx={{ width: '100%', height: '100%' }}>
<TagManagementDialog<T>
items={items}
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();
Expand Down
78 changes: 66 additions & 12 deletions src/components/TagManagementDialog/AddTagForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,21 +21,41 @@ const RESERVED_NAMESPACES = ['io.resin.', 'io.balena.'];

const newTagValidationRules = <T extends object>(
t: ReturnType<typeof useTranslation>['t'],
key: string,
schema: JSONSchema | undefined,
existingTags: Array<ResourceTagInfo<T>>,
) => {
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(', '),
}),
Expand All @@ -45,30 +65,61 @@ const newTagValidationRules = <T extends object>(
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<typeof newTagValidationRules>)
: []),
...(tagValueMaxLength != null
? ([
{
test: () => value.length > tagValueMaxLength,
field: 'value',
message: t(
'fields_errors.tag_value_cannot_longer_than_maximum_characters',
{ maximum: tagValueMaxLength },
),
},
] satisfies ReturnType<typeof newTagValidationRules>)
: []),
];
};

interface AddTagFormProps<T> {
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<ResourceTagInfo<T>>;
overridableTags?: Array<ResourceTagInfo<T>>;
addTag: (tag: ResourceTagInfo<T>) => void;
}

export const AddTagForm = <T extends object>({
itemType,
schema,
existingTags,
overridableTags = [],
addTag,
}: AddTagFormProps<T>) => {
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<ReturnType<typeof newTagValidationRules>[number]>();
const [canSubmit, setCanSubmit] = React.useState(false);
const [confirmationDialogOptions, setConfirmationDialogOptions] =
React.useState<SimpleConfirmationDialogProps>();
Expand All @@ -79,13 +130,16 @@ export const AddTagForm = <T extends object>({
const formUuid = `add-tag-form-${formId}`;

const checkNewTagValidity = (key: string) => {
const failedRule = newTagValidationRules<T>(t, key, existingTags).find(
(rule) => rule.test(),
);
const failedRule = newTagValidationRules<T>(
t,
schema,
existingTags,
key,
value,
).find((rule) => rule.test());

const hasErrors = !!failedRule;

setTagKeyIsInvalid(hasErrors);
setError(failedRule);
setCanSubmit(!hasErrors);
return hasErrors;
Expand Down Expand Up @@ -143,7 +197,6 @@ export const AddTagForm = <T extends object>({

setTagKey('');
setValue('');
setTagKeyIsInvalid(false);
setError(undefined);
setCanSubmit(false);

Expand Down Expand Up @@ -180,7 +233,7 @@ export const AddTagForm = <T extends object>({
checkNewTagValidity(e.target.value);
}}
value={tagKey}
error={tagKeyIsInvalid}
error={error?.field === 'tag_key'}
placeholder={t('labels.tag_name')}
/>

Expand All @@ -196,6 +249,7 @@ export const AddTagForm = <T extends object>({
setValue(e.target.value);
}}
value={value}
error={error?.field === 'value'}
placeholder={t('labels.value')}
/>

Expand Down
17 changes: 16 additions & 1 deletion src/components/TagManagementDialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -141,6 +142,8 @@ export interface TagManagementDialogProps<T> {
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 */
Expand All @@ -154,6 +157,7 @@ export const TagManagementDialog = <T extends TaggedResource>({
itemType,
titleField,
tagField,
tagSchema,
cancel,
done,
}: TagManagementDialogProps<T>) => {
Expand All @@ -163,6 +167,12 @@ export const TagManagementDialog = <T extends TaggedResource>({
const [partialTags, setPartialTags] =
React.useState<Array<ResourceTagInfo<T>>>();

const tagValueSchema = tagSchema?.properties?.value;
const tagValueMaxLength =
tagValueSchema != null && typeof tagValueSchema === 'object'
? tagValueSchema.maxLength
: null;

const tagDiffs = React.useMemo(
() => getResourceTagSubmitInfo(tags ?? []),
[tags],
Expand Down Expand Up @@ -295,10 +305,10 @@ export const TagManagementDialog = <T extends TaggedResource>({
<DialogContent>
<AddTagForm<T>
itemType={itemType}
schema={tagSchema}
existingTags={tags}
overridableTags={partialTags}
addTag={addTag}
t={t}
/>
<Table>
<TableHead>
Expand Down Expand Up @@ -394,6 +404,11 @@ export const TagManagementDialog = <T extends TaggedResource>({
}}
value={editingTag.value}
placeholder={t('labels.tag_value')}
inputProps={
tagValueMaxLength != null
? { maxLength: tagValueMaxLength }
: undefined
}
/>
)}
</TableCell>
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/useTranslations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down