@@ -43,6 +43,7 @@ import { | |||
GitlabBindingDefinition, | |||
isBitbucketCloudBindingDefinition | |||
} from '../../../../types/alm-settings'; | |||
import { BITBUCKET_CLOUD_WORKSPACE_ID_FORMAT } from '../../constants'; | |||
import AlmBindingDefinitionFormRenderer from './AlmBindingDefinitionFormRenderer'; | |||
interface Props { | |||
@@ -224,9 +225,17 @@ export default class AlmBindingDefinitionForm extends React.PureComponent<Props, | |||
}; | |||
canSubmit = () => { | |||
const { formData, touched } = this.state; | |||
const { bitbucketVariant, formData, touched } = this.state; | |||
const allFieldsProvided = touched && !Object.values(formData).some(v => !v); | |||
return touched && !Object.values(formData).some(v => !v); | |||
if ( | |||
bitbucketVariant === AlmKeys.BitbucketCloud && | |||
isBitbucketCloudBindingDefinition(formData) | |||
) { | |||
return allFieldsProvided && BITBUCKET_CLOUD_WORKSPACE_ID_FORMAT.test(formData.workspace); | |||
} | |||
return allFieldsProvided; | |||
}; | |||
render() { |
@@ -21,6 +21,9 @@ import * as React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { Link } from 'react-router'; | |||
import { ButtonLink } from '../../../../components/controls/buttons'; | |||
import ValidationInput, { | |||
ValidationInputErrorPlacement | |||
} from '../../../../components/controls/ValidationInput'; | |||
import { Alert } from '../../../../components/ui/Alert'; | |||
import MandatoryFieldMarker from '../../../../components/ui/MandatoryFieldMarker'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
@@ -29,8 +32,10 @@ import '../../styles.css'; | |||
export interface AlmBindingDefinitionFormFieldProps<B extends AlmBindingDefinitionBase> { | |||
autoFocus?: boolean; | |||
error?: string; | |||
help?: React.ReactNode; | |||
id: string; | |||
isInvalid?: boolean; | |||
isTextArea?: boolean; | |||
maxLength?: number; | |||
onFieldChange: (id: keyof B, value: string) => void; | |||
@@ -46,8 +51,10 @@ export function AlmBindingDefinitionFormField<B extends AlmBindingDefinitionBase | |||
) { | |||
const { | |||
autoFocus, | |||
error, | |||
help, | |||
id, | |||
isInvalid = false, | |||
isTextArea, | |||
maxLength, | |||
optional, | |||
@@ -94,17 +101,23 @@ export function AlmBindingDefinitionFormField<B extends AlmBindingDefinitionBase | |||
)} | |||
{showField && !isTextArea && ( | |||
<input | |||
className="width-100" | |||
autoFocus={autoFocus} | |||
id={id} | |||
maxLength={maxLength || 100} | |||
name={id} | |||
onChange={e => props.onFieldChange(propKey, e.currentTarget.value)} | |||
size={50} | |||
type="text" | |||
value={value} | |||
/> | |||
<ValidationInput | |||
error={error} | |||
errorPlacement={ValidationInputErrorPlacement.Bottom} | |||
isValid={false} | |||
isInvalid={isInvalid}> | |||
<input | |||
className="width-100" | |||
autoFocus={autoFocus} | |||
id={id} | |||
maxLength={maxLength || 100} | |||
name={id} | |||
onChange={e => props.onFieldChange(propKey, e.currentTarget.value)} | |||
size={50} | |||
type="text" | |||
value={value} | |||
/> | |||
</ValidationInput> | |||
)} | |||
{showField && isSecret && ( |
@@ -24,6 +24,7 @@ import { Alert } from '../../../../components/ui/Alert'; | |||
import { ALM_DOCUMENTATION_PATHS } from '../../../../helpers/constants'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { AlmKeys, BitbucketCloudBindingDefinition } from '../../../../types/alm-settings'; | |||
import { BITBUCKET_CLOUD_WORKSPACE_ID_FORMAT } from '../../constants'; | |||
import { AlmBindingDefinitionFormField } from './AlmBindingDefinitionFormField'; | |||
export interface BitbucketCloudFormProps { | |||
@@ -33,6 +34,9 @@ export interface BitbucketCloudFormProps { | |||
export default function BitbucketCloudForm(props: BitbucketCloudFormProps) { | |||
const { formData } = props; | |||
const workspaceIDIsInvalid = Boolean( | |||
formData.workspace && !BITBUCKET_CLOUD_WORKSPACE_ID_FORMAT.test(formData.workspace) | |||
); | |||
return ( | |||
<> | |||
@@ -62,6 +66,12 @@ export default function BitbucketCloudForm(props: BitbucketCloudFormProps) { | |||
/> | |||
} | |||
id="workspace.bitbucketcloud" | |||
error={ | |||
workspaceIDIsInvalid | |||
? translate('settings.almintegration.form.workspace.bitbucketcloud.error') | |||
: undefined | |||
} | |||
isInvalid={workspaceIDIsInvalid} | |||
maxLength={80} | |||
onFieldChange={props.onFieldChange} | |||
propKey="workspace" |
@@ -147,7 +147,11 @@ it.each([ | |||
); | |||
it('should call the proper api for BBC', async () => { | |||
const wrapper = shallowRender({ alm: AlmKeys.BitbucketServer, bindingDefinition: undefined }); | |||
const wrapper = shallowRender({ | |||
// Reminder: due to the way the settings app works, we never pass AlmKeys.BitbucketCloud as `alm`. | |||
alm: AlmKeys.BitbucketServer, | |||
bindingDefinition: undefined | |||
}); | |||
wrapper.instance().handleBitbucketVariantChange(AlmKeys.BitbucketCloud); | |||
@@ -179,7 +183,25 @@ it('should store bitbucket variant', async () => { | |||
}); | |||
}); | |||
it('should (dis)allow submit by validating its state', () => { | |||
it('should (dis)allow submit by validating its state (Bitbucket Cloud)', () => { | |||
const wrapper = shallowRender({ | |||
// Reminder: due to the way the settings app works, we never pass AlmKeys.BitbucketCloud as `alm`. | |||
alm: AlmKeys.BitbucketServer, | |||
bindingDefinition: mockBitbucketCloudBindingDefinition() | |||
}); | |||
expect(wrapper.instance().canSubmit()).toBe(false); | |||
wrapper.setState({ | |||
formData: mockBitbucketCloudBindingDefinition({ workspace: 'foo/bar' }), | |||
touched: true | |||
}); | |||
expect(wrapper.instance().canSubmit()).toBe(false); | |||
wrapper.setState({ formData: mockBitbucketCloudBindingDefinition() }); | |||
expect(wrapper.instance().canSubmit()).toBe(true); | |||
}); | |||
it('should (dis)allow submit by validating its state (others)', () => { | |||
const wrapper = shallowRender(); | |||
expect(wrapper.instance().canSubmit()).toBe(false); | |||
@@ -34,6 +34,9 @@ it('should render correctly', () => { | |||
expect(shallowRender({ optional: true })).toMatchSnapshot('optional'); | |||
expect(shallowRender({ overwriteOnly: true })).toMatchSnapshot('secret'); | |||
expect(shallowRender({ isSecret: true })).toMatchSnapshot('encryptable'); | |||
expect(shallowRender({ error: 'some error message', isInvalid: true })).toMatchSnapshot( | |||
'invalid with error' | |||
); | |||
}); | |||
it('should call onFieldChange', () => { |
@@ -23,8 +23,10 @@ import { mockBitbucketCloudBindingDefinition } from '../../../../../helpers/mock | |||
import BitbucketCloudForm, { BitbucketCloudFormProps } from '../BitbucketCloudForm'; | |||
it('should render correctly', () => { | |||
const wrapper = shallowRender(); | |||
expect(wrapper).toMatchSnapshot(); | |||
expect(shallowRender()).toMatchSnapshot('default'); | |||
expect( | |||
shallowRender({ formData: mockBitbucketCloudBindingDefinition({ workspace: 'my/workspace' }) }) | |||
).toMatchSnapshot('invalid workspace ID'); | |||
}); | |||
function shallowRender(props: Partial<BitbucketCloudFormProps> = {}) { |
@@ -18,16 +18,22 @@ exports[`should render correctly: default 1`] = ` | |||
<div | |||
className="settings-definition-right big-padded-top display-flex-column" | |||
> | |||
<input | |||
className="width-100" | |||
id="key" | |||
maxLength={40} | |||
name="key" | |||
onChange={[Function]} | |||
size={50} | |||
type="text" | |||
value="key" | |||
/> | |||
<ValidationInput | |||
errorPlacement={1} | |||
isInvalid={false} | |||
isValid={false} | |||
> | |||
<input | |||
className="width-100" | |||
id="key" | |||
maxLength={40} | |||
name="key" | |||
onChange={[Function]} | |||
size={50} | |||
type="text" | |||
value="key" | |||
/> | |||
</ValidationInput> | |||
</div> | |||
</div> | |||
`; | |||
@@ -50,16 +56,22 @@ exports[`should render correctly: encryptable 1`] = ` | |||
<div | |||
className="settings-definition-right big-padded-top display-flex-column" | |||
> | |||
<input | |||
className="width-100" | |||
id="key" | |||
maxLength={40} | |||
name="key" | |||
onChange={[Function]} | |||
size={50} | |||
type="text" | |||
value="key" | |||
/> | |||
<ValidationInput | |||
errorPlacement={1} | |||
isInvalid={false} | |||
isValid={false} | |||
> | |||
<input | |||
className="width-100" | |||
id="key" | |||
maxLength={40} | |||
name="key" | |||
onChange={[Function]} | |||
size={50} | |||
type="text" | |||
value="key" | |||
/> | |||
</ValidationInput> | |||
<Alert | |||
className="spacer-top" | |||
variant="info" | |||
@@ -89,6 +101,45 @@ exports[`should render correctly: encryptable 1`] = ` | |||
</div> | |||
`; | |||
exports[`should render correctly: invalid with error 1`] = ` | |||
<div | |||
className="settings-definition" | |||
> | |||
<div | |||
className="settings-definition-left" | |||
> | |||
<label | |||
className="h3" | |||
htmlFor="key" | |||
> | |||
settings.almintegration.form.key | |||
</label> | |||
<MandatoryFieldMarker /> | |||
</div> | |||
<div | |||
className="settings-definition-right big-padded-top display-flex-column" | |||
> | |||
<ValidationInput | |||
error="some error message" | |||
errorPlacement={1} | |||
isInvalid={true} | |||
isValid={false} | |||
> | |||
<input | |||
className="width-100" | |||
id="key" | |||
maxLength={40} | |||
name="key" | |||
onChange={[Function]} | |||
size={50} | |||
type="text" | |||
value="key" | |||
/> | |||
</ValidationInput> | |||
</div> | |||
</div> | |||
`; | |||
exports[`should render correctly: optional 1`] = ` | |||
<div | |||
className="settings-definition" | |||
@@ -106,16 +157,22 @@ exports[`should render correctly: optional 1`] = ` | |||
<div | |||
className="settings-definition-right big-padded-top display-flex-column" | |||
> | |||
<input | |||
className="width-100" | |||
id="key" | |||
maxLength={40} | |||
name="key" | |||
onChange={[Function]} | |||
size={50} | |||
type="text" | |||
value="key" | |||
/> | |||
<ValidationInput | |||
errorPlacement={1} | |||
isInvalid={false} | |||
isValid={false} | |||
> | |||
<input | |||
className="width-100" | |||
id="key" | |||
maxLength={40} | |||
name="key" | |||
onChange={[Function]} | |||
size={50} | |||
type="text" | |||
value="key" | |||
/> | |||
</ValidationInput> | |||
</div> | |||
</div> | |||
`; | |||
@@ -206,16 +263,22 @@ exports[`should render correctly: with help 1`] = ` | |||
<div | |||
className="settings-definition-right big-padded-top display-flex-column" | |||
> | |||
<input | |||
className="width-100" | |||
id="key" | |||
maxLength={40} | |||
name="key" | |||
onChange={[Function]} | |||
size={50} | |||
type="text" | |||
value="key" | |||
/> | |||
<ValidationInput | |||
errorPlacement={1} | |||
isInvalid={false} | |||
isValid={false} | |||
> | |||
<input | |||
className="width-100" | |||
id="key" | |||
maxLength={40} | |||
name="key" | |||
onChange={[Function]} | |||
size={50} | |||
type="text" | |||
value="key" | |||
/> | |||
</ValidationInput> | |||
</div> | |||
</div> | |||
`; |
@@ -1,6 +1,6 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
exports[`should render correctly: default 1`] = ` | |||
<Fragment> | |||
<AlmBindingDefinitionFormField | |||
autoFocus={true} | |||
@@ -30,6 +30,7 @@ exports[`should render correctly 1`] = ` | |||
/> | |||
} | |||
id="workspace.bitbucketcloud" | |||
isInvalid={false} | |||
maxLength={80} | |||
onFieldChange={[MockFunction]} | |||
propKey="workspace" | |||
@@ -86,3 +87,92 @@ exports[`should render correctly 1`] = ` | |||
/> | |||
</Fragment> | |||
`; | |||
exports[`should render correctly: invalid workspace ID 1`] = ` | |||
<Fragment> | |||
<AlmBindingDefinitionFormField | |||
autoFocus={true} | |||
help="settings.almintegration.form.name.bitbucketcloud.help" | |||
id="name.bitbucket" | |||
maxLength={200} | |||
onFieldChange={[MockFunction]} | |||
propKey="key" | |||
value="key" | |||
/> | |||
<AlmBindingDefinitionFormField | |||
error="settings.almintegration.form.workspace.bitbucketcloud.error" | |||
help={ | |||
<FormattedMessage | |||
defaultMessage="settings.almintegration.form.workspace.bitbucketcloud.help" | |||
id="settings.almintegration.form.workspace.bitbucketcloud.help" | |||
values={ | |||
Object { | |||
"example": <React.Fragment> | |||
https://bitbucket.org/ | |||
<strong> | |||
{workspace} | |||
</strong> | |||
/{repository} | |||
</React.Fragment>, | |||
} | |||
} | |||
/> | |||
} | |||
id="workspace.bitbucketcloud" | |||
isInvalid={true} | |||
maxLength={80} | |||
onFieldChange={[MockFunction]} | |||
propKey="workspace" | |||
value="my/workspace" | |||
/> | |||
<Alert | |||
className="big-spacer-top" | |||
variant="info" | |||
> | |||
<FormattedMessage | |||
defaultMessage="settings.almintegration.bitbucketcloud.info" | |||
id="settings.almintegration.bitbucketcloud.info" | |||
values={ | |||
Object { | |||
"doc_link": <Link | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
target="_blank" | |||
to="/documentation/analysis/bitbucket-cloud-integration/" | |||
> | |||
learn_more | |||
</Link>, | |||
"oauth": <a | |||
href="https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/" | |||
rel="noopener noreferrer" | |||
target="_blank" | |||
> | |||
settings.almintegration.bitbucketcloud.oauth | |||
</a>, | |||
"permission": <strong> | |||
Pull Requests: Read | |||
</strong>, | |||
} | |||
} | |||
/> | |||
</Alert> | |||
<AlmBindingDefinitionFormField | |||
help="settings.almintegration.form.oauth_key.bitbucketcloud.help" | |||
id="client_id.bitbucketcloud" | |||
maxLength={80} | |||
onFieldChange={[MockFunction]} | |||
propKey="clientId" | |||
value="client1" | |||
/> | |||
<AlmBindingDefinitionFormField | |||
help="settings.almintegration.form.oauth_secret.bitbucketcloud.help" | |||
id="client_secret.bitbucketcloud" | |||
isSecret={true} | |||
maxLength={160} | |||
onFieldChange={[MockFunction]} | |||
overwriteOnly={true} | |||
propKey="clientSecret" | |||
value="**clientsecret**" | |||
/> | |||
</Fragment> | |||
`; |
@@ -23,39 +23,77 @@ import AlertSuccessIcon from '../icons/AlertSuccessIcon'; | |||
import MandatoryFieldMarker from '../ui/MandatoryFieldMarker'; | |||
import HelpTooltip from './HelpTooltip'; | |||
interface Props { | |||
export interface ValidationInputProps { | |||
description?: React.ReactNode; | |||
children: React.ReactNode; | |||
className?: string; | |||
error: string | undefined; | |||
error?: string; | |||
errorPlacement?: ValidationInputErrorPlacement; | |||
help?: string; | |||
id: string; | |||
id?: string; | |||
isInvalid: boolean; | |||
isValid: boolean; | |||
label: React.ReactNode; | |||
label?: React.ReactNode; | |||
required?: boolean; | |||
} | |||
export default function ValidationInput(props: Props) { | |||
const hasError = props.isInvalid && props.error !== undefined; | |||
export enum ValidationInputErrorPlacement { | |||
Right, | |||
Bottom | |||
} | |||
export default function ValidationInput(props: ValidationInputProps) { | |||
const { | |||
children, | |||
className, | |||
description, | |||
error, | |||
errorPlacement = ValidationInputErrorPlacement.Right, | |||
help, | |||
id, | |||
isInvalid, | |||
isValid, | |||
label, | |||
required | |||
} = props; | |||
const hasError = isInvalid && error !== undefined; | |||
let childrenWithStatus: React.ReactNode; | |||
if (errorPlacement === ValidationInputErrorPlacement.Right) { | |||
childrenWithStatus = ( | |||
<> | |||
{children} | |||
{isValid && <AlertSuccessIcon className="spacer-left text-middle" />} | |||
{isInvalid && <AlertErrorIcon className="spacer-left text-middle" />} | |||
{hasError && <span className="little-spacer-left text-danger text-middle">{error}</span>} | |||
</> | |||
); | |||
} else { | |||
childrenWithStatus = ( | |||
<> | |||
{children} | |||
{isValid && <AlertSuccessIcon className="spacer-left text-middle" />} | |||
<div className="spacer-top"> | |||
{isInvalid && <AlertErrorIcon className="text-middle" />} | |||
{hasError && <span className="little-spacer-left text-danger text-middle">{error}</span>} | |||
</div> | |||
</> | |||
); | |||
} | |||
return ( | |||
<div className={props.className}> | |||
<label htmlFor={props.id}> | |||
<span className="text-middle"> | |||
<strong>{props.label}</strong> | |||
{props.required && <MandatoryFieldMarker />} | |||
</span> | |||
{props.help && <HelpTooltip className="spacer-left" overlay={props.help} />} | |||
</label> | |||
<div className="little-spacer-top spacer-bottom"> | |||
{props.children} | |||
{props.isInvalid && <AlertErrorIcon className="spacer-left text-middle" />} | |||
{hasError && ( | |||
<span className="little-spacer-left text-danger text-middle">{props.error}</span> | |||
)} | |||
{props.isValid && <AlertSuccessIcon className="spacer-left text-middle" />} | |||
</div> | |||
{props.description && <div className="note abs-width-400">{props.description}</div>} | |||
<div className={className}> | |||
{id && label && ( | |||
<label htmlFor={id}> | |||
<span className="text-middle"> | |||
<strong>{label}</strong> | |||
{required && <MandatoryFieldMarker />} | |||
</span> | |||
{help && <HelpTooltip className="spacer-left" overlay={help} />} | |||
</label> | |||
)} | |||
<div className="little-spacer-top spacer-bottom">{childrenWithStatus}</div> | |||
{description && <div className="note abs-width-400">{description}</div>} | |||
</div> | |||
); | |||
} |
@@ -19,55 +19,46 @@ | |||
*/ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import ValidationInput from '../ValidationInput'; | |||
import ValidationInput, { | |||
ValidationInputErrorPlacement, | |||
ValidationInputProps | |||
} from '../ValidationInput'; | |||
it('should render', () => { | |||
it('should render correctly', () => { | |||
expect(shallowRender()).toMatchSnapshot('default'); | |||
expect(shallowRender({ help: 'Help message', isValid: false })).toMatchSnapshot('with help'); | |||
expect( | |||
shallow( | |||
<ValidationInput | |||
description="My description" | |||
error={undefined} | |||
help="Help message" | |||
id="field-id" | |||
isInvalid={false} | |||
isValid={false} | |||
label="Field label" | |||
required={true}> | |||
<div /> | |||
</ValidationInput> | |||
) | |||
).toMatchSnapshot(); | |||
}); | |||
it('should render with error', () => { | |||
shallowRender({ | |||
description: <div>My description</div>, | |||
error: 'Field error message', | |||
isInvalid: true, | |||
isValid: false, | |||
required: false | |||
}) | |||
).toMatchSnapshot('with error'); | |||
expect( | |||
shallow( | |||
<ValidationInput | |||
description={<div>My description</div>} | |||
error="Field error message" | |||
id="field-id" | |||
isInvalid={true} | |||
isValid={false} | |||
label="Field label"> | |||
<div /> | |||
</ValidationInput> | |||
) | |||
).toMatchSnapshot(); | |||
shallowRender({ | |||
error: 'Field error message', | |||
errorPlacement: ValidationInputErrorPlacement.Bottom, | |||
isInvalid: true, | |||
isValid: false | |||
}) | |||
).toMatchSnapshot('error under the input'); | |||
expect(shallowRender({ id: undefined, label: undefined })).toMatchSnapshot('no label'); | |||
}); | |||
it('should render when valid', () => { | |||
expect( | |||
shallow( | |||
<ValidationInput | |||
description="My description" | |||
error={undefined} | |||
id="field-id" | |||
isInvalid={false} | |||
isValid={true} | |||
label="Field label" | |||
required={true}> | |||
<div /> | |||
</ValidationInput> | |||
) | |||
).toMatchSnapshot(); | |||
}); | |||
function shallowRender(props: Partial<ValidationInputProps> = {}) { | |||
return shallow<ValidationInputProps>( | |||
<ValidationInput | |||
description="My description" | |||
error={undefined} | |||
id="field-id" | |||
isInvalid={false} | |||
isValid={true} | |||
label="Field label" | |||
required={true} | |||
{...props}> | |||
<div /> | |||
</ValidationInput> | |||
); | |||
} |
@@ -1,6 +1,6 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render 1`] = ` | |||
exports[`should render correctly: default 1`] = ` | |||
<div> | |||
<label | |||
htmlFor="field-id" | |||
@@ -13,15 +13,14 @@ exports[`should render 1`] = ` | |||
</strong> | |||
<MandatoryFieldMarker /> | |||
</span> | |||
<HelpTooltip | |||
className="spacer-left" | |||
overlay="Help message" | |||
/> | |||
</label> | |||
<div | |||
className="little-spacer-top spacer-bottom" | |||
> | |||
<div /> | |||
<AlertSuccessIcon | |||
className="spacer-left text-middle" | |||
/> | |||
</div> | |||
<div | |||
className="note abs-width-400" | |||
@@ -31,7 +30,7 @@ exports[`should render 1`] = ` | |||
</div> | |||
`; | |||
exports[`should render when valid 1`] = ` | |||
exports[`should render correctly: error under the input 1`] = ` | |||
<div> | |||
<label | |||
htmlFor="field-id" | |||
@@ -45,6 +44,33 @@ exports[`should render when valid 1`] = ` | |||
<MandatoryFieldMarker /> | |||
</span> | |||
</label> | |||
<div | |||
className="little-spacer-top spacer-bottom" | |||
> | |||
<div /> | |||
<div | |||
className="spacer-top" | |||
> | |||
<AlertErrorIcon | |||
className="text-middle" | |||
/> | |||
<span | |||
className="little-spacer-left text-danger text-middle" | |||
> | |||
Field error message | |||
</span> | |||
</div> | |||
</div> | |||
<div | |||
className="note abs-width-400" | |||
> | |||
My description | |||
</div> | |||
</div> | |||
`; | |||
exports[`should render correctly: no label 1`] = ` | |||
<div> | |||
<div | |||
className="little-spacer-top spacer-bottom" | |||
> | |||
@@ -61,7 +87,7 @@ exports[`should render when valid 1`] = ` | |||
</div> | |||
`; | |||
exports[`should render with error 1`] = ` | |||
exports[`should render correctly: with error 1`] = ` | |||
<div> | |||
<label | |||
htmlFor="field-id" | |||
@@ -96,3 +122,34 @@ exports[`should render with error 1`] = ` | |||
</div> | |||
</div> | |||
`; | |||
exports[`should render correctly: with help 1`] = ` | |||
<div> | |||
<label | |||
htmlFor="field-id" | |||
> | |||
<span | |||
className="text-middle" | |||
> | |||
<strong> | |||
Field label | |||
</strong> | |||
<MandatoryFieldMarker /> | |||
</span> | |||
<HelpTooltip | |||
className="spacer-left" | |||
overlay="Help message" | |||
/> | |||
</label> | |||
<div | |||
className="little-spacer-top spacer-bottom" | |||
> | |||
<div /> | |||
</div> | |||
<div | |||
className="note abs-width-400" | |||
> | |||
My description | |||
</div> | |||
</div> | |||
`; |
@@ -19,6 +19,7 @@ | |||
*/ | |||
package org.sonar.server.almsettings.ws; | |||
import java.util.regex.Pattern; | |||
import org.sonar.api.server.ServerSide; | |||
import org.sonar.db.DbClient; | |||
import org.sonar.db.DbSession; | |||
@@ -38,6 +39,8 @@ import static org.sonar.api.web.UserRole.ADMIN; | |||
@ServerSide | |||
public class AlmSettingsSupport { | |||
private static final Pattern WORKSPACE_ID_PATTERN = Pattern.compile("^[a-z0-9\\-_]+$"); | |||
private final DbClient dbClient; | |||
private final UserSession userSession; | |||
private final ComponentFinder componentFinder; | |||
@@ -70,6 +73,15 @@ public class AlmSettingsSupport { | |||
} | |||
} | |||
public void checkBitbucketCloudWorkspaceIDFormat(String workspaceId) { | |||
if (!WORKSPACE_ID_PATTERN.matcher(workspaceId).matches()) { | |||
throw BadRequestException.create(String.format( | |||
"Workspace ID '%s' has an incorrect format. Should only contain lowercase letters, numbers, dashes, and underscores.", | |||
workspaceId | |||
)); | |||
} | |||
} | |||
public ProjectDto getProjectAsAdmin(DbSession dbSession, String projectKey) { | |||
return getProject(dbSession, projectKey, ADMIN); | |||
} |
@@ -91,6 +91,7 @@ public class CreateBitbucketCloudAction implements AlmSettingsWsAction { | |||
almSettingsSupport.checkAlmMultipleFeatureEnabled(BITBUCKET); | |||
almSettingsSupport.checkAlmMultipleFeatureEnabled(BITBUCKET_CLOUD); | |||
almSettingsSupport.checkAlmSettingDoesNotAlreadyExist(dbSession, key); | |||
almSettingsSupport.checkBitbucketCloudWorkspaceIDFormat(workspace); | |||
dbClient.almSettingDao().insert(dbSession, new AlmSettingDto() | |||
.setAlm(BITBUCKET_CLOUD) | |||
.setKey(key) |
@@ -102,6 +102,8 @@ public class UpdateBitbucketCloudAction implements AlmSettingsWsAction { | |||
almSettingDto.setClientSecret(clientSecret); | |||
} | |||
almSettingsSupport.checkBitbucketCloudWorkspaceIDFormat(workspace); | |||
dbClient.almSettingDao().update(dbSession, almSettingDto | |||
.setKey(isNotBlank(newKey) ? newKey : key) | |||
.setClientId(clientId) |
@@ -32,9 +32,11 @@ import org.sonar.server.component.ComponentFinder; | |||
import org.sonar.server.exceptions.BadRequestException; | |||
import org.sonar.server.exceptions.ForbiddenException; | |||
import org.sonar.server.tester.UserSessionRule; | |||
import org.sonar.server.ws.TestRequest; | |||
import org.sonar.server.ws.WsActionTester; | |||
import static org.assertj.core.api.Assertions.assertThat; | |||
import static org.assertj.core.api.Assertions.assertThatNoException; | |||
import static org.assertj.core.api.Assertions.assertThatThrownBy; | |||
import static org.assertj.core.groups.Tuple.tuple; | |||
import static org.mockito.Mockito.mock; | |||
@@ -141,6 +143,43 @@ public class CreateBitbucketCloudActionTest { | |||
.isInstanceOf(ForbiddenException.class); | |||
} | |||
@Test | |||
public void fail_when_workspace_id_format_is_incorrect() { | |||
when(multipleAlmFeatureProvider.enabled()).thenReturn(false); | |||
String workspace = "workspace/name"; | |||
UserDto user = db.users().insertUser(); | |||
userSession.logIn(user).setSystemAdministrator(); | |||
TestRequest request = ws.newRequest() | |||
.setParam("key", "another new key") | |||
.setParam("workspace", workspace) | |||
.setParam("clientId", "id") | |||
.setParam("clientSecret", "secret"); | |||
assertThatThrownBy(request::execute) | |||
.isInstanceOf(BadRequestException.class) | |||
.hasMessageContaining(String.format( | |||
"Workspace ID '%s' has an incorrect format. Should only contain lowercase letters, numbers, dashes, and underscores.", | |||
workspace | |||
)); | |||
} | |||
@Test | |||
public void do_not_fail_when_workspace_id_format_is_correct() { | |||
when(multipleAlmFeatureProvider.enabled()).thenReturn(false); | |||
String workspace = "work-space_123"; | |||
UserDto user = db.users().insertUser(); | |||
userSession.logIn(user).setSystemAdministrator(); | |||
TestRequest request = ws.newRequest() | |||
.setParam("key", "yet another new key") | |||
.setParam("workspace", workspace) | |||
.setParam("clientId", "id") | |||
.setParam("clientSecret", "secret"); | |||
assertThatNoException().isThrownBy(request::execute); | |||
} | |||
@Test | |||
public void definition() { | |||
WebService.Action def = ws.getDef(); |
@@ -29,6 +29,7 @@ import org.sonar.db.alm.setting.AlmSettingDto; | |||
import org.sonar.db.user.UserDto; | |||
import org.sonar.server.almsettings.MultipleAlmFeatureProvider; | |||
import org.sonar.server.component.ComponentFinder; | |||
import org.sonar.server.exceptions.BadRequestException; | |||
import org.sonar.server.exceptions.ForbiddenException; | |||
import org.sonar.server.exceptions.NotFoundException; | |||
import org.sonar.server.tester.UserSessionRule; | |||
@@ -37,6 +38,7 @@ import org.sonar.server.ws.WsActionTester; | |||
import static java.lang.String.format; | |||
import static org.assertj.core.api.Assertions.assertThat; | |||
import static org.assertj.core.api.Assertions.assertThatNoException; | |||
import static org.assertj.core.api.Assertions.assertThatThrownBy; | |||
import static org.assertj.core.groups.Tuple.tuple; | |||
import static org.mockito.ArgumentMatchers.any; | |||
@@ -188,6 +190,43 @@ public class UpdateBitbucketCloudActionTest { | |||
.isInstanceOf(ForbiddenException.class); | |||
} | |||
@Test | |||
public void fail_when_workspace_id_format_is_incorrect() { | |||
String workspace = "workspace/name"; | |||
UserDto user = db.users().insertUser(); | |||
userSession.logIn(user).setSystemAdministrator(); | |||
AlmSettingDto almSettingDto = db.almSettings().insertBitbucketAlmSetting(); | |||
TestRequest request = ws.newRequest() | |||
.setParam("key", almSettingDto.getKey()) | |||
.setParam("workspace", workspace) | |||
.setParam("clientId", "id") | |||
.setParam("clientSecret", "secret"); | |||
assertThatThrownBy(request::execute) | |||
.isInstanceOf(BadRequestException.class) | |||
.hasMessageContaining(String.format( | |||
"Workspace ID '%s' has an incorrect format. Should only contain lowercase letters, numbers, dashes, and underscores.", | |||
workspace | |||
)); | |||
} | |||
@Test | |||
public void do_not_fail_when_workspace_id_format_is_correct() { | |||
String workspace = "work-space_123"; | |||
UserDto user = db.users().insertUser(); | |||
userSession.logIn(user).setSystemAdministrator(); | |||
AlmSettingDto almSettingDto = db.almSettings().insertBitbucketAlmSetting(); | |||
TestRequest request = ws.newRequest() | |||
.setParam("key", almSettingDto.getKey()) | |||
.setParam("workspace", workspace) | |||
.setParam("clientId", "id") | |||
.setParam("clientSecret", "secret"); | |||
assertThatNoException().isThrownBy(request::execute); | |||
} | |||
@Test | |||
public void definition() { | |||
WebService.Action def = ws.getDef(); |
@@ -1177,6 +1177,7 @@ settings.almintegration.form.name.github.help=Give your configuration a clear an | |||
settings.almintegration.form.name.gitlab=Configuration name | |||
settings.almintegration.form.name.gitlab.help=Give your configuration a clear and succinct name. This name will be used at project level to identify the correct configured GitLab instance for a project. | |||
settings.almintegration.form.workspace.bitbucketcloud=Workspace ID | |||
settings.almintegration.form.workspace.bitbucketcloud.error=Workspace ID's can only contain lowercase letters, numbers, dashes, and underscores. | |||
settings.almintegration.form.workspace.bitbucketcloud.help=The workspace ID is part of your bitbucket cloud URL {example} | |||
settings.almintegration.form.oauth_key.bitbucketcloud.help=Bitbucket automatically creates an OAuth key when you create your OAuth consumer. You can find it in your Bitbucket Cloud workspace settings under OAuth consumers. | |||
settings.almintegration.form.oauth_secret.bitbucketcloud.help=Bitbucket automatically creates an OAuth secret when you create your OAuth consumer. You can find it in your Bitbucket Cloud workspace settings under OAuth consumers. |