Browse Source

SONAR-21070 - Changed project settings webhooks to adapt new UI

tags/10.4.0.87286
Kevin Silva 7 months ago
parent
commit
7b21628bae
24 changed files with 482 additions and 302 deletions
  1. 115
    0
      server/sonar-web/design-system/src/components/TextAccordion.tsx
  2. 55
    0
      server/sonar-web/design-system/src/components/__tests__/TextAccordion-test.tsx
  3. 1
    0
      server/sonar-web/design-system/src/components/index.ts
  4. 7
    2
      server/sonar-web/design-system/src/components/input/InputField.tsx
  5. 2
    0
      server/sonar-web/src/main/js/app/components/GlobalContainer.tsx
  6. 11
    13
      server/sonar-web/src/main/js/apps/webhooks/components/App.tsx
  7. 5
    18
      server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx
  8. 25
    25
      server/sonar-web/src/main/js/apps/webhooks/components/DeleteWebhookForm.tsx
  9. 25
    28
      server/sonar-web/src/main/js/apps/webhooks/components/DeliveriesForm.tsx
  10. 17
    8
      server/sonar-web/src/main/js/apps/webhooks/components/DeliveryAccordion.tsx
  11. 12
    7
      server/sonar-web/src/main/js/apps/webhooks/components/DeliveryItem.tsx
  12. 8
    16
      server/sonar-web/src/main/js/apps/webhooks/components/LatestDeliveryForm.tsx
  13. 10
    12
      server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx
  14. 20
    22
      server/sonar-web/src/main/js/apps/webhooks/components/PageHeader.tsx
  15. 21
    6
      server/sonar-web/src/main/js/apps/webhooks/components/UpdateWebhookSecretField.tsx
  16. 10
    21
      server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx
  17. 10
    9
      server/sonar-web/src/main/js/apps/webhooks/components/WebhookItem.tsx
  18. 15
    20
      server/sonar-web/src/main/js/apps/webhooks/components/WebhookItemLatestDelivery.tsx
  19. 25
    22
      server/sonar-web/src/main/js/apps/webhooks/components/WebhooksList.tsx
  20. 24
    20
      server/sonar-web/src/main/js/apps/webhooks/components/__tests__/App-it.tsx
  21. 8
    7
      server/sonar-web/src/main/js/components/controls/InputValidationField.tsx
  22. 26
    13
      server/sonar-web/src/main/js/components/controls/ModalValidationField.tsx
  23. 26
    30
      server/sonar-web/src/main/js/components/controls/ValidationModal.tsx
  24. 4
    3
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 115
- 0
server/sonar-web/design-system/src/components/TextAccordion.tsx View File

@@ -0,0 +1,115 @@
/*
* SonarQube
* Copyright (C) 2009-2023 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import styled from '@emotion/styled';
import classNames from 'classnames';
import { uniqueId } from 'lodash';
import React, { ReactNode } from 'react';
import tw from 'twin.macro';
import { themeColor } from '../helpers';
import { Note } from './Text';
import { BareButton } from './buttons';
import { OpenCloseIndicator } from './icons';

interface Props {
ariaLabel: string;
children: ReactNode;
className?: string;
data?: string;
onClick: (data?: string) => void;
open: boolean;
renderHeader?: () => ReactNode;
title: ReactNode;
}

export function TextAccordion(props: Readonly<Props>) {
const [hoveringInner, setHoveringInner] = React.useState(false);

const { className, open, renderHeader, title, ariaLabel } = props;

const id = React.useMemo(() => uniqueId('accordion-'), []);

function handleClick() {
props.onClick(props.data);
}

function onDetailEnter() {
setHoveringInner(true);
}

function onDetailLeave() {
setHoveringInner(false);
}

return (
<StyledAccordion
className={classNames('it__text-accordion', className, {
'no-hover': hoveringInner,
})}
>
<Note as="h3">
<BareButton
aria-controls={`${id}-panel`}
aria-expanded={open}
aria-label={ariaLabel}
className="sw-flex sw-items-center sw-px-2 sw-py-2 sw-box-border sw-w-full"
id={`${id}-header`}
onClick={handleClick}
>
<AccordionTitle>
<OpenCloseIndicator className="sw-mr-1" open={open} />
{title}
</AccordionTitle>
{renderHeader?.()}
</BareButton>
</Note>
{open && (
<AccordionContent onMouseEnter={onDetailEnter} onMouseLeave={onDetailLeave} role="region">
{props.children}
</AccordionContent>
)}
</StyledAccordion>
);
}

const StyledAccordion = styled.div`
transition: border-color 0.3s ease;
`;

const AccordionTitle = styled.span`
cursor: pointer;
position: relative;
display: inline-flex;
align-items: center;
font-weight: bold;
vertical-align: middle;
transition: color 0.3s ease;

${tw`sw-select-none`}
${tw`sw-pt-4 sw-px-page sw-pb-2`}

&:hover {
color: ${themeColor('linkDefault')};
}
`;

const AccordionContent = styled.div`
${tw`sw-pl-10`}
`;

+ 55
- 0
server/sonar-web/design-system/src/components/__tests__/TextAccordion-test.tsx View File

@@ -0,0 +1,55 @@
/*
* SonarQube
* Copyright (C) 2009-2023 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { render } from '../../helpers/testUtils';
import { TextAccordion } from '../TextAccordion';

it('should behave correctly', async () => {
const user = userEvent.setup();
const children = 'hello';
renderAccordion(children);
expect(screen.queryByText(children)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'test-aria' }));
expect(screen.getByText(children)).toBeInTheDocument();
});

function renderAccordion(children: React.ReactNode) {
function AccordionTest() {
const [open, setOpen] = React.useState(false);

return (
<TextAccordion
ariaLabel="test-aria"
onClick={() => {
setOpen(!open);
}}
open={open}
renderHeader={() => 'Expand'}
title="test"
>
<div>{children}</div>
</TextAccordion>
);
}

return render(<AccordionTest />);
}

+ 1
- 0
server/sonar-web/design-system/src/components/index.ts View File

@@ -75,6 +75,7 @@ export * from './Switch';
export * from './Table';
export * from './Tags';
export * from './Text';
export * from './TextAccordion';
export * from './Title';
export { ToggleButton } from './ToggleButton';
export { Tooltip } from './Tooltip';

+ 7
- 2
server/sonar-web/design-system/src/components/input/InputField.tsx View File

@@ -45,7 +45,7 @@ export const InputField = forwardRef<HTMLInputElement, InputProps>(
return (
<StyledInput ref={ref} style={{ ...style, '--inputSize': INPUT_SIZES[size] }} {...props} />
);
}
},
);
InputField.displayName = 'InputField';

@@ -54,7 +54,7 @@ export const InputTextArea = forwardRef<HTMLTextAreaElement, InputTextAreaProps>
return (
<StyledTextArea ref={ref} style={{ ...style, '--inputSize': INPUT_SIZES[size] }} {...props} />
);
}
},
);
InputTextArea.displayName = 'InputTextArea';

@@ -140,6 +140,11 @@ const StyledInput = styled.input`
${baseStyle}
${tw`sw-h-control`}
}
input[type='password']& {
${getInputVariant}
${baseStyle}
${tw`sw-h-control`}
}
`;

const StyledTextArea = styled.textarea`

+ 2
- 0
server/sonar-web/src/main/js/app/components/GlobalContainer.tsx View File

@@ -65,6 +65,8 @@ const TEMP_PAGELIST_WITH_NEW_BACKGROUND_WHITE = [
'/project/import_export',
'/project/quality_gate',
'/project/quality_profiles',
'/project/webhooks',
'/admin/webhooks',
];

export default function GlobalContainer() {

+ 11
- 13
server/sonar-web/src/main/js/apps/webhooks/components/App.tsx View File

@@ -17,6 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { LargeCenteredLayout, PageContentFontWrapper, Spinner } from 'design-system';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Helmet } from 'react-helmet-async';
@@ -97,22 +98,19 @@ export function App({ component }: AppProps) {
}

return (
<>
<Suggestions suggestions="webhooks" />
<Helmet defer={false} title={translate('webhooks.page')} />

<div className="page page-limited">
<PageHeader loading={loading}>
<LargeCenteredLayout id="project-webhooks">
<PageContentFontWrapper className="sw-my-8 sw-body-sm">
<Suggestions suggestions="webhooks" />
<Helmet defer={false} title={translate('webhooks.page')} />
<PageHeader>
<PageActions loading={loading} onCreate={handleCreate} webhooksCount={webhooks.length} />
</PageHeader>

{!loading && (
<div className="boxed-group boxed-group-inner">
<WebhooksList onDelete={handleDelete} onUpdate={handleUpdate} webhooks={webhooks} />
</div>
)}
</div>
</>
<Spinner loading={loading}>
<WebhooksList onDelete={handleDelete} onUpdate={handleUpdate} webhooks={webhooks} />
</Spinner>
</PageContentFontWrapper>
</LargeCenteredLayout>
);
}


+ 5
- 18
server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx View File

@@ -21,8 +21,6 @@ import * as React from 'react';
import { isWebUri } from 'valid-url';
import InputValidationField from '../../../components/controls/InputValidationField';
import ValidationModal from '../../../components/controls/ValidationModal';
import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker';
import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
import { translate } from '../../../helpers/l10n';
import { WebhookBasePayload, WebhookResponse } from '../../../types/webhook';
import UpdateWebhookSecretField from './UpdateWebhookSecretField';
@@ -68,25 +66,18 @@ export default function CreateWebhookForm({ webhook, onClose, onDone }: Props) {
}}
onClose={onClose}
onSubmit={onDone}
size="small"
validate={handleValidate}
>
{({ dirty, errors, handleBlur, handleChange, isSubmitting, touched, values }) => (
<>
<MandatoryFieldsExplanation className="big-spacer-bottom" />

<InputValidationField
required
autoFocus
dirty={dirty}
disabled={isSubmitting}
error={errors.name}
id="webhook-name"
label={
<label htmlFor="webhook-name">
{translate('webhooks.name')}
<MandatoryFieldMarker />
</label>
}
label={translate('webhooks.name')}
name="name"
onBlur={handleBlur}
onChange={handleChange}
@@ -95,17 +86,13 @@ export default function CreateWebhookForm({ webhook, onClose, onDone }: Props) {
value={values.name}
/>
<InputValidationField
required
description={translate('webhooks.url.description')}
dirty={dirty}
disabled={isSubmitting}
error={errors.url}
id="webhook-url"
label={
<label htmlFor="webhook-url">
{translate('webhooks.url')}
<MandatoryFieldMarker />
</label>
}
label={translate('webhooks.url')}
name="url"
onBlur={handleBlur}
onChange={handleChange}
@@ -122,7 +109,7 @@ export default function CreateWebhookForm({ webhook, onClose, onDone }: Props) {
error={errors.secret}
id="webhook-secret"
isUpdateForm={isUpdate}
label={<label htmlFor="webhook-secret">{translate('webhooks.secret')}</label>}
label={translate('webhooks.secret')}
name="secret"
onBlur={handleBlur}
onChange={handleChange}

+ 25
- 25
server/sonar-web/src/main/js/apps/webhooks/components/DeleteWebhookForm.tsx View File

@@ -17,10 +17,8 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { DangerButtonPrimary, Modal } from 'design-system';
import * as React from 'react';
import SimpleModal from '../../../components/controls/SimpleModal';
import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
import Spinner from '../../../components/ui/Spinner';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { WebhookResponse } from '../../../types/webhook';

@@ -30,32 +28,34 @@ interface Props {
webhook: WebhookResponse;
}

const FORM_ID = 'delete-webhook-modal';

export default function DeleteWebhookForm({ onClose, onSubmit, webhook }: Props) {
const header = translate('webhooks.delete');

return (
<SimpleModal header={header} onClose={onClose} onSubmit={onSubmit}>
{({ onCloseClick, onFormSubmit, submitting }) => (
<form onSubmit={onFormSubmit}>
<header className="modal-head">
<h2>{header}</h2>
</header>
const onFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit();
};

<div className="modal-body">
{translateWithParameters('webhooks.delete.confirm', webhook.name)}
</div>
const renderForm = (
<form id={FORM_ID} onSubmit={onFormSubmit}>
{translateWithParameters('webhooks.delete.confirm', webhook.name)}
</form>
);

<footer className="modal-foot">
<Spinner className="spacer-right" loading={submitting} />
<SubmitButton className="button-red" disabled={submitting}>
{translate('delete')}
</SubmitButton>
<ResetButtonLink disabled={submitting} onClick={onCloseClick}>
{translate('cancel')}
</ResetButtonLink>
</footer>
</form>
)}
</SimpleModal>
return (
<Modal
onClose={onClose}
headerTitle={header}
isOverflowVisible
body={renderForm}
primaryButton={
<DangerButtonPrimary form={FORM_ID} type="submit">
{translate('delete')}
</DangerButtonPrimary>
}
secondaryButtonLabel={translate('cancel')}
/>
);
}

+ 25
- 28
server/sonar-web/src/main/js/apps/webhooks/components/DeliveriesForm.tsx View File

@@ -17,13 +17,11 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { Modal, Spinner } from 'design-system';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { searchDeliveries } from '../../../api/webhooks';
import ListFooter from '../../../components/controls/ListFooter';
import Modal from '../../../components/controls/Modal';
import { ResetButtonLink } from '../../../components/controls/buttons';
import Spinner from '../../../components/ui/Spinner';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { Paging } from '../../../types/types';
import { WebhookDelivery, WebhookResponse } from '../../../types/webhook';
@@ -77,31 +75,30 @@ export default function DeliveriesForm({ onClose, webhook }: Props) {
}
}

const formBody = (
<Spinner loading={loading}>
{deliveries.map((delivery) => (
<DeliveryAccordion delivery={delivery} key={delivery.id} />
))}
{paging !== undefined && (
<ListFooter
className="sw-mb-2"
count={deliveries.length}
loadMore={fetchMoreDeliveries}
ready={!loading}
total={paging.total}
useMIUIButtons
/>
)}
</Spinner>
);

return (
<Modal contentLabel={header} onRequestClose={onClose}>
<header className="modal-head">
<h2>{header}</h2>
</header>
<div className="modal-body modal-container">
{deliveries.map((delivery) => (
<DeliveryAccordion delivery={delivery} key={delivery.id} />
))}
<div className="text-center">
<Spinner loading={loading} />
</div>
{paging !== undefined && (
<ListFooter
className="little-spacer-bottom"
count={deliveries.length}
loadMore={fetchMoreDeliveries}
ready={!loading}
total={paging.total}
/>
)}
</div>
<footer className="modal-foot">
<ResetButtonLink onClick={onClose}>{translate('close')}</ResetButtonLink>
</footer>
</Modal>
<Modal
onClose={onClose}
headerTitle={header}
body={formBody}
secondaryButtonLabel={translate('close')}
/>
);
}

+ 17
- 8
server/sonar-web/src/main/js/apps/webhooks/components/DeliveryAccordion.tsx View File

@@ -17,12 +17,12 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { FlagErrorIcon, FlagSuccessIcon, TextAccordion } from 'design-system';
import * as React from 'react';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { getDelivery } from '../../../api/webhooks';
import BoxedGroupAccordion from '../../../components/controls/BoxedGroupAccordion';
import AlertErrorIcon from '../../../components/icons/AlertErrorIcon';
import AlertSuccessIcon from '../../../components/icons/AlertSuccessIcon';
import { longFormatterOption } from '../../../components/intl/DateFormatter';
import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
import { translate } from '../../../helpers/l10n';
import { WebhookDelivery } from '../../../types/webhook';
@@ -37,6 +37,8 @@ export default function DeliveryAccordion({ delivery }: Props) {
const [open, setOpen] = useState(false);
const [payload, setPayload] = useState<string | undefined>(undefined);

const intl = useIntl();

async function fetchPayload() {
setLoading(true);
try {
@@ -55,24 +57,31 @@ export default function DeliveryAccordion({ delivery }: Props) {
}

return (
<BoxedGroupAccordion
<TextAccordion
ariaLabel={intl.formatDate(delivery.at, longFormatterOption)}
onClick={handleClick}
open={open}
renderHeader={() =>
delivery.success ? (
<AlertSuccessIcon aria-label={translate('success')} className="it__success" />
<FlagSuccessIcon
aria-label={translate('success')}
className="sw-pt-4 sw-pb-2 sw-pr-4 sw-float-right it__success"
/>
) : (
<AlertErrorIcon aria-label={translate('error')} />
<FlagErrorIcon
aria-label={translate('error')}
className="sw-pt-4 sw-pb-2 sw-pr-4 sw-float-right js-error"
/>
)
}
title={<DateTimeFormatter date={delivery.at} />}
>
<DeliveryItem
className="big-spacer-left"
className="it__accordion-content sw-ml-4"
delivery={delivery}
loading={loading}
payload={payload}
/>
</BoxedGroupAccordion>
</TextAccordion>
);
}

+ 12
- 7
server/sonar-web/src/main/js/apps/webhooks/components/DeliveryItem.tsx View File

@@ -17,9 +17,8 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { CodeSnippet, Spinner } from 'design-system';
import * as React from 'react';
import CodeSnippet from '../../../components/common/CodeSnippet';
import Spinner from '../../../components/ui/Spinner';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { formatMeasure } from '../../../helpers/measures';
import { WebhookDelivery } from '../../../types/webhook';
@@ -35,21 +34,27 @@ interface Props {
export default function DeliveryItem({ className, delivery, loading, payload }: Props) {
return (
<div className={className}>
<p className="spacer-bottom">
<p className="sw-mb-2">
{translateWithParameters(
'webhooks.delivery.response_x',
delivery.httpStatus ?? translate('webhooks.delivery.server_unreachable'),
)}
</p>
<p className="spacer-bottom">
<p className="sw-mb-2">
{translateWithParameters(
'webhooks.delivery.duration_x',
formatMeasure(delivery.durationMs, 'MILLISEC'),
)}
</p>
<p className="spacer-bottom">{translate('webhooks.delivery.payload')}</p>
<Spinner className="spacer-left spacer-top" loading={loading}>
{payload !== undefined && <CodeSnippet noCopy snippet={formatPayload(payload)} />}
<p className="sw-mb-2">{translate('webhooks.delivery.payload')}</p>
<Spinner loading={loading}>
{payload !== undefined && (
<CodeSnippet
className="sw-p-2 sw-max-h-abs-200 sw-overflow-y-scroll"
noCopy
snippet={formatPayload(payload)}
/>
)}
</Spinner>
</div>
);

+ 8
- 16
server/sonar-web/src/main/js/apps/webhooks/components/LatestDeliveryForm.tsx View File

@@ -17,11 +17,10 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { Modal } from 'design-system';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { getDelivery } from '../../../api/webhooks';
import Modal from '../../../components/controls/Modal';
import { ResetButtonLink } from '../../../components/controls/buttons';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { WebhookDelivery, WebhookResponse } from '../../../types/webhook';
import DeliveryItem from './DeliveryItem';
@@ -53,19 +52,12 @@ export default function LatestDeliveryForm(props: Props) {
}, [fetchPayload]);

return (
<Modal contentLabel={header} onRequestClose={onClose}>
<header className="modal-head">
<h2>{header}</h2>
</header>
<DeliveryItem
className="modal-body modal-container"
delivery={delivery}
loading={loading}
payload={payload}
/>
<footer className="modal-foot">
<ResetButtonLink onClick={onClose}>{translate('close')}</ResetButtonLink>
</footer>
</Modal>
<Modal
onClose={onClose}
headerTitle={header}
isOverflowVisible
body={<DeliveryItem delivery={delivery} loading={loading} payload={payload} />}
secondaryButtonLabel={translate('cancel')}
/>
);
}

+ 10
- 12
server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx View File

@@ -17,10 +17,10 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { ButtonPrimary } from 'design-system/lib';
import * as React from 'react';
import { useState } from 'react';
import Tooltip from '../../../components/controls/Tooltip';
import { Button } from '../../../components/controls/buttons';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import CreateWebhookForm from './CreateWebhookForm';

@@ -51,22 +51,20 @@ export default function PageActions(props: Props) {

if (webhooksCount >= WEBHOOKS_LIMIT) {
return (
<div className="page-actions">
<Tooltip overlay={translateWithParameters('webhooks.maximum_reached', WEBHOOKS_LIMIT)}>
<Button className="it__webhook-create" disabled>
{translate('create')}
</Button>
</Tooltip>
</div>
<Tooltip overlay={translateWithParameters('webhooks.maximum_reached', WEBHOOKS_LIMIT)}>
<ButtonPrimary className="it__webhook-create" disabled>
{translate('create')}
</ButtonPrimary>
</Tooltip>
);
}

return (
<div className="page-actions">
<Button className="it__webhook-create" onClick={handleCreateOpen}>
<>
<ButtonPrimary className="it__webhook-create" onClick={handleCreateOpen}>
{translate('create')}
</Button>
</ButtonPrimary>
{openCreate && <CreateWebhookForm onClose={handleCreateClose} onDone={onCreate} />}
</div>
</>
);
}

+ 20
- 22
server/sonar-web/src/main/js/apps/webhooks/components/PageHeader.tsx View File

@@ -17,37 +17,35 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { Link, Title } from 'design-system';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import DocLink from '../../../components/common/DocLink';
import { useDocUrl } from '../../../helpers/docs';
import { translate } from '../../../helpers/l10n';

interface Props {
children?: React.ReactNode;
loading: boolean;
}

export default function PageHeader({ children, loading }: Props) {
return (
<header className="page-header">
<h1 className="page-title">{translate('webhooks.page')}</h1>
{loading && <i className="spinner" />}

{children}
export default function PageHeader({ children }: Readonly<Props>) {
const toUrl = useDocUrl('/project-administration/webhooks/');

<p className="page-description">
<FormattedMessage
defaultMessage={translate('webhooks.description')}
id="webhooks.description"
values={{
url: (
<DocLink to="/project-administration/webhooks/">
{translate('webhooks.documentation_link')}
</DocLink>
),
}}
/>
</p>
return (
<header className="sw-mb-2 sw-flex sw-items-center sw-justify-between">
<div>
<Title>{translate('webhooks.page')}</Title>
<p>{translate('webhooks.description0')}</p>
<p>
<FormattedMessage
defaultMessage={translate('webhooks.description1')}
id="webhooks.description"
values={{
url: <Link to={toUrl}>{translate('webhooks.documentation_link')}</Link>,
}}
/>
</p>
</div>
<div>{children}</div>
</header>
);
}

+ 21
- 6
server/sonar-web/src/main/js/apps/webhooks/components/UpdateWebhookSecretField.tsx View File

@@ -17,11 +17,12 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { DiscreetLink, FlagMessage } from 'design-system';
import { useField } from 'formik';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import InputValidationField from '../../../components/controls/InputValidationField';
import ModalValidationField from '../../../components/controls/ModalValidationField';
import { ButtonLink } from '../../../components/controls/buttons';
import { translate } from '../../../helpers/l10n';

interface Props {
@@ -89,11 +90,25 @@ export default function UpdateWebhookSecretField(props: Props) {
touched
>
{() => (
<div className="sw-mb-5 sw-leading-6 sw-flex sw-items-center">
<span className="sw-mr-1/2">{translate('webhooks.secret.field_mask.description')}</span>
<ButtonLink onClick={showSecretInput}>
{translate('webhooks.secret.field_mask.link')}
</ButtonLink>
<div>
<FlagMessage variant="info" className="sw-w-full">
<FormattedMessage
defaultMessage={translate('webhooks.secret.field_mask.description')}
id="webhooks.secret.field_mask.description"
values={{
link: (
<DiscreetLink
className="sw-ml-1"
onClick={showSecretInput}
preventDefault
to={{}}
>
{translate('webhooks.secret.field_mask.link')}
</DiscreetLink>
),
}}
/>
</FlagMessage>
</div>
)}
</ModalValidationField>

+ 10
- 21
server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx View File

@@ -17,12 +17,10 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { ActionsDropdown, ItemButton, ItemDangerButton } from 'design-system';
import * as React from 'react';
import { useState } from 'react';
import ActionsDropdown, {
ActionsDropdownDivider,
ActionsDropdownItem,
} from '../../../components/controls/ActionsDropdown';

import { translate, translateWithParameters } from '../../../helpers/l10n';
import { WebhookResponse, WebhookUpdatePayload } from '../../../types/webhook';
import CreateWebhookForm from './CreateWebhookForm';
@@ -49,28 +47,19 @@ export default function WebhookActions(props: Props) {
return (
<>
<ActionsDropdown
className="big-spacer-left"
label={translateWithParameters('webhooks.show_actions', webhook.name)}
toggleClassName="it__webhook-actions"
id={webhook.key}
ariaLabel={translateWithParameters('webhooks.show_actions', webhook.name)}
>
<ActionsDropdownItem onClick={() => setUpdating(true)}>
{translate('update_verb')}
</ActionsDropdownItem>
<ItemButton onClick={() => setUpdating(true)}>{translate('update_verb')}</ItemButton>
{webhook.latestDelivery && (
<ActionsDropdownItem
className="it__webhook-deliveries"
onClick={() => setDeliveries(true)}
>
<ItemButton className="it__webhook-deliveries" onClick={() => setDeliveries(true)}>
{translate('webhooks.deliveries.show')}
</ActionsDropdownItem>
</ItemButton>
)}
<ActionsDropdownDivider />
<ActionsDropdownItem
className="it__webhook-delete"
destructive
onClick={() => setDeleting(true)}
>
<ItemDangerButton className="it__webhook-delete" onClick={() => setDeleting(true)}>
{translate('delete')}
</ActionsDropdownItem>
</ItemDangerButton>
</ActionsDropdown>

{deliveries && <DeliveriesForm onClose={() => setDeliveries(false)} webhook={webhook} />}

+ 10
- 9
server/sonar-web/src/main/js/apps/webhooks/components/WebhookItem.tsx View File

@@ -17,6 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { ActionCell, ContentCell, TableRowInteractive } from 'design-system';
import * as React from 'react';
import { translate } from '../../../helpers/l10n';
import { WebhookResponse, WebhookUpdatePayload } from '../../../types/webhook';
@@ -31,16 +32,16 @@ interface Props {

export default function WebhookItem({ onDelete, onUpdate, webhook }: Props) {
return (
<tr>
<td>{webhook.name}</td>
<td>{webhook.url}</td>
<td>{webhook.hasSecret ? translate('yes') : translate('no')}</td>
<td>
<TableRowInteractive>
<ContentCell>{webhook.name}</ContentCell>
<ContentCell>{webhook.url}</ContentCell>
<ContentCell>{webhook.hasSecret ? translate('yes') : translate('no')}</ContentCell>
<ContentCell>
<WebhookItemLatestDelivery webhook={webhook} />
</td>
<td className="sw-text-right">
</ContentCell>
<ActionCell className="sw-text-right">
<WebhookActions onDelete={onDelete} onUpdate={onUpdate} webhook={webhook} />
</td>
</tr>
</ActionCell>
</TableRowInteractive>
);
}

+ 15
- 20
server/sonar-web/src/main/js/apps/webhooks/components/WebhookItemLatestDelivery.tsx View File

@@ -17,12 +17,9 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { FlagErrorIcon, FlagSuccessIcon, InteractiveIcon, MenuIcon } from 'design-system';
import * as React from 'react';
import { useState } from 'react';
import { ButtonIcon } from '../../../components/controls/buttons';
import AlertErrorIcon from '../../../components/icons/AlertErrorIcon';
import AlertSuccessIcon from '../../../components/icons/AlertSuccessIcon';
import BulletListIcon from '../../../components/icons/BulletListIcon';
import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { WebhookResponse } from '../../../types/webhook';
@@ -40,22 +37,20 @@ export default function WebhookItemLatestDelivery({ webhook }: Props) {
}

return (
<>
{webhook.latestDelivery.success ? (
<AlertSuccessIcon className="text-text-top" />
) : (
<AlertErrorIcon className="text-text-top" />
)}
<span className="spacer-left display-inline-flex-center">
<div className="sw-flex sw-items-center">
{webhook.latestDelivery.success ? <FlagSuccessIcon /> : <FlagErrorIcon />}
<div className="sw-ml-2 sw-flex sw-items-center">
<DateTimeFormatter date={webhook.latestDelivery.at} />
<ButtonIcon
aria-label={translateWithParameters('webhooks.last_execution.open_for_x', webhook.name)}
className="button-small little-spacer-left"
onClick={() => setModalOpen(true)}
>
<BulletListIcon />
</ButtonIcon>
</span>
<span title={translateWithParameters('webhooks.last_execution.open_for_x', webhook.name)}>
<InteractiveIcon
className="sw-ml-2"
Icon={MenuIcon}
aria-label={translateWithParameters('webhooks.last_execution.open_for_x', webhook.name)}
onClick={() => setModalOpen(true)}
size="small"
/>
</span>
</div>

{modalOpen && (
<LatestDeliveryForm
@@ -64,6 +59,6 @@ export default function WebhookItemLatestDelivery({ webhook }: Props) {
webhook={webhook}
/>
)}
</>
</div>
);
}

+ 25
- 22
server/sonar-web/src/main/js/apps/webhooks/components/WebhooksList.tsx View File

@@ -17,6 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { ActionCell, ContentCell, Table, TableRow } from 'design-system';
import { sortBy } from 'lodash';
import * as React from 'react';
import { translate } from '../../../helpers/l10n';
@@ -29,32 +30,34 @@ interface Props {
webhooks: WebhookResponse[];
}

const COLUMN_WIDTHS = ['auto', 'auto', 'auto', 'auto', '5%'];

export default function WebhooksList({ webhooks, onDelete, onUpdate }: Props) {
if (webhooks.length < 1) {
return <p>{translate('webhooks.no_result')}</p>;
return <p className="it__webhook-empty-list">{translate('webhooks.no_result')}</p>;
}

const tableHeader = (
<TableRow>
<ContentCell>{translate('name')}</ContentCell>
<ContentCell>{translate('webhooks.url')}</ContentCell>
<ContentCell>{translate('webhooks.secret_header')}</ContentCell>
<ContentCell>{translate('webhooks.last_execution')}</ContentCell>
<ActionCell>{translate('actions')}</ActionCell>
</TableRow>
);

return (
<table className="data zebra">
<thead>
<tr>
<th>{translate('name')}</th>
<th>{translate('webhooks.url')}</th>
<th>{translate('webhooks.secret_header')}</th>
<th>{translate('webhooks.last_execution')}</th>
<th className="sw-text-right">{translate('actions')}</th>
</tr>
</thead>
<tbody>
{sortBy(webhooks, (webhook) => webhook.name.toLowerCase()).map((webhook) => (
<WebhookItem
key={webhook.key}
onDelete={onDelete}
onUpdate={onUpdate}
webhook={webhook}
/>
))}
</tbody>
</table>
<Table
className="it__webhooks-list"
noHeaderTopBorder
columnCount={COLUMN_WIDTHS.length}
columnWidths={COLUMN_WIDTHS}
header={tableHeader}
>
{sortBy(webhooks, (webhook) => webhook.name.toLowerCase()).map((webhook) => (
<WebhookItem key={webhook.key} onDelete={onDelete} onUpdate={onUpdate} webhook={webhook} />
))}
</Table>
);
}

+ 24
- 20
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/App-it.tsx View File

@@ -128,7 +128,7 @@ describe('webhook CRUD', () => {
renderWebhooksApp();
await ui.waitForWebhooksLoaded();

await ui.clickWebhookRowAction(1, 'Global webhook 2', 'update_verb');
await ui.clickWebhookRowAction(1, 'Global webhook 2', 'update_verb', 'menuitem');
await ui.fillUpdateForm('modified-webhook', 'https://webhook.example.sonarqube.com', 'secret');
await ui.submitForm();

@@ -140,7 +140,7 @@ describe('webhook CRUD', () => {
});

// Edit again, removing the secret
await ui.clickWebhookRowAction(1, 'modified-webhook', 'update_verb');
await ui.clickWebhookRowAction(1, 'modified-webhook', 'update_verb', 'menuitem');
await ui.fillUpdateForm(undefined, undefined, '');
await ui.submitForm();

@@ -152,7 +152,7 @@ describe('webhook CRUD', () => {
});

// Edit once again, not touching the secret
await ui.clickWebhookRowAction(1, 'modified-webhook', 'update_verb');
await ui.clickWebhookRowAction(1, 'modified-webhook', 'update_verb', 'menuitem');
await ui.fillUpdateForm('modified-webhook2');
await ui.submitForm();

@@ -170,7 +170,7 @@ describe('webhook CRUD', () => {
await ui.waitForWebhooksLoaded();

expect(ui.webhookRow.getAll()).toHaveLength(3); // We count the header
await ui.clickWebhookRowAction(0, 'Global webhook 1', 'delete');
await ui.clickWebhookRowAction(0, 'Global webhook 1', 'delete', 'menuitem');
await ui.submitForm();
expect(ui.webhookRow.getAll()).toHaveLength(2);
});
@@ -182,31 +182,31 @@ describe('should properly show deliveries', () => {
renderWebhooksApp();
await ui.waitForWebhooksLoaded();

await ui.clickWebhookRowAction(0, 'Global webhook 1', 'webhooks.deliveries.show');
ui.checkDeliveryRow(0, {
await ui.clickWebhookRowAction(0, 'Global webhook 1', 'webhooks.deliveries.show', 'menuitem');
ui.checkDeliveryRow(1, {
date: 'June 24, 2019',
status: 'success',
});
ui.checkDeliveryRow(1, {
ui.checkDeliveryRow(2, {
date: 'June 23, 2019',
status: 'success',
});
ui.checkDeliveryRow(2, {
ui.checkDeliveryRow(3, {
date: 'June 22, 2019',
status: 'error',
});
ui.checkDeliveryRow(3, {
ui.checkDeliveryRow(4, {
date: 'June 21, 2019',
status: 'success',
});

await ui.toggleDeliveryRow(1);
await ui.toggleDeliveryRow(2);
expect(screen.getByText('webhooks.delivery.response_x.200')).toBeInTheDocument();
expect(screen.getByText('webhooks.delivery.duration_x.1s')).toBeInTheDocument();
expect(screen.getByText('{ "id": "global-webhook-1-delivery-0" }')).toBeInTheDocument();

await ui.toggleDeliveryRow(1);
await ui.toggleDeliveryRow(2);
await ui.toggleDeliveryRow(3);
expect(
screen.getByText('webhooks.delivery.response_x.webhooks.delivery.server_unreachable'),
).toBeInTheDocument();
@@ -230,7 +230,7 @@ describe('should properly show deliveries', () => {
renderWebhooksApp();
await ui.waitForWebhooksLoaded();

await ui.clickWebhookRowAction(0, 'Global webhook 1', 'webhooks.deliveries.show');
await ui.clickWebhookRowAction(0, 'Global webhook 1', 'webhooks.deliveries.show', 'menuitem');
expect(screen.getByText('x_of_y_shown.10.16')).toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'show_more' }));
@@ -253,9 +253,9 @@ function getPageObject() {
formDialog: byRole('dialog'),
formNameInput: byRole('textbox', { name: 'webhooks.name field_required' }),
formUrlInput: byRole('textbox', { name: 'webhooks.url field_required' }),
formSecretInput: byLabelText('webhooks.secret'),
formSecretInputMaskButton: byRole('button', { name: 'webhooks.secret.field_mask.link' }),
formUpdateButton: byRole('button', { name: 'update_verb' }),
formSecretInput: byLabelText(/webhooks.secret/),
formSecretInputMaskButton: byRole('link', { name: 'webhooks.secret.field_mask.link' }),
formUpdateButton: byRole('menuitem', { name: 'update_verb' }),
};

const ui = {
@@ -273,12 +273,17 @@ function getPageObject() {
getWebhookRow: (index: number) => {
return selectors.webhookRow.getAll()[index + 1];
},
clickWebhookRowAction: async (rowIndex: number, webhookName: string, actionName: string) => {
clickWebhookRowAction: async (
rowIndex: number,
webhookName: string,
actionName: string,
role: string = 'button',
) => {
const row = ui.getWebhookRow(rowIndex);
await user.click(
within(row).getByRole('button', { name: `webhooks.show_actions.${webhookName}` }),
);
await user.click(within(row).getByRole('button', { name: actionName }));
await user.click(within(row).getByRole(role, { name: actionName }));
},
clickWebhookLatestDelivery: async (rowIndex: number, webhookName: string) => {
const row = ui.getWebhookRow(rowIndex);
@@ -344,13 +349,12 @@ function getPageObject() {
// Deliveries
getDeliveryRow: (index: number) => {
const dialog = selectors.formDialog.get();
const rows = within(dialog).getAllByRole('listitem');
const rows = within(dialog).getAllByRole('heading');
return rows[index];
},
checkDeliveryRow: (index: number, expected: { date: string; status: 'success' | 'error' }) => {
const row = ui.getDeliveryRow(index);
const date = within(row).getByRole('button');
expect(date).toHaveTextContent(new RegExp(expected.date));
expect(row).toHaveTextContent(new RegExp(expected.date));
const status = within(row).getByLabelText(expected.status);
expect(status).toBeInTheDocument();
},

+ 8
- 7
server/sonar-web/src/main/js/components/controls/InputValidationField.tsx View File

@@ -17,7 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';
import { InputField } from 'design-system';
import * as React from 'react';
import ModalValidationField from './ModalValidationField';

@@ -37,15 +37,16 @@ interface Props {
touched: boolean | undefined;
type?: string;
value: string;
required?: boolean;
}

export default function InputValidationField({ className, ...props }: Props) {
const { description, dirty, error, label, touched, ...inputProps } = props;
const modalValidationProps = { description, dirty, error, label, touched };
export default function InputValidationField({ ...props }: Readonly<Props>) {
const { description, dirty, error, label, touched, required, ...inputProps } = props;
const modalValidationProps = { description, dirty, error, label, touched, required };
return (
<ModalValidationField {...modalValidationProps}>
{({ className: validationClassName }) => (
<input className={classNames(className, validationClassName)} {...inputProps} />
<ModalValidationField id={props.id} {...modalValidationProps}>
{({ isInvalid, isValid }) => (
<InputField size="full" isInvalid={isInvalid} isValid={isValid} {...inputProps} />
)}
</ModalValidationField>
);

+ 26
- 13
server/sonar-web/src/main/js/components/controls/ModalValidationField.tsx View File

@@ -17,33 +17,46 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';
import styled from '@emotion/styled';
import { FlagErrorIcon, FlagSuccessIcon, FormField, Note, themeColor } from 'design-system';
import * as React from 'react';
import AlertErrorIcon from '../icons/AlertErrorIcon';
import AlertSuccessIcon from '../icons/AlertSuccessIcon';
import { translate } from '../../helpers/l10n';

interface Props {
children: (props: { className?: string }) => React.ReactNode;
children: (props: { isInvalid?: boolean; isValid?: boolean }) => React.ReactNode;
description?: string;
dirty: boolean;
error: string | undefined;
id?: string;
label?: React.ReactNode;
required?: boolean;
touched: boolean | undefined;
}

export default function ModalValidationField(props: Props) {
const { description, dirty, error } = props;
const { description, dirty, error, label, id, required } = props;

const isValid = dirty && props.touched && error === undefined;
const showError = dirty && props.touched && error !== undefined;
return (
<div className="modal-validation-field">
{props.label}
{props.children({ className: classNames({ 'is-invalid': showError, 'is-valid': isValid }) })}
{showError && <AlertErrorIcon className="little-spacer-top" />}
{isValid && <AlertSuccessIcon className="little-spacer-top" />}
{showError && <p className="text-danger">{error}</p>}
{description && <div className="modal-field-description">{description}</div>}
</div>
<FormField
label={label}
htmlFor={id}
required={required}
requiredAriaLabel={translate('field_required')}
>
<div className="sw-flex sw-items-center sw-justify-between">
{props.children({ isInvalid: showError, isValid })}
{showError && <FlagErrorIcon className="sw-ml-2" />}
{isValid && <FlagSuccessIcon className="sw-ml-2" />}
</div>

{showError && <StyledNote className="sw-mt-2">{error}</StyledNote>}
{description && <Note className="sw-mt-2">{description}</Note>}
</FormField>
);
}

const StyledNote = styled(Note)`
color: ${themeColor('errorText')};
`;

+ 26
- 30
server/sonar-web/src/main/js/components/controls/ValidationModal.tsx View File

@@ -17,20 +17,17 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { ButtonPrimary, ButtonSecondary, Modal } from 'design-system';
import { FormikValues } from 'formik';
import * as React from 'react';
import { translate } from '../../helpers/l10n';
import Spinner from '../ui/Spinner';
import Modal, { ModalProps } from './Modal';
import ValidationForm, { ChildrenProps } from './ValidationForm';
import { ResetButtonLink, SubmitButton } from './buttons';

interface Props<V> extends Omit<ModalProps, 'children'> {
interface Props<V> {
children: (props: ChildrenProps<V>) => React.ReactNode;
confirmButtonText: string;
header: string;
initialValues: V;
isDestructive?: boolean;
onClose: () => void;
onSubmit: (data: V) => Promise<void>;
validate: (data: V) => { [P in keyof V]?: string };
@@ -45,37 +42,36 @@ export default class ValidationModal<V extends FormikValues> extends React.PureC

render() {
return (
<Modal
contentLabel={this.props.header}
noBackdrop={this.props.noBackdrop}
onRequestClose={this.props.onClose}
size={this.props.size}
>
<Modal onClose={this.props.onClose}>
<ValidationForm
initialValues={this.props.initialValues}
onSubmit={this.handleSubmit}
validate={this.props.validate}
>
{(props) => (
{(formState) => (
<>
<header className="modal-head">
<h2>{this.props.header}</h2>
</header>

<div className="modal-body">{this.props.children(props)}</div>

<footer className="modal-foot">
<Spinner className="spacer-right" loading={props.isSubmitting} />
<SubmitButton
className={this.props.isDestructive ? 'button-red' : undefined}
disabled={props.isSubmitting || !props.isValid || !props.dirty}
>
{this.props.confirmButtonText}
</SubmitButton>
<ResetButtonLink disabled={props.isSubmitting} onClick={this.props.onClose}>
{translate('cancel')}
</ResetButtonLink>
</footer>
<Modal.Header title={this.props.header} />
<div className="sw-py-4">{this.props.children(formState)}</div>
<Modal.Footer
loading={formState.isSubmitting}
primaryButton={
<ButtonPrimary
type="submit"
disabled={formState.isSubmitting || !formState.isValid || !formState.dirty}
>
{this.props.confirmButtonText}
</ButtonPrimary>
}
secondaryButton={
<ButtonSecondary
className="sw-ml-2"
disabled={formState.isSubmitting}
onClick={this.props.onClose}
>
{translate('cancel')}
</ButtonSecondary>
}
/>
</>
)}
</ValidationForm>

+ 4
- 3
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -5113,7 +5113,8 @@ webhooks.page=Webhooks
webhooks.create=Create Webhook
webhooks.delete=Delete Webhook
webhooks.delete.confirm=Are you sure you want to delete the webhook "{0}"?
webhooks.description=Webhooks are used to notify external services when a project analysis is done. An HTTP POST request including a JSON payload is sent to each of the provided URLs. Learn more in the {url}.
webhooks.description0=Webhooks are used to notify external services when a project analysis is done.
webhooks.description1=An HTTP POST request including a JSON payload is sent to each of the provided URLs. Learn more in the {url}.
webhooks.deliveries.show=Show recent deliveries
webhooks.show_actions=Show actions for webhook {0}
webhooks.deliveries_for_x=Recent deliveries of {0}
@@ -5136,8 +5137,8 @@ webhooks.secret_header=Has secret?
webhooks.secret.bad_format=Secret must have a maximum length of 200 characters
webhooks.secret.description=If provided, secret will be used as the key to generate the HMAC hex (lowercase) digest value in the 'X-Sonar-Webhook-HMAC-SHA256' header.
webhooks.secret.description.update=If blank, any secret previously configured will be removed. If not set, the secret will remain unchanged.
webhooks.secret.field_mask.description=Hidden for security reasons.
webhooks.secret.field_mask.link=Click here to update the secret
webhooks.secret.field_mask.description=Hidden for security reasons: {link}.
webhooks.secret.field_mask.link= edit secret
webhooks.url=URL
webhooks.url.bad_format=Bad format of URL.
webhooks.url.bad_protocol=URL must start with "http://" or "https://".

Loading…
Cancel
Save