docIntegrationModal.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addLoadingMessage, clearIndicators} from 'sentry/actionCreators/indicator';
  4. import type {ModalRenderProps} from 'sentry/actionCreators/modal';
  5. import AvatarChooser from 'sentry/components/avatarChooser';
  6. import {Button} from 'sentry/components/core/button';
  7. import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
  8. import NumberField from 'sentry/components/forms/fields/numberField';
  9. import SelectField from 'sentry/components/forms/fields/selectField';
  10. import TextareaField from 'sentry/components/forms/fields/textareaField';
  11. import TextField from 'sentry/components/forms/fields/textField';
  12. import Form from 'sentry/components/forms/form';
  13. import {IconAdd, IconClose} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {DocIntegration, IntegrationFeature} from 'sentry/types/integrations';
  17. import {browserHistory} from 'sentry/utils/browserHistory';
  18. const fieldProps = {
  19. stacked: true,
  20. inline: false,
  21. flexibleControlStateSize: true,
  22. } as const;
  23. type Props = ModalRenderProps &
  24. DeprecatedAsyncComponent['props'] & {
  25. docIntegration?: DocIntegration;
  26. onSubmit?: (docIntegration: DocIntegration) => void;
  27. };
  28. type State = DeprecatedAsyncComponent['state'] & {
  29. features: IntegrationFeature[];
  30. lastResourceId: number;
  31. resources: {[id: number]: {title?: string; url?: string}};
  32. };
  33. class DocIntegrationModal extends DeprecatedAsyncComponent<Props, State> {
  34. getDefaultState(): State {
  35. const {docIntegration} = this.props;
  36. return {
  37. ...this.state,
  38. features: [],
  39. resources: {...(docIntegration?.resources ?? {0: {}})},
  40. lastResourceId: docIntegration?.resources?.length ?? 0,
  41. };
  42. }
  43. getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
  44. return [['features', `/integration-features/`]];
  45. }
  46. getFeatures(): Array<[number, string]> {
  47. const {features} = this.state;
  48. if (!features) {
  49. return [];
  50. }
  51. return features.map(({featureId, featureGate}) => [
  52. featureId,
  53. featureGate.replace(/(^integrations-)/, ''),
  54. ]);
  55. }
  56. renderResourceSection() {
  57. const {resources} = this.state;
  58. const resourceRows = Object.entries(resources).map(([id, entry]) => (
  59. <ResourceContainer key={id}>
  60. <ResourceTextField
  61. {...fieldProps}
  62. name={`___resource-title-${id}`}
  63. label="Resource Title"
  64. placeholder="Report Issue"
  65. help="The title of the resource."
  66. required
  67. onChange={(value: any) => {
  68. this.setState({
  69. resources: {
  70. ...this.state.resources,
  71. [id]: {...entry, title: value},
  72. },
  73. });
  74. }}
  75. />
  76. <ResourceTextField
  77. {...fieldProps}
  78. name={`___resource-url-${id}`}
  79. label="Resource URL"
  80. placeholder="https://www.meow.com/report-issue/"
  81. help="A link to the resource."
  82. required
  83. onChange={(value: any) => {
  84. this.setState({
  85. resources: {
  86. ...this.state.resources,
  87. [id]: {...entry, url: value},
  88. },
  89. });
  90. }}
  91. />
  92. <RemoveButton
  93. borderless
  94. icon={<IconClose />}
  95. size="zero"
  96. onClick={e => {
  97. e.preventDefault();
  98. this.setState(state => {
  99. const existingResources = {...state.resources};
  100. delete existingResources[id as unknown as number];
  101. return {resources: existingResources};
  102. });
  103. }}
  104. aria-label={t('Close')}
  105. />
  106. </ResourceContainer>
  107. ));
  108. resourceRows.push(
  109. <AddButton
  110. priority="link"
  111. onClick={e => {
  112. e.preventDefault();
  113. this.setState({
  114. resources: {
  115. ...this.state.resources,
  116. [this.state.lastResourceId + 1]: {},
  117. },
  118. lastResourceId: this.state.lastResourceId + 1,
  119. });
  120. }}
  121. icon={<IconAdd size="xs" isCircled />}
  122. key="add-button"
  123. >
  124. Add a resource link (e.g. docs, source code, feedback forms)
  125. </AddButton>
  126. );
  127. return resourceRows;
  128. }
  129. getInitialData() {
  130. const {docIntegration} = this.props;
  131. // The form uses the 'name' attribute to track what to send as a payload.
  132. // Unfortunately, we can't send `resource-title-0` to the API, so we ignore
  133. // remove those fields when sending data, and only use them to load defaults
  134. const resourceFields = Object.entries(this.state.resources).reduce(
  135. (previousFields, [currentId, currentResource]) => {
  136. return {
  137. ...previousFields,
  138. [`___resource-title-${currentId}`]: currentResource.title,
  139. [`___resource-url-${currentId}`]: currentResource.url,
  140. };
  141. },
  142. {}
  143. );
  144. return {
  145. ...docIntegration,
  146. ...resourceFields,
  147. features: docIntegration?.features?.map(({featureId}) => featureId),
  148. };
  149. }
  150. /**
  151. * This function prepares the outgoing data to match what the API is expecting
  152. * @param data The form data
  153. */
  154. prepareData(data: Record<string, any>) {
  155. const outgoingData = {...data};
  156. // Remove any ignored fields (e.g. ResourceTextFields that saved to the form model)
  157. Object.keys(outgoingData).forEach(field => {
  158. if (field.startsWith('___')) {
  159. delete outgoingData[field];
  160. }
  161. });
  162. // We're using the 'resources' data from state since we have onChange calls
  163. // on those fields, See renderResourceSection()
  164. outgoingData.resources = Object.values(this.state.resources);
  165. return outgoingData;
  166. }
  167. onSubmit = (
  168. data: Record<string, any>,
  169. onSuccess: (response: Record<string, any>) => void,
  170. onError: (error: any) => void
  171. ) => {
  172. const {docIntegration} = this.props;
  173. addLoadingMessage(t('Saving changes\u2026'));
  174. this.api.request(
  175. docIntegration ? `/doc-integrations/${docIntegration.slug}/` : '/doc-integrations/',
  176. {
  177. method: docIntegration ? 'PUT' : 'POST',
  178. data: this.prepareData(data),
  179. success: response => {
  180. clearIndicators();
  181. onSuccess(response);
  182. },
  183. error: error => {
  184. clearIndicators();
  185. onError(error);
  186. },
  187. }
  188. );
  189. };
  190. render() {
  191. const {Body, Header, docIntegration, onSubmit, closeModal} = this.props;
  192. return (
  193. <Fragment>
  194. <Header closeButton>
  195. {docIntegration ? (
  196. <Fragment>
  197. Edit <b>{docIntegration.name}</b>
  198. </Fragment>
  199. ) : (
  200. 'Add New Doc Integration'
  201. )}
  202. </Header>
  203. <Body>
  204. <Form
  205. onSubmit={this.onSubmit}
  206. onSubmitSuccess={(newDocIntegration: DocIntegration) => {
  207. if (onSubmit) {
  208. onSubmit(newDocIntegration);
  209. }
  210. if (docIntegration) {
  211. closeModal();
  212. } else {
  213. browserHistory.push(
  214. `/_admin/doc-integrations/${newDocIntegration.slug}/`
  215. );
  216. }
  217. }}
  218. initialData={this.getInitialData()}
  219. submitLabel={docIntegration ? 'Update' : 'Create'}
  220. >
  221. <TextField
  222. {...fieldProps}
  223. name="name"
  224. label="Name"
  225. placeholder={docIntegration ? docIntegration.name : 'Meow meow'}
  226. help="The name of the document integration."
  227. minLength={5}
  228. required
  229. />
  230. <TextField
  231. {...fieldProps}
  232. name="author"
  233. label="Author"
  234. placeholder={docIntegration ? docIntegration.author : 'Hellboy'}
  235. help="Who maintains this integration?"
  236. required
  237. />
  238. <TextareaField
  239. {...fieldProps}
  240. name="description"
  241. label="Description"
  242. placeholder={
  243. docIntegration ? docIntegration.description : 'A cool cool integration.'
  244. }
  245. help="What does this integration do?"
  246. />
  247. <TextField
  248. {...fieldProps}
  249. name="url"
  250. label="URL"
  251. placeholder={docIntegration ? docIntegration.url : 'https://www.meow.com'}
  252. help="The link to the installation document."
  253. required
  254. />
  255. {this.renderResourceSection()}
  256. <NumberField
  257. {...fieldProps}
  258. name="popularity"
  259. label="Popularity"
  260. placeholder={docIntegration ? docIntegration.popularity : 8}
  261. help="Higher values will be more prominent on the integration directory."
  262. required
  263. />
  264. <SelectField
  265. {...fieldProps}
  266. multiple
  267. name="features"
  268. label="Features"
  269. help="What features does this integration have?"
  270. choices={this.getFeatures()}
  271. required
  272. />
  273. {docIntegration && (
  274. <AvatarChooser
  275. type="docIntegration"
  276. allowGravatar={false}
  277. allowLetter={false}
  278. endpoint={`/doc-integrations/${docIntegration.slug}/avatar/`}
  279. model={docIntegration.avatar ? docIntegration : {}}
  280. onSave={() => {}}
  281. title="Logo"
  282. help={"The company's logo"}
  283. />
  284. )}
  285. </Form>
  286. </Body>
  287. </Fragment>
  288. );
  289. }
  290. }
  291. const AddButton = styled(Button)`
  292. margin-bottom: ${space(2)};
  293. `;
  294. const RemoveButton = styled(Button)`
  295. margin-top: ${space(4)};
  296. `;
  297. const ResourceContainer = styled('div')`
  298. display: flex;
  299. gap: ${space(2)};
  300. `;
  301. const ResourceTextField = styled(TextField)`
  302. flex: 1;
  303. `;
  304. export default DocIntegrationModal;