-
Notifications
You must be signed in to change notification settings - Fork 73
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
9390b72
commit 7e1e999
Showing
9 changed files
with
325 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
.system-name-label-text { | ||
display: inline; | ||
margin-right: 5px; | ||
} |
197 changes: 197 additions & 0 deletions
197
portafly/src/components/pages/product/CreateProductPage.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,197 @@ | ||
import React, { | ||
useState, | ||
FormEventHandler, | ||
FocusEventHandler, | ||
useEffect | ||
} from 'react' | ||
|
||
import { | ||
PageSection, | ||
TextContent, | ||
Text, | ||
Card, | ||
CardBody, | ||
Form, | ||
FormGroup, | ||
TextInput, | ||
TextArea, | ||
ActionGroup, | ||
Button | ||
} from '@patternfly/react-core' | ||
import { useTranslation } from 'i18n/useTranslation' | ||
import { useHistory, Redirect } from 'react-router' | ||
import { createProduct, NewProduct } from 'dal/products' | ||
import { useAsync } from 'react-async' | ||
import { useAlertsContext, useDocumentTitle } from 'components/util' | ||
import { ValidationException, Validator } from 'utils' | ||
|
||
type Validations = Record<string, { | ||
validation: 'default' | 'success' | 'error', | ||
errors?: string[] | ||
}> | ||
|
||
const CreateProductPage = () => { | ||
const { t } = useTranslation('product') | ||
useDocumentTitle(t('create.pagetitle')) | ||
const { goBack } = useHistory() | ||
const { addAlert } = useAlertsContext() | ||
|
||
const [name, setName] = useState('') | ||
const [systemName, setSystemName] = useState('') | ||
const [description, setDescription] = useState('') | ||
const [validations, setValidations] = useState<Validations>({ | ||
name: { validation: 'default' }, | ||
system_name: { validation: 'default' }, | ||
description: { validation: 'default' } | ||
}) | ||
|
||
const { | ||
isPending, error, run, data | ||
} = useAsync({ deferFn: createProduct }) | ||
|
||
useEffect(() => { | ||
if (error) { | ||
if (Object.prototype.hasOwnProperty.call(error, 'validationErrors')) { | ||
const { validationErrors } = (error as unknown as ValidationException) | ||
const newValidations: Validations = {} | ||
Object.keys(validationErrors).forEach((id) => { | ||
newValidations[id] = { | ||
validation: 'error', | ||
errors: validationErrors[id] | ||
} | ||
}) | ||
setValidations({ ...validations, ...newValidations }) | ||
} else { | ||
addAlert({ id: String(Date.now()), title: error.message, variant: 'danger' }) | ||
} | ||
} | ||
}, [error]) | ||
|
||
if (data) { | ||
const { service } = data as NewProduct | ||
return <Redirect to={`/products/${service.id}`} /> | ||
} | ||
|
||
const isValid = validations.name.validation === 'success' && validations.system_name.validation !== 'error' | ||
|
||
const validator = Validator() | ||
.for('name', () => (name.length > 0 ? 'success' : 'error')) | ||
// eslint-disable-next-line no-nested-ternary | ||
.for('system_name', () => (!systemName ? 'default' : (systemName.length > 0 ? 'success' : 'error'))) | ||
.for('description', () => (description.length > 0 ? 'success' : 'default')) | ||
|
||
const onBlur: FocusEventHandler = (ev) => { | ||
const { name: inputName } = ev.currentTarget as HTMLInputElement | ||
|
||
const newValidations = { ...validations } | ||
|
||
newValidations[inputName] = { | ||
validation: validator.validate(inputName) | ||
} | ||
|
||
setValidations(newValidations) | ||
} | ||
|
||
const onSubmit: FormEventHandler = (ev) => { | ||
ev.preventDefault() | ||
const formData = new FormData(ev.currentTarget as HTMLFormElement) | ||
run(formData) | ||
} | ||
|
||
return ( | ||
<> | ||
<PageSection variant="light"> | ||
<TextContent> | ||
<Text component="h1">{t('create.bodytitle')}</Text> | ||
</TextContent> | ||
</PageSection> | ||
|
||
<PageSection> | ||
<Card> | ||
<CardBody> | ||
<Form onSubmit={onSubmit}> | ||
<FormGroup | ||
aria-labelledby="name" | ||
label={t('create.name')} | ||
fieldId="name" | ||
helperTextInvalid={validations.name.errors?.flat()} | ||
validated={validations.name.validation} | ||
isRequired | ||
> | ||
<TextInput | ||
validated={validations.name.validation} | ||
id="name" | ||
type="text" | ||
name="name" | ||
value={name} | ||
onChange={setName} | ||
onBlur={onBlur} | ||
isRequired | ||
/> | ||
</FormGroup> | ||
|
||
<FormGroup | ||
aria-labelledby="system_name" | ||
label={t('create.system_name.label')} | ||
fieldId="system_name" | ||
helperText={t('create.system_name.helper')} | ||
helperTextInvalid={validations.system_name.errors?.flat()} | ||
validated={validations.system_name.validation} | ||
> | ||
<TextInput | ||
validated={validations.system_name.validation} | ||
id="system_name" | ||
type="text" | ||
name="system_name" | ||
value={systemName} | ||
onChange={setSystemName} | ||
onBlur={onBlur} | ||
placeholder={t('create.system_name.placeholder')} | ||
/> | ||
</FormGroup> | ||
|
||
<FormGroup | ||
aria-labelledby="description" | ||
label={t('create.description')} | ||
fieldId="description" | ||
helperTextInvalid={validations.description.errors?.flat()} | ||
validated={validations.description.validation} | ||
> | ||
<TextArea | ||
validated={validations.description.validation} | ||
id="description" | ||
name="description" | ||
value={description} | ||
onChange={setDescription} | ||
onBlur={onBlur} | ||
/> | ||
</FormGroup> | ||
|
||
<ActionGroup> | ||
<Button | ||
aria-label={t('shared:shared_elements.create_button')} | ||
type="submit" | ||
isDisabled={isPending || !isValid} | ||
variant="primary" | ||
> | ||
{t('shared:shared_elements.create_button')} | ||
</Button> | ||
<Button | ||
aria-label={t('shared:shared_elements.cancel_button')} | ||
onClick={goBack} | ||
variant="link" | ||
> | ||
{t('shared:shared_elements.cancel_button')} | ||
</Button> | ||
</ActionGroup> | ||
</Form> | ||
</CardBody> | ||
</Card> | ||
</PageSection> | ||
</> | ||
) | ||
} | ||
|
||
// Default export needed for React.lazy | ||
// eslint-disable-next-line import/no-default-export | ||
export default CreateProductPage |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default as CreateProductPage } from './CreateProductPage' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import { craftRequest, postData } from 'utils' | ||
import { DeferFn } from 'react-async' | ||
|
||
type CreateProductParams = { | ||
name: string | ||
description?: string | ||
deployment_option?: string | ||
backend_version?: string | ||
system_name?: string | ||
} | ||
|
||
export interface NewProduct { | ||
service: { | ||
id: number, | ||
name: string, | ||
state: string, | ||
system_name: string, | ||
backend_version: string, | ||
deployment_option: string, | ||
support_email: string, | ||
description: string, | ||
intentions_required: boolean, | ||
buyers_manage_apps: boolean, | ||
buyers_manage_keys: boolean, | ||
referrer_filters_required: boolean, | ||
custom_keys_enabled: boolean, | ||
buyer_key_regenerate_enabled: boolean, | ||
mandatory_app_key: boolean, | ||
buyer_can_select_plan: boolean, | ||
buyer_plan_change_permission: string, | ||
created_at: string, | ||
updated_at: string, | ||
links: Array<{ rel: string, href: string }> | ||
} | ||
} | ||
|
||
export type CreateProductValidationErrors = { | ||
name?: string[], | ||
system_name?: string[] | ||
} | ||
|
||
const createProduct: DeferFn<NewProduct> = async ([data]) => { | ||
const request = craftRequest('/admin/api/services.json') | ||
return postData(request, data) | ||
} | ||
|
||
export { createProduct } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './createProduct' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
66 changes: 66 additions & 0 deletions
66
portafly/src/tests/components/pages/product/CreateProductPage.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import React from 'react' | ||
|
||
import { fireEvent } from '@testing-library/react' | ||
import { render } from 'tests/custom-render' | ||
import { CreateProductPage } from 'components/pages/product' | ||
import { useAsync, AsyncState } from 'react-async' | ||
import { IProduct } from 'types' | ||
|
||
jest.mock('react-async') | ||
|
||
const setup = (asyncState: Partial<AsyncState<IProduct[]>>) => { | ||
(useAsync as jest.Mock).mockReturnValue(asyncState) | ||
const wrapper = render(<CreateProductPage />) | ||
const inputs = { | ||
nameInput: wrapper.getByRole('textbox', { name: 'create.name' }), | ||
systemNameInput: wrapper.getByRole('textbox', { name: 'create.system_name.label' }), | ||
createButton: wrapper.getByRole('button', { name: 'shared:shared_elements.create_button' }), | ||
cancelButton: wrapper.getByRole('button', { name: 'shared:shared_elements.cancel_button' }) | ||
} | ||
|
||
return { ...wrapper, ...inputs } | ||
} | ||
|
||
it('button is disabled as long as request is pending', () => { | ||
const { createButton } = setup({ isPending: true }) | ||
expect(createButton).toHaveProperty('disabled') | ||
}) | ||
|
||
it('should render an alert if there is an error', () => { | ||
const { container, getByText } = setup({ error: { name: 'SomeError', message: 'ERROR' } }) | ||
expect(container.querySelector('.pf-c-alert.pf-m-danger')).toBeInTheDocument() | ||
expect(getByText('ERROR')).toBeInTheDocument() | ||
}) | ||
|
||
it('should render inline errors', () => { | ||
const error = { | ||
validationErrors: { | ||
name: ['Invalid name', 'duplicated name'], | ||
system_name: ['Invalid system name'] | ||
} | ||
} | ||
const { getByText } = setup({ error }) | ||
expect(getByText(/Invalid name/)).toBeInTheDocument() | ||
expect(getByText(/duplicated name/)).toBeInTheDocument() | ||
expect(getByText(/Invalid system name/)).toBeInTheDocument() | ||
}) | ||
|
||
it('button is disabled as long as it is invalid', () => { | ||
const { createButton, nameInput, systemNameInput } = setup({ isPending: false }) | ||
expect(createButton.getAttribute('disabled')).not.toBeNull() | ||
|
||
// Only name is good | ||
fireEvent.change(nameInput, { target: { value: 'My API' } }) | ||
fireEvent.blur(nameInput) | ||
expect(createButton.getAttribute('disabled')).toBeNull() | ||
|
||
// Both name and systemName is good | ||
fireEvent.change(systemNameInput, { target: { value: 'my-api' } }) | ||
fireEvent.blur(systemNameInput) | ||
expect(createButton.getAttribute('disabled')).toBeNull() | ||
|
||
// No name, no good | ||
fireEvent.change(nameInput, { target: { value: '' } }) | ||
fireEvent.blur(nameInput) | ||
expect(createButton.getAttribute('disabled')).not.toBeNull() | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export interface IProduct { | ||
id: number | ||
name: string | ||
} |