From 09178dd5f21923999b494d6604d97c552730fb52 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Mon, 17 Aug 2020 14:16:18 -0700 Subject: [PATCH] flush out notification form and type subform --- awx/ui_next/src/components/Lookup/index.js | 1 + .../Credential/shared/CredentialForm.jsx | 4 +- .../NotificationTemplate.jsx | 23 +- .../NotificationTemplateDetail.jsx | 2 +- .../NotificationTemplateEdit.jsx | 20 +- .../shared/NotificationTemplateForm.jsx | 147 ++++- .../shared/TypeInputsSubForm.jsx | 528 ++++++++++++++++++ awx/ui_next/src/types.js | 24 + awx/ui_next/src/util/validators.jsx | 15 + awx/ui_next/src/util/validators.test.js | 21 + 10 files changed, 763 insertions(+), 22 deletions(-) create mode 100644 awx/ui_next/src/screens/NotificationTemplate/shared/TypeInputsSubForm.jsx diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js index 2b9b147941..a2fcfbe570 100644 --- a/awx/ui_next/src/components/Lookup/index.js +++ b/awx/ui_next/src/components/Lookup/index.js @@ -6,3 +6,4 @@ export { default as MultiCredentialsLookup } from './MultiCredentialsLookup'; export { default as CredentialLookup } from './CredentialLookup'; export { default as ApplicationLookup } from './ApplicationLookup'; export { default as HostFilterLookup } from './HostFilterLookup'; +export { default as OrganizationLookup } from './OrganizationLookup'; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx index 62193584d3..e8ee83b1dd 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx @@ -104,7 +104,7 @@ function CredentialFormFields({ error={orgMeta.error} /> { - const { data } = await NotificationTemplatesAPI.readDetail(templateId); - setBreadcrumb(data); - return data; + const [detail, options] = await Promise.all([ + NotificationTemplatesAPI.readDetail(templateId), + NotificationTemplatesAPI.readOptions(), + ]); + setBreadcrumb(detail.data); + return { + template: detail.data, + defaultMessages: options.data.actions.POST.messages, + }; }, [templateId, setBreadcrumb]), - null + { template: null, defaultMessages: null } ); useEffect(() => { @@ -88,11 +94,18 @@ function NotificationTemplate({ setBreadcrumb, i18n }) { to="/notification_templates/:id/details" exact /> + {/* + + */} {template && ( <> diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx index 951ba5bd8b..a70f691e02 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx @@ -234,7 +234,7 @@ function NotificationTemplateDetail({ i18n, template }) { - - {({ me }) => ( - - )} - + ); } diff --git a/awx/ui_next/src/screens/NotificationTemplate/shared/NotificationTemplateForm.jsx b/awx/ui_next/src/screens/NotificationTemplate/shared/NotificationTemplateForm.jsx index c08caaa3e5..078c2aac6b 100644 --- a/awx/ui_next/src/screens/NotificationTemplate/shared/NotificationTemplateForm.jsx +++ b/awx/ui_next/src/screens/NotificationTemplate/shared/NotificationTemplateForm.jsx @@ -1,3 +1,146 @@ -export default function NotificationTemplateForm() { - // +import React, { useContext, useEffect, useState } from 'react'; +import { shape, func } from 'prop-types'; +import { Formik, useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Form, FormGroup } from '@patternfly/react-core'; + +import { OrganizationsAPI } from '../../../api'; +import { ConfigContext } from '../../../contexts/Config'; +import AnsibleSelect from '../../../components/AnsibleSelect'; +import ContentError from '../../../components/ContentError'; +import ContentLoading from '../../../components/ContentLoading'; +import FormField, { FormSubmitError } from '../../../components/FormField'; +import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup'; +import { OrganizationLookup } from '../../../components/Lookup'; +import { getAddedAndRemoved } from '../../../util/lists'; +import { required, minMaxValue } from '../../../util/validators'; +import { FormColumnLayout } from '../../../components/FormLayout'; +import TypeInputsSubForm from './TypeInputsSubForm'; +import { NotificationTemplate } from '../../../types'; + +function NotificationTemplateFormFields({ i18n, defaultMessages }) { + const [orgField, orgMeta, orgHelpers] = useField('organization'); + const [typeField, typeMeta, typeHelpers] = useField({ + name: 'notification_type', + validate: required(i18n._(t`Select a value for this field`), i18n), + }); + + return ( + <> + + + orgHelpers.setTouched()} + onChange={value => { + orgHelpers.setValue(value); + }} + value={orgField.value} + touched={orgMeta.touched} + error={orgMeta.error} + required + /> + + + + {typeField.value && } + + ); } + +function NotificationTemplateForm({ + template, + defaultMessages, + onSubmit, + onCancel, + submitError, + i18n, +}) { + const handleSubmit = values => { + console.log(values); + // onSubmit(values); + }; + + return ( + + {formik => ( +
+ + + + + +
+ )} +
+ ); +} + +NotificationTemplateForm.propTypes = { + template: NotificationTemplate, + defaultMessages: shape().isRequired, + onSubmit: func.isRequired, + onCancel: func.isRequired, + submitError: shape(), +}; + +NotificationTemplateForm.defaultProps = { + template: { + name: '', + description: '', + notification_type: '', + }, + submitError: null, +}; + +export default withI18n()(NotificationTemplateForm); diff --git a/awx/ui_next/src/screens/NotificationTemplate/shared/TypeInputsSubForm.jsx b/awx/ui_next/src/screens/NotificationTemplate/shared/TypeInputsSubForm.jsx new file mode 100644 index 0000000000..1ed9eb2724 --- /dev/null +++ b/awx/ui_next/src/screens/NotificationTemplate/shared/TypeInputsSubForm.jsx @@ -0,0 +1,528 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { FormGroup, Title } from '@patternfly/react-core'; +import { + FormCheckboxLayout, + FormColumnLayout, + FormFullWidthLayout, + SubFormLayout, +} from '../../../components/FormLayout'; +import FormField, { + PasswordField, + CheckboxField, + FieldTooltip, +} from '../../../components/FormField'; +import AnsibleSelect from '../../../components/AnsibleSelect'; +import CodeMirrorInput from '../../../components/CodeMirrorInput'; +import { + combine, + required, + requiredEmail, + url, +} from '../../../util/validators'; +import { NotificationType } from '../../../types'; + +const TypeFields = { + email: EmailFields, + grafana: GrafanaFields, + irc: IRCFields, + mattermost: MattermostFields, + pagerduty: PagerdutyFields, + rocketchat: RocketChatFields, + slack: SlackFields, + twilio: TwilioFields, + webhook: WebhookFields, +}; + +function TypeInputsSubForm({ type, i18n }) { + const Fields = TypeFields[type]; + return ( + + + {i18n._(t`Type Details`)} + + + + + + ); +} +TypeInputsSubForm.propTypes = { + type: NotificationType.isRequired, +}; + +export default withI18n()(TypeInputsSubForm); + +function EmailFields({ i18n }) { + const [optionsField, optionsMeta] = useField({ + name: 'emailOptions', + validate: required(i18n._(t`Select a value for this field`), i18n), + }); + return ( + <> + + + + + + + + + + + + ); +} + +function GrafanaFields({ i18n }) { + return ( + <> + + + + + + + + ); +} + +function IRCFields({ i18n }) { + return ( + <> + + + + + + + + ); +} + +function MattermostFields({ i18n }) { + return ( + <> + + + + + + + ); +} + +function PagerdutyFields({ i18n }) { + return ( + <> + + + + + + ); +} + +function RocketChatFields({ i18n }) { + return ( + <> + + + + + + ); +} + +function SlackFields({ i18n }) { + return ( + <> + + + + + ); +} + +function TwilioFields({ i18n }) { + return ( + <> + + + + + + ); +} + +function WebhookFields({ i18n }) { + const [methodField, methodMeta] = useField({ + name: 'notification_configuration.http_method', + validate: required(i18n._(t`Select a value for this field`), i18n), + }); + const [headersField, headersMeta, headersHelpers] = useField({ + name: 'notification_configuration.headers', + validate: required(i18n._(t`Select enter a value for this field`), i18n), + }); + return ( + <> + + + + + + + } + > + { + headersHelpers.setValue(value); + }} + mode="javascript" + rows="5" + /> + + + + + + + ); +} diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index e1fa5a5163..7f83e31490 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -367,3 +367,27 @@ export const CredentialType = shape({ namespace: string, inputs: shape({}).isRequired, }); + +export const NotificationType = oneOf([ + 'email', + 'grafana', + 'irc', + 'mattermost', + 'pagerduty', + 'rocketchat', + 'slack', + 'twilio', + 'webhook', +]); + +export const NotificationTemplate = shape({ + id: number.isRequired, + name: string.isRequired, + description: string, + url: string.isRequired, + organization: number.isRequired, + notification_type: NotificationType, + summary_fields: shape({ + organization: Organization, + }), +}); diff --git a/awx/ui_next/src/util/validators.jsx b/awx/ui_next/src/util/validators.jsx index 035b4dfb56..959740d317 100644 --- a/awx/ui_next/src/util/validators.jsx +++ b/awx/ui_next/src/util/validators.jsx @@ -76,6 +76,21 @@ export function integer(i18n) { }; } +export function url(i18n) { + return value => { + // URL regex from https://urlregex.com/ + if ( + // eslint-disable-next-line max-len + !/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www\.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w\-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[.!/\\\w]*))?)/.test( + value + ) + ) { + return i18n._(t`Please enter a valid URL`); + } + return undefined; + }; +} + export function combine(validators) { return value => { for (let i = 0; i < validators.length; i++) { diff --git a/awx/ui_next/src/util/validators.test.js b/awx/ui_next/src/util/validators.test.js index 11b1a3bfd9..fcde053dbb 100644 --- a/awx/ui_next/src/util/validators.test.js +++ b/awx/ui_next/src/util/validators.test.js @@ -4,6 +4,7 @@ import { maxLength, noWhiteSpace, integer, + url, combine, regExp, } from './validators'; @@ -111,6 +112,26 @@ describe('validators', () => { }); }); + test('url should reject incomplete url', () => { + expect(url(i18n)('abcd')).toEqual({ + id: 'Please enter a valid URL', + }); + }); + + test('url should accept fully qualified url', () => { + expect(url(i18n)('http://example.com/foo')).toBeUndefined(); + }); + + test('url should accept url with query params', () => { + expect(url(i18n)('https://example.com/foo?bar=baz')).toBeUndefined(); + }); + + test('url should reject short protocol', () => { + expect(url(i18n)('h://example.com/foo')).toEqual({ + id: 'Please enter a valid URL', + }); + }); + test('combine should run all validators', () => { const validators = [required(null, i18n), noWhiteSpace(i18n)]; expect(combine(validators)('')).toEqual({