Add slider to adjust capacity_adjustment

Add slider to adjust capacity adjustment.

Also add a new custom hook, useDebounce.

See: https://github.com/ansible/awx/issues/7777
This commit is contained in:
nixocio 2021-04-06 19:06:20 -04:00
parent 7f90a8b2b3
commit dfb0710d69
12 changed files with 2004 additions and 1367 deletions

View File

@ -350,7 +350,7 @@ You can learn more about the ways lingui and its React helpers at [this link](ht
### Setting up .po files to give to translation team
1. `npm run add-locale` to add the language that you want to translate to (we should only have to do this once and the commit to repo afaik). Example: `npm run add-locale en es fr` # Add English, Spanish and French locale
2. `npm run extract-strings` to create .po files for each language specified. The .po files will be placed in src/locales. When updating strings that are used by `<Plural>` or `plural()` you will need to run this command to get the strings to render properly. This commmand will create `.po` files for each of the supported languages that will need to be commited with your PR.
2. `npm run extract-strings` to create .po files for each language specified. The .po files will be placed in src/locales. When updating strings that are used by `<Plural>` or `plural()` you will need to run this command to get the strings to render properly. This command will create `.po` files for each of the supported languages that will need to be committed with your PR.
3. Open up the .po file for the language you want to test and add some translations. In production we would pass this .po file off to the translation team.
4. Once you've edited your .po file (or we've gotten a .po file back from the translation team) run `npm run compile-strings`. This command takes the .po files and turns them into a minified JSON object and can be seen in the `messages.js` file in each locale directory. These files get loaded at the App root level (see: App.jsx).
5. Change the language in your browser and reload the page. You should see your specified translations in place of English strings.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
import React from 'react';
import React, { useState, useCallback } from 'react';
import { bool, func } from 'prop-types';
import { t } from '@lingui/macro';
import { t, Plural } from '@lingui/macro';
import styled from 'styled-components';
import 'styled-components/macro';
import {
@ -9,23 +8,48 @@ import {
Progress,
ProgressMeasureLocation,
ProgressSize,
DataListAction,
DataListAction as PFDataListAction,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
DataListItem as PFDataListItem,
DataListItemRow as PFDataListItemRow,
DataListItemCells as PFDataListItemCells,
Slider,
} from '@patternfly/react-core';
import _DataListCell from '../../../components/DataListCell';
import InstanceToggle from '../../../components/InstanceToggle';
import { Instance } from '../../../types';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import useDebounce from '../../../util/useDebounce';
import { InstancesAPI } from '../../../api';
import { useConfig } from '../../../contexts/Config';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
const DataListItem = styled(PFDataListItem)`
display: flex;
flex-direction: column;
justify-content: center;
`;
const DataListItemRow = styled(PFDataListItemRow)`
align-items: center;
`;
const DataListItemCells = styled(PFDataListItemCells)`
align-items: center;
`;
const DataListAction = styled(PFDataListAction)`
align-items: center;
`;
const Unavailable = styled.span`
color: var(--pf-global--danger-color--200);
`;
const DataListCell = styled(_DataListCell)`
white-space: nowrap;
align-items: center;
`;
const Badge = styled(PFBadge)`
@ -40,7 +64,37 @@ const ListGroup = styled.span`
}
`;
const SliderHolder = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const SliderForks = styled.div`
flex-grow: 1;
margin-right: 8px;
margin-left: 8px;
text-align: center;
`;
function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) {
const minCapacity = Math.min(memCapacity, cpuCapacity);
const maxCapacity = Math.max(memCapacity, cpuCapacity);
return Math.floor(
minCapacity + (maxCapacity - minCapacity) * selectedCapacityAdjustment
);
}
function InstanceListItem({ instance, isSelected, onSelect, fetchInstances }) {
const { me = {} } = useConfig();
const [forks, setForks] = useState(
computeForks(
instance.mem_capacity,
instance.cpu_capacity,
instance.capacity_adjustment
)
);
const labelId = `check-action-${instance.id}`;
function usedCapacity(item) {
@ -57,68 +111,133 @@ function InstanceListItem({ instance, isSelected, onSelect, fetchInstances }) {
return <Unavailable>{t`Unavailable`}</Unavailable>;
}
return (
<DataListItem
aria-labelledby={labelId}
id={`${instance.id}`}
key={instance.id}
>
<DataListItemRow>
<DataListCheck
aria-labelledby={labelId}
checked={isSelected}
id={`instances-${instance.id}`}
onChange={onSelect}
/>
const { error: updateInstanceError, request: updateInstance } = useRequest(
useCallback(
async values => {
await InstancesAPI.update(instance.id, values);
},
[instance]
)
);
<DataListItemCells
dataListCells={[
<DataListCell key="name" aria-label={t`instance host name`}>
<b>{instance.hostname}</b>
</DataListCell>,
<DataListCell key="type" aria-label={t`instance type`}>
<b css="margin-right: 24px">{t`Type`}</b>
<span id={labelId}>
{instance.managed_by_policy ? t`Auto` : t`Manual`}
</span>
</DataListCell>,
<DataListCell
key="related-field-counts"
aria-label={t`instance counts`}
width={2}
>
<ListGroup>
<b>{t`Running jobs`}</b>
<Badge isRead>{instance.jobs_running}</Badge>
</ListGroup>
<ListGroup>
<b>{t`Total jobs`}</b>
<Badge isRead>{instance.jobs_total}</Badge>
</ListGroup>
</DataListCell>,
<DataListCell
key="capacity"
aria-label={t`instance group used capacity`}
>
{usedCapacity(instance)}
</DataListCell>,
]}
/>
<DataListAction
aria-label={t`actions`}
aria-labelledby={labelId}
id={labelId}
>
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchInstances}
instance={instance}
const {
error: updateError,
dismissError: dismissUpdateError,
} = useDismissableError(updateInstanceError);
const debounceUpdateInstance = useDebounce(updateInstance, 200);
const handleChangeValue = value => {
const roundedValue = Math.round(value * 100) / 100;
setForks(
computeForks(instance.mem_capacity, instance.cpu_capacity, roundedValue)
);
debounceUpdateInstance({ capacity_adjustment: roundedValue });
};
return (
<>
<DataListItem
aria-labelledby={labelId}
id={`${instance.id}`}
key={instance.id}
>
<DataListItemRow>
<DataListCheck
aria-labelledby={labelId}
checked={isSelected}
id={`instances-${instance.id}`}
onChange={onSelect}
/>
</DataListAction>
</DataListItemRow>
</DataListItem>
<DataListItemCells
dataListCells={[
<DataListCell key="name" aria-label={t`instance host name`}>
<b>{instance.hostname}</b>
</DataListCell>,
<DataListCell key="type" aria-label={t`instance type`}>
<b css="margin-right: 24px">{t`Type`}</b>
<span id={labelId}>
{instance.managed_by_policy ? t`Auto` : t`Manual`}
</span>
</DataListCell>,
<DataListCell
key="related-field-counts"
aria-label={t`instance counts`}
width={3}
>
<ListGroup>
<b>{t`Running jobs`}</b>
<Badge isRead>{instance.jobs_running}</Badge>
</ListGroup>
<ListGroup>
<b>{t`Total jobs`}</b>
<Badge isRead>{instance.jobs_total}</Badge>
</ListGroup>
</DataListCell>,
<DataListCell
key="capacity-adjustment"
aria-label={t`capacity adjustment`}
width={4}
>
<SliderHolder data-cy="slider-holder">
<div data-cy="cpu-capacity">{t`CPU ${instance.cpu_capacity}`}</div>
<SliderForks data-cy="slider-forks">
<div data-cy="number-forks">
<Plural value={forks} one="# fork" other="# forks" />
</div>
<Slider
areCustomStepsContinuous
max={1}
min={0}
step={0.1}
value={instance.capacity_adjustment}
onChange={handleChangeValue}
isDisabled={!me?.is_superuser || !instance.enabled}
data-cy="slider"
/>
</SliderForks>
<div data-cy="mem-capacity">{t`RAM ${instance.mem_capacity}`}</div>
</SliderHolder>
</DataListCell>,
<DataListCell
key="capacity"
aria-label={t`instance group used capacity`}
>
{usedCapacity(instance)}
</DataListCell>,
]}
/>
<DataListAction
aria-label={t`actions`}
aria-labelledby={labelId}
id={labelId}
>
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchInstances}
instance={instance}
/>
</DataListAction>
</DataListItemRow>
</DataListItem>
{updateError && (
<AlertModal
variant="error"
title={t`Error!`}
isOpen
onClose={dismissUpdateError}
>
{t`Failed to update capacity adjustment.`}
<ErrorDetail error={updateError} />
</AlertModal>
)}
</>
);
}
InstanceListItem.prototype = {
instance: Instance.isRequired,
isSelected: bool.isRequired,

View File

@ -2,9 +2,14 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { InstancesAPI } from '../../../api';
import useDebounce from '../../../util/useDebounce';
import InstanceListItem from './InstanceListItem';
jest.mock('../../../api');
jest.mock('../../../util/useDebounce');
const instance = [
{
id: 1,
@ -37,6 +42,10 @@ const instance = [
describe('<InstanceListItem/>', () => {
let wrapper;
beforeEach(() => {
useDebounce.mockImplementation(fn => fn);
});
test('should mount successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
@ -44,12 +53,55 @@ describe('<InstanceListItem/>', () => {
instance={instance[0]}
isSelected={false}
onSelect={() => {}}
fetchInstances={() => {}}
/>
);
});
expect(wrapper.find('InstanceListItem').length).toBe(1);
});
test('should calculate number of forks when slide changes', async () => {
await act(async () => {
wrapper = mountWithContexts(
<InstanceListItem
instance={instance[0]}
isSelected={false}
onSelect={() => {}}
fetchInstances={() => {}}
/>
);
});
expect(wrapper.find('InstanceListItem').length).toBe(1);
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
'10 forks'
);
await act(async () => {
wrapper.find('Slider').prop('onChange')(1);
});
wrapper.update();
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
'24 forks'
);
await act(async () => {
wrapper.find('Slider').prop('onChange')(0);
});
wrapper.update();
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
'1 fork'
);
await act(async () => {
wrapper.find('Slider').prop('onChange')(0.5);
});
wrapper.update();
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
'12 forks'
);
});
test('should render the proper data instance', async () => {
await act(async () => {
wrapper = mountWithContexts(
@ -57,6 +109,7 @@ describe('<InstanceListItem/>', () => {
instance={instance[0]}
isSelected={false}
onSelect={() => {}}
fetchInstances={() => {}}
/>
);
});
@ -68,6 +121,19 @@ describe('<InstanceListItem/>', () => {
wrapper.find('PFDataListCell[aria-label="instance type"]').text()
).toBe('TypeAuto');
expect(wrapper.find('input#instances-1').prop('checked')).toBe(false);
expect(
wrapper
.find('PFDataListCell[aria-label="capacity adjustment"]')
.containsMatchingElement(<div>CPU 24</div>)
);
expect(
wrapper
.find('PFDataListCell[aria-label="capacity adjustment"]')
.containsMatchingElement(<div>RAM 24</div>)
);
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
'10 forks'
);
});
test('should be checked', async () => {
@ -77,6 +143,7 @@ describe('<InstanceListItem/>', () => {
instance={instance[0]}
isSelected
onSelect={() => {}}
fetchInstances={() => {}}
/>
);
});
@ -86,4 +153,52 @@ describe('<InstanceListItem/>', () => {
test('should display instance toggle', () => {
expect(wrapper.find('InstanceToggle').length).toBe(1);
});
test('should display error', async () => {
jest.useFakeTimers();
InstancesAPI.update.mockRejectedValue(
new Error({
response: {
config: {
method: 'patch',
url: '/api/v2/instances/1',
data: { capacity_adjustment: 0.30001 },
},
data: {
capacity_adjustment: [
'Ensure that there are no more than 3 digits in total.',
],
},
status: 400,
statusText: 'Bad Request',
},
})
);
await act(async () => {
wrapper = mountWithContexts(
<InstanceListItem
instance={instance[0]}
isSelected={false}
onSelect={() => {}}
fetchInstances={() => {}}
/>,
{ context: { network: { handleHttpError: () => {} } } }
);
});
await act(async () => {
wrapper.update();
});
expect(wrapper.find('ErrorDetail').length).toBe(0);
await act(async () => {
wrapper.find('Slider').prop('onChange')(0.30001);
});
await act(async () => {
wrapper.update();
});
jest.advanceTimersByTime(210);
await act(async () => {
wrapper.update();
});
expect(wrapper.find('ErrorDetail').length).toBe(1);
});
});

View File

@ -0,0 +1,14 @@
import { useRef } from 'react';
export default function useDebounce(fn, delay) {
const timeOutRef = useRef(null);
function debouncedFunction(...args) {
window.clearTimeout(timeOutRef.current);
timeOutRef.current = window.setTimeout(() => {
fn(...args);
}, delay);
}
return debouncedFunction;
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import { mount } from 'enzyme';
import useDebounce from './useDebounce';
function TestInner() {
return <div />;
}
function Test({ fn, delay = 500, data }) {
const debounce = useDebounce(fn, delay);
debounce(data);
return <TestInner />;
}
test('useDebounce', () => {
jest.useFakeTimers();
const fn = jest.fn();
mount(<Test fn={fn} data={{ data: 123 }} />);
expect(fn).toHaveBeenCalledTimes(0);
jest.advanceTimersByTime(510);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith({ data: 123 });
});