foreman_resource_quota 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +619 -0
  3. data/README.md +51 -0
  4. data/Rakefile +49 -0
  5. data/app/controllers/concerns/foreman/controller/parameters/resource_quota.rb +28 -0
  6. data/app/controllers/foreman_resource_quota/api/v2/resource_quotas_controller.rb +96 -0
  7. data/app/controllers/foreman_resource_quota/application_controller.rb +9 -0
  8. data/app/controllers/foreman_resource_quota/resource_quotas_controller.rb +50 -0
  9. data/app/helpers/foreman_resource_quota/hosts_helper.rb +18 -0
  10. data/app/helpers/foreman_resource_quota/resource_quota_helper.rb +107 -0
  11. data/app/models/concerns/foreman_resource_quota/host_managed_extensions.rb +115 -0
  12. data/app/models/concerns/foreman_resource_quota/user_extensions.rb +15 -0
  13. data/app/models/concerns/foreman_resource_quota/usergroup_extensions.rb +14 -0
  14. data/app/models/foreman_resource_quota/resource_quota.rb +83 -0
  15. data/app/models/foreman_resource_quota/resource_quota_user.rb +10 -0
  16. data/app/models/foreman_resource_quota/resource_quota_usergroup.rb +10 -0
  17. data/app/services/foreman_resource_quota/resource_origin.rb +97 -0
  18. data/app/services/foreman_resource_quota/resource_origins/compute_attributes_origin.rb +64 -0
  19. data/app/services/foreman_resource_quota/resource_origins/compute_resource_origin.rb +82 -0
  20. data/app/services/foreman_resource_quota/resource_origins/facts_origin.rb +68 -0
  21. data/app/services/foreman_resource_quota/resource_origins/vm_attributes_origin.rb +40 -0
  22. data/app/views/foreman_resource_quota/api/v2/hosts/resource_quota.json.rabl +3 -0
  23. data/app/views/foreman_resource_quota/api/v2/resource_quotas/base.json.rabl +6 -0
  24. data/app/views/foreman_resource_quota/api/v2/resource_quotas/create.json.rabl +5 -0
  25. data/app/views/foreman_resource_quota/api/v2/resource_quotas/hosts.json.rabl +7 -0
  26. data/app/views/foreman_resource_quota/api/v2/resource_quotas/index.json.rabl +5 -0
  27. data/app/views/foreman_resource_quota/api/v2/resource_quotas/main.json.rabl +7 -0
  28. data/app/views/foreman_resource_quota/api/v2/resource_quotas/show.json.rabl +5 -0
  29. data/app/views/foreman_resource_quota/api/v2/resource_quotas/update.json.rabl +5 -0
  30. data/app/views/foreman_resource_quota/api/v2/resource_quotas/usergroups.json.rabl +7 -0
  31. data/app/views/foreman_resource_quota/api/v2/resource_quotas/users.json.rabl +7 -0
  32. data/app/views/foreman_resource_quota/api/v2/resource_quotas/utilization.json.rabl +7 -0
  33. data/app/views/foreman_resource_quota/api/v2/usergroups/resource_quota.json.rabl +3 -0
  34. data/app/views/foreman_resource_quota/api/v2/users/resource_quota.json.rabl +3 -0
  35. data/app/views/foreman_resource_quota/resource_quotas/_form.html.erb +21 -0
  36. data/app/views/foreman_resource_quota/resource_quotas/edit.html.erb +12 -0
  37. data/app/views/foreman_resource_quota/resource_quotas/index.html.erb +55 -0
  38. data/app/views/foreman_resource_quota/resource_quotas/new.html.erb +10 -0
  39. data/app/views/foreman_resource_quota/resource_quotas/welcome.html.erb +10 -0
  40. data/app/views/hosts/_form_quota_fields.html.erb +4 -0
  41. data/app/views/users/_form_quota_tab.html.erb +45 -0
  42. data/config/initializers/inflections.rb +5 -0
  43. data/config/routes.rb +43 -0
  44. data/db/migrate/20230306120001_create_resource_quotas.rb +31 -0
  45. data/lib/foreman_resource_quota/engine.rb +56 -0
  46. data/lib/foreman_resource_quota/exceptions.rb +11 -0
  47. data/lib/foreman_resource_quota/register.rb +106 -0
  48. data/lib/foreman_resource_quota/version.rb +5 -0
  49. data/lib/foreman_resource_quota.rb +6 -0
  50. data/lib/tasks/foreman_resource_quota_tasks.rake +50 -0
  51. data/locale/Makefile +60 -0
  52. data/locale/en/foreman_resource_quota.po +18 -0
  53. data/locale/foreman_resource_quota.pot +19 -0
  54. data/locale/gemspec.rb +4 -0
  55. data/package.json +44 -0
  56. data/webpack/api_helper.js +113 -0
  57. data/webpack/api_helper.test.js +96 -0
  58. data/webpack/components/CreateResourceQuotaModal.js +46 -0
  59. data/webpack/components/ResourceQuotaEmptyState/index.js +58 -0
  60. data/webpack/components/ResourceQuotaForm/ResourceQuotaForm.scss +1 -0
  61. data/webpack/components/ResourceQuotaForm/ResourceQuotaFormConstants.js +71 -0
  62. data/webpack/components/ResourceQuotaForm/components/Properties/Properties.scss +9 -0
  63. data/webpack/components/ResourceQuotaForm/components/Properties/StaticDetail.js +72 -0
  64. data/webpack/components/ResourceQuotaForm/components/Properties/StatusPropertiesLabel.js +71 -0
  65. data/webpack/components/ResourceQuotaForm/components/Properties/StatusPropertiesLabel.test.js +50 -0
  66. data/webpack/components/ResourceQuotaForm/components/Properties/TextInputField.js +131 -0
  67. data/webpack/components/ResourceQuotaForm/components/Properties/index.js +190 -0
  68. data/webpack/components/ResourceQuotaForm/components/QuotaState.js +157 -0
  69. data/webpack/components/ResourceQuotaForm/components/Resource/Resource.scss +13 -0
  70. data/webpack/components/ResourceQuotaForm/components/Resource/UnitInputField.js +224 -0
  71. data/webpack/components/ResourceQuotaForm/components/Resource/UtilizationProgress.js +151 -0
  72. data/webpack/components/ResourceQuotaForm/components/Resource/UtilizationProgress.scss +10 -0
  73. data/webpack/components/ResourceQuotaForm/components/Resource/index.js +239 -0
  74. data/webpack/components/ResourceQuotaForm/components/Resources.js +105 -0
  75. data/webpack/components/ResourceQuotaForm/components/Submit.js +72 -0
  76. data/webpack/components/ResourceQuotaForm/index.js +185 -0
  77. data/webpack/components/UpdateResourceQuotaModal.js +143 -0
  78. data/webpack/global_index.js +15 -0
  79. data/webpack/global_test_setup.js +11 -0
  80. data/webpack/helper.js +86 -0
  81. data/webpack/index.js +23 -0
  82. data/webpack/lib/ActionableDetail.js +115 -0
  83. data/webpack/lib/ActionableDetail.scss +4 -0
  84. data/webpack/lib/EditableSwitch.js +47 -0
  85. data/webpack/lib/EditableTextInput/EditableTextInput.js +206 -0
  86. data/webpack/lib/EditableTextInput/PencilEditButton.js +27 -0
  87. data/webpack/lib/EditableTextInput/__tests__/editableTextInput.test.js +193 -0
  88. data/webpack/lib/EditableTextInput/editableTextInput.scss +38 -0
  89. data/webpack/lib/EditableTextInput/index.js +4 -0
  90. data/webpack/lib/react-testing-lib-wrapper.js +80 -0
  91. data/webpack/test_setup.js +17 -0
  92. 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,4 @@
1
+
2
+ dt {
3
+ font-size: var(--pf-global--FontSize--sm);
4
+ }
@@ -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
+ };