You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

CustomRuleFormModal.tsx 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2024 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import { HttpStatusCode } from 'axios';
  21. import {
  22. ButtonPrimary,
  23. FlagMessage,
  24. FormField,
  25. InputField,
  26. InputSelect,
  27. InputTextArea,
  28. LabelValueSelectOption,
  29. LightLabel,
  30. Modal,
  31. } from 'design-system';
  32. import * as React from 'react';
  33. import FormattingTips from '../../../components/common/FormattingTips';
  34. import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
  35. import { RULE_STATUSES } from '../../../helpers/constants';
  36. import { csvEscape } from '../../../helpers/csv';
  37. import { translate } from '../../../helpers/l10n';
  38. import { sanitizeString } from '../../../helpers/sanitize';
  39. import { latinize } from '../../../helpers/strings';
  40. import { useCreateRuleMutation, useUpdateRuleMutation } from '../../../queries/rules';
  41. import {
  42. CleanCodeAttribute,
  43. CleanCodeAttributeCategory,
  44. SoftwareImpact,
  45. } from '../../../types/clean-code-taxonomy';
  46. import { Dict, RuleDetails, RuleParameter, Status } from '../../../types/types';
  47. import {
  48. CleanCodeAttributeField,
  49. CleanCodeCategoryField,
  50. SoftwareQualitiesFields,
  51. } from './CustomRuleFormFieldsCCT';
  52. interface Props {
  53. customRule?: RuleDetails;
  54. onClose: () => void;
  55. templateRule: RuleDetails;
  56. }
  57. const FORM_ID = 'custom-rule-form';
  58. export default function CustomRuleFormModal(props: Readonly<Props>) {
  59. const { customRule, templateRule } = props;
  60. const [description, setDescription] = React.useState(customRule?.mdDesc ?? '');
  61. const [key, setKey] = React.useState(customRule?.key ?? '');
  62. const [keyModifiedByUser, setKeyModifiedByUser] = React.useState(false);
  63. const [name, setName] = React.useState(customRule?.name ?? '');
  64. const [params, setParams] = React.useState(getParams(customRule));
  65. const [reactivating, setReactivating] = React.useState(false);
  66. const [status, setStatus] = React.useState(customRule?.status ?? templateRule.status);
  67. const [ccCategory, setCCCategory] = React.useState<CleanCodeAttributeCategory>(
  68. templateRule.cleanCodeAttributeCategory ?? CleanCodeAttributeCategory.Consistent,
  69. );
  70. const [ccAttribute, setCCAtribute] = React.useState<CleanCodeAttribute>(
  71. templateRule.cleanCodeAttribute ?? CleanCodeAttribute.Conventional,
  72. );
  73. const [impacts, setImpacts] = React.useState<SoftwareImpact[]>(templateRule?.impacts ?? []);
  74. const customRulesSearchParams = {
  75. f: 'name,severity,params',
  76. template_key: templateRule.key,
  77. };
  78. const { mutate: updateRule, isPending: updatingRule } = useUpdateRuleMutation(
  79. customRulesSearchParams,
  80. props.onClose,
  81. );
  82. const { mutate: createRule, isPending: creatingRule } = useCreateRuleMutation(
  83. customRulesSearchParams,
  84. props.onClose,
  85. (response: Response) => {
  86. setReactivating(response.status === HttpStatusCode.Conflict);
  87. },
  88. );
  89. const warningRef = React.useRef<HTMLDivElement>(null);
  90. const submitting = updatingRule || creatingRule;
  91. const hasError = impacts.length === 0;
  92. const submit = () => {
  93. const stringifiedParams = Object.keys(params)
  94. .map((key) => `${key}=${csvEscape(params[key])}`)
  95. .join(';');
  96. const ruleData = {
  97. name,
  98. status,
  99. markdownDescription: description,
  100. };
  101. if (customRule) {
  102. updateRule({
  103. ...ruleData,
  104. params: stringifiedParams,
  105. key: customRule.key,
  106. });
  107. } else if (reactivating) {
  108. updateRule({
  109. ...ruleData,
  110. params: stringifiedParams,
  111. key: `${templateRule.repo}:${key}`,
  112. });
  113. } else {
  114. createRule({
  115. ...ruleData,
  116. key: `${templateRule.repo}:${key}`,
  117. templateKey: templateRule.key,
  118. cleanCodeAttribute: ccAttribute,
  119. impacts,
  120. parameters: Object.entries(params).map(([key, value]) => ({ key, defaultValue: value })),
  121. });
  122. }
  123. };
  124. // If key changes, then most likely user did it to create a new rule instead of reactivating one
  125. React.useEffect(() => {
  126. setReactivating(false);
  127. }, [key]);
  128. // scroll to warning when it appears
  129. React.useEffect(() => {
  130. if (reactivating) {
  131. warningRef.current?.scrollIntoView({ behavior: 'smooth' });
  132. }
  133. }, [reactivating]);
  134. const NameField = React.useMemo(
  135. () => (
  136. <FormField
  137. ariaLabel={translate('name')}
  138. label={translate('name')}
  139. htmlFor="coding-rules-custom-rule-creation-name"
  140. required
  141. >
  142. <InputField
  143. autoFocus
  144. disabled={submitting}
  145. id="coding-rules-custom-rule-creation-name"
  146. onChange={({
  147. currentTarget: { value: name },
  148. }: React.SyntheticEvent<HTMLInputElement>) => {
  149. setName(name);
  150. setKey(keyModifiedByUser ? key : latinize(name).replace(/[^A-Za-z0-9]/g, '_'));
  151. }}
  152. required
  153. size="full"
  154. type="text"
  155. value={name}
  156. />
  157. </FormField>
  158. ),
  159. [key, keyModifiedByUser, name, submitting],
  160. );
  161. const KeyField = React.useMemo(
  162. () => (
  163. <FormField
  164. ariaLabel={translate('key')}
  165. label={translate('key')}
  166. htmlFor="coding-rules-custom-rule-creation-key"
  167. required
  168. >
  169. {customRule ? (
  170. <span title={customRule.key}>{customRule.key}</span>
  171. ) : (
  172. <InputField
  173. disabled={submitting}
  174. id="coding-rules-custom-rule-creation-key"
  175. onChange={(event: React.SyntheticEvent<HTMLInputElement>) => {
  176. setKey(event.currentTarget.value);
  177. setKeyModifiedByUser(true);
  178. }}
  179. required
  180. size="full"
  181. type="text"
  182. value={key}
  183. />
  184. )}
  185. </FormField>
  186. ),
  187. [customRule, key, submitting],
  188. );
  189. const DescriptionField = React.useMemo(
  190. () => (
  191. <FormField
  192. ariaLabel={translate('description')}
  193. label={translate('description')}
  194. htmlFor="coding-rules-custom-rule-creation-html-description"
  195. required
  196. >
  197. <InputTextArea
  198. disabled={submitting}
  199. id="coding-rules-custom-rule-creation-html-description"
  200. onChange={(event: React.SyntheticEvent<HTMLTextAreaElement>) =>
  201. setDescription(event.currentTarget.value)
  202. }
  203. required
  204. rows={5}
  205. size="full"
  206. value={description}
  207. />
  208. <FormattingTips />
  209. </FormField>
  210. ),
  211. [description, submitting],
  212. );
  213. const StatusField = React.useMemo(() => {
  214. const statusesOptions = RULE_STATUSES.map((status) => ({
  215. label: translate('rules.status', status),
  216. value: status,
  217. }));
  218. return (
  219. <FormField
  220. ariaLabel={translate('coding_rules.filters.status')}
  221. label={translate('coding_rules.filters.status')}
  222. htmlFor="coding-rules-custom-rule-status"
  223. >
  224. <InputSelect
  225. inputId="coding-rules-custom-rule-status"
  226. isClearable={false}
  227. isDisabled={submitting}
  228. aria-labelledby="coding-rules-custom-rule-status"
  229. onChange={({ value }: LabelValueSelectOption<Status>) => setStatus(value)}
  230. options={statusesOptions}
  231. isSearchable={false}
  232. value={statusesOptions.find((s) => s.value === status)}
  233. />
  234. </FormField>
  235. );
  236. }, [status, submitting]);
  237. const handleParameterChange = React.useCallback(
  238. (event: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>) => {
  239. const { name, value } = event.currentTarget;
  240. setParams({ ...params, [name]: value });
  241. },
  242. [params],
  243. );
  244. const renderParameterField = React.useCallback(
  245. (param: RuleParameter) => {
  246. // Gets the actual value from params from the state.
  247. // Without it, we have a issue with string 'constructor' as key
  248. const actualValue = new Map(Object.entries(params)).get(param.key) ?? '';
  249. return (
  250. <FormField
  251. ariaLabel={param.key}
  252. className="sw-capitalize"
  253. label={param.key}
  254. htmlFor={`coding-rule-custom-rule-${param.key}`}
  255. key={param.key}
  256. >
  257. {param.type === 'TEXT' ? (
  258. <InputTextArea
  259. disabled={submitting}
  260. id={`coding-rule-custom-rule-${param.key}`}
  261. name={param.key}
  262. onChange={handleParameterChange}
  263. placeholder={param.defaultValue}
  264. size="full"
  265. rows={3}
  266. value={actualValue}
  267. />
  268. ) : (
  269. <InputField
  270. disabled={submitting}
  271. id={`coding-rule-custom-rule-${param.key}`}
  272. name={param.key}
  273. onChange={handleParameterChange}
  274. placeholder={param.defaultValue}
  275. size="full"
  276. type="text"
  277. value={actualValue}
  278. />
  279. )}
  280. {param.htmlDesc !== undefined && (
  281. <LightLabel
  282. // eslint-disable-next-line react/no-danger
  283. dangerouslySetInnerHTML={{ __html: sanitizeString(param.htmlDesc) }}
  284. />
  285. )}
  286. </FormField>
  287. );
  288. },
  289. [params, submitting, handleParameterChange],
  290. );
  291. const { params: templateParams = [] } = templateRule;
  292. const header = customRule
  293. ? translate('coding_rules.update_custom_rule')
  294. : translate('coding_rules.create_custom_rule');
  295. let buttonText = translate(customRule ? 'save' : 'create');
  296. if (reactivating) {
  297. buttonText = translate('coding_rules.reactivate');
  298. }
  299. return (
  300. <Modal
  301. headerTitle={header}
  302. onClose={props.onClose}
  303. body={
  304. <form
  305. className="sw-flex sw-flex-col sw-justify-stretch sw-pb-4"
  306. id={FORM_ID}
  307. onSubmit={(event: React.SyntheticEvent<HTMLFormElement>) => {
  308. event.preventDefault();
  309. submit();
  310. }}
  311. >
  312. {reactivating && (
  313. <div ref={warningRef}>
  314. <FlagMessage variant="warning" className="sw-mb-6">
  315. {translate('coding_rules.reactivate.help')}
  316. </FlagMessage>
  317. </div>
  318. )}
  319. <MandatoryFieldsExplanation className="sw-mb-4" />
  320. {NameField}
  321. {KeyField}
  322. {/* do not allow to change CCT fields of existing rule */}
  323. {!customRule && !reactivating && (
  324. <>
  325. <div className="sw-flex sw-justify-between sw-gap-6">
  326. <CleanCodeCategoryField
  327. value={ccCategory}
  328. disabled={submitting}
  329. onChange={setCCCategory}
  330. />
  331. <CleanCodeAttributeField
  332. value={ccAttribute}
  333. category={ccCategory}
  334. disabled={submitting}
  335. onChange={setCCAtribute}
  336. />
  337. </div>
  338. <SoftwareQualitiesFields
  339. error={hasError}
  340. value={impacts}
  341. onChange={setImpacts}
  342. disabled={submitting}
  343. />
  344. </>
  345. )}
  346. {StatusField}
  347. {DescriptionField}
  348. {templateParams.map(renderParameterField)}
  349. </form>
  350. }
  351. primaryButton={
  352. <ButtonPrimary disabled={submitting || hasError} type="submit" form={FORM_ID}>
  353. {buttonText}
  354. </ButtonPrimary>
  355. }
  356. loading={submitting}
  357. secondaryButtonLabel={translate('cancel')}
  358. />
  359. );
  360. }
  361. function getParams(customRule?: RuleDetails) {
  362. const params: Dict<string> = {};
  363. if (customRule?.params) {
  364. for (const param of customRule.params) {
  365. params[param.key] = param.defaultValue ?? '';
  366. }
  367. }
  368. return params;
  369. }