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.

ProjectValidation.tsx 9.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  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 classNames from 'classnames';
  21. import {
  22. ButtonSecondary,
  23. Card,
  24. FlagErrorIcon,
  25. FlagSuccessIcon,
  26. FormField,
  27. InputField,
  28. Note,
  29. TextError,
  30. TrashIcon,
  31. } from 'design-system';
  32. import { isEmpty } from 'lodash';
  33. import * as React from 'react';
  34. import { doesComponentExists } from '../../../../api/components';
  35. import { translate } from '../../../../helpers/l10n';
  36. import { validateProjectKey } from '../../../../helpers/projects';
  37. import { ProjectKeyValidationResult } from '../../../../types/component';
  38. import { PROJECT_NAME_MAX_LEN } from '../constants';
  39. import { getSanitizedProjectKey } from '../utils';
  40. interface Props<I> {
  41. initialKey?: string;
  42. initialName?: string;
  43. monorepoSetupProjectKeys?: string[];
  44. onChange: (project: ProjectData<I>) => void;
  45. onRemove?: () => void;
  46. projectId?: I;
  47. }
  48. interface State {
  49. name: string;
  50. nameError?: boolean;
  51. nameTouched: boolean;
  52. key: string;
  53. keyError?: ProjectKeyErrors;
  54. keyTouched: boolean;
  55. validatingKey: boolean;
  56. }
  57. export interface ProjectData<I = string> {
  58. hasError: boolean;
  59. id?: I;
  60. name: string;
  61. key: string;
  62. touched: boolean;
  63. }
  64. enum ProjectKeyErrors {
  65. DuplicateKey = 'DUPLICATE_KEY',
  66. MonorepoDuplicateKey = 'MONOREPO_DUPLICATE_KEY',
  67. WrongFormat = 'WRONG_FORMAT',
  68. }
  69. const DEBOUNCE_DELAY = 250;
  70. export default function ProjectValidation<I>(props: Readonly<Props<I>>) {
  71. const {
  72. initialKey = '',
  73. initialName = '',
  74. monorepoSetupProjectKeys,
  75. onChange,
  76. projectId,
  77. } = props;
  78. const checkFreeKeyTimeout = React.useRef<NodeJS.Timeout | undefined>();
  79. const [project, setProject] = React.useState<State>({
  80. key: initialKey,
  81. name: initialName,
  82. keyTouched: false,
  83. nameTouched: false,
  84. validatingKey: false,
  85. });
  86. const { key, keyError, keyTouched, name, nameError, nameTouched, validatingKey } = project;
  87. React.useEffect(() => {
  88. onChange({
  89. hasError: keyError !== undefined || nameError !== undefined,
  90. id: projectId,
  91. key,
  92. name,
  93. touched: keyTouched || nameTouched,
  94. });
  95. // eslint-disable-next-line react-hooks/exhaustive-deps
  96. }, [key, name, keyError, keyTouched, nameError, nameTouched]);
  97. const checkFreeKey = (keyVal: string) => {
  98. setProject((prevProject) => ({ ...prevProject, validatingKey: true }));
  99. doesComponentExists({ component: keyVal })
  100. .then((alreadyExist) => {
  101. setProject((prevProject) => {
  102. if (keyVal === prevProject.key) {
  103. return {
  104. ...prevProject,
  105. keyError: alreadyExist ? ProjectKeyErrors.DuplicateKey : undefined,
  106. validatingKey: false,
  107. };
  108. }
  109. return prevProject;
  110. });
  111. })
  112. .catch(() => {
  113. setProject((prevProject) => {
  114. if (keyVal === prevProject.key) {
  115. return {
  116. ...prevProject,
  117. keyError: undefined,
  118. validatingKey: false,
  119. };
  120. }
  121. return prevProject;
  122. });
  123. });
  124. };
  125. const handleProjectKeyChange = (projectKey: string, fromUI = false) => {
  126. const keyError = validateKey(projectKey);
  127. setProject((prevProject) => ({
  128. ...prevProject,
  129. key: projectKey,
  130. keyError,
  131. keyTouched: fromUI,
  132. }));
  133. };
  134. React.useEffect(() => {
  135. if (nameTouched && !keyTouched) {
  136. const sanitizedProjectKey = getSanitizedProjectKey(name);
  137. handleProjectKeyChange(sanitizedProjectKey);
  138. }
  139. // eslint-disable-next-line react-hooks/exhaustive-deps
  140. }, [name, keyTouched]);
  141. React.useEffect(() => {
  142. if (!keyError && key !== '') {
  143. checkFreeKeyTimeout.current = setTimeout(() => {
  144. checkFreeKey(key);
  145. checkFreeKeyTimeout.current = undefined;
  146. }, DEBOUNCE_DELAY);
  147. }
  148. return () => {
  149. if (checkFreeKeyTimeout.current !== undefined) {
  150. clearTimeout(checkFreeKeyTimeout.current);
  151. }
  152. };
  153. // eslint-disable-next-line react-hooks/exhaustive-deps
  154. }, [key]);
  155. React.useEffect(() => {
  156. if (
  157. (keyError === undefined || keyError === ProjectKeyErrors.MonorepoDuplicateKey) &&
  158. key !== ''
  159. ) {
  160. if (monorepoSetupProjectKeys?.indexOf(key) !== monorepoSetupProjectKeys?.lastIndexOf(key)) {
  161. setProject((prevProject) => ({
  162. ...prevProject,
  163. keyError: ProjectKeyErrors.MonorepoDuplicateKey,
  164. }));
  165. } else {
  166. setProject((prevProject) => {
  167. if (prevProject.keyError === ProjectKeyErrors.MonorepoDuplicateKey) {
  168. return {
  169. ...prevProject,
  170. keyError: undefined,
  171. };
  172. }
  173. return prevProject;
  174. });
  175. }
  176. }
  177. // eslint-disable-next-line react-hooks/exhaustive-deps
  178. }, [monorepoSetupProjectKeys]);
  179. const handleProjectNameChange = (projectName: string, fromUI = false) => {
  180. setProject({
  181. ...project,
  182. name: projectName,
  183. nameError: validateName(projectName),
  184. nameTouched: fromUI,
  185. });
  186. };
  187. const validateKey = (projectKey: string) => {
  188. const result = validateProjectKey(projectKey);
  189. if (result !== ProjectKeyValidationResult.Valid) {
  190. return ProjectKeyErrors.WrongFormat;
  191. }
  192. return undefined;
  193. };
  194. const validateName = (projectName: string) => {
  195. if (isEmpty(projectName)) {
  196. return true;
  197. }
  198. return undefined;
  199. };
  200. const touched = Boolean(keyTouched || nameTouched);
  201. const projectNameIsInvalid = nameTouched && nameError !== undefined;
  202. const projectNameIsValid = nameTouched && nameError === undefined;
  203. const projectKeyIsInvalid = touched && keyError !== undefined;
  204. const projectKeyIsValid = touched && !validatingKey && keyError === undefined;
  205. const projectKeyInputId = projectId !== undefined ? `project-key-${projectId}` : 'project-key';
  206. const projectNameInputId = projectId !== undefined ? `project-name-${projectId}` : 'project-name';
  207. return (
  208. <>
  209. <FormField
  210. htmlFor={projectNameInputId}
  211. label={translate('onboarding.create_project.display_name')}
  212. required
  213. >
  214. <div>
  215. <InputField
  216. className={classNames({
  217. 'js__is-invalid': projectNameIsInvalid,
  218. })}
  219. size="large"
  220. id={projectNameInputId}
  221. maxLength={PROJECT_NAME_MAX_LEN}
  222. minLength={1}
  223. onChange={(e) => handleProjectNameChange(e.currentTarget.value, true)}
  224. type="text"
  225. value={name}
  226. autoFocus
  227. isInvalid={projectNameIsInvalid}
  228. isValid={projectNameIsValid}
  229. required
  230. />
  231. {projectNameIsInvalid && <FlagErrorIcon className="sw-ml-2" />}
  232. {projectNameIsValid && <FlagSuccessIcon className="sw-ml-2" />}
  233. </div>
  234. {nameError !== undefined && (
  235. <Note className="sw-mt-2">
  236. {translate('onboarding.create_project.display_name.description')}
  237. </Note>
  238. )}
  239. </FormField>
  240. <FormField
  241. htmlFor={projectKeyInputId}
  242. label={translate('onboarding.create_project.project_key')}
  243. required
  244. >
  245. <div>
  246. <InputField
  247. className={classNames({
  248. 'js__is-invalid': projectKeyIsInvalid,
  249. })}
  250. size="large"
  251. id={projectKeyInputId}
  252. minLength={1}
  253. onChange={(e) => handleProjectKeyChange(e.currentTarget.value, true)}
  254. type="text"
  255. value={key}
  256. isInvalid={projectKeyIsInvalid}
  257. isValid={projectKeyIsValid}
  258. required
  259. />
  260. {projectKeyIsInvalid && <FlagErrorIcon className="sw-ml-2" />}
  261. {projectKeyIsValid && <FlagSuccessIcon className="sw-ml-2" />}
  262. </div>
  263. {keyError !== undefined && (
  264. <Note className="sw-flex-col sw-mt-2">
  265. {keyError === ProjectKeyErrors.DuplicateKey ||
  266. (keyError === ProjectKeyErrors.MonorepoDuplicateKey && (
  267. <TextError
  268. text={translate('onboarding.create_project.project_key.duplicate_key')}
  269. />
  270. ))}
  271. {!isEmpty(key) && keyError === ProjectKeyErrors.WrongFormat && (
  272. <TextError text={translate('onboarding.create_project.project_key.wrong_format')} />
  273. )}
  274. <p>{translate('onboarding.create_project.project_key.description')}</p>
  275. </Note>
  276. )}
  277. </FormField>
  278. </>
  279. );
  280. }
  281. export function ProjectValidationCard<I>({
  282. initialKey,
  283. initialName,
  284. monorepoSetupProjectKeys,
  285. onChange,
  286. onRemove,
  287. projectId,
  288. ...cardProps
  289. }: Readonly<
  290. Props<I> & Omit<React.ComponentPropsWithoutRef<typeof Card>, 'onChange' | 'children'>
  291. >) {
  292. return (
  293. <Card {...cardProps}>
  294. <ProjectValidation
  295. initialKey={initialKey}
  296. initialName={initialName}
  297. monorepoSetupProjectKeys={monorepoSetupProjectKeys}
  298. onChange={onChange}
  299. projectId={projectId}
  300. />
  301. <ButtonSecondary
  302. className="sw-mt-4 sw-mr-4"
  303. icon={<TrashIcon />}
  304. onClick={onRemove}
  305. type="button"
  306. >
  307. {translate('onboarding.create_project.monorepo.remove_project')}
  308. </ButtonSecondary>
  309. </Card>
  310. );
  311. }