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

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

@@ -4,12 +4,10 @@
import {shallow} from 'enzyme'; import {shallow} from 'enzyme';
import type {ReactWrapper} from 'enzyme'; import type {ReactWrapper} from 'enzyme';
import React from 'react'; import React from 'react';
import {FormattedMessage} from 'react-intl';
import DisplayName from 'components/create_team/components/display_name'; import DisplayName from 'components/create_team/components/display_name';
import {mountWithIntl} from 'tests/helpers/intl-test-helper'; import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import Constants from 'utils/constants';
import {cleanUpUrlable} from 'utils/url'; import {cleanUpUrlable} from 'utils/url';
jest.mock('images/logo.png', () => 'logo.png'); 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')).toHaveBeenCalledWith(defaultProps.state);
expect(wrapper.prop('updateParent').mock.calls[0][0]).toEqual(newState); 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 {trackEvent} from 'actions/telemetry_actions.jsx';
import Input from 'components/widgets/inputs/input/input';
import logoImage from 'images/logo.png'; import logoImage from 'images/logo.png';
import Constants from 'utils/constants'; import Constants from 'utils/constants';
import {cleanUpUrlable} from 'utils/url'; import {cleanUpUrlable} from 'utils/url';
@@ -32,15 +34,11 @@ type Props = {
type State = { type State = {
teamDisplayName: string; teamDisplayName: string;
nameError?: React.ReactNode;
} }
export default class TeamSignupDisplayNamePage extends React.PureComponent<Props, State> { export default class TeamSignupDisplayNamePage extends React.PureComponent<Props, State> {
teamNameInput: React.RefObject<HTMLInputElement>;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.teamNameInput = React.createRef();
this.state = { this.state = {
teamDisplayName: this.props.state.team?.display_name || '', 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'); 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 => { submitNext = (e: React.MouseEvent): void => {
if (!this.isValidTeamName()) {
return;
}
e.preventDefault(); e.preventDefault();
trackEvent('display_name', 'click_next'); trackEvent('display_name', 'click_next');
const displayName = this.state.teamDisplayName.trim(); 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; const newState = this.props.state;
newState.wizard = 'team_url'; newState.wizard = 'team_url';
@@ -86,31 +69,11 @@ export default class TeamSignupDisplayNamePage extends React.PureComponent<Props
this.props.updateParent(newState); this.props.updateParent(newState);
}; };
handleFocus = (e: React.FocusEvent<HTMLInputElement>): void => {
e.preventDefault();
e.currentTarget.select();
};
handleDisplayNameChange = (e: React.ChangeEvent<HTMLInputElement>): void => { handleDisplayNameChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({teamDisplayName: e.target.value}); this.setState({teamDisplayName: e.target.value});
}; };
render(): React.ReactNode { 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 ( return (
<div> <div>
<form> <form>
@@ -126,26 +89,23 @@ export default class TeamSignupDisplayNamePage extends React.PureComponent<Props
defaultMessage='Team Name' defaultMessage='Team Name'
/> />
</label> </label>
<div className={nameDivClass}> <div className='form-group'>
<div className='row'> <div className='row'>
<div className='col-sm-9'> <div className='col-sm-9'>
<input <Input
id='teamNameInput' id='teamNameInput'
name='teamNameInput'
type='text' type='text'
ref={this.teamNameInput}
className='form-control'
placeholder=''
maxLength={128}
value={this.state.teamDisplayName} value={this.state.teamDisplayName}
autoFocus={true} autoFocus={true}
onFocus={this.handleFocus}
onChange={this.handleDisplayNameChange} onChange={this.handleDisplayNameChange}
required={true}
maxLength={Constants.MAX_TEAMNAME_LENGTH}
minLength={Constants.MIN_TEAMNAME_LENGTH}
spellCheck='false' spellCheck='false'
aria-describedby='teamNameInputError'
/> />
</div> </div>
</div> </div>
{nameError}
</div> </div>
<div> <div>
<FormattedMessage <FormattedMessage
@@ -158,6 +118,7 @@ export default class TeamSignupDisplayNamePage extends React.PureComponent<Props
type='submit' type='submit'
className='btn btn-primary mt-8' className='btn btn-primary mt-8'
onClick={this.submitNext} onClick={this.submitNext}
disabled={!this.isValidTeamName()}
> >
<FormattedMessage <FormattedMessage
id='create_team.display_name.next' id='create_team.display_name.next'

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

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

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

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

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

@@ -61,7 +61,7 @@ describe('components/widgets/inputs/Input', () => {
}); });
describe('minLength validation', () => { 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( renderWithContext(
<Input <Input
value={''} 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 // Check for error styling
const fieldset = screen.getByRole('group'); const fieldset = screen.getByRole('group');
expect(fieldset).toHaveClass('Input_fieldset___error'); expect(fieldset).toHaveClass('Input_fieldset___error');
@@ -96,6 +103,8 @@ describe('components/widgets/inputs/Input', () => {
// Then type the new value // Then type the new value
userEvent.type(inputElement, 'a'); userEvent.type(inputElement, 'a');
inputElement.blur();
}); });
// Check for error styling // 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 // Find the input and blur it to trigger validation
const inputElement = screen.getByRole('textbox');
// Check for the -X indicator await act(async () => {
const indicator = screen.getByText('-1'); inputElement.focus();
expect(indicator).toBeInTheDocument(); inputElement.blur();
});
// Check for error styling // Check for error styling
const fieldset = screen.getByRole('group'); 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 // Check that error message is not present
expect(screen.queryByText(/Must be no more than 5 characters/i)).not.toBeInTheDocument(); 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 // Check that minLength error message is not present
expect(screen.queryByText(/Must be at least 2 characters/i)).not.toBeInTheDocument(); 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; inputClassName?: string;
limit?: number; limit?: number;
minLength?: number; minLength?: number;
showMinLengthIndicator?: boolean;
useLegend?: boolean; useLegend?: boolean;
customMessage?: CustomMessageInputType; customMessage?: CustomMessageInputType;
inputSize?: SIZE; inputSize?: SIZE;
@@ -65,7 +64,6 @@ const Input = React.forwardRef((
inputClassName, inputClassName,
limit, limit,
minLength, minLength,
showMinLengthIndicator = false,
customMessage, customMessage,
maxLength, maxLength,
inputSize = SIZE.MEDIUM, inputSize = SIZE.MEDIUM,
@@ -157,6 +155,7 @@ const Input = React.forwardRef((
const validationError = validate(value); const validationError = validate(value);
if (validationError) { if (validationError) {
setCustomInputLabel(validationError); setCustomInputLabel(validationError);
return;
} }
} }
@@ -165,32 +164,35 @@ const Input = React.forwardRef((
if (required && (value === null || value === '')) { if (required && (value === null || value === '')) {
const validationErrorMsg = formatMessage({id: 'widget.input.required', defaultMessage: 'This field is required'}); const validationErrorMsg = formatMessage({id: 'widget.input.required', defaultMessage: 'This field is required'});
setCustomInputLabel({type: ItemStatus.ERROR, value: validationErrorMsg}); 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 showLegend = Boolean(focused || value);
const error = customInputLabel?.type === ItemStatus.ERROR; const error = customInputLabel?.type === ItemStatus.ERROR;
const warning = customInputLabel?.type === ItemStatus.WARNING; 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 ? ( const clearButton = value && clearable ? (
<div <div
@@ -220,7 +222,7 @@ const Input = React.forwardRef((
placeholder={placeholderValue} placeholder={placeholderValue}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-describedby={customInputLabel ? errorId : undefined} aria-describedby={customInputLabel ? errorId : undefined}
aria-invalid={error || hasError || limitExceeded > 0} aria-invalid={error || hasError}
rows={3} rows={3}
name={name} name={name}
disabled={disabled} disabled={disabled}
@@ -240,7 +242,7 @@ const Input = React.forwardRef((
placeholder={placeholderValue} placeholder={placeholderValue}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-describedby={customInputLabel ? errorId : undefined} aria-describedby={customInputLabel ? errorId : undefined}
aria-invalid={error || hasError || limitExceeded > 0} aria-invalid={error || hasError}
name={name} name={name}
disabled={disabled} disabled={disabled}
{...otherProps} {...otherProps}
@@ -256,7 +258,7 @@ const Input = React.forwardRef((
<div className={classNames('Input_container', containerClassName, {disabled})}> <div className={classNames('Input_container', containerClassName, {disabled})}>
<fieldset <fieldset
className={classNames('Input_fieldset', className, { 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, Input_fieldset___legend: showLegend,
})} })}
> >
@@ -269,23 +271,13 @@ const Input = React.forwardRef((
{inputPrefix} {inputPrefix}
{textPrefix && <span>{textPrefix}</span>} {textPrefix && <span>{textPrefix}</span>}
{generateInput()} {generateInput()}
{limitExceeded > 0 && (
<span className='Input_limit-exceeded'>
{'-'}{limitExceeded}
</span>
)}
{Boolean(isMinLengthError && showMinLengthIndicator) && (
<span className='Input_limit-exceeded'>
{'+'}{minLengthNotMet}
</span>
)}
{inputSuffix} {inputSuffix}
{clearButton} {clearButton}
</div> </div>
{addon} {addon}
</fieldset> </fieldset>
{/* Display custom or derived error messages */} {/* Display custom or derived error messages */}
{(customInputLabel || derivedErrorMessage) && ( {customInputLabel && (
<div <div
className={`Input___customMessage Input___${customInputLabel?.type || 'error'}`} className={`Input___customMessage Input___${customInputLabel?.type || 'error'}`}
id={errorId} id={errorId}
@@ -298,8 +290,10 @@ const Input = React.forwardRef((
'icon-information-outline': (customInputLabel?.type || 'error') === ItemStatus.INFO, 'icon-information-outline': (customInputLabel?.type || 'error') === ItemStatus.INFO,
'icon-check': (customInputLabel?.type || 'error') === ItemStatus.SUCCESS, '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>
)} )}
</div> </div>

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

@@ -3838,10 +3838,8 @@
"create_post.write": "Write to {channelDisplayName}", "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.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.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.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.next": "Next",
"create_team.display_name.required": "This field is required",
"create_team.display_name.teamName": "Team Name", "create_team.display_name.teamName": "Team Name",
"create_team.pageTitle": "Create a team - {siteName}", "create_team.pageTitle": "Create a team - {siteName}",
"create_team.team_url.back": "Back to previous step", "create_team.team_url.back": "Back to previous step",