foreman_resource_quota 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +619 -0
- data/README.md +51 -0
- data/Rakefile +49 -0
- data/app/controllers/concerns/foreman/controller/parameters/resource_quota.rb +28 -0
- data/app/controllers/foreman_resource_quota/api/v2/resource_quotas_controller.rb +96 -0
- data/app/controllers/foreman_resource_quota/application_controller.rb +9 -0
- data/app/controllers/foreman_resource_quota/resource_quotas_controller.rb +50 -0
- data/app/helpers/foreman_resource_quota/hosts_helper.rb +18 -0
- data/app/helpers/foreman_resource_quota/resource_quota_helper.rb +107 -0
- data/app/models/concerns/foreman_resource_quota/host_managed_extensions.rb +115 -0
- data/app/models/concerns/foreman_resource_quota/user_extensions.rb +15 -0
- data/app/models/concerns/foreman_resource_quota/usergroup_extensions.rb +14 -0
- data/app/models/foreman_resource_quota/resource_quota.rb +83 -0
- data/app/models/foreman_resource_quota/resource_quota_user.rb +10 -0
- data/app/models/foreman_resource_quota/resource_quota_usergroup.rb +10 -0
- data/app/services/foreman_resource_quota/resource_origin.rb +97 -0
- data/app/services/foreman_resource_quota/resource_origins/compute_attributes_origin.rb +64 -0
- data/app/services/foreman_resource_quota/resource_origins/compute_resource_origin.rb +82 -0
- data/app/services/foreman_resource_quota/resource_origins/facts_origin.rb +68 -0
- data/app/services/foreman_resource_quota/resource_origins/vm_attributes_origin.rb +40 -0
- data/app/views/foreman_resource_quota/api/v2/hosts/resource_quota.json.rabl +3 -0
- data/app/views/foreman_resource_quota/api/v2/resource_quotas/base.json.rabl +6 -0
- data/app/views/foreman_resource_quota/api/v2/resource_quotas/create.json.rabl +5 -0
- data/app/views/foreman_resource_quota/api/v2/resource_quotas/hosts.json.rabl +7 -0
- data/app/views/foreman_resource_quota/api/v2/resource_quotas/index.json.rabl +5 -0
- data/app/views/foreman_resource_quota/api/v2/resource_quotas/main.json.rabl +7 -0
- data/app/views/foreman_resource_quota/api/v2/resource_quotas/show.json.rabl +5 -0
- data/app/views/foreman_resource_quota/api/v2/resource_quotas/update.json.rabl +5 -0
- data/app/views/foreman_resource_quota/api/v2/resource_quotas/usergroups.json.rabl +7 -0
- data/app/views/foreman_resource_quota/api/v2/resource_quotas/users.json.rabl +7 -0
- data/app/views/foreman_resource_quota/api/v2/resource_quotas/utilization.json.rabl +7 -0
- data/app/views/foreman_resource_quota/api/v2/usergroups/resource_quota.json.rabl +3 -0
- data/app/views/foreman_resource_quota/api/v2/users/resource_quota.json.rabl +3 -0
- data/app/views/foreman_resource_quota/resource_quotas/_form.html.erb +21 -0
- data/app/views/foreman_resource_quota/resource_quotas/edit.html.erb +12 -0
- data/app/views/foreman_resource_quota/resource_quotas/index.html.erb +55 -0
- data/app/views/foreman_resource_quota/resource_quotas/new.html.erb +10 -0
- data/app/views/foreman_resource_quota/resource_quotas/welcome.html.erb +10 -0
- data/app/views/hosts/_form_quota_fields.html.erb +4 -0
- data/app/views/users/_form_quota_tab.html.erb +45 -0
- data/config/initializers/inflections.rb +5 -0
- data/config/routes.rb +43 -0
- data/db/migrate/20230306120001_create_resource_quotas.rb +31 -0
- data/lib/foreman_resource_quota/engine.rb +56 -0
- data/lib/foreman_resource_quota/exceptions.rb +11 -0
- data/lib/foreman_resource_quota/register.rb +106 -0
- data/lib/foreman_resource_quota/version.rb +5 -0
- data/lib/foreman_resource_quota.rb +6 -0
- data/lib/tasks/foreman_resource_quota_tasks.rake +50 -0
- data/locale/Makefile +60 -0
- data/locale/en/foreman_resource_quota.po +18 -0
- data/locale/foreman_resource_quota.pot +19 -0
- data/locale/gemspec.rb +4 -0
- data/package.json +44 -0
- data/webpack/api_helper.js +113 -0
- data/webpack/api_helper.test.js +96 -0
- data/webpack/components/CreateResourceQuotaModal.js +46 -0
- data/webpack/components/ResourceQuotaEmptyState/index.js +58 -0
- data/webpack/components/ResourceQuotaForm/ResourceQuotaForm.scss +1 -0
- data/webpack/components/ResourceQuotaForm/ResourceQuotaFormConstants.js +71 -0
- data/webpack/components/ResourceQuotaForm/components/Properties/Properties.scss +9 -0
- data/webpack/components/ResourceQuotaForm/components/Properties/StaticDetail.js +72 -0
- data/webpack/components/ResourceQuotaForm/components/Properties/StatusPropertiesLabel.js +71 -0
- data/webpack/components/ResourceQuotaForm/components/Properties/StatusPropertiesLabel.test.js +50 -0
- data/webpack/components/ResourceQuotaForm/components/Properties/TextInputField.js +131 -0
- data/webpack/components/ResourceQuotaForm/components/Properties/index.js +190 -0
- data/webpack/components/ResourceQuotaForm/components/QuotaState.js +157 -0
- data/webpack/components/ResourceQuotaForm/components/Resource/Resource.scss +13 -0
- data/webpack/components/ResourceQuotaForm/components/Resource/UnitInputField.js +224 -0
- data/webpack/components/ResourceQuotaForm/components/Resource/UtilizationProgress.js +151 -0
- data/webpack/components/ResourceQuotaForm/components/Resource/UtilizationProgress.scss +10 -0
- data/webpack/components/ResourceQuotaForm/components/Resource/index.js +239 -0
- data/webpack/components/ResourceQuotaForm/components/Resources.js +105 -0
- data/webpack/components/ResourceQuotaForm/components/Submit.js +72 -0
- data/webpack/components/ResourceQuotaForm/index.js +185 -0
- data/webpack/components/UpdateResourceQuotaModal.js +143 -0
- data/webpack/global_index.js +15 -0
- data/webpack/global_test_setup.js +11 -0
- data/webpack/helper.js +86 -0
- data/webpack/index.js +23 -0
- data/webpack/lib/ActionableDetail.js +115 -0
- data/webpack/lib/ActionableDetail.scss +4 -0
- data/webpack/lib/EditableSwitch.js +47 -0
- data/webpack/lib/EditableTextInput/EditableTextInput.js +206 -0
- data/webpack/lib/EditableTextInput/PencilEditButton.js +27 -0
- data/webpack/lib/EditableTextInput/__tests__/editableTextInput.test.js +193 -0
- data/webpack/lib/EditableTextInput/editableTextInput.scss +38 -0
- data/webpack/lib/EditableTextInput/index.js +4 -0
- data/webpack/lib/react-testing-lib-wrapper.js +80 -0
- data/webpack/test_setup.js +17 -0
- metadata +134 -0
@@ -0,0 +1,143 @@
|
|
1
|
+
import PropTypes from 'prop-types';
|
2
|
+
import React, { useState } from 'react';
|
3
|
+
import { Modal, ModalVariant } from '@patternfly/react-core';
|
4
|
+
|
5
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
6
|
+
|
7
|
+
import ResourceQuotaForm from './ResourceQuotaForm';
|
8
|
+
import {
|
9
|
+
MODAL_ID_UPDATE_RESOURCE_QUOTA,
|
10
|
+
RESOURCE_IDENTIFIER_ID,
|
11
|
+
RESOURCE_IDENTIFIER_NAME,
|
12
|
+
RESOURCE_IDENTIFIER_DESCRIPTION,
|
13
|
+
RESOURCE_IDENTIFIER_CPU,
|
14
|
+
RESOURCE_IDENTIFIER_MEMORY,
|
15
|
+
RESOURCE_IDENTIFIER_DISK,
|
16
|
+
RESOURCE_IDENTIFIER_STATUS_NUM_HOSTS,
|
17
|
+
RESOURCE_IDENTIFIER_STATUS_NUM_USERS,
|
18
|
+
RESOURCE_IDENTIFIER_STATUS_NUM_USERGROUPS,
|
19
|
+
RESOURCE_IDENTIFIER_STATUS_MISSING_HOSTS,
|
20
|
+
RESOURCE_IDENTIFIER_STATUS_UTILIZATION,
|
21
|
+
} from './ResourceQuotaForm/ResourceQuotaFormConstants';
|
22
|
+
|
23
|
+
const UpdateResourceQuotaModal = ({ initialProperties, initialStatus }) => {
|
24
|
+
const staticId = `${MODAL_ID_UPDATE_RESOURCE_QUOTA}-${initialProperties[RESOURCE_IDENTIFIER_ID]}`;
|
25
|
+
const [isOpen, setIsOpen] = useState(false);
|
26
|
+
const [quotaProperties, setQuotaProperties] = useState(initialProperties);
|
27
|
+
const [quotaStatus, setQuotaStatus] = useState(initialStatus);
|
28
|
+
|
29
|
+
const onQuotaChangesCallback = (updatedProperties, updatedStatus) => {
|
30
|
+
setQuotaProperties(updatedProperties);
|
31
|
+
setQuotaStatus(updatedStatus);
|
32
|
+
};
|
33
|
+
|
34
|
+
return (
|
35
|
+
<div>
|
36
|
+
<a
|
37
|
+
onClick={() => {
|
38
|
+
setIsOpen(true);
|
39
|
+
}}
|
40
|
+
>
|
41
|
+
{quotaProperties[RESOURCE_IDENTIFIER_NAME]}
|
42
|
+
</a>
|
43
|
+
<Modal
|
44
|
+
ouiaId={staticId}
|
45
|
+
title={__(`Edit: ${quotaProperties[RESOURCE_IDENTIFIER_NAME]}`)}
|
46
|
+
variant={ModalVariant.small}
|
47
|
+
isOpen={isOpen}
|
48
|
+
onClose={() => {
|
49
|
+
setIsOpen(false);
|
50
|
+
}}
|
51
|
+
appendTo={document.body}
|
52
|
+
>
|
53
|
+
<ResourceQuotaForm
|
54
|
+
isNewQuota={false}
|
55
|
+
initialProperties={quotaProperties}
|
56
|
+
initialStatus={quotaStatus}
|
57
|
+
quotaChangesCallback={onQuotaChangesCallback}
|
58
|
+
/>
|
59
|
+
</Modal>
|
60
|
+
</div>
|
61
|
+
);
|
62
|
+
};
|
63
|
+
|
64
|
+
UpdateResourceQuotaModal.defaultProps = {
|
65
|
+
initialProperties: {
|
66
|
+
[RESOURCE_IDENTIFIER_NAME]: '',
|
67
|
+
[RESOURCE_IDENTIFIER_DESCRIPTION]: '',
|
68
|
+
[RESOURCE_IDENTIFIER_CPU]: null,
|
69
|
+
[RESOURCE_IDENTIFIER_MEMORY]: null,
|
70
|
+
[RESOURCE_IDENTIFIER_DISK]: null,
|
71
|
+
},
|
72
|
+
initialStatus: {
|
73
|
+
[RESOURCE_IDENTIFIER_STATUS_NUM_HOSTS]: null,
|
74
|
+
[RESOURCE_IDENTIFIER_STATUS_NUM_USERS]: null,
|
75
|
+
[RESOURCE_IDENTIFIER_STATUS_NUM_USERGROUPS]: null,
|
76
|
+
[RESOURCE_IDENTIFIER_STATUS_MISSING_HOSTS]: null,
|
77
|
+
[RESOURCE_IDENTIFIER_STATUS_UTILIZATION]: {
|
78
|
+
[RESOURCE_IDENTIFIER_CPU]: null,
|
79
|
+
[RESOURCE_IDENTIFIER_MEMORY]: null,
|
80
|
+
[RESOURCE_IDENTIFIER_DISK]: null,
|
81
|
+
},
|
82
|
+
},
|
83
|
+
};
|
84
|
+
|
85
|
+
UpdateResourceQuotaModal.propTypes = {
|
86
|
+
initialProperties: PropTypes.shape({
|
87
|
+
[RESOURCE_IDENTIFIER_ID]: PropTypes.number.isRequired,
|
88
|
+
[RESOURCE_IDENTIFIER_NAME]: PropTypes.oneOfType([
|
89
|
+
PropTypes.string,
|
90
|
+
PropTypes.oneOf([null]),
|
91
|
+
]),
|
92
|
+
[RESOURCE_IDENTIFIER_DESCRIPTION]: PropTypes.oneOfType([
|
93
|
+
PropTypes.string,
|
94
|
+
PropTypes.oneOf([null]),
|
95
|
+
]),
|
96
|
+
[RESOURCE_IDENTIFIER_CPU]: PropTypes.oneOfType([
|
97
|
+
PropTypes.number,
|
98
|
+
PropTypes.oneOf([null]),
|
99
|
+
]),
|
100
|
+
[RESOURCE_IDENTIFIER_MEMORY]: PropTypes.oneOfType([
|
101
|
+
PropTypes.number,
|
102
|
+
PropTypes.oneOf([null]),
|
103
|
+
]),
|
104
|
+
[RESOURCE_IDENTIFIER_DISK]: PropTypes.oneOfType([
|
105
|
+
PropTypes.number,
|
106
|
+
PropTypes.oneOf([null]),
|
107
|
+
]),
|
108
|
+
}),
|
109
|
+
initialStatus: PropTypes.shape({
|
110
|
+
[RESOURCE_IDENTIFIER_STATUS_NUM_HOSTS]: PropTypes.oneOfType([
|
111
|
+
PropTypes.number,
|
112
|
+
PropTypes.oneOf([null]),
|
113
|
+
]),
|
114
|
+
[RESOURCE_IDENTIFIER_STATUS_NUM_USERS]: PropTypes.oneOfType([
|
115
|
+
PropTypes.number,
|
116
|
+
PropTypes.oneOf([null]),
|
117
|
+
]),
|
118
|
+
[RESOURCE_IDENTIFIER_STATUS_NUM_USERGROUPS]: PropTypes.oneOfType([
|
119
|
+
PropTypes.number,
|
120
|
+
PropTypes.oneOf([null]),
|
121
|
+
]),
|
122
|
+
[RESOURCE_IDENTIFIER_STATUS_MISSING_HOSTS]: PropTypes.oneOfType([
|
123
|
+
PropTypes.number,
|
124
|
+
PropTypes.oneOf([null]),
|
125
|
+
]),
|
126
|
+
[RESOURCE_IDENTIFIER_STATUS_UTILIZATION]: PropTypes.shape({
|
127
|
+
[RESOURCE_IDENTIFIER_CPU]: PropTypes.oneOfType([
|
128
|
+
PropTypes.number,
|
129
|
+
PropTypes.oneOf([null]),
|
130
|
+
]),
|
131
|
+
[RESOURCE_IDENTIFIER_MEMORY]: PropTypes.oneOfType([
|
132
|
+
PropTypes.number,
|
133
|
+
PropTypes.oneOf([null]),
|
134
|
+
]),
|
135
|
+
[RESOURCE_IDENTIFIER_DISK]: PropTypes.oneOfType([
|
136
|
+
PropTypes.number,
|
137
|
+
PropTypes.oneOf([null]),
|
138
|
+
]),
|
139
|
+
}),
|
140
|
+
}),
|
141
|
+
};
|
142
|
+
|
143
|
+
export default UpdateResourceQuotaModal;
|
@@ -0,0 +1,15 @@
|
|
1
|
+
// Placeholder to initialize routes (compare foreman_ansible)
|
2
|
+
|
3
|
+
/*
|
4
|
+
import { addGlobalFill } from 'foremanReact/components/common/Fill/GlobalFill';
|
5
|
+
import { registerReducer } from 'foremanReact/common/MountingService';
|
6
|
+
import { registerRoutes } from 'foremanReact/routes/RoutingService';
|
7
|
+
import Routes from './src/Router/routes'
|
8
|
+
|
9
|
+
register client routes
|
10
|
+
registerRoutes('PluginTemplate', Routes);
|
11
|
+
|
12
|
+
register fills for extending foreman core
|
13
|
+
http://foreman.surge.sh/?path=/docs/introduction-slot-and-fill--page
|
14
|
+
addGlobalFill('<slotId>', '<fillId>', <div key='plugin-template-example' />, 300);
|
15
|
+
*/
|
@@ -0,0 +1,11 @@
|
|
1
|
+
// runs before each test to make sure console.error output will
|
2
|
+
// fail a test (i.e. default PropType missing). Check the error
|
3
|
+
// output and traceback for actual error.
|
4
|
+
global.console.error = (error, stack) => {
|
5
|
+
/* eslint-disable-next-line no-console */
|
6
|
+
if (stack) console.log(stack); // Prints out original stack trace
|
7
|
+
throw new Error(error);
|
8
|
+
};
|
9
|
+
|
10
|
+
// Increase jest timeout as some tests using multiple http mocks can time out on CI systems.
|
11
|
+
jest.setTimeout(10000);
|
data/webpack/helper.js
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
import ReactDOMServer from 'react-dom/server';
|
2
|
+
|
3
|
+
/**
|
4
|
+
* Performs a deep equality comparison between two objects, including nested objects and arrays.
|
5
|
+
* @param {Object} obj1 - The first object to compare.
|
6
|
+
* @param {Object} obj2 - The second object to compare.
|
7
|
+
* @returns {boolean} True if the objects are deeply equal, false otherwise.
|
8
|
+
*/
|
9
|
+
const deepEqual = (obj1, obj2) => {
|
10
|
+
if (obj1 === obj2) {
|
11
|
+
return true;
|
12
|
+
}
|
13
|
+
|
14
|
+
if (
|
15
|
+
typeof obj1 !== 'object' ||
|
16
|
+
typeof obj2 !== 'object' ||
|
17
|
+
obj1 === null ||
|
18
|
+
obj2 === null
|
19
|
+
) {
|
20
|
+
return false;
|
21
|
+
}
|
22
|
+
|
23
|
+
const keys1 = Object.keys(obj1);
|
24
|
+
const keys2 = Object.keys(obj2);
|
25
|
+
|
26
|
+
if (keys1.length !== keys2.length) {
|
27
|
+
return false;
|
28
|
+
}
|
29
|
+
|
30
|
+
// eslint-disable-next-line no-unused-vars
|
31
|
+
for (const key of keys1) {
|
32
|
+
if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) {
|
33
|
+
return false;
|
34
|
+
}
|
35
|
+
}
|
36
|
+
|
37
|
+
return true;
|
38
|
+
};
|
39
|
+
|
40
|
+
const areReactElementsEqual = (element1, element2) => {
|
41
|
+
const elementToStr = element =>
|
42
|
+
element && ReactDOMServer.renderToStaticMarkup(element);
|
43
|
+
|
44
|
+
const element1Str = elementToStr(element1);
|
45
|
+
const element2Str = elementToStr(element2);
|
46
|
+
|
47
|
+
return element1Str === element2Str;
|
48
|
+
};
|
49
|
+
|
50
|
+
/**
|
51
|
+
* Recursively copies values from the source hash (`src`) to the destination hash (`dest`).
|
52
|
+
* Only keys that are a member of dest will copied from src.
|
53
|
+
*
|
54
|
+
* @param {Object} dest - The destination hash to copy values into.
|
55
|
+
* @param {Object} src - The source hash from which values are copied.
|
56
|
+
* @returns {void} - The function modifies the destination hash in place.
|
57
|
+
*/
|
58
|
+
function deepCopy(dest, src) {
|
59
|
+
if (dest) {
|
60
|
+
Object.keys(dest).forEach(key => {
|
61
|
+
if (src.hasOwnProperty(key)) {
|
62
|
+
if (typeof dest[key] === 'object' && typeof src[key] === 'object') {
|
63
|
+
deepCopy(dest[key], src[key]);
|
64
|
+
} else if (dest[key] !== src[key]) {
|
65
|
+
dest[key] = src[key];
|
66
|
+
}
|
67
|
+
}
|
68
|
+
});
|
69
|
+
}
|
70
|
+
}
|
71
|
+
|
72
|
+
/**
|
73
|
+
* Finds the largest unit from a list that fits the given value.
|
74
|
+
*
|
75
|
+
* @param {number} value - The value to find the largest fitting unit for.
|
76
|
+
* @param {Array<Object>} unitList - An array of unit objects with 'factor' properties.
|
77
|
+
* @returns {Object} The largest unit object that fits the value.
|
78
|
+
*/
|
79
|
+
const findLargestFittingUnit = (value, unitList) => {
|
80
|
+
for (let i = unitList.length - 1; i >= 0; i--) {
|
81
|
+
if (value >= unitList[i].factor) return unitList[i];
|
82
|
+
}
|
83
|
+
return unitList[0];
|
84
|
+
};
|
85
|
+
|
86
|
+
export { deepEqual, deepCopy, findLargestFittingUnit, areReactElementsEqual };
|
data/webpack/index.js
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
import componentRegistry from 'foremanReact/components/componentRegistry';
|
2
|
+
import ResourceQuotaEmptyState from './components/ResourceQuotaEmptyState';
|
3
|
+
import ResourceQuotaForm from './components/ResourceQuotaForm';
|
4
|
+
import CreateResourceQuotaModal from './components/CreateResourceQuotaModal';
|
5
|
+
import UpdateResourceQuotaModal from './components/UpdateResourceQuotaModal';
|
6
|
+
|
7
|
+
/* register React components for erb mounting */
|
8
|
+
componentRegistry.register({
|
9
|
+
name: 'ResourceQuotaEmptyState',
|
10
|
+
type: ResourceQuotaEmptyState,
|
11
|
+
});
|
12
|
+
componentRegistry.register({
|
13
|
+
name: 'ResourceQuotaForm',
|
14
|
+
type: ResourceQuotaForm,
|
15
|
+
});
|
16
|
+
componentRegistry.register({
|
17
|
+
name: 'UpdateResourceQuotaModal',
|
18
|
+
type: UpdateResourceQuotaModal,
|
19
|
+
});
|
20
|
+
componentRegistry.register({
|
21
|
+
name: 'CreateResourceQuotaModal',
|
22
|
+
type: CreateResourceQuotaModal,
|
23
|
+
});
|
@@ -0,0 +1,115 @@
|
|
1
|
+
/* Credits: https://github.com/Katello/katello/blob/631d5bb83dc5d87320ee9002a6de33809a281b3e/webpack/components/ActionableDetail.js */
|
2
|
+
import React from 'react';
|
3
|
+
import {
|
4
|
+
TextListItem,
|
5
|
+
TextListItemVariants,
|
6
|
+
Tooltip,
|
7
|
+
TooltipPosition,
|
8
|
+
Spinner,
|
9
|
+
} from '@patternfly/react-core';
|
10
|
+
import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons';
|
11
|
+
import PropTypes from 'prop-types';
|
12
|
+
|
13
|
+
import './ActionableDetail.scss';
|
14
|
+
import EditableTextInput from './EditableTextInput';
|
15
|
+
import EditableSwitch from './EditableSwitch';
|
16
|
+
|
17
|
+
// To be used within a TextList
|
18
|
+
const ActionableDetail = ({
|
19
|
+
attribute,
|
20
|
+
label,
|
21
|
+
value,
|
22
|
+
textArea,
|
23
|
+
boolean,
|
24
|
+
tooltip,
|
25
|
+
onEdit,
|
26
|
+
currentAttribute,
|
27
|
+
setCurrentAttribute,
|
28
|
+
disabled,
|
29
|
+
loading,
|
30
|
+
...rest
|
31
|
+
}) => {
|
32
|
+
const displayProps = {
|
33
|
+
attribute,
|
34
|
+
value,
|
35
|
+
onEdit,
|
36
|
+
disabled,
|
37
|
+
currentAttribute,
|
38
|
+
setCurrentAttribute,
|
39
|
+
...rest,
|
40
|
+
};
|
41
|
+
|
42
|
+
return (
|
43
|
+
<React.Fragment key={label}>
|
44
|
+
{label && (
|
45
|
+
<TextListItem component={TextListItemVariants.dt}>
|
46
|
+
{label}
|
47
|
+
{tooltip && (
|
48
|
+
<span className="foreman-spaced-icon">
|
49
|
+
<Tooltip position={TooltipPosition.top} content={tooltip}>
|
50
|
+
<OutlinedQuestionCircleIcon />
|
51
|
+
</Tooltip>
|
52
|
+
</span>
|
53
|
+
)}
|
54
|
+
</TextListItem>
|
55
|
+
)}
|
56
|
+
<TextListItem
|
57
|
+
component={TextListItemVariants.dd}
|
58
|
+
className="foreman-spaced-list"
|
59
|
+
>
|
60
|
+
{loading ? (
|
61
|
+
<Spinner
|
62
|
+
key={label + currentAttribute}
|
63
|
+
size="lg"
|
64
|
+
diameter={textArea ? '53px' : '1.5rem'}
|
65
|
+
/>
|
66
|
+
) : (
|
67
|
+
<>
|
68
|
+
{boolean ? (
|
69
|
+
<EditableSwitch {...displayProps} />
|
70
|
+
) : (
|
71
|
+
<EditableTextInput
|
72
|
+
{...{
|
73
|
+
...displayProps,
|
74
|
+
textArea,
|
75
|
+
}}
|
76
|
+
/>
|
77
|
+
)}
|
78
|
+
</>
|
79
|
+
)}
|
80
|
+
</TextListItem>
|
81
|
+
</React.Fragment>
|
82
|
+
);
|
83
|
+
};
|
84
|
+
|
85
|
+
ActionableDetail.propTypes = {
|
86
|
+
attribute: PropTypes.string.isRequired, // back-end name for API call
|
87
|
+
label: PropTypes.string,
|
88
|
+
value: PropTypes.oneOfType([
|
89
|
+
// displayed value
|
90
|
+
PropTypes.string,
|
91
|
+
PropTypes.bool,
|
92
|
+
]),
|
93
|
+
onEdit: PropTypes.func.isRequired,
|
94
|
+
textArea: PropTypes.bool,
|
95
|
+
boolean: PropTypes.bool,
|
96
|
+
tooltip: PropTypes.string,
|
97
|
+
currentAttribute: PropTypes.string,
|
98
|
+
setCurrentAttribute: PropTypes.func,
|
99
|
+
disabled: PropTypes.bool,
|
100
|
+
loading: PropTypes.bool,
|
101
|
+
};
|
102
|
+
|
103
|
+
ActionableDetail.defaultProps = {
|
104
|
+
label: undefined,
|
105
|
+
textArea: false,
|
106
|
+
boolean: false,
|
107
|
+
tooltip: null,
|
108
|
+
value: null,
|
109
|
+
currentAttribute: undefined,
|
110
|
+
setCurrentAttribute: undefined,
|
111
|
+
disabled: false,
|
112
|
+
loading: false,
|
113
|
+
};
|
114
|
+
|
115
|
+
export default ActionableDetail;
|
@@ -0,0 +1,47 @@
|
|
1
|
+
/* Credits: https://github.com/Katello/katello/blob/631d5bb83dc5d87320ee9002a6de33809a281b3e/webpack/components/EditableSwitch.js */
|
2
|
+
import React from 'react';
|
3
|
+
import { Switch } from '@patternfly/react-core';
|
4
|
+
import { noop } from 'foremanReact/common/helpers';
|
5
|
+
import PropTypes from 'prop-types';
|
6
|
+
|
7
|
+
const EditableSwitch = ({
|
8
|
+
value,
|
9
|
+
attribute,
|
10
|
+
onEdit,
|
11
|
+
disabled,
|
12
|
+
setCurrentAttribute,
|
13
|
+
}) => {
|
14
|
+
const identifier = `${attribute} switch`;
|
15
|
+
const onSwitch = val => {
|
16
|
+
if (setCurrentAttribute) setCurrentAttribute(attribute);
|
17
|
+
onEdit(val, attribute);
|
18
|
+
};
|
19
|
+
|
20
|
+
return (
|
21
|
+
<Switch
|
22
|
+
id={identifier}
|
23
|
+
aria-label={identifier}
|
24
|
+
ouiaId={`switch-${identifier}`}
|
25
|
+
isChecked={value}
|
26
|
+
onChange={onSwitch}
|
27
|
+
disabled={disabled}
|
28
|
+
/>
|
29
|
+
);
|
30
|
+
};
|
31
|
+
|
32
|
+
EditableSwitch.propTypes = {
|
33
|
+
value: PropTypes.bool.isRequired,
|
34
|
+
attribute: PropTypes.string,
|
35
|
+
onEdit: PropTypes.func,
|
36
|
+
disabled: PropTypes.bool,
|
37
|
+
setCurrentAttribute: PropTypes.func,
|
38
|
+
};
|
39
|
+
|
40
|
+
EditableSwitch.defaultProps = {
|
41
|
+
attribute: '',
|
42
|
+
onEdit: noop,
|
43
|
+
disabled: false,
|
44
|
+
setCurrentAttribute: undefined,
|
45
|
+
};
|
46
|
+
|
47
|
+
export default EditableSwitch;
|
@@ -0,0 +1,206 @@
|
|
1
|
+
/* Credits: https://github.com/Katello/katello/blob/631d5bb83dc5d87320ee9002a6de33809a281b3e/webpack/components/EditableTextInput/EditableTextInput.js */
|
2
|
+
import React, { useState, useEffect } from 'react';
|
3
|
+
import {
|
4
|
+
TextInput,
|
5
|
+
TextArea,
|
6
|
+
Text,
|
7
|
+
Button,
|
8
|
+
Split,
|
9
|
+
SplitItem,
|
10
|
+
} from '@patternfly/react-core';
|
11
|
+
import {
|
12
|
+
EyeIcon,
|
13
|
+
EyeSlashIcon,
|
14
|
+
TimesIcon,
|
15
|
+
CheckIcon,
|
16
|
+
} from '@patternfly/react-icons';
|
17
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
18
|
+
import PropTypes from 'prop-types';
|
19
|
+
import './editableTextInput.scss';
|
20
|
+
import PencilEditButton from './PencilEditButton';
|
21
|
+
|
22
|
+
const PASSWORD_MASK = '••••••••';
|
23
|
+
|
24
|
+
const EditableTextInput = ({
|
25
|
+
onEdit,
|
26
|
+
value,
|
27
|
+
textArea,
|
28
|
+
attribute,
|
29
|
+
placeholder,
|
30
|
+
isPassword,
|
31
|
+
hasPassword,
|
32
|
+
component,
|
33
|
+
currentAttribute,
|
34
|
+
setCurrentAttribute,
|
35
|
+
disabled,
|
36
|
+
ouiaId,
|
37
|
+
valid,
|
38
|
+
}) => {
|
39
|
+
const [inputValue, setInputValue] = useState(value);
|
40
|
+
const [editing, setEditing] = useState(false);
|
41
|
+
const [passwordPlaceholder, setPasswordPlaceholder] = useState(
|
42
|
+
hasPassword ? PASSWORD_MASK : null
|
43
|
+
);
|
44
|
+
const [showPassword, setShowPassword] = useState(false);
|
45
|
+
|
46
|
+
useEffect(() => {
|
47
|
+
if (setCurrentAttribute && currentAttribute) {
|
48
|
+
if (attribute !== currentAttribute) {
|
49
|
+
setEditing(false);
|
50
|
+
}
|
51
|
+
}
|
52
|
+
}, [attribute, currentAttribute, setCurrentAttribute]);
|
53
|
+
|
54
|
+
const onEditClick = () => {
|
55
|
+
setEditing(true);
|
56
|
+
if (isPassword) setPasswordPlaceholder(null);
|
57
|
+
if (setCurrentAttribute && attribute !== currentAttribute)
|
58
|
+
setCurrentAttribute(attribute);
|
59
|
+
};
|
60
|
+
|
61
|
+
const onSubmit = async () => {
|
62
|
+
setEditing(false);
|
63
|
+
if (isPassword) {
|
64
|
+
if (inputValue?.length > 0) {
|
65
|
+
setPasswordPlaceholder(PASSWORD_MASK);
|
66
|
+
}
|
67
|
+
}
|
68
|
+
await onEdit(inputValue, attribute);
|
69
|
+
};
|
70
|
+
|
71
|
+
const onClear = () => {
|
72
|
+
if (isPassword) {
|
73
|
+
if (hasPassword || inputValue?.length > 0) {
|
74
|
+
setPasswordPlaceholder(PASSWORD_MASK);
|
75
|
+
}
|
76
|
+
}
|
77
|
+
setInputValue(value);
|
78
|
+
setEditing(false);
|
79
|
+
};
|
80
|
+
|
81
|
+
const toggleShowPassword = () => {
|
82
|
+
setShowPassword(prevShowPassword => !prevShowPassword);
|
83
|
+
};
|
84
|
+
|
85
|
+
const onKeyUp = ({ key, charCode }) =>
|
86
|
+
(key === 'Enter' || charCode === '13') && onSubmit();
|
87
|
+
|
88
|
+
const inputProps = {
|
89
|
+
onKeyUp,
|
90
|
+
component,
|
91
|
+
value: inputValue || '',
|
92
|
+
onChange: setInputValue,
|
93
|
+
validated: valid,
|
94
|
+
};
|
95
|
+
|
96
|
+
return editing ? (
|
97
|
+
<Split>
|
98
|
+
<SplitItem>
|
99
|
+
{textArea ? (
|
100
|
+
<TextArea {...inputProps} aria-label={`${attribute} text area`} />
|
101
|
+
) : (
|
102
|
+
<TextInput
|
103
|
+
{...inputProps}
|
104
|
+
type={isPassword && !showPassword ? 'password' : 'text'}
|
105
|
+
aria-label={`${attribute} text input`}
|
106
|
+
ouiaId={ouiaId}
|
107
|
+
/>
|
108
|
+
)}
|
109
|
+
</SplitItem>
|
110
|
+
<SplitItem>
|
111
|
+
<Button
|
112
|
+
ouiaId={`submit-button-${attribute}`}
|
113
|
+
aria-label={`submit ${attribute}`}
|
114
|
+
variant="plain"
|
115
|
+
onClick={onSubmit}
|
116
|
+
>
|
117
|
+
<CheckIcon />
|
118
|
+
</Button>
|
119
|
+
</SplitItem>
|
120
|
+
<SplitItem>
|
121
|
+
<Button
|
122
|
+
ouiaId={`clear-button-${attribute}`}
|
123
|
+
aria-label={`clear ${attribute}`}
|
124
|
+
variant="plain"
|
125
|
+
onClick={onClear}
|
126
|
+
>
|
127
|
+
<TimesIcon />
|
128
|
+
</Button>
|
129
|
+
</SplitItem>
|
130
|
+
{isPassword ? (
|
131
|
+
<SplitItem>
|
132
|
+
<Button
|
133
|
+
ouiaId={`show-button-${attribute}`}
|
134
|
+
aria-label={`show-password ${attribute}`}
|
135
|
+
variant="plain"
|
136
|
+
isDisabled={!inputValue?.length}
|
137
|
+
onClick={toggleShowPassword}
|
138
|
+
>
|
139
|
+
{showPassword ? <EyeSlashIcon /> : <EyeIcon />}
|
140
|
+
</Button>
|
141
|
+
</SplitItem>
|
142
|
+
) : null}
|
143
|
+
</Split>
|
144
|
+
) : (
|
145
|
+
<Split>
|
146
|
+
<SplitItem>
|
147
|
+
{inputValue ? (
|
148
|
+
<Text
|
149
|
+
className={`text${textArea ? 'Area' : 'Input'}-value`}
|
150
|
+
ouiaId={`${attribute}-text-value`}
|
151
|
+
aria-label={`${attribute} text value`}
|
152
|
+
component={component}
|
153
|
+
>
|
154
|
+
{editing ? inputValue : passwordPlaceholder || inputValue}
|
155
|
+
</Text>
|
156
|
+
) : (
|
157
|
+
<Text
|
158
|
+
className={`text${textArea ? 'Area' : 'Input'}-placeholder`}
|
159
|
+
ouiaId={`${attribute}-text-value`}
|
160
|
+
aria-label={`${attribute} text value`}
|
161
|
+
component={component}
|
162
|
+
>
|
163
|
+
{passwordPlaceholder || placeholder}
|
164
|
+
</Text>
|
165
|
+
)}
|
166
|
+
</SplitItem>
|
167
|
+
{!disabled && (
|
168
|
+
<SplitItem>
|
169
|
+
<PencilEditButton {...{ attribute, onEditClick }} />
|
170
|
+
</SplitItem>
|
171
|
+
)}
|
172
|
+
</Split>
|
173
|
+
);
|
174
|
+
};
|
175
|
+
|
176
|
+
EditableTextInput.propTypes = {
|
177
|
+
onEdit: PropTypes.func.isRequired,
|
178
|
+
value: PropTypes.string,
|
179
|
+
attribute: PropTypes.string.isRequired,
|
180
|
+
textArea: PropTypes.bool, // Is a text area instead of input when editing
|
181
|
+
placeholder: PropTypes.string,
|
182
|
+
component: PropTypes.string,
|
183
|
+
currentAttribute: PropTypes.string,
|
184
|
+
setCurrentAttribute: PropTypes.func,
|
185
|
+
disabled: PropTypes.bool,
|
186
|
+
isPassword: PropTypes.bool,
|
187
|
+
hasPassword: PropTypes.bool,
|
188
|
+
ouiaId: PropTypes.string,
|
189
|
+
valid: PropTypes.string,
|
190
|
+
};
|
191
|
+
|
192
|
+
EditableTextInput.defaultProps = {
|
193
|
+
textArea: false,
|
194
|
+
placeholder: __('None provided'),
|
195
|
+
value: '', // API can return null, so default to empty string
|
196
|
+
component: undefined,
|
197
|
+
currentAttribute: undefined,
|
198
|
+
setCurrentAttribute: undefined,
|
199
|
+
disabled: false,
|
200
|
+
isPassword: false,
|
201
|
+
hasPassword: false,
|
202
|
+
ouiaId: undefined,
|
203
|
+
valid: 'default',
|
204
|
+
};
|
205
|
+
|
206
|
+
export default EditableTextInput;
|
@@ -0,0 +1,27 @@
|
|
1
|
+
/* Credits: https://github.com/Katello/katello/blob/631d5bb83dc5d87320ee9002a6de33809a281b3e/webpack/components/EditableTextInput/PencilEditButton.js */
|
2
|
+
import React from 'react';
|
3
|
+
import PropTypes from 'prop-types';
|
4
|
+
import { Button, Tooltip, TooltipPosition } from '@patternfly/react-core';
|
5
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
6
|
+
import { PencilAltIcon } from '@patternfly/react-icons';
|
7
|
+
|
8
|
+
const PencilEditButton = ({ attribute, onEditClick }) => (
|
9
|
+
<Tooltip position={TooltipPosition.top} content={__('Edit')}>
|
10
|
+
<Button
|
11
|
+
className="foreman-edit-icon"
|
12
|
+
ouiaId={`edit-button-${attribute}`}
|
13
|
+
aria-label={`edit ${attribute}`}
|
14
|
+
variant="plain"
|
15
|
+
onClick={onEditClick}
|
16
|
+
>
|
17
|
+
<PencilAltIcon />
|
18
|
+
</Button>
|
19
|
+
</Tooltip>
|
20
|
+
);
|
21
|
+
|
22
|
+
export default PencilEditButton;
|
23
|
+
|
24
|
+
PencilEditButton.propTypes = {
|
25
|
+
attribute: PropTypes.string.isRequired,
|
26
|
+
onEditClick: PropTypes.func.isRequired,
|
27
|
+
};
|