From 78bed4d742406d19bbd7a2605743e6e5dc8d8090 Mon Sep 17 00:00:00 2001 From: Sarath Nair <91882341+sarath-nair-sonarsource@users.noreply.github.com> Date: Tue, 17 Sep 2024 09:30:09 +0200 Subject: [PATCH] SONAR-22325 Fix a11y issues on project setting page (#11737) --- .../design-system/src/components/Switch.tsx | 6 ++- .../design-system/src/components/Text.tsx | 10 ++++- .../subnavigation/SubnavigationGroup.tsx | 7 ++-- .../settings/components/AllCategoriesList.tsx | 11 +++++- .../settings/components/AnalysisScope.tsx | 2 +- .../apps/settings/components/Definition.tsx | 28 +++++++++++--- .../settings/components/DefinitionActions.tsx | 16 ++++++-- .../components/DefinitionDescription.tsx | 4 +- .../components/SubCategoryDefinitionsList.tsx | 2 +- .../components/__tests__/Definition-it.tsx | 27 ++++++------- .../components/__tests__/Languages-it.tsx | 6 +-- .../__tests__/Authentication-Bitbucket-it.tsx | 4 +- .../apps/settings/components/inputs/Input.tsx | 13 ++++--- .../components/inputs/InputForBoolean.tsx | 9 ++++- .../inputs/InputForFormattedText.tsx | 8 +++- .../components/inputs/InputForJSON.tsx | 15 +++++++- .../components/inputs/InputForNumber.tsx | 9 ++++- .../components/inputs/InputForPassword.tsx | 10 ++++- .../components/inputs/InputForSecured.tsx | 17 +++++++-- .../inputs/InputForSingleSelectList.tsx | 8 +++- .../components/inputs/InputForString.tsx | 9 ++++- .../components/inputs/InputForText.tsx | 15 +++++++- .../components/inputs/MultiValueInput.tsx | 17 ++++++++- .../components/inputs/PrimitiveInput.tsx | 38 ++++++++++++------- .../components/inputs/PropertySetInput.tsx | 19 ++++++++-- .../components/inputs/SimpleInput.tsx | 23 ++++++++++- .../src/main/js/apps/settings/utils.ts | 3 ++ .../resources/org/sonar/l10n/core.properties | 1 + 28 files changed, 259 insertions(+), 78 deletions(-) diff --git a/server/sonar-web/design-system/src/components/Switch.tsx b/server/sonar-web/design-system/src/components/Switch.tsx index e75e76fd325..fb0aa8a389a 100644 --- a/server/sonar-web/design-system/src/components/Switch.tsx +++ b/server/sonar-web/design-system/src/components/Switch.tsx @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import styled from '@emotion/styled'; +import { ForwardedRef, forwardRef } from 'react'; import tw from 'twin.macro'; import { themeBorder, themeColor, themeContrast, themeShadow } from '../helpers'; import { CheckIcon } from './icons'; @@ -37,7 +38,7 @@ const getValue = (value: boolean | string) => { return typeof value === 'string' ? value === 'true' : value; }; -export function Switch(props: Readonly) { +function SwitchWithRef(props: Readonly, ref: ForwardedRef) { const { disabled, onChange, name, labels } = props; const value = getValue(props.value); @@ -56,6 +57,7 @@ export function Switch(props: Readonly) { disabled={disabled} name={name} onClick={handleClick} + ref={ref} role="switch" type="button" > @@ -118,3 +120,5 @@ const StyledSwitch = styled.button` active ? themeBorder('focus', 'switchActive') : themeBorder('focus', 'switch')}; } `; + +export const Switch = forwardRef(SwitchWithRef); diff --git a/server/sonar-web/design-system/src/components/Text.tsx b/server/sonar-web/design-system/src/components/Text.tsx index 1170d99bf56..da407eed159 100644 --- a/server/sonar-web/design-system/src/components/Text.tsx +++ b/server/sonar-web/design-system/src/components/Text.tsx @@ -68,20 +68,26 @@ export function PageTitle({ } export function TextError({ + as, text, className, }: Readonly<{ + as?: React.ElementType; className?: string; text: string | React.ReactNode; }>) { if (typeof text === 'string') { return ( - + {text} ); } - return {text}; + return ( + + {text} + + ); } export function TextSuccess({ text, className }: Readonly<{ className?: string; text: string }>) { diff --git a/server/sonar-web/design-system/src/components/subnavigation/SubnavigationGroup.tsx b/server/sonar-web/design-system/src/components/subnavigation/SubnavigationGroup.tsx index 0b2fb552302..797eb87d0fc 100644 --- a/server/sonar-web/design-system/src/components/subnavigation/SubnavigationGroup.tsx +++ b/server/sonar-web/design-system/src/components/subnavigation/SubnavigationGroup.tsx @@ -18,20 +18,21 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import styled from '@emotion/styled'; -import { Children, Fragment, HtmlHTMLAttributes, ReactNode } from 'react'; +import { Children, ElementType, Fragment, HtmlHTMLAttributes, ReactNode } from 'react'; import tw from 'twin.macro'; import { themeBorder, themeColor } from '../../helpers/theme'; import { isDefined } from '../../helpers/types'; interface Props extends HtmlHTMLAttributes { + as?: ElementType; children: ReactNode; className?: string; } -export function SubnavigationGroup({ className, children, ...htmlProps }: Props) { +export function SubnavigationGroup({ as, className, children, ...htmlProps }: Readonly) { const childrenArray = Children.toArray(children).filter(isDefined); return ( - + {childrenArray.map((child, index) => ( {child} diff --git a/server/sonar-web/src/main/js/apps/settings/components/AllCategoriesList.tsx b/server/sonar-web/src/main/js/apps/settings/components/AllCategoriesList.tsx index ad4cd62e690..158c5a9c7eb 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/AllCategoriesList.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/AllCategoriesList.tsx @@ -24,6 +24,7 @@ import { useNavigate } from 'react-router-dom'; import withAvailableFeatures, { WithAvailableFeaturesProps, } from '../../../app/components/available-features/withAvailableFeatures'; +import { translate } from '../../../helpers/l10n'; import { getGlobalSettingsUrl, getProjectSettingsUrl } from '../../../helpers/urls'; import { Feature } from '../../../types/features'; import { Component } from '../../../types/types'; @@ -78,12 +79,18 @@ function CategoriesList(props: Readonly) { const sortedCategories = sortBy(categoriesWithName, (category) => category.name.toLowerCase()); return ( - + {sortedCategories.map((c) => { const category = c.key !== defaultCategory ? c.key.toLowerCase() : undefined; + const isActive = c.key.toLowerCase() === selectedCategory.toLowerCase(); return ( openCategory(category)} key={c.key} > diff --git a/server/sonar-web/src/main/js/apps/settings/components/AnalysisScope.tsx b/server/sonar-web/src/main/js/apps/settings/components/AnalysisScope.tsx index f60b393aa82..1e47c8781f4 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/AnalysisScope.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/AnalysisScope.tsx @@ -48,7 +48,7 @@ export function AnalysisScope(props: AdditionalCategoryComponentProps) {
- {translate('learn_more')} + {translate('settings.analysis_scope.learn_more')}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/Definition.tsx b/server/sonar-web/src/main/js/apps/settings/components/Definition.tsx index ddb94afff83..2037998ad75 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/Definition.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/Definition.tsx @@ -33,6 +33,7 @@ import { Component } from '../../../types/types'; import { combineDefinitionAndSettingValue, getSettingValue, + getUniqueName, isDefaultOrInherited, isEmptyValue, isURLKind, @@ -59,6 +60,8 @@ export default function Definition(props: Readonly) { const [success, setSuccess] = React.useState(false); const [changedValue, setChangedValue] = React.useState(); const [validationMessage, setValidationMessage] = React.useState(); + const ref = React.useRef(null); + const name = getUniqueName(definition); const { data: loadedSettingValue, isLoading } = useGetValueQuery({ key: definition.key, @@ -67,6 +70,7 @@ export default function Definition(props: Readonly) { // WARNING: do *not* remove `?? undefined` below, it is required to change `null` to `undefined`! // (Yes, it's ugly, we really shouldn't use `null` as the fallback value in useGetValueQuery) + // prettier-ignore const settingValue = isLoading ? initialSettingValue : (loadedSettingValue ?? undefined); const { mutateAsync: resetSettingValue } = useResetSettingsMutation(); @@ -92,6 +96,7 @@ export default function Definition(props: Readonly) { setChangedValue(undefined); setLoading(false); setSuccess(true); + ref.current?.focus(); setValidationMessage(undefined); timeout.current = window.setTimeout(() => { @@ -101,6 +106,7 @@ export default function Definition(props: Readonly) { const validationMessage = await parseError(e as Response); setLoading(false); setValidationMessage(validationMessage); + ref.current?.focus(); } }; @@ -117,6 +123,7 @@ export default function Definition(props: Readonly) { } else { setValidationMessage(translate('settings.state.value_cant_be_empty')); } + ref.current?.focus(); return false; } @@ -129,6 +136,7 @@ export default function Definition(props: Readonly) { setValidationMessage( translateWithParameters('settings.state.url_not_valid', value?.toString() ?? ''), ); + ref.current?.focus(); return false; } @@ -139,6 +147,7 @@ export default function Definition(props: Readonly) { JSON.parse(value?.toString() ?? ''); } catch (e) { setValidationMessage((e as Error).message); + ref.current?.focus(); return false; } @@ -154,6 +163,7 @@ export default function Definition(props: Readonly) { if (isEmptyValue(definition, changedValue)) { setValidationMessage(translate('settings.state.value_cant_be_empty')); + ref.current?.focus(); return; } @@ -167,6 +177,7 @@ export default function Definition(props: Readonly) { setIsEditing(false); setLoading(false); setSuccess(true); + ref.current?.focus(); timeout.current = window.setTimeout(() => { setSuccess(false); @@ -175,6 +186,7 @@ export default function Definition(props: Readonly) { const validationMessage = await parseError(e as Response); setLoading(false); setValidationMessage(validationMessage); + ref.current?.focus(); } } }; @@ -189,15 +201,16 @@ export default function Definition(props: Readonly) { return (
-
setIsEditing(true)} + ref={ref} isEditing={isEditing} isInvalid={hasError} setting={settingDefinitionAndValue} @@ -206,16 +219,18 @@ export default function Definition(props: Readonly) {
{loading && ( -
- +
+ {translate('settings.state.saving')}
)} {!loading && validationMessage && ( -
+
) { )} {!loading && !hasError && success && ( - {translate('settings.state.saved')} + + {translate('settings.state.saved')} + )}
} render() { - const { setting, changedValue, isDefault, isEditing, hasValueChanged, hasError } = this.props; + const { definition, setting, changedValue, isDefault, isEditing, hasValueChanged, hasError } = + this.props; const hasBeenChangedToEmptyValue = changedValue !== undefined && isEmptyValue(setting.definition, changedValue); const showReset = hasBeenChangedToEmptyValue || (!isDefault && setting.hasValue); const showCancel = hasValueChanged || isEditing; + const propertyName = getPropertyName(definition); + const saveButtonLabel = `${translate('save')} ${propertyName}`; + const cancelButtonLabel = `${translate('cancel')} ${propertyName}`; return (
{hasValueChanged && ( )} - {showCancel && } + {showCancel && ( + + )} {showReset && ( diff --git a/server/sonar-web/src/main/js/apps/settings/components/DefinitionDescription.tsx b/server/sonar-web/src/main/js/apps/settings/components/DefinitionDescription.tsx index 342f00dde2b..69cf100f557 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/DefinitionDescription.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/DefinitionDescription.tsx @@ -35,7 +35,9 @@ export default function DefinitionDescription({ definition }: Readonly) { return (
- {propertyName} + + {propertyName} + {description && (
{displaySubCategoryTitle && ( diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/Definition-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/Definition-it.tsx index d85b7e53b5d..6c6c81735ee 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/__tests__/Definition-it.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/Definition-it.tsx @@ -50,7 +50,8 @@ const ui = { securedInput: byRole('textbox', { name: 'property.sonar.announcement.message.secured.name' }), multiValuesInput: byRole('textbox', { name: 'property.sonar.javascript.globals.name' }), urlKindInput: byRole('textbox', { name: /sonar.auth.gitlab.url/ }), - fieldsInput: (name: string) => byRole('textbox', { name: `property.${name}.name` }), + nameInput: byRole('textbox', { name: /property.name.name/ }), + valueInput: byRole('textbox', { name: /property.value.name/ }), savedMsg: byText('settings.state.saved'), validationMsg: byText(/settings.state.validation_failed/), jsonFormatStatus: byText('settings.json.format_error'), @@ -58,8 +59,8 @@ const ui = { toggleButton: byRole('switch'), selectOption: (name: string) => byRole('option', { name }), selectInput: byRole('combobox', { name: 'property.test.single.select.list.name' }), - saveButton: byRole('button', { name: 'save' }), - cancelButton: byRole('button', { name: 'cancel' }), + saveButton: byRole('button', { name: /save/ }), + cancelButton: byRole('button', { name: /cancel/ }), changeButton: byRole('button', { name: 'change_verb' }), resetButton: (name: string | RegExp = 'reset_verb') => byRole('button', { name }), deleteValueButton: byRole('button', { @@ -291,22 +292,22 @@ it('renders definition for SettingType = PROPERTY_SET and can do operations', as expect(screen.getByRole('columnheader', { name: 'Value' })).toBeInTheDocument(); // Should type new values - await user.type(ui.fieldsInput('name').get(), 'any name'); - expect(ui.fieldsInput('name').getAll()).toHaveLength(2); + await user.type(ui.nameInput.get(), 'any name'); + expect(ui.nameInput.getAll()).toHaveLength(2); // Can cancel changes await user.click(ui.cancelButton.get()); - expect(ui.fieldsInput('name').getAll()).toHaveLength(1); - expect(ui.fieldsInput('name').get()).toHaveValue(''); + expect(ui.nameInput.getAll()).toHaveLength(1); + expect(ui.nameInput.get()).toHaveValue(''); // Can save new values - await user.type(ui.fieldsInput('name').get(), 'any name'); - await user.type(ui.fieldsInput('value').getAll()[0], 'any value'); + await user.type(ui.nameInput.get(), 'any name'); + await user.type(ui.valueInput.getAll()[0], 'any value'); await user.click(ui.saveButton.get()); expect(ui.savedMsg.get()).toBeInTheDocument(); - expect(ui.fieldsInput('name').getAll()[0]).toHaveValue('any name'); - expect(ui.fieldsInput('value').getAll()[0]).toHaveValue('any value'); + expect(ui.nameInput.getAll()[0]).toHaveValue('any name'); + expect(ui.valueInput.getAll()[0]).toHaveValue('any value'); // Deleting previous value show validation message await user.click(ui.deleteFieldsButton.get()); @@ -317,8 +318,8 @@ it('renders definition for SettingType = PROPERTY_SET and can do operations', as await user.click(ui.resetButton().get()); expect(ui.savedMsg.get()).toBeInTheDocument(); - expect(ui.fieldsInput('name').get()).toHaveValue(''); - expect(ui.fieldsInput('value').get()).toHaveValue(''); + expect(ui.nameInput.get()).toHaveValue(''); + expect(ui.valueInput.get()).toHaveValue(''); }); it('renders secured definition and can do operations', async () => { diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/Languages-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/Languages-it.tsx index 80e02f2f0af..889dde318b0 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/__tests__/Languages-it.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/Languages-it.tsx @@ -53,14 +53,14 @@ const ui = { jsFileSuffixesHeading: byRole('heading', { name: 'property.sonar.javascript.file.suffixes.name', }), - jsGlobalVariablesInput: byRole('textbox', { name: 'property.sonar.javascript.globals.name' }), + jsGlobalVariablesInput: byRole('textbox', { name: /property.sonar.javascript.globals.name -/ }), jsResetGlobalVariablesButton: byRole('button', { name: 'settings.definition.reset.property.sonar.javascript.globals.name', }), validationMsg: byText('settings.state.validation_failed.A non empty value must be provided'), - saveButton: byRole('button', { name: 'save' }), - cancelButton: byRole('button', { name: 'cancel' }), + saveButton: byRole('button', { name: 'save property.sonar.javascript.globals.name' }), + cancelButton: byRole('button', { name: 'cancel property.sonar.javascript.globals.name' }), resetButton: byRole('button', { name: 'reset_verb' }), }; diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Bitbucket-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Bitbucket-it.tsx index b6cf04e6fc4..61600662b49 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Bitbucket-it.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Bitbucket-it.tsx @@ -46,8 +46,8 @@ const allowUsersToSignUpDefinition = byTestId('sonar.auth.bitbucket.allowUsersTo const workspacesDefinition = byTestId('sonar.auth.bitbucket.workspaces'); const ui = { - save: byRole('button', { name: 'save' }), - cancel: byRole('button', { name: 'cancel' }), + save: byRole('button', { name: /save/ }), + cancel: byRole('button', { name: /cancel/ }), reset: byRole('button', { name: /settings.definition.reset/ }), confirmReset: byRole('dialog').byRole('button', { name: 'reset_verb' }), change: byRole('button', { name: 'change_verb' }), diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/Input.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/Input.tsx index 98f7f17b729..4337ca52831 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/Input.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/Input.tsx @@ -32,13 +32,14 @@ import MultiValueInput from './MultiValueInput'; import PrimitiveInput from './PrimitiveInput'; import PropertySetInput from './PropertySetInput'; -export default function Input(props: Readonly) { +function Input(props: Readonly, ref: React.ForwardedRef) { const { setting } = props; const { definition } = setting; const name = getUniqueName(definition); - let Input: React.ComponentType> = - PrimitiveInput; + let Input: React.ComponentType< + React.PropsWithChildren & React.RefAttributes + > = PrimitiveInput; if (isCategoryDefinition(definition) && definition.multiValues) { Input = MultiValueInput; @@ -49,8 +50,10 @@ export default function Input(props: Readonly) { } if (isSecuredDefinition(definition)) { - return ; + return ; } - return ; + return ; } + +export default React.forwardRef(Input); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForBoolean.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForBoolean.tsx index 3dcc86492cd..62f094d0b04 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForBoolean.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForBoolean.tsx @@ -26,7 +26,10 @@ interface Props extends DefaultSpecializedInputProps { value: string | boolean | undefined; } -export default function InputForBoolean({ onChange, name, value, setting }: Props) { +function InputForBoolean( + { onChange, name, value, setting, ...other }: Readonly, + ref: React.ForwardedRef, +) { const toggleValue = getToggleValue(value != null ? value : false); const propertyName = getPropertyName(setting.definition); @@ -36,11 +39,13 @@ export default function InputForBoolean({ onChange, name, value, setting }: Prop {value == null && {translate('settings.not_set')}}
@@ -50,3 +55,5 @@ export default function InputForBoolean({ onChange, name, value, setting }: Prop function getToggleValue(value: string | boolean) { return typeof value === 'string' ? value === 'true' : value; } + +export default React.forwardRef(InputForBoolean); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForFormattedText.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForFormattedText.tsx index 92b1fabb5f7..d2372b36a5f 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForFormattedText.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForFormattedText.tsx @@ -33,7 +33,10 @@ import { translate } from '../../../../helpers/l10n'; import { sanitizeUserInput } from '../../../../helpers/sanitize'; import { DefaultSpecializedInputProps, getPropertyName } from '../../utils'; -export default function InputForFormattedText(props: DefaultSpecializedInputProps) { +function InputForFormattedText( + props: DefaultSpecializedInputProps, + ref: React.ForwardedRef, +) { const { isEditing, setting, name, value } = props; const { values, hasValue } = setting; const editMode = !hasValue || isEditing; @@ -52,6 +55,7 @@ export default function InputForFormattedText(props: DefaultSpecializedInputProp className="settings-large-input sw-mr-2" name={name} onChange={handleInputChange} + ref={ref} rows={5} value={value || ''} /> @@ -82,3 +86,5 @@ const FormattedPreviewBox = styled.div` overflow-wrap: break-word; line-height: 1.5; `; + +export default React.forwardRef(InputForFormattedText); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForJSON.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForJSON.tsx index 39ca71cd72e..ea106b989db 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForJSON.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForJSON.tsx @@ -26,11 +26,15 @@ import { DefaultSpecializedInputProps, getPropertyName } from '../../utils'; const JSON_SPACE_SIZE = 4; +interface Props extends DefaultSpecializedInputProps { + innerRef: React.ForwardedRef; +} + interface State { formatError: boolean; } -export default class InputForJSON extends React.PureComponent { +class InputForJSON extends React.PureComponent { state: State = { formatError: false }; handleInputChange = (event: React.ChangeEvent) => { @@ -50,7 +54,7 @@ export default class InputForJSON extends React.PureComponent) => ( + + ), +); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForNumber.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForNumber.tsx index 20cee416a72..858a3ccb0e7 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForNumber.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForNumber.tsx @@ -21,6 +21,11 @@ import * as React from 'react'; import { DefaultSpecializedInputProps } from '../../utils'; import SimpleInput from './SimpleInput'; -export default function InputForNumber(props: DefaultSpecializedInputProps) { - return ; +function InputForNumber( + props: DefaultSpecializedInputProps, + ref: React.ForwardedRef, +) { + return ; } + +export default React.forwardRef(InputForNumber); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForPassword.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForPassword.tsx index 2606a4595f3..552a993489d 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForPassword.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForPassword.tsx @@ -17,10 +17,16 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import * as React from 'react'; import { DefaultSpecializedInputProps } from '../../utils'; import SimpleInput from './SimpleInput'; -export default function InputForPassword(props: DefaultSpecializedInputProps) { - return ; +function InputForPassword( + props: DefaultSpecializedInputProps, + ref: React.ForwardedRef, +) { + return ; } + +export default React.forwardRef(InputForPassword); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForSecured.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForSecured.tsx index d374ff41838..c3f55d09ff8 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForSecured.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForSecured.tsx @@ -34,10 +34,13 @@ interface State { } interface Props extends DefaultInputProps { - input: React.ComponentType>; + innerRef: React.ForwardedRef; + input: React.ComponentType< + React.PropsWithChildren & React.RefAttributes + >; } -export default class InputForSecured extends React.PureComponent { +class InputForSecured extends React.PureComponent { state: State = { changing: !this.props.setting.hasValue, }; @@ -66,7 +69,7 @@ export default class InputForSecured extends React.PureComponent { }; renderInput() { - const { input: Input, setting, value } = this.props; + const { input: Input, innerRef, setting, value } = this.props; const name = getUniqueName(setting.definition); return ( // The input hidden will prevent browser asking for saving login information @@ -76,9 +79,11 @@ export default class InputForSecured extends React.PureComponent { aria-label={getPropertyName(setting.definition)} autoComplete="off" className="js-setting-input" + id={`input-${name}`} isDefault={isDefaultOrInherited(setting)} name={name} onChange={this.handleInputChange} + ref={innerRef} setting={setting} size="large" type="password" @@ -101,3 +106,9 @@ export default class InputForSecured extends React.PureComponent { ); } } + +export default React.forwardRef( + (props: Omit, ref: React.ForwardedRef) => ( + + ), +); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForSingleSelectList.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForSingleSelectList.tsx index 25ab1b3e4f3..9ece399691c 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForSingleSelectList.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForSingleSelectList.tsx @@ -24,7 +24,10 @@ import { DefaultSpecializedInputProps, getPropertyName } from '../../utils'; type Props = DefaultSpecializedInputProps & Pick; -export default function InputForSingleSelectList(props: Readonly) { +function InputForSingleSelectList( + props: Readonly, + ref: React.ForwardedRef, +) { const { name, options: opts, value, setting } = props; const options = React.useMemo( @@ -39,8 +42,11 @@ export default function InputForSingleSelectList(props: Readonly) { isNotClearable name={name} onChange={props.onChange} + ref={ref} size={InputSize.Large} value={value} /> ); } + +export default React.forwardRef(InputForSingleSelectList); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForString.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForString.tsx index 986fb4713eb..9eb65edc9ad 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForString.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForString.tsx @@ -21,6 +21,11 @@ import * as React from 'react'; import { DefaultSpecializedInputProps } from '../../utils'; import SimpleInput from './SimpleInput'; -export default function InputForString(props: DefaultSpecializedInputProps) { - return ; +function InputForString( + props: DefaultSpecializedInputProps, + ref: React.ForwardedRef, +) { + return ; } + +export default React.forwardRef(InputForString); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForText.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForText.tsx index 4c15764bb73..43002fdef8d 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForText.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/InputForText.tsx @@ -21,18 +21,23 @@ import { InputTextArea } from 'design-system'; import * as React from 'react'; import { DefaultSpecializedInputProps, getPropertyName } from '../../utils'; -export default class InputForText extends React.PureComponent { +interface Props extends DefaultSpecializedInputProps { + innerRef: React.ForwardedRef; +} + +class InputForText extends React.PureComponent { handleInputChange = (event: React.ChangeEvent) => { this.props.onChange(event.target.value); }; render() { - const { setting, name, value } = this.props; + const { setting, name, innerRef, value } = this.props; return ( ) => ( + + ), +); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/MultiValueInput.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/MultiValueInput.tsx index df400bdc67f..f210200a113 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/MultiValueInput.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/MultiValueInput.tsx @@ -23,7 +23,11 @@ import { translateWithParameters } from '../../../../helpers/l10n'; import { DefaultSpecializedInputProps, getEmptyValue, getPropertyName } from '../../utils'; import PrimitiveInput from './PrimitiveInput'; -export default class MultiValueInput extends React.PureComponent { +interface Props extends DefaultSpecializedInputProps { + innerRef: React.ForwardedRef; +} + +class MultiValueInput extends React.PureComponent { ensureValue = () => { return this.props.value || []; }; @@ -41,14 +45,17 @@ export default class MultiValueInput extends React.PureComponent this.handleSingleInputChange(index, value)} + ref={index === 0 ? innerRef : null} setting={setting} value={value} /> @@ -85,3 +92,9 @@ export default class MultiValueInput extends React.PureComponent) => ( + + ), +); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/PrimitiveInput.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/PrimitiveInput.tsx index e9c8ec6ac38..023e1f2b1c9 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/PrimitiveInput.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/PrimitiveInput.tsx @@ -29,20 +29,15 @@ import InputForSingleSelectList from './InputForSingleSelectList'; import InputForString from './InputForString'; import InputForText from './InputForText'; -function withOptions( - options: string[], -): React.ComponentType> { - return function Wrapped(props: DefaultSpecializedInputProps) { - return ; - }; -} - -export default function PrimitiveInput(props: DefaultSpecializedInputProps) { - const { setting, name, isDefault, ...other } = props; +function PrimitiveInput( + props: DefaultSpecializedInputProps, + ref: React.ForwardedRef, +) { + const { ariaDescribedBy, setting, name, isDefault, ...other } = props; const { definition } = setting; const typeMapping: { [type in SettingType]?: React.ComponentType< - React.PropsWithChildren + React.PropsWithChildren >; } = React.useMemo( () => ({ @@ -54,13 +49,30 @@ export default function PrimitiveInput(props: DefaultSpecializedInputProps) { INTEGER: InputForNumber, LONG: InputForNumber, FLOAT: InputForNumber, - SINGLE_SELECT_LIST: withOptions(definition.options), + SINGLE_SELECT_LIST: InputForSingleSelectList, FORMATTED_TEXT: InputForFormattedText, }), [definition.options], ); const InputComponent = (definition.type && typeMapping[definition.type]) || InputForString; + let id = `input-${name}`; + if (typeof props.index === 'number') { + id = `${id}-${props.index}`; + } - return ; + return ( + + ); } + +export default React.forwardRef(PrimitiveInput); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/PropertySetInput.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/PropertySetInput.tsx index b500a281376..71297a1e665 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/PropertySetInput.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/PropertySetInput.tsx @@ -37,7 +37,11 @@ import { } from '../../utils'; import PrimitiveInput from './PrimitiveInput'; -export default class PropertySetInput extends React.PureComponent { +interface Props extends DefaultSpecializedInputProps { + innerRef: React.ForwardedRef; +} + +class PropertySetInput extends React.PureComponent { ensureValue() { return this.props.value || []; } @@ -57,13 +61,13 @@ export default class PropertySetInput extends React.PureComponent {isCategoryDefinition(definition) && - definition.fields.map((field) => { + definition.fields.map((field, idx) => { const newSetting = { ...setting, definition: field, @@ -72,10 +76,13 @@ export default class PropertySetInput extends React.PureComponent this.handleInputChange(index, field.key, value)} + ref={index === 0 && idx === 0 ? innerRef : null} setting={newSetting} size="full" value={fieldValues[field.key]} @@ -145,3 +152,9 @@ export default class PropertySetInput extends React.PureComponent) => ( + + ), +); diff --git a/server/sonar-web/src/main/js/apps/settings/components/inputs/SimpleInput.tsx b/server/sonar-web/src/main/js/apps/settings/components/inputs/SimpleInput.tsx index 81c6b1eafaf..ae7dd820e89 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/inputs/SimpleInput.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/inputs/SimpleInput.tsx @@ -23,10 +23,11 @@ import { KeyboardKeys } from '../../../../helpers/keycodes'; import { DefaultSpecializedInputProps, getPropertyName } from '../../utils'; export interface SimpleInputProps extends DefaultSpecializedInputProps { + innerRef: React.ForwardedRef; value: string | number; } -export default class SimpleInput extends React.PureComponent { +class SimpleInput extends React.PureComponent { handleInputChange = (event: React.ChangeEvent) => { this.props.onChange(event.currentTarget.value); }; @@ -41,9 +42,12 @@ export default class SimpleInput extends React.PureComponent { render() { const { + ariaDescribedBy, autoComplete, autoFocus, className, + index, + innerRef, isInvalid, name, value = '', @@ -51,8 +55,16 @@ export default class SimpleInput extends React.PureComponent { size, type, } = this.props; + + let label = getPropertyName(setting.definition); + if (typeof index === 'number') { + label = label.concat(` - ${index + 1}`); + } + return ( { name={name} onChange={this.handleInputChange} onKeyDown={this.handleKeyDown} + ref={innerRef} type={type} value={value} size={size} - aria-label={getPropertyName(setting.definition)} + aria-label={label} /> ); } } + +export default React.forwardRef( + (props: Omit, ref: React.ForwardedRef) => ( + + ), +); diff --git a/server/sonar-web/src/main/js/apps/settings/utils.ts b/server/sonar-web/src/main/js/apps/settings/utils.ts index f2ba7bdcf6a..3154efa57d9 100644 --- a/server/sonar-web/src/main/js/apps/settings/utils.ts +++ b/server/sonar-web/src/main/js/apps/settings/utils.ts @@ -41,14 +41,17 @@ export const DEFAULT_CATEGORY = 'general'; export type DefaultSpecializedInputProps = DefaultInputProps & { autoComplete?: string; className?: string; + index?: number; isDefault: boolean; name: string; type?: string; }; export interface DefaultInputProps { + ariaDescribedBy?: string; autoFocus?: boolean; hasValueChanged?: boolean; + id?: string; isEditing?: boolean; isInvalid?: boolean; onCancel?: () => void; diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 102fbef632a..9295d0724d0 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1452,6 +1452,7 @@ settings.analysis_scope.wildcards.introduction=You can use the following wildcar settings.analysis_scope.wildcards.zero_more_char=Match zero or more characters settings.analysis_scope.wildcards.zero_more_dir=Match zero or more directories settings.analysis_scope.wildcards.single_char=Match a single character +settings.analysis_scope.learn_more=Read more about analysis scope settings.new_code_period.category=New Code settings.new_code_period.title=New Code -- 2.39.5