projectMapperField.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import {Component, Fragment} from 'react';
  2. import {components} from 'react-select';
  3. import styled from '@emotion/styled';
  4. import Button from 'sentry/components/button';
  5. import SelectControl from 'sentry/components/forms/controls/selectControl';
  6. import FieldErrorReason from 'sentry/components/forms/field/fieldErrorReason';
  7. import FormFieldControlState from 'sentry/components/forms/formField/controlState';
  8. import FormModel from 'sentry/components/forms/model';
  9. import {ProjectMapperType} from 'sentry/components/forms/types';
  10. import IdBadge from 'sentry/components/idBadge';
  11. import ExternalLink from 'sentry/components/links/externalLink';
  12. import {PanelAlert} from 'sentry/components/panels';
  13. import {
  14. IconAdd,
  15. IconArrow,
  16. IconDelete,
  17. IconGeneric,
  18. IconOpen,
  19. IconVercel,
  20. } from 'sentry/icons';
  21. import {t} from 'sentry/locale';
  22. import space from 'sentry/styles/space';
  23. import {safeGetQsParam} from 'sentry/utils/integrationUtil';
  24. import {removeAtArrayIndex} from 'sentry/utils/removeAtArrayIndex';
  25. import InputField, {InputFieldProps} from './inputField';
  26. export interface ProjectMapperProps extends Omit<InputFieldProps, 'type'> {}
  27. interface RenderProps extends ProjectMapperProps, ProjectMapperType {
  28. model: FormModel;
  29. }
  30. type MappedValue = string | number;
  31. type State = {
  32. selectedMappedValue: MappedValue | null;
  33. selectedSentryProjectId: number | null;
  34. };
  35. const DISABLED_TOOLTIP_TEXT = 'Please link at least one project to continue.';
  36. // Get the icon
  37. const getIcon = (iconType: string) => {
  38. switch (iconType) {
  39. case 'vercel':
  40. return <IconVercel />;
  41. default:
  42. return <IconGeneric />;
  43. }
  44. };
  45. export class RenderField extends Component<RenderProps, State> {
  46. state: State = {
  47. selectedSentryProjectId: null,
  48. selectedMappedValue: null,
  49. };
  50. render() {
  51. const {
  52. onChange,
  53. onBlur,
  54. value: incomingValues,
  55. sentryProjects,
  56. mappedDropdown: {items: mappedDropdownItems, placeholder: mappedValuePlaceholder},
  57. nextButton: {text: nextButtonText, description: nextDescription, allowedDomain},
  58. iconType,
  59. model,
  60. id: formElementId,
  61. error,
  62. } = this.props;
  63. const existingValues: Array<[number, MappedValue]> = incomingValues || [];
  64. const nextUrlOrArray = safeGetQsParam('next');
  65. let nextUrl = Array.isArray(nextUrlOrArray) ? nextUrlOrArray[0] : nextUrlOrArray;
  66. if (nextUrl && !nextUrl.startsWith(allowedDomain)) {
  67. // eslint-disable-next-line no-console
  68. console.warn(`Got unexpected next url: ${nextUrl}`);
  69. nextUrl = undefined;
  70. }
  71. const {selectedSentryProjectId, selectedMappedValue} = this.state;
  72. // create maps by the project id for constant time lookups
  73. const sentryProjectsById = Object.fromEntries(
  74. sentryProjects.map(project => [project.id, project])
  75. );
  76. const mappedItemsByValue = Object.fromEntries(
  77. mappedDropdownItems.map(item => [item.value, item])
  78. );
  79. // prevent a single mapped item from being associated with multiple Sentry projects
  80. const mappedValuesUsed = new Set(existingValues.map(tuple => tuple[1]));
  81. const projectOptions = sentryProjects.map(({slug, id}) => ({label: slug, value: id}));
  82. const mappedItemsToShow = mappedDropdownItems.filter(
  83. item => !mappedValuesUsed.has(item.value)
  84. );
  85. const handleSelectProject = ({value}: {value: number}) => {
  86. this.setState({selectedSentryProjectId: value});
  87. };
  88. const handleSelectMappedValue = ({value}: {value: MappedValue}) => {
  89. this.setState({selectedMappedValue: value});
  90. };
  91. const handleAdd = () => {
  92. // add the new value to the list of existing values
  93. const projectMappings = [
  94. ...existingValues,
  95. [selectedSentryProjectId, selectedMappedValue],
  96. ];
  97. // trigger events so we save the value and show the check mark
  98. onChange?.(projectMappings, []);
  99. onBlur?.(projectMappings, []);
  100. this.setState({selectedSentryProjectId: null, selectedMappedValue: null});
  101. };
  102. const handleDelete = (index: number) => {
  103. const projectMappings = removeAtArrayIndex(existingValues, index);
  104. // trigger events so we save the value and show the check mark
  105. onChange?.(projectMappings, []);
  106. onBlur?.(projectMappings, []);
  107. };
  108. const renderItem = (itemTuple: [number, any], index: number) => {
  109. const [projectId, mappedValue] = itemTuple;
  110. const project = sentryProjectsById[projectId];
  111. // TODO: add special formatting if deleted
  112. const mappedItem = mappedItemsByValue[mappedValue];
  113. return (
  114. <Item key={index}>
  115. <MappedItemValue>
  116. {mappedItem ? (
  117. <Fragment>
  118. <IntegrationIconWrapper>{getIcon(iconType)}</IntegrationIconWrapper>
  119. {mappedItem.label}
  120. <StyledExternalLink href={mappedItem.url}>
  121. <IconOpen size="xs" />
  122. </StyledExternalLink>
  123. </Fragment>
  124. ) : (
  125. t('Deleted')
  126. )}
  127. </MappedItemValue>
  128. <RightArrow size="xs" direction="right" />
  129. <MappedProjectWrapper>
  130. {project ? (
  131. <IdBadge
  132. project={project}
  133. avatarSize={20}
  134. displayName={project.slug}
  135. avatarProps={{consistentWidth: true}}
  136. />
  137. ) : (
  138. t('Deleted')
  139. )}
  140. </MappedProjectWrapper>
  141. <DeleteButtonWrapper>
  142. <Button
  143. onClick={() => handleDelete(index)}
  144. icon={<IconDelete color="gray300" />}
  145. size="sm"
  146. type="button"
  147. aria-label={t('Delete')}
  148. />
  149. </DeleteButtonWrapper>
  150. </Item>
  151. );
  152. };
  153. const customValueContainer = containerProps => {
  154. // if no value set, we want to return the default component that is rendered
  155. const project = sentryProjectsById[selectedSentryProjectId || ''];
  156. if (!project) {
  157. return <components.ValueContainer {...containerProps} />;
  158. }
  159. return (
  160. <components.ValueContainer {...containerProps}>
  161. <IdBadge
  162. project={project}
  163. avatarSize={20}
  164. displayName={project.slug}
  165. avatarProps={{consistentWidth: true}}
  166. disableLink
  167. />
  168. </components.ValueContainer>
  169. );
  170. };
  171. const customOptionProject = projectProps => {
  172. const project = sentryProjectsById[projectProps.value];
  173. // Should never happen for a dropdown item
  174. if (!project) {
  175. return null;
  176. }
  177. return (
  178. <components.Option {...projectProps}>
  179. <IdBadge
  180. project={project}
  181. avatarSize={20}
  182. displayName={project.slug}
  183. avatarProps={{consistentWidth: true}}
  184. disableLink
  185. />
  186. </components.Option>
  187. );
  188. };
  189. const customMappedValueContainer = containerProps => {
  190. // if no value set, we want to return the default component that is rendered
  191. const mappedValue = mappedItemsByValue[selectedMappedValue || ''];
  192. if (!mappedValue) {
  193. return <components.ValueContainer {...containerProps} />;
  194. }
  195. return (
  196. <components.ValueContainer {...containerProps}>
  197. <IntegrationIconWrapper>{getIcon(iconType)}</IntegrationIconWrapper>
  198. <OptionLabelWrapper>{mappedValue.label}</OptionLabelWrapper>
  199. </components.ValueContainer>
  200. );
  201. };
  202. const customOptionMappedValue = optionProps => {
  203. return (
  204. <components.Option {...optionProps}>
  205. <OptionWrapper>
  206. <IntegrationIconWrapper>{getIcon(iconType)}</IntegrationIconWrapper>
  207. <OptionLabelWrapper>{optionProps.label}</OptionLabelWrapper>
  208. </OptionWrapper>
  209. </components.Option>
  210. );
  211. };
  212. return (
  213. <Fragment>
  214. {existingValues.map(renderItem)}
  215. <Item>
  216. <SelectControl
  217. placeholder={mappedValuePlaceholder}
  218. name="mappedDropdown"
  219. options={mappedItemsToShow}
  220. components={{
  221. Option: customOptionMappedValue,
  222. ValueContainer: customMappedValueContainer,
  223. }}
  224. onChange={handleSelectMappedValue}
  225. value={selectedMappedValue}
  226. />
  227. <RightArrow size="xs" direction="right" />
  228. <SelectControl
  229. placeholder={t('Sentry project\u2026')}
  230. name="project"
  231. options={projectOptions}
  232. components={{
  233. Option: customOptionProject,
  234. ValueContainer: customValueContainer,
  235. }}
  236. onChange={handleSelectProject}
  237. value={selectedSentryProjectId}
  238. />
  239. <AddProjectWrapper>
  240. <Button
  241. type="button"
  242. disabled={!selectedSentryProjectId || !selectedMappedValue}
  243. size="sm"
  244. priority="primary"
  245. onClick={handleAdd}
  246. icon={<IconAdd />}
  247. aria-label={t('Add project')}
  248. />
  249. </AddProjectWrapper>
  250. <FieldControlWrapper>
  251. {formElementId && (
  252. <div>
  253. <FormFieldControlState model={model} name={formElementId} />
  254. {error ? <StyledFieldErrorReason>{error}</StyledFieldErrorReason> : null}
  255. </div>
  256. )}
  257. </FieldControlWrapper>
  258. </Item>
  259. {nextUrl && (
  260. <NextButtonPanelAlert type="muted">
  261. <NextButtonWrapper>
  262. {nextDescription ?? ''}
  263. <Button
  264. type="button"
  265. size="sm"
  266. priority="primary"
  267. icon={<IconOpen size="xs" />}
  268. disabled={!existingValues.length}
  269. href={nextUrl}
  270. title={DISABLED_TOOLTIP_TEXT}
  271. tooltipProps={{
  272. disabled: !!existingValues.length,
  273. }}
  274. >
  275. {nextButtonText}
  276. </Button>
  277. </NextButtonWrapper>
  278. </NextButtonPanelAlert>
  279. )}
  280. </Fragment>
  281. );
  282. }
  283. }
  284. function ProjectMapperField(props: InputFieldProps) {
  285. return (
  286. <StyledInputField
  287. {...props}
  288. resetOnError
  289. inline={false}
  290. stacked={false}
  291. hideControlState
  292. field={(renderProps: RenderProps) => <RenderField {...renderProps} />}
  293. />
  294. );
  295. }
  296. export default ProjectMapperField;
  297. const MappedProjectWrapper = styled('div')`
  298. display: flex;
  299. align-items: center;
  300. justify-content: space-between;
  301. margin-right: ${space(1)};
  302. grid-area: sentry-project;
  303. `;
  304. const Item = styled('div')`
  305. min-height: 60px;
  306. padding: ${space(2)};
  307. &:not(:last-child) {
  308. border-bottom: 1px solid ${p => p.theme.innerBorder};
  309. }
  310. display: grid;
  311. grid-column-gap: ${space(1)};
  312. align-items: center;
  313. grid-template-columns: 2.5fr min-content 2.5fr max-content 30px;
  314. grid-template-areas: 'mapped-value arrow sentry-project manage-project field-control';
  315. `;
  316. const MappedItemValue = styled('div')`
  317. display: grid;
  318. grid-auto-flow: column;
  319. grid-auto-columns: max-content;
  320. align-items: center;
  321. gap: ${space(1)};
  322. width: 100%;
  323. grid-area: mapped-value;
  324. `;
  325. const RightArrow = styled(IconArrow)`
  326. grid-area: arrow;
  327. `;
  328. const DeleteButtonWrapper = styled('div')`
  329. grid-area: manage-project;
  330. `;
  331. const IntegrationIconWrapper = styled('span')`
  332. display: flex;
  333. align-items: center;
  334. `;
  335. const AddProjectWrapper = styled('div')`
  336. grid-area: manage-project;
  337. `;
  338. const OptionLabelWrapper = styled('div')`
  339. margin-left: ${space(0.5)};
  340. `;
  341. const StyledInputField = styled(InputField)`
  342. padding: 0;
  343. `;
  344. const StyledExternalLink = styled(ExternalLink)`
  345. display: flex;
  346. `;
  347. const OptionWrapper = styled('div')`
  348. align-items: center;
  349. display: flex;
  350. `;
  351. const FieldControlWrapper = styled('div')`
  352. position: relative;
  353. grid-area: field-control;
  354. `;
  355. const NextButtonPanelAlert = styled(PanelAlert)`
  356. align-items: center;
  357. margin-bottom: -1px;
  358. border-bottom-left-radius: ${p => p.theme.borderRadius};
  359. border-bottom-right-radius: ${p => p.theme.borderRadius};
  360. `;
  361. const NextButtonWrapper = styled('div')`
  362. display: grid;
  363. grid-template-columns: 1fr max-content;
  364. gap: ${space(1)};
  365. align-items: center;
  366. `;
  367. const StyledFieldErrorReason = styled(FieldErrorReason)``;