mirror of
https://github.com/ansible/awx.git
synced 2026-01-14 19:30:39 -03:30
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:
parent
7f90a8b2b3
commit
dfb0710d69
@ -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
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
14
awx/ui_next/src/util/useDebounce.jsx
Normal file
14
awx/ui_next/src/util/useDebounce.jsx
Normal 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;
|
||||
}
|
||||
22
awx/ui_next/src/util/useDebounce.test.jsx
Normal file
22
awx/ui_next/src/util/useDebounce.test.jsx
Normal 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 });
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user