[MM-63021] Modify input to have min/max length validation work the same as the validation around required, replace Create Team input with Input component (#31406)

Этот коммит содержится в:
Devin Binnie
2025-06-16 17:57:02 -04:00
коммит произвёл GitHub
родитель 548a47ae56
Коммит b99a22f175
8 изменённых файлов: 72 добавлений и 242 удалений

Просмотреть файл

@@ -26,15 +26,14 @@ exports[`/components/create_team/components/display_name should match snapshot 1
<div
className="col-sm-9"
>
<input
aria-describedby="teamNameInputError"
<ForwardRef
autoFocus={true}
className="form-control"
id="teamNameInput"
maxLength={128}
maxLength={64}
minLength={2}
name="teamNameInput"
onChange={[Function]}
onFocus={[Function]}
placeholder=""
required={true}
spellCheck="false"
type="text"
value="test-team"
@@ -50,6 +49,7 @@ exports[`/components/create_team/components/display_name should match snapshot 1
</div>
<button
className="btn btn-primary mt-8"
disabled={false}
id="teamNameNextButton"
onClick={[Function]}
type="submit"

Просмотреть файл

@@ -4,12 +4,10 @@
import {shallow} from 'enzyme';
import type {ReactWrapper} from 'enzyme';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import DisplayName from 'components/create_team/components/display_name';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import Constants from 'utils/constants';
import {cleanUpUrlable} from 'utils/url';
jest.mock('images/logo.png', () => 'logo.png');
@@ -73,115 +71,4 @@ describe('/components/create_team/components/display_name', () => {
expect(wrapper.prop('updateParent')).toHaveBeenCalledWith(defaultProps.state);
expect(wrapper.prop('updateParent').mock.calls[0][0]).toEqual(newState);
});
test('should display isRequired error', () => {
const wrapper = mountWithIntl(<DisplayName {...defaultProps}/>);
(wrapper.find('.form-control') as unknown as ReactWrapper<any, any, HTMLInputElement>).instance().value = '';
wrapper.find('.form-control').simulate('change');
wrapper.find('button').simulate('click', {
preventDefault: () => jest.fn(),
});
expect(wrapper.state('nameError')).toEqual(
<FormattedMessage
id='create_team.display_name.required'
defaultMessage='This field is required'
/>,
);
});
test('should display isRequired error for null team in props', () => {
const nullTeamProps = {
updateParent: jest.fn(),
state: {
wizard: 'display_name',
},
actions: {
trackEvent: jest.fn(),
},
};
const wrapper = mountWithIntl(<DisplayName {...nullTeamProps}/>);
(wrapper.find('.form-control') as unknown as ReactWrapper<any, any, HTMLInputElement>).instance().value = '';
wrapper.find('.form-control').simulate('change');
wrapper.find('button').simulate('click', {
preventDefault: () => jest.fn(),
});
expect(wrapper.state('nameError')).toEqual(
<FormattedMessage
id='create_team.display_name.required'
defaultMessage='This field is required'
/>,
);
});
test('should display isRequired error for empty team in props', () => {
const nullTeamProps = {
updateParent: jest.fn(),
state: {
team: {},
wizard: 'display_name',
},
actions: {
trackEvent: jest.fn(),
},
};
const wrapper = mountWithIntl(<DisplayName {...nullTeamProps}/>);
(wrapper.find('.form-control') as unknown as ReactWrapper<any, any, HTMLInputElement>).instance().value = '';
wrapper.find('.form-control').simulate('change');
wrapper.find('button').simulate('click', {
preventDefault: () => jest.fn(),
});
expect(wrapper.state('nameError')).toEqual(
<FormattedMessage
id='create_team.display_name.required'
defaultMessage='This field is required'
/>,
);
});
test('should display charLength error', () => {
const wrapper = mountWithIntl(<DisplayName {...defaultProps}/>);
const input = (wrapper.find('.form-control') as unknown as ReactWrapper<any, any, HTMLInputElement>).instance();
input.value = 'a'.repeat(Constants.MAX_TEAMNAME_LENGTH + 1);
wrapper.find('.form-control').simulate('change');
wrapper.find('button').simulate('click', {
preventDefault: () => jest.fn(),
});
expect(wrapper.state('nameError')).toEqual(
<FormattedMessage
id='create_team.display_name.charLength'
defaultMessage='Name must be {min} or more characters up to a maximum of {max}. You can add a longer team description later.'
values={{
min: Constants.MIN_TEAMNAME_LENGTH,
max: Constants.MAX_TEAMNAME_LENGTH,
}}
/>,
);
});
test('should focus input when validation error occurs', () => {
const wrapper = mountWithIntl(<DisplayName {...defaultProps}/>);
const input = wrapper.find('.form-control').getDOMNode() as HTMLInputElement;
const focusSpy = jest.spyOn(input, 'focus');
// Trigger validation error
input.value = '';
wrapper.find('.form-control').simulate('change');
wrapper.find('button').simulate('click', {
preventDefault: () => jest.fn(),
});
expect(focusSpy).toHaveBeenCalled();
});
});

Просмотреть файл

@@ -8,6 +8,8 @@ import type {Team} from '@mattermost/types/teams';
import {trackEvent} from 'actions/telemetry_actions.jsx';
import Input from 'components/widgets/inputs/input/input';
import logoImage from 'images/logo.png';
import Constants from 'utils/constants';
import {cleanUpUrlable} from 'utils/url';
@@ -32,15 +34,11 @@ type Props = {
type State = {
teamDisplayName: string;
nameError?: React.ReactNode;
}
export default class TeamSignupDisplayNamePage extends React.PureComponent<Props, State> {
teamNameInput: React.RefObject<HTMLInputElement>;
constructor(props: Props) {
super(props);
this.teamNameInput = React.createRef();
this.state = {
teamDisplayName: this.props.state.team?.display_name || '',
@@ -51,33 +49,18 @@ export default class TeamSignupDisplayNamePage extends React.PureComponent<Props
trackEvent('signup', 'signup_team_01_name');
}
isValidTeamName = (): boolean => {
return this.state.teamDisplayName.length >= Constants.MIN_TEAMNAME_LENGTH && this.state.teamDisplayName.length <= Constants.MAX_TEAMNAME_LENGTH;
};
submitNext = (e: React.MouseEvent): void => {
if (!this.isValidTeamName()) {
return;
}
e.preventDefault();
trackEvent('display_name', 'click_next');
const displayName = this.state.teamDisplayName.trim();
if (!displayName) {
this.setState({nameError: (
<FormattedMessage
id='create_team.display_name.required'
defaultMessage='This field is required'
/>),
});
this.teamNameInput.current?.focus();
return;
} else if (displayName.length < Constants.MIN_TEAMNAME_LENGTH || displayName.length > Constants.MAX_TEAMNAME_LENGTH) {
this.setState({nameError: (
<FormattedMessage
id='create_team.display_name.charLength'
defaultMessage='Name must be {min} or more characters up to a maximum of {max}. You can add a longer team description later.'
values={{
min: Constants.MIN_TEAMNAME_LENGTH,
max: Constants.MAX_TEAMNAME_LENGTH,
}}
/>),
});
this.teamNameInput.current?.focus();
return;
}
const newState = this.props.state;
newState.wizard = 'team_url';
@@ -86,31 +69,11 @@ export default class TeamSignupDisplayNamePage extends React.PureComponent<Props
this.props.updateParent(newState);
};
handleFocus = (e: React.FocusEvent<HTMLInputElement>): void => {
e.preventDefault();
e.currentTarget.select();
};
handleDisplayNameChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({teamDisplayName: e.target.value});
};
render(): React.ReactNode {
let nameError = null;
let nameDivClass = 'form-group';
if (this.state.nameError) {
nameError = (
<label
role='alert'
className='control-label'
id='teamNameInputError'
>
{this.state.nameError}
</label>
);
nameDivClass += ' has-error';
}
return (
<div>
<form>
@@ -126,26 +89,23 @@ export default class TeamSignupDisplayNamePage extends React.PureComponent<Props
defaultMessage='Team Name'
/>
</label>
<div className={nameDivClass}>
<div className='form-group'>
<div className='row'>
<div className='col-sm-9'>
<input
<Input
id='teamNameInput'
name='teamNameInput'
type='text'
ref={this.teamNameInput}
className='form-control'
placeholder=''
maxLength={128}
value={this.state.teamDisplayName}
autoFocus={true}
onFocus={this.handleFocus}
onChange={this.handleDisplayNameChange}
required={true}
maxLength={Constants.MAX_TEAMNAME_LENGTH}
minLength={Constants.MIN_TEAMNAME_LENGTH}
spellCheck='false'
aria-describedby='teamNameInputError'
/>
</div>
</div>
{nameError}
</div>
<div>
<FormattedMessage
@@ -158,6 +118,7 @@ export default class TeamSignupDisplayNamePage extends React.PureComponent<Props
type='submit'
className='btn btn-primary mt-8'
onClick={this.submitNext}
disabled={!this.isValidTeamName()}
>
<FormattedMessage
id='create_team.display_name.next'

Просмотреть файл

@@ -76,7 +76,6 @@ Object {
class="Input_wrapper"
>
<input
aria-invalid="false"
aria-label="Name"
class="Input form-control large"
id="input_name"
@@ -121,6 +120,8 @@ Object {
role="alert"
>
<i
aria-hidden="true"
aria-label=""
class="icon error icon-alert-circle-outline"
/>
<span>
@@ -141,7 +142,6 @@ Object {
class="Input_wrapper"
>
<input
aria-invalid="false"
aria-label="Company Name"
class="Input form-control large"
id="input_company_name"

Просмотреть файл

@@ -15,7 +15,6 @@ exports[`components/widgets/inputs/Input should match snapshot 1`] = `
class="Input_wrapper"
>
<input
aria-invalid="false"
class="Input form-control medium"
id="input_"
value=""

Просмотреть файл

@@ -61,7 +61,7 @@ describe('components/widgets/inputs/Input', () => {
});
describe('minLength validation', () => {
test('should show error styling when input is empty with minLength set', () => {
test('should show error styling when input is empty with minLength set', async () => {
renderWithContext(
<Input
value={''}
@@ -69,6 +69,13 @@ describe('components/widgets/inputs/Input', () => {
/>,
);
// Find the input and blur it to trigger validation
const inputElement = screen.getByRole('textbox');
await act(async () => {
inputElement.focus();
inputElement.blur();
});
// Check for error styling
const fieldset = screen.getByRole('group');
expect(fieldset).toHaveClass('Input_fieldset___error');
@@ -96,6 +103,8 @@ describe('components/widgets/inputs/Input', () => {
// Then type the new value
userEvent.type(inputElement, 'a');
inputElement.blur();
});
// Check for error styling
@@ -142,11 +151,12 @@ describe('components/widgets/inputs/Input', () => {
/>,
);
// With 6 characters and limit of 5, there should be an error
// Check for the -X indicator
const indicator = screen.getByText('-1');
expect(indicator).toBeInTheDocument();
// Find the input and blur it to trigger validation
const inputElement = screen.getByRole('textbox');
await act(async () => {
inputElement.focus();
inputElement.blur();
});
// Check for error styling
const fieldset = screen.getByRole('group');
@@ -164,10 +174,6 @@ describe('components/widgets/inputs/Input', () => {
/>,
);
// With exactly 5 characters and limit of 5, there should be no error
// Check that the -X indicator is not present
expect(screen.queryByText(/-\d+/)).not.toBeInTheDocument();
// Check that error message is not present
expect(screen.queryByText(/Must be no more than 5 characters/i)).not.toBeInTheDocument();
});
@@ -251,20 +257,5 @@ describe('components/widgets/inputs/Input', () => {
// Check that minLength error message is not present
expect(screen.queryByText(/Must be at least 2 characters/i)).not.toBeInTheDocument();
});
test('should show both minLength indicator and limit indicator when applicable', () => {
renderWithContext(
<Input
value={'abc'}
minLength={5}
limit={10}
showMinLengthIndicator={true}
/>,
);
// Check for the +X indicator for minLength
const indicator = screen.getByText('+2');
expect(indicator).toBeInTheDocument();
});
});
});

Просмотреть файл

@@ -36,7 +36,6 @@ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
inputClassName?: string;
limit?: number;
minLength?: number;
showMinLengthIndicator?: boolean;
useLegend?: boolean;
customMessage?: CustomMessageInputType;
inputSize?: SIZE;
@@ -65,7 +64,6 @@ const Input = React.forwardRef((
inputClassName,
limit,
minLength,
showMinLengthIndicator = false,
customMessage,
maxLength,
inputSize = SIZE.MEDIUM,
@@ -157,6 +155,7 @@ const Input = React.forwardRef((
const validationError = validate(value);
if (validationError) {
setCustomInputLabel(validationError);
return;
}
}
@@ -165,32 +164,35 @@ const Input = React.forwardRef((
if (required && (value === null || value === '')) {
const validationErrorMsg = formatMessage({id: 'widget.input.required', defaultMessage: 'This field is required'});
setCustomInputLabel({type: ItemStatus.ERROR, value: validationErrorMsg});
return;
}
const limitExceeded = limit && value && !Array.isArray(value) ? value.toString().length - limit : 0;
const minLengthNotMet = minLength && value !== undefined && !Array.isArray(value) ? minLength - value.toString().length : (minLength || 0);
// Show min length error even when the input is empty (to match existing behavior in tests)
// Generate derived error messages
if (limitExceeded > 0) {
setCustomInputLabel({
type: ItemStatus.ERROR,
value: formatMessage(
{id: 'widget.input.max_length', defaultMessage: 'Must be no more than {limit} characters'},
{limit},
)});
} else if (minLengthNotMet > 0) {
setCustomInputLabel({
type: ItemStatus.ERROR,
value: formatMessage(
{id: 'widget.input.min_length', defaultMessage: 'Must be at least {minLength} characters'},
{minLength},
),
});
}
};
const showLegend = Boolean(focused || value);
const error = customInputLabel?.type === ItemStatus.ERROR;
const warning = customInputLabel?.type === ItemStatus.WARNING;
const limitExceeded = limit && value && !Array.isArray(value) ? value.toString().length - limit : 0;
const minLengthNotMet = minLength && value !== undefined && !Array.isArray(value) ? minLength - value.toString().length : (minLength || 0);
// Show min length error even when the input is empty (to match existing behavior in tests)
const isMinLengthError = minLengthNotMet > 0;
const isMaxLengthError = limitExceeded > 0;
// Generate derived error messages
let derivedErrorMessage: React.ReactNode | null = null;
if (isMaxLengthError && !customInputLabel) {
derivedErrorMessage = formatMessage(
{id: 'widget.input.max_length', defaultMessage: 'Must be no more than {limit} characters'},
{limit},
);
} else if (isMinLengthError && !customInputLabel) {
derivedErrorMessage = formatMessage(
{id: 'widget.input.min_length', defaultMessage: 'Must be at least {minLength} characters'},
{minLength},
);
}
const clearButton = value && clearable ? (
<div
@@ -220,7 +222,7 @@ const Input = React.forwardRef((
placeholder={placeholderValue}
aria-label={ariaLabel}
aria-describedby={customInputLabel ? errorId : undefined}
aria-invalid={error || hasError || limitExceeded > 0}
aria-invalid={error || hasError}
rows={3}
name={name}
disabled={disabled}
@@ -240,7 +242,7 @@ const Input = React.forwardRef((
placeholder={placeholderValue}
aria-label={ariaLabel}
aria-describedby={customInputLabel ? errorId : undefined}
aria-invalid={error || hasError || limitExceeded > 0}
aria-invalid={error || hasError}
name={name}
disabled={disabled}
{...otherProps}
@@ -256,7 +258,7 @@ const Input = React.forwardRef((
<div className={classNames('Input_container', containerClassName, {disabled})}>
<fieldset
className={classNames('Input_fieldset', className, {
Input_fieldset___error: hasError || limitExceeded > 0 || isMinLengthError || customInputLabel?.type === 'error',
Input_fieldset___error: hasError || customInputLabel?.type === 'error',
Input_fieldset___legend: showLegend,
})}
>
@@ -269,23 +271,13 @@ const Input = React.forwardRef((
{inputPrefix}
{textPrefix && <span>{textPrefix}</span>}
{generateInput()}
{limitExceeded > 0 && (
<span className='Input_limit-exceeded'>
{'-'}{limitExceeded}
</span>
)}
{Boolean(isMinLengthError && showMinLengthIndicator) && (
<span className='Input_limit-exceeded'>
{'+'}{minLengthNotMet}
</span>
)}
{inputSuffix}
{clearButton}
</div>
{addon}
</fieldset>
{/* Display custom or derived error messages */}
{(customInputLabel || derivedErrorMessage) && (
{customInputLabel && (
<div
className={`Input___customMessage Input___${customInputLabel?.type || 'error'}`}
id={errorId}
@@ -298,8 +290,10 @@ const Input = React.forwardRef((
'icon-information-outline': (customInputLabel?.type || 'error') === ItemStatus.INFO,
'icon-check': (customInputLabel?.type || 'error') === ItemStatus.SUCCESS,
})}
aria-hidden={Boolean(customInputLabel.value)}
aria-label={customInputLabel.value ? '' : customInputLabel.type || 'error'}
/>
<span>{customInputLabel?.value || derivedErrorMessage}</span>
<span>{customInputLabel?.value}</span>
</div>
)}
</div>

Просмотреть файл

@@ -3838,10 +3838,8 @@
"create_post.write": "Write to {channelDisplayName}",
"create_team.createTeamRestricted.message": "Your workspace plan has reached the limit on the number of teams. Create unlimited teams with a free 30-day trial. Contact your System Administrator.",
"create_team.createTeamRestricted.title": "Professional feature",
"create_team.display_name.charLength": "Name must be {min} or more characters up to a maximum of {max}. You can add a longer team description later.",
"create_team.display_name.nameHelp": "Name your team in any language. Your team name shows in menus and headings.",
"create_team.display_name.next": "Next",
"create_team.display_name.required": "This field is required",
"create_team.display_name.teamName": "Team Name",
"create_team.pageTitle": "Create a team - {siteName}",
"create_team.team_url.back": "Back to previous step",