diff --git a/hailstorm-web-client/package-lock.json b/hailstorm-web-client/package-lock.json index 93bb071d..ca81fbf1 100644 --- a/hailstorm-web-client/package-lock.json +++ b/hailstorm-web-client/package-lock.json @@ -1,6 +1,6 @@ { "name": "hailstorm-web-client", - "version": "1.5.6", + "version": "1.5.8", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/hailstorm-web-client/package.json b/hailstorm-web-client/package.json index a76b4513..fc97f66e 100644 --- a/hailstorm-web-client/package.json +++ b/hailstorm-web-client/package.json @@ -1,6 +1,6 @@ { "name": "hailstorm-web-client", - "version": "1.5.7", + "version": "1.5.8", "private": true, "dependencies": { "date-fns": "^2.6.0", diff --git a/hailstorm-web-client/src/ClusterConfiguration/AWSForm.test.tsx b/hailstorm-web-client/src/ClusterConfiguration/AWSForm.test.tsx index a5369538..504824b0 100644 --- a/hailstorm-web-client/src/ClusterConfiguration/AWSForm.test.tsx +++ b/hailstorm-web-client/src/ClusterConfiguration/AWSForm.test.tsx @@ -142,4 +142,37 @@ describe('', () => { const action = dispatch.mock.calls[0][0] as {payload: Cluster}; expect(action.payload).toEqual(savedCluster); }); + + it('should update number of instances', async () => { + const {findByTestId, debug} = render(createComponent()); + await fetchRegions; + await fetchPricing; + + const maxPlannedUsers = await findByTestId('MaxPlannedUsers'); + fireEvent.change(maxPlannedUsers, {target: {value: '400'}}); + + const maxThreadsByInst = await findByTestId('Max. Users / Instance'); + fireEvent.change(maxThreadsByInst, {target: {value: '80'}}); + + const numInstances = await findByTestId('# Instances'); + expect(numInstances.textContent).toEqual('5'); + + const hourlyCost = await findByTestId('Hourly Cluster Cost'); + expect(hourlyCost.textContent).toMatch(/0.46/); + }); + + it('should not set number of instances below 1', async () => { + const {findByTestId, debug} = render(createComponent()); + await fetchRegions; + await fetchPricing; + + const maxPlannedUsers = await findByTestId('MaxPlannedUsers'); + fireEvent.change(maxPlannedUsers, {target: {value: '5000'}}); + + const maxThreadsByInst = await findByTestId('Max. Users / Instance'); + fireEvent.change(maxThreadsByInst, {target: {value: '10000'}}); + + const numInstances = await findByTestId('# Instances'); + expect(numInstances.textContent).toEqual('1'); + }); }); diff --git a/hailstorm-web-client/src/ClusterConfiguration/AWSForm.tsx b/hailstorm-web-client/src/ClusterConfiguration/AWSForm.tsx index 41645539..b76e5fc0 100644 --- a/hailstorm-web-client/src/ClusterConfiguration/AWSForm.tsx +++ b/hailstorm-web-client/src/ClusterConfiguration/AWSForm.tsx @@ -24,10 +24,10 @@ export function AWSForm({ dispatch, activeProject }: { return ApiFactory().awsInstancePricing().list(regionCode); }; - const handleAWSInstanceChange = (value: AWSInstanceChoiceOption) => { - setSelectedInstanceType(value); - if (value.hourlyCostByInstance > 0) { - setHourlyCostByCluster(value.hourlyCostByCluster()); + const handleAWSInstanceChange = (choice: AWSInstanceChoiceOption) => { + setSelectedInstanceType(choice); + if (choice.hourlyCostByInstance > 0) { + setHourlyCostByCluster(choice.hourlyCostByCluster()); } }; diff --git a/hailstorm-web-client/src/ClusterConfiguration/AWSInstanceChoice.test.tsx b/hailstorm-web-client/src/ClusterConfiguration/AWSInstanceChoice.test.tsx index 6c18bb13..26730bbb 100644 --- a/hailstorm-web-client/src/ClusterConfiguration/AWSInstanceChoice.test.tsx +++ b/hailstorm-web-client/src/ClusterConfiguration/AWSInstanceChoice.test.tsx @@ -3,6 +3,7 @@ import { render, mount } from 'enzyme'; import { AWSInstanceChoice } from './AWSInstanceChoice'; import { AWSInstanceChoiceOption } from './domain'; import { render as renderComponent, fireEvent } from '@testing-library/react'; +import { Form, Formik } from 'formik'; jest.mock('./NonLinearSlider', () => ({ __esModule: true, @@ -15,6 +16,34 @@ describe('', () => { const advanceModeTrigger = /specify aws instance type/i; let fetchPricing: Promise; + function FormComponent({ + fetchPricing, + disabled, + onChange, + regionCode, + hourlyCostByCluster, + key, + setHourlyCostByCluster + }: { + fetchPricing: (s: string) => Promise, + disabled?: boolean, + onChange: (c: AWSInstanceChoiceOption) => void, + regionCode: string, + hourlyCostByCluster?: number | undefined, + key?: string | number | undefined, + setHourlyCostByCluster?: React.Dispatch> | undefined + }) { + return ( + +
+ + +
+ ) + } + beforeEach(() => { jest.resetAllMocks(); }); @@ -28,18 +57,18 @@ describe('', () => { }); it('should render without crashing', () => { - render(); + render(); }); it('should render a component to select max number of users', async () => { - const component = mount( fetchPricing} regionCode="us-east-1" />); + const component = mount( fetchPricing} regionCode="us-east-1" />); await fetchPricing; component.update(); expect(component).toContainExactlyOneMatchingElement('NonLinearSlider'); }); it('should show default values', async () => { - const component = mount( fetchPricing} regionCode="us-east-1" />); + const component = mount( fetchPricing} regionCode="us-east-1" />); await fetchPricing; component.update(); expect(component.find('NonLinearSlider')).toHaveProp('initialValue'); @@ -55,7 +84,7 @@ describe('', () => { hourlyCostByCluster: jest.fn().mockReturnValue(8.34567) }); - const component = mount( fetchPricing} regionCode="us-east-1" />); + const component = mount( fetchPricing} regionCode="us-east-1" />); await fetchPricing; component.update(); const onChange = component.find('NonLinearSlider').prop('onChange') as unknown as (value: number) => void; @@ -65,7 +94,7 @@ describe('', () => { it('should switch to advanced mode', async () => { const {findByText, findByTestId} = renderComponent( - fetchPricing} regionCode="us-east-1" /> + fetchPricing} regionCode="us-east-1" /> ); await fetchPricing; @@ -78,7 +107,7 @@ describe('', () => { describe('when in advanced mode', () => { it('should edit AWS instance type and max users by instance', async () => { const {findByText, findByTestId, findByDisplayValue} = renderComponent( - fetchPricing} regionCode="us-east-1" /> + fetchPricing} regionCode="us-east-1" /> ); await fetchPricing; @@ -105,7 +134,7 @@ describe('', () => { }); const {findByText, findByTestId} = renderComponent( - fetchPricing} regionCode="us-east-1" /> + fetchPricing} regionCode="us-east-1" /> ); await fetchPricing; @@ -127,7 +156,7 @@ describe('', () => { it('should not report hourly cost', async () => { const setHourlyCostByCluster = jest.fn(); const {findByText} = renderComponent( - fetchPricing} regionCode="us-east-1" @@ -152,7 +181,7 @@ describe('', () => { hourlyCostByCluster: jest.fn().mockReturnValue(8.34567) }); - const component = mount( fetchPricing} regionCode="us-east-1" />); + const component = mount( fetchPricing} regionCode="us-east-1" />); await fetchPricing; component.update(); const onChange = component.find('NonLinearSlider').prop('onChange') as unknown as (value: number) => void; @@ -173,7 +202,7 @@ describe('', () => { it('should be possible to disable the control', async () => { const component = mount( - fetchPricing} regionCode="us-east-1" diff --git a/hailstorm-web-client/src/ClusterConfiguration/AWSInstanceChoice.tsx b/hailstorm-web-client/src/ClusterConfiguration/AWSInstanceChoice.tsx index c45cca6a..98a6f621 100644 --- a/hailstorm-web-client/src/ClusterConfiguration/AWSInstanceChoice.tsx +++ b/hailstorm-web-client/src/ClusterConfiguration/AWSInstanceChoice.tsx @@ -3,8 +3,10 @@ import { NonLinearSlider } from './NonLinearSlider'; import { computeChoice, maxThreadsByCluster } from './AWSInstanceCalculator'; import { AWSInstanceChoiceOption } from './domain'; import { Loader } from '../Loader/Loader'; +import { Field } from 'formik'; -const MIN_VALUE = 50; +const MIN_PLANNED_USERS = 50; +const DEFAULT_THREADS_BY_INST = 10; export function AWSInstanceChoice({ regionCode, @@ -23,13 +25,13 @@ export function AWSInstanceChoice({ }) { const [pricingData, setPricingData] = useState([]); const [instanceType, setInstanceType] = useState(''); - const [maxThreadsByInstance, setMaxThreadsByInstance] = useState(0); - const [numInstances, setNumInstances] = useState(0); + const [maxThreadsByInstance, setMaxThreadsByInstance] = useState(DEFAULT_THREADS_BY_INST); + const [numInstances, setNumInstances] = useState(1); const [quickMode, setQuickMode] = useState(true); - const [sliderValue, setSliderValue] = useState(MIN_VALUE); + const [maxPlannedThreads, setMaxPlannedThreads] = useState(MIN_PLANNED_USERS); const handleSliderChange = (value: number, data?: AWSInstanceChoiceOption[]) => { - setSliderValue(value); + setMaxPlannedThreads(value); const choice = computeChoice(value, pricingData && pricingData.length > 0 ? pricingData : data!); setInstanceType(choice.instanceType); setMaxThreadsByInstance(choice.maxThreadsByInstance); @@ -45,7 +47,7 @@ export function AWSInstanceChoice({ fetchPricing(regionCode) .then((data) => { setPricingData(data); - const choice = handleSliderChange(sliderValue, data); + const choice = handleSliderChange(maxPlannedThreads, data); setHourlyCostByCluster && setHourlyCostByCluster(choice.hourlyCostByCluster()); }) .catch((reason) => console.error(reason)); @@ -55,6 +57,31 @@ export function AWSInstanceChoice({ return ; } + const handleMaxUsersByInstChange = (event: React.ChangeEvent) => { + let new_value = DEFAULT_THREADS_BY_INST; + if (event.target.value) { + new_value = parseInt(event.target.value); + } else if (pricingData.length > 0) { + new_value = computeChoice(maxPlannedThreads, pricingData).maxThreadsByInstance; + } + + setMaxThreadsByInstance(new_value); + + const nextNumInstances = Math.ceil(maxPlannedThreads / new_value); + setNumInstances(nextNumInstances); + + const matchingOption = pricingData.find((option) => option.instanceType === instanceType); + + const nextOption = new AWSInstanceChoiceOption({ + maxThreadsByInstance: new_value, + instanceType, + numInstances: nextNumInstances, + hourlyCostByInstance: matchingOption ? matchingOption.hourlyCostByInstance : undefined + }); + + onChange(nextOption); + }; + return ( <> { - setMaxThreadsByInstance(parseInt(event.target.value)); - onChange(new AWSInstanceChoiceOption({ - maxThreadsByInstance: parseInt(event.target.value), - instanceType - })); - }} + onChange={handleMaxUsersByInstChange} /> {quickMode && (
@@ -219,7 +240,7 @@ function InstanceTypeInput({ {!disabled && ( { - handleSliderChange(MIN_VALUE); + handleSliderChange(MIN_PLANNED_USERS); setQuickMode(true); }} > @@ -246,7 +267,7 @@ function InstanceTypeMeter({ {numInstances} - {hourlyCostByCluster && ( + {hourlyCostByCluster && ( ${hourlyCostByCluster.toFixed(4)} )} @@ -297,7 +318,7 @@ export function MaxUsersByInstance({
- ) => { + event.target.setSelectionRange(0, event.target.value.length); + }} {...{ disabled }} />
diff --git a/hailstorm-web-client/src/ClusterConfiguration/AWSView.test.tsx b/hailstorm-web-client/src/ClusterConfiguration/AWSView.test.tsx index c52fac00..a48f1bcd 100644 --- a/hailstorm-web-client/src/ClusterConfiguration/AWSView.test.tsx +++ b/hailstorm-web-client/src/ClusterConfiguration/AWSView.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { findByRole, fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, wait } from '@testing-library/react'; import { AmazonCluster, Project } from '../domain'; import { AWSView } from './AWSView'; -import { CreateClusterAction, UpdateClusterAction } from './actions'; +import { UpdateClusterAction } from './actions'; import { ClusterService } from '../services/ClusterService'; describe('', () => { @@ -34,20 +34,24 @@ describe('', () => { it('should update Max users per instance', async () => { const promise: Promise = Promise.resolve({...cluster, maxThreadsByInstance: 50}); const apiSpy = jest.spyOn(ClusterService.prototype, 'update').mockReturnValue(promise); - const { findByRole, findByTestId } = render(); + const { findByRole, findByTestId, debug } = render(); const input = await findByTestId('Max. Users / Instance'); fireEvent.focus(input); - fireEvent.change(input, {target: {value: '50'}}); + fireEvent.change(input, {target: {value: '200'}}); fireEvent.blur(input); + debug(input); const updateTrigger = await findByRole('Update Cluster'); + debug(updateTrigger); fireEvent.click(updateTrigger); - expect(apiSpy).toHaveBeenCalled(); - await promise; - expect(dispatch).toHaveBeenCalled(); - const action = dispatch.mock.calls[0][0]; - expect(action).toBeInstanceOf(UpdateClusterAction); + await wait(async () => { + await promise; + expect(apiSpy).toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalled(); + const action = dispatch.mock.calls[0][0]; + expect(action).toBeInstanceOf(UpdateClusterAction); + }, {timeout: 1000}); }); }); diff --git a/hailstorm-web-client/src/ClusterConfiguration/AWSView.tsx b/hailstorm-web-client/src/ClusterConfiguration/AWSView.tsx index 3bb56a9f..940f46af 100644 --- a/hailstorm-web-client/src/ClusterConfiguration/AWSView.tsx +++ b/hailstorm-web-client/src/ClusterConfiguration/AWSView.tsx @@ -7,18 +7,31 @@ import { ClusterViewHeader } from './ClusterViewHeader'; import { MaxUsersByInstance } from './AWSInstanceChoice'; import { ApiFactory } from '../api'; import { UpdateClusterAction } from './actions'; +import { Form, Formik } from 'formik'; +import { FormikActionsHandler } from '../JMeterConfiguration/domain'; export function AWSView({ cluster, dispatch, activeProject }: { cluster: AmazonCluster; dispatch?: React.Dispatch; activeProject?: Project; }) { - const [disabledUpdate, setDisabledUpdate] = useState(false); - const [maxThreadsByInstance, setMaxThreadsByInstance] = useState(); - useEffect(() => { - console.debug("AWSView#useEffect()"); - setMaxThreadsByInstance(cluster.maxThreadsByInstance); - }, [cluster]); + const handleSubmit: FormikActionsHandler = async (values, {resetForm, setSubmitting}) => { + setSubmitting(true); + try { + const updatedCluster = await ApiFactory().clusters().update( + activeProject!.id, + cluster.id!, + { maxThreadsByInstance: values.maxThreadsByInstance } + ); + + dispatch && dispatch(new UpdateClusterAction(updatedCluster)); + resetForm(values); + } catch (error) { + console.error(error); + } finally { + setSubmitting(false); + } + }; return (
@@ -26,54 +39,48 @@ export function AWSView({ cluster, dispatch, activeProject }: { title={cluster.title} icon={()} /> -
-
- - - - - {cluster.disabled || !dispatch ? ( - - ) : ( - { - setMaxThreadsByInstance(event.target.value as unknown as number); - }} - /> - )} -
-
- {activeProject && dispatch && (
- - {!cluster.disabled && ( -
- -
+ + {props => ( +
+
+
+ + + + + {cluster.disabled || !dispatch ? ( + + ) : ( + { + if (event.target.value && parseInt(event.target.value) > 0) { + props.handleChange(event); + } + }} + value={props.values.maxThreadsByInstance} + /> + )} +
+
+ {activeProject && dispatch && ( +
+ + {!cluster.disabled && ( +
+ +
+ )} +
)} +
)} -
)} +
); } diff --git a/hailstorm-web-client/src/ClusterConfiguration/NonLinearSlider.tsx b/hailstorm-web-client/src/ClusterConfiguration/NonLinearSlider.tsx index 6fe9ccf6..1b5d3afd 100644 --- a/hailstorm-web-client/src/ClusterConfiguration/NonLinearSlider.tsx +++ b/hailstorm-web-client/src/ClusterConfiguration/NonLinearSlider.tsx @@ -54,6 +54,7 @@ export function NonLinearSlider({ onChange={handleChange} onBlur={handleBlur} disabled={disabled} + data-testid="MaxPlannedUsers" />

This is a number between {minimumValue} and {maximumValue}