diff --git a/packages/core/admin/admin/src/content-manager/components/Hint/index.js b/packages/core/admin/admin/src/content-manager/components/Hint/index.js index 92822a9be9..a4d7daaf0e 100644 --- a/packages/core/admin/admin/src/content-manager/components/Hint/index.js +++ b/packages/core/admin/admin/src/content-manager/components/Hint/index.js @@ -1,18 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { useIntl } from 'react-intl'; import { Typography } from '@strapi/design-system/Typography'; -export const Hint = ({ id, error, name, description }) => { - const { formatMessage } = useIntl(); - const hint = description - ? formatMessage( - { id: description.id, defaultMessage: description.defaultMessage }, - { ...description.values } - ) - : ''; - - if (!hint || error) { +export const Hint = ({ id, error, name, hint }) => { + if (hint.length === 0 || error) { return null; } @@ -25,16 +16,12 @@ export const Hint = ({ id, error, name, description }) => { Hint.defaultProps = { id: undefined, - description: undefined, error: undefined, + hint: '', }; Hint.propTypes = { - description: PropTypes.shape({ - id: PropTypes.string.isRequired, - defaultMessage: PropTypes.string.isRequired, - values: PropTypes.object, - }), + hint: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), error: PropTypes.string, id: PropTypes.string, name: PropTypes.string.isRequired, diff --git a/packages/core/admin/admin/src/content-manager/components/InputUID/index.js b/packages/core/admin/admin/src/content-manager/components/InputUID/index.js index ea443a1382..2c79c74a24 100644 --- a/packages/core/admin/admin/src/content-manager/components/InputUID/index.js +++ b/packages/core/admin/admin/src/content-manager/components/InputUID/index.js @@ -23,7 +23,7 @@ import { const InputUID = ({ attribute, contentTypeUID, - description, + hint, disabled, error, intlLabel, @@ -54,13 +54,6 @@ const InputUID = ({ ) : name; - const hint = description - ? formatMessage( - { id: description.id, defaultMessage: description.defaultMessage }, - { ...description.values } - ) - : ''; - const formattedPlaceholder = placeholder ? formatMessage( { id: placeholder.id, defaultMessage: placeholder.defaultMessage }, @@ -251,11 +244,6 @@ InputUID.propTypes = { required: PropTypes.bool, }).isRequired, contentTypeUID: PropTypes.string.isRequired, - description: PropTypes.shape({ - id: PropTypes.string.isRequired, - defaultMessage: PropTypes.string.isRequired, - values: PropTypes.object, - }), disabled: PropTypes.bool, error: PropTypes.string, intlLabel: PropTypes.shape({ @@ -273,16 +261,17 @@ InputUID.propTypes = { values: PropTypes.object, }), required: PropTypes.bool, + hint: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), }; InputUID.defaultProps = { - description: undefined, disabled: false, error: undefined, labelAction: undefined, placeholder: undefined, value: '', required: false, + hint: '', }; export default InputUID; diff --git a/packages/core/admin/admin/src/content-manager/components/Wysiwyg/index.js b/packages/core/admin/admin/src/content-manager/components/Wysiwyg/index.js index d8fdbe8e3c..cdf094b577 100644 --- a/packages/core/admin/admin/src/content-manager/components/Wysiwyg/index.js +++ b/packages/core/admin/admin/src/content-manager/components/Wysiwyg/index.js @@ -31,7 +31,7 @@ const TypographyAsterisk = styled(Typography)` `; const Wysiwyg = ({ - description, + hint, disabled, error, intlLabel, @@ -167,7 +167,7 @@ const Wysiwyg = ({ {!isExpandMode && } - + {error && ( @@ -186,21 +186,17 @@ const Wysiwyg = ({ }; Wysiwyg.defaultProps = { - description: null, disabled: false, error: '', labelAction: undefined, placeholder: null, required: false, value: '', + hint: '', }; Wysiwyg.propTypes = { - description: PropTypes.shape({ - id: PropTypes.string.isRequired, - defaultMessage: PropTypes.string.isRequired, - values: PropTypes.object, - }), + hint: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), disabled: PropTypes.bool, error: PropTypes.string, intlLabel: PropTypes.shape({ diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index 284fff154d..80e8ffdc46 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -699,6 +699,9 @@ "content-manager.form.Input.sort.field": "Enable sort on this field", "content-manager.form.Input.sort.order": "Default sort order", "content-manager.form.Input.wysiwyg": "Display as WYSIWYG", + "content-manager.form.Input.hint.text": "{min, select, undefined {} other {min. {min}}}{divider}{max, select, undefined {} other {max. {max}}}{unit}{br}{description}", + "content-manager.form.Input.hint.minMaxDivider": " / ", + "content-manager.form.Input.hint.character.unit": "{maxValue, plural, one { character} other { characters}}", "content-manager.global.displayedFields": "Displayed Fields", "content-manager.groups": "Groups", "content-manager.groups.numbered": "Groups ({number})", diff --git a/packages/core/content-manager/server/services/utils/configuration/metadatas.js b/packages/core/content-manager/server/services/utils/configuration/metadatas.js index 5033970694..406055e1b0 100644 --- a/packages/core/content-manager/server/services/utils/configuration/metadatas.js +++ b/packages/core/content-manager/server/services/utils/configuration/metadatas.js @@ -36,8 +36,9 @@ function createDefaultMetadata(schema, name) { editable: true, }; - if (isRelation(schema.attributes[name])) { - const { targetModel } = schema.attributes[name]; + const fieldAttributes = schema.attributes[name]; + if (isRelation(fieldAttributes)) { + const { targetModel } = fieldAttributes; const targetSchema = getTargetSchema(targetModel); diff --git a/packages/core/helper-plugin/lib/src/components/GenericInput/index.js b/packages/core/helper-plugin/lib/src/components/GenericInput/index.js index 1b584b0076..aa5e1910a1 100644 --- a/packages/core/helper-plugin/lib/src/components/GenericInput/index.js +++ b/packages/core/helper-plugin/lib/src/components/GenericInput/index.js @@ -9,6 +9,7 @@ import PropTypes from 'prop-types'; import parseISO from 'date-fns/parseISO'; import formatISO from 'date-fns/formatISO'; import { useIntl } from 'react-intl'; + import { Checkbox, DatePicker, @@ -24,7 +25,9 @@ import { import { Option } from '@strapi/design-system/Select'; import EyeStriked from '@strapi/icons/EyeStriked'; import Eye from '@strapi/icons/Eye'; + import NotSupported from './NotSupported'; +import useFieldHint from '../../hooks/useFieldHint'; const GenericInput = ({ autoComplete, @@ -43,9 +46,16 @@ const GenericInput = ({ type, value: defaultValue, isNullable, + attribute, ...rest }) => { const { formatMessage } = useIntl(); + + const { hint } = useFieldHint({ + description, + fieldSchema: attribute, + type: attribute?.type || type, + }); const [showPassword, setShowPassword] = useState(false); const CustomInput = customInputs ? customInputs[type] : null; @@ -91,7 +101,9 @@ const GenericInput = ({ return ( `Date picker, current is ${formattedDate}`} - selectButtonTitle={formatMessage({ id: 'selectButtonTitle', defaultMessage: 'Select' })} + selectButtonTitle={formatMessage({ + id: 'selectButtonTitle', + defaultMessage: 'Select', + })} /> ); } @@ -451,6 +459,7 @@ GenericInput.defaultProps = { options: [], step: 1, value: undefined, + attribute: null, }; GenericInput.propTypes = { @@ -461,6 +470,7 @@ GenericInput.propTypes = { defaultMessage: PropTypes.string.isRequired, values: PropTypes.object, }), + attribute: PropTypes.object, disabled: PropTypes.bool, error: PropTypes.oneOfType([ PropTypes.string, diff --git a/packages/core/helper-plugin/lib/src/hooks/useFieldHint/index.js b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/index.js new file mode 100644 index 0000000000..a5db9e2b9d --- /dev/null +++ b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/index.js @@ -0,0 +1,75 @@ +import React from 'react'; +import { useIntl } from 'react-intl'; +import { getFieldUnits, getMinMax } from './utils'; + +/** + * @description + * A hook for generating the hint for a field + * @type { + * ({ description: { id: string, defaultMessage: string }, + * type: string, + * fieldSchema: { minLength?: number|string; maxLength?: number|string; max?: number|string; min?: number|string } + * }) + * => { hint: ''|Array } + * } + */ +const useFieldHint = ({ description, fieldSchema, type }) => { + const { formatMessage } = useIntl(); + + /** + * @returns {String} + */ + const buildDescription = () => + description?.id + ? formatMessage( + { id: description.id, defaultMessage: description.defaultMessage }, + { ...description.values } + ) + : ''; + + /** + * @returns {''|Array} + */ + const buildHint = () => { + const { maximum, minimum } = getMinMax(fieldSchema); + const units = getFieldUnits({ + type, + minimum, + maximum, + }); + + const minIsNumber = typeof minimum === 'number'; + const maxIsNumber = typeof maximum === 'number'; + const hasMinAndMax = maxIsNumber && minIsNumber; + const hasMinOrMax = maxIsNumber || minIsNumber; + + if (!description?.id && !hasMinOrMax) { + return ''; + } + + return formatMessage( + { + id: 'content-manager.form.Input.hint.text', + defaultMessage: + '{min, select, undefined {} other {min. {min}}}{divider}{max, select, undefined {} other {max. {max}}}{unit}{br}{description}', + }, + { + min: minimum, + max: maximum, + description: buildDescription(), + unit: units?.message && hasMinOrMax ? formatMessage(units.message, units.values) : null, + divider: hasMinAndMax + ? formatMessage({ + id: 'content-manager.form.Input.hint.minMaxDivider', + defaultMessage: ' / ', + }) + : null, + br: hasMinOrMax ?
: null, + } + ); + }; + + return { hint: buildHint() }; +}; + +export default useFieldHint; diff --git a/packages/core/helper-plugin/lib/src/hooks/useFieldHint/tests/useFieldHint.test.js b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/tests/useFieldHint.test.js new file mode 100644 index 0000000000..e27656d44e --- /dev/null +++ b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/tests/useFieldHint.test.js @@ -0,0 +1,110 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { IntlProvider } from 'react-intl'; + +import useFieldHint from '../index'; + +const messages = { 'message.id': 'response' }; +const knownDescription = { id: 'message.id', defaultMessage: '' }; + +// eslint-disable-next-line react/prop-types +export const IntlWrapper = ({ children }) => ( + + {children} + +); + +function setup(args) { + return new Promise((resolve) => { + act(() => { + resolve(renderHook(() => useFieldHint(args), { wrapper: IntlWrapper })); + }); + }); +} + +describe('useFieldHint', () => { + describe('descriptions', () => { + test('generates a known description', async () => { + const { result } = await setup({ + description: knownDescription, + }); + + expect(result.current.hint).toEqual('response'); + }); + + test('fails to generate an unknown description', async () => { + const { result } = await setup({ + description: {}, + }); + + expect(result.current.hint).toEqual(''); + }); + }); + + describe('minimum/maximum limits', () => { + test('generates a minimum limit', async () => { + const minimum = 1; + const fieldSchema = { min: minimum }; + + const { result } = await setup({ + fieldSchema, + }); + + expect(result.current.hint.length).toEqual(3); + + expect(result.current.hint[0]).toEqual(`min. ${minimum} character`); + expect(result.current.hint[2]).toEqual(''); + }); + + test('generates a maximum limit', async () => { + const maximum = 5; + const fieldSchema = { max: maximum }; + + const { result } = await setup({ + fieldSchema, + }); + + expect(result.current.hint.length).toEqual(3); + + expect(result.current.hint[0]).toEqual(`max. ${maximum} characters`); + expect(result.current.hint[2]).toEqual(''); + }); + + test('generates a minimum/maximum limits', async () => { + const minimum = 1; + const maximum = 5; + const fieldSchema = { minLength: minimum, maxLength: maximum }; + + const { result } = await setup({ + fieldSchema, + }); + + expect(result.current.hint.length).toEqual(3); + + expect(result.current.hint).toContain(`min. ${minimum} / max. ${maximum} characters`); + expect(result.current.hint[2]).toEqual(''); + }); + }); + + test('returns an empty string when there is no description or minimum and maximum limits', async () => { + const { result } = await setup({}); + + expect(result.current.hint).toEqual(''); + }); + + test('generates the description and min max hint', async () => { + const minimum = 1; + const maximum = 5; + const fieldSchema = { minLength: minimum, maxLength: maximum }; + + const { result } = await setup({ + description: knownDescription, + fieldSchema, + }); + + expect(result.current.hint.length).toEqual(3); + + expect(result.current.hint[0]).toEqual(`min. ${minimum} / max. ${maximum} characters`); + expect(result.current.hint[2]).toEqual('response'); + }); +}); diff --git a/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/getFieldUnits.js b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/getFieldUnits.js new file mode 100644 index 0000000000..8a129af6b2 --- /dev/null +++ b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/getFieldUnits.js @@ -0,0 +1,22 @@ +/** + * @type { ({ type?: string; minimum?: number; maximum: number; } ) => { + * message?: {id: string, defaultMessage: string}; values?: {maxValue: number} } } + */ +const getFieldUnits = ({ type, minimum, maximum }) => { + if (['biginteger', 'integer', 'number'].includes(type)) { + return {}; + } + const maxValue = Math.max(minimum || 0, maximum || 0); + + return { + message: { + id: 'content-manager.form.Input.hint.character.unit', + defaultMessage: '{maxValue, plural, one { character} other { characters}}', + }, + values: { + maxValue, + }, + }; +}; + +export default getFieldUnits; diff --git a/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/getMinMax.js b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/getMinMax.js new file mode 100644 index 0000000000..453092c4ba --- /dev/null +++ b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/getMinMax.js @@ -0,0 +1,38 @@ +/** + * Get the minimum and maximum limits for an input + * @type { + * (fieldSchema: { minLength?: number|string; maxLength?: number|string; max?: number|string; min?: number|string } ) + * => { maximum: number; minimum: number } } + */ +const getMinMax = (fieldSchema) => { + if (!fieldSchema) { + return { maximum: undefined, minimum: undefined }; + } + + const { minLength, maxLength, max, min } = fieldSchema; + + let minimum; + let maximum; + + const parsedMin = parseInt(min, 10); + const parsedMinLength = parseInt(minLength, 10); + + if (!Number.isNaN(parsedMin)) { + minimum = parsedMin; + } else if (!Number.isNaN(parsedMinLength)) { + minimum = parsedMinLength; + } + + const parsedMax = parseInt(max, 10); + const parsedMaxLength = parseInt(maxLength, 10); + + if (!Number.isNaN(parsedMax)) { + maximum = parsedMax; + } else if (!Number.isNaN(parsedMaxLength)) { + maximum = parsedMaxLength; + } + + return { maximum, minimum }; +}; + +export default getMinMax; diff --git a/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/index.js b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/index.js new file mode 100644 index 0000000000..9cd686e859 --- /dev/null +++ b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/index.js @@ -0,0 +1,2 @@ +export { default as getFieldUnits } from './getFieldUnits'; +export { default as getMinMax } from './getMinMax'; diff --git a/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/tests/getFieldUnits.test.js b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/tests/getFieldUnits.test.js new file mode 100644 index 0000000000..2921f28fd0 --- /dev/null +++ b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/tests/getFieldUnits.test.js @@ -0,0 +1,29 @@ +import { getFieldUnits } from '../index'; + +describe('Content Manager | Inputs | Utils', () => { + describe('getFieldUnits', () => { + it('returns for number types', () => { + expect(getFieldUnits({ type: 'number' })).toEqual({}); + }); + + it('returns for biginteger types', () => { + expect(getFieldUnits({ type: 'biginteger' })).toEqual({}); + }); + + it('returns for integer types', () => { + expect(getFieldUnits({ type: 'integer' })).toEqual({}); + }); + + it('correctly returns units translation object', () => { + expect(getFieldUnits({ type: 'text', minimum: 1, maximum: 5 })).toEqual({ + message: { + id: 'content-manager.form.Input.hint.character.unit', + defaultMessage: '{maxValue, plural, one { character} other { characters}}', + }, + values: { + maxValue: 5, + }, + }); + }); + }); +}); diff --git a/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/tests/getMinMax.test.js b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/tests/getMinMax.test.js new file mode 100644 index 0000000000..ea36389cc3 --- /dev/null +++ b/packages/core/helper-plugin/lib/src/hooks/useFieldHint/utils/tests/getMinMax.test.js @@ -0,0 +1,50 @@ +import getMinMax from '../getMinMax'; + +describe('Content Manager | Inputs | Utils', () => { + describe('getMinMax', () => { + it('ignores a blank schema', () => { + expect(getMinMax({})).toEqual({ maximum: undefined, minimium: undefined }); + }); + + it('ignores a null schema', () => { + expect(getMinMax(null)).toEqual({ maximum: undefined, minimium: undefined }); + }); + + it('ignores values provided as strings that cannot be parsed to integers', () => { + const notANumber = 'NOT_A_NUMBER'; + const fieldSchema = { + min: notANumber, + max: notANumber, + minLength: notANumber, + maxLength: notANumber, + }; + expect(getMinMax(fieldSchema)).toEqual({ maximum: undefined, minimum: undefined }); + }); + + it('correctly parses integer values from strings', () => { + const fieldSchema = { + min: '2', + max: '5', + }; + expect(getMinMax(fieldSchema)).toEqual({ maximum: 5, minimum: 2 }); + }); + + it('returns based on minLength and maxLength values', () => { + const fieldSchema = { + minLength: 10, + maxLength: 20, + }; + + expect(getMinMax(fieldSchema)).toEqual({ maximum: 20, minimum: 10 }); + }); + + it('returns based on min and max values', () => { + const fieldSchema = { + min: 10, + max: 20, + }; + + expect(getMinMax(fieldSchema)).toEqual({ maximum: 20, minimum: 10 }); + }); + }); +});