BuildpackStack.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. import { DeviconsNameList } from "assets/devicons-name-list";
  2. import Helper from "components/form-components/Helper";
  3. import InputRow from "components/form-components/InputRow";
  4. import Select from "components/porter/Select";
  5. import Loading from "components/Loading";
  6. import React, { useContext, useEffect, useMemo, useState } from "react";
  7. import api from "shared/api";
  8. import { Context } from "shared/Context";
  9. import { ActionConfigType } from "shared/types";
  10. import styled, { keyframes } from "styled-components";
  11. // Add the following imports
  12. import { Button as MuiButton, Modal as MuiModal } from "@material-ui/core";
  13. import { makeStyles, withStyles } from "@material-ui/core/styles";
  14. import Button from "components/porter/Button";
  15. import Modal from "components/porter/Modal";
  16. import Spacer from "components/porter/Spacer";
  17. import Text from "components/porter/Text";
  18. const DEFAULT_BUILDER_NAME = "heroku";
  19. const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
  20. const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
  21. type BuildConfig = {
  22. builder: string;
  23. buildpacks: string[];
  24. config: null | {
  25. [key: string]: string;
  26. };
  27. };
  28. type Buildpack = {
  29. name: string;
  30. buildpack: string;
  31. config: {
  32. [key: string]: string;
  33. };
  34. };
  35. type DetectedBuildpack = {
  36. name: string;
  37. builders: string[];
  38. detected: Buildpack[];
  39. others: Buildpack[];
  40. buildConfig: BuildConfig;
  41. };
  42. type DetectBuildpackResponse = DetectedBuildpack[];
  43. export const BuildpackStack: React.FC<{
  44. actionConfig: ActionConfigType;
  45. folderPath: string;
  46. branch: string;
  47. hide: boolean;
  48. onChange: (config: BuildConfig) => void;
  49. currentBuildConfig?: BuildConfig;
  50. setBuildConfig?: (config: BuildConfig) => void;
  51. }> = ({
  52. actionConfig,
  53. folderPath,
  54. branch,
  55. hide,
  56. onChange,
  57. currentBuildConfig,
  58. setBuildConfig,
  59. }) => {
  60. const { currentProject } = useContext(Context);
  61. const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
  62. const [stacks, setStacks] = useState<string[]>(null);
  63. const [selectedStack, setSelectedStack] = useState<string>(
  64. currentBuildConfig?.builder || null
  65. );
  66. const [isModalOpen, setIsModalOpen] = useState(false);
  67. const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
  68. const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
  69. []
  70. );
  71. const renderModalContent = () => {
  72. return (
  73. <>
  74. <Text size={16}>Buildpack Configuration</Text>
  75. <Spacer y={1} />
  76. <Scrollable>
  77. <Text color="helper">Configure your buildpacks here.</Text>
  78. <Spacer y={1} />
  79. {!!selectedBuildpacks?.length &&
  80. renderBuildpacksList(selectedBuildpacks, "remove")}
  81. <Spacer y={1} />
  82. <Spacer y={1} />
  83. {!!availableBuildpacks?.length && (
  84. <>
  85. <Text color="helper">Available buildpacks:</Text>
  86. <>{renderBuildpacksList(availableBuildpacks, "add")}</>
  87. </>
  88. )}
  89. <Spacer y={1} />
  90. <Text color="helper">
  91. You may also add buildpacks by directly providing their GitHub links
  92. or links to ZIP files that contain the buildpack source code.
  93. </Text>
  94. <Spacer y={1} />
  95. <AddCustomBuildpackForm onAdd={handleAddCustomBuildpack} />
  96. </Scrollable>
  97. <Spacer y={1} />
  98. <Button onClick={() => setIsModalOpen(false)}>Save</Button>
  99. </>
  100. );
  101. };
  102. useEffect(() => {
  103. let buildConfig: BuildConfig = {} as BuildConfig;
  104. buildConfig.builder = selectedStack;
  105. buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
  106. return buildpack.buildpack;
  107. });
  108. if (typeof onChange === "function") {
  109. onChange(buildConfig);
  110. if (currentBuildConfig) {
  111. setBuildConfig(buildConfig);
  112. }
  113. }
  114. }, [selectedStack, selectedBuildpacks]);
  115. const detectBuildpack = () => {
  116. if (actionConfig.kind === "gitlab") {
  117. return api.detectGitlabBuildpack<DetectBuildpackResponse>(
  118. "<token>",
  119. { dir: folderPath || "." },
  120. {
  121. project_id: currentProject.id,
  122. integration_id: actionConfig.gitlab_integration_id,
  123. repo_owner: actionConfig.git_repo.split("/")[0],
  124. repo_name: actionConfig.git_repo.split("/")[1],
  125. branch: branch,
  126. }
  127. );
  128. }
  129. return api.detectBuildpack<DetectBuildpackResponse>(
  130. "<token>",
  131. {
  132. dir: folderPath || ".",
  133. },
  134. {
  135. project_id: currentProject.id,
  136. git_repo_id: actionConfig.git_repo_id,
  137. kind: "github",
  138. owner: actionConfig.git_repo.split("/")[0],
  139. name: actionConfig.git_repo.split("/")[1],
  140. branch: branch,
  141. }
  142. );
  143. };
  144. const classes = useStyles();
  145. useEffect(() => {
  146. detectBuildpack()
  147. // getMockData()
  148. .then(({ data }) => {
  149. const builders = data;
  150. const defaultBuilder = builders.find(
  151. (builder) => builder.name.toLowerCase() === DEFAULT_BUILDER_NAME
  152. );
  153. var detectedBuildpacks = defaultBuilder.detected;
  154. var availableBuildpacks = defaultBuilder.others;
  155. var defaultStack = "";
  156. if (currentBuildConfig) {
  157. if (!detectedBuildpacks) {
  158. detectedBuildpacks = [];
  159. }
  160. defaultStack = currentBuildConfig.builder;
  161. for (const buildpackName of currentBuildConfig.buildpacks) {
  162. const matchingBuildpackIndex = availableBuildpacks.findIndex(
  163. (buildpack) => buildpack.buildpack === buildpackName
  164. );
  165. if (matchingBuildpackIndex >= 0) {
  166. const matchingBuildpack = availableBuildpacks.splice(
  167. matchingBuildpackIndex,
  168. 1
  169. )[0];
  170. const existingBuildpackIndex = detectedBuildpacks.findIndex(
  171. (buildpack) => buildpack.buildpack === buildpackName
  172. );
  173. if (existingBuildpackIndex < 0) {
  174. detectedBuildpacks.push(matchingBuildpack);
  175. }
  176. } else {
  177. const newBuildpack: Buildpack = {
  178. name: buildpackName,
  179. buildpack: buildpackName,
  180. config: null,
  181. };
  182. const existingBuildpackIndex = detectedBuildpacks.findIndex(
  183. (buildpack) => buildpack.buildpack === buildpackName
  184. );
  185. if (existingBuildpackIndex < 0) {
  186. detectedBuildpacks.push(newBuildpack);
  187. }
  188. }
  189. }
  190. } else {
  191. detectedBuildpacks = defaultBuilder.detected;
  192. availableBuildpacks = defaultBuilder.others;
  193. defaultStack = builders
  194. .flatMap((builder) => builder.builders)
  195. .find((stack) => {
  196. return (
  197. stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK
  198. );
  199. });
  200. }
  201. setBuilders(builders);
  202. setSelectedStack(defaultStack);
  203. setStacks(defaultBuilder.builders);
  204. setSelectedStack(defaultStack);
  205. if (!Array.isArray(detectedBuildpacks)) {
  206. setSelectedBuildpacks([]);
  207. } else {
  208. setSelectedBuildpacks(detectedBuildpacks);
  209. console.log(selectedBuildpacks);
  210. }
  211. if (!Array.isArray(availableBuildpacks)) {
  212. setAvailableBuildpacks([]);
  213. } else {
  214. setAvailableBuildpacks(availableBuildpacks);
  215. }
  216. })
  217. .catch((err) => {
  218. console.error(err);
  219. });
  220. }, [currentProject, actionConfig]);
  221. const builderOptions = useMemo(() => {
  222. if (!Array.isArray(builders)) {
  223. return;
  224. }
  225. return builders.map((builder) => ({
  226. label: builder.name,
  227. value: builder.name.toLowerCase(),
  228. }));
  229. }, [builders]);
  230. const stackOptions = useMemo(() => {
  231. if (!Array.isArray(builders)) {
  232. return;
  233. }
  234. return builders.flatMap((builder) => {
  235. return builder.builders.map((stack) => ({
  236. label: `${builder.name} - ${stack}`,
  237. value: stack.toLowerCase(),
  238. }));
  239. });
  240. }, [builders]);
  241. // const handleSelectBuilder = (builderName: string) => {
  242. // const builder = builders.find(
  243. // (b) => b.name.toLowerCase() === builderName.toLowerCase()
  244. // );
  245. // const detectedBuildpacks = builder.detected;
  246. // const availableBuildpacks = builder.others;
  247. // const defaultStack = builder.builders.find((stack) => {
  248. // return stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK;
  249. // });
  250. // setSelectedBuilder(builderName);
  251. // setBuilders(builders);
  252. // setSelectedBuilder(builderName.toLowerCase());
  253. // setStacks(builder.builders);
  254. // setSelectedStack(defaultStack);
  255. // if (!Array.isArray(detectedBuildpacks)) {
  256. // setSelectedBuildpacks([]);
  257. // } else {
  258. // setSelectedBuildpacks(detectedBuildpacks);
  259. // }
  260. // if (!Array.isArray(availableBuildpacks)) {
  261. // setAvailableBuildpacks([]);
  262. // } else {
  263. // setAvailableBuildpacks(availableBuildpacks);
  264. // }
  265. // };
  266. const renderBuildpacksList = (
  267. buildpacks: Buildpack[],
  268. action: "remove" | "add",
  269. isLast: boolean = false
  270. ) => {
  271. return buildpacks?.map((buildpack, index) => {
  272. const [languageName] = buildpack.name?.split("/").reverse();
  273. const devicon = DeviconsNameList.find(
  274. (devicon) => languageName.toLowerCase() === devicon.name
  275. );
  276. const icon = `devicon-${devicon?.name}-plain colored`;
  277. let disableIcon = false;
  278. if (!devicon) {
  279. disableIcon = true;
  280. }
  281. return (
  282. <StyledCard key={buildpack.name} marginBottom="5px">
  283. <ContentContainer>
  284. <Icon disableMarginRight={disableIcon} className={icon} />
  285. <EventInformation>
  286. <EventName>{buildpack?.name}</EventName>
  287. </EventInformation>
  288. </ContentContainer>
  289. <ActionContainer>
  290. {action === "add" && (
  291. <ActionButton
  292. onClick={() => handleAddBuildpack(buildpack.buildpack)}
  293. >
  294. <span className="material-icons-outlined">add</span>
  295. </ActionButton>
  296. )}
  297. {action === "remove" && (
  298. <ActionButton
  299. onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
  300. >
  301. <span className="material-icons">delete</span>
  302. </ActionButton>
  303. )}
  304. </ActionContainer>
  305. </StyledCard>
  306. );
  307. });
  308. };
  309. const handleRemoveBuildpack = (buildpackToRemove: string) => {
  310. setSelectedBuildpacks((selBuildpacks) => {
  311. const tmpSelectedBuildpacks = [...selBuildpacks];
  312. const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
  313. (buildpack) => buildpack.buildpack === buildpackToRemove
  314. );
  315. const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
  316. setAvailableBuildpacks((availableBuildpacks) => [
  317. ...availableBuildpacks,
  318. buildpack,
  319. ]);
  320. tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
  321. return [...tmpSelectedBuildpacks];
  322. });
  323. };
  324. const handleAddBuildpack = (buildpackToAdd: string) => {
  325. setAvailableBuildpacks((avBuildpacks) => {
  326. const tmpAvailableBuildpacks = [...avBuildpacks];
  327. const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
  328. (buildpack) => buildpack.buildpack === buildpackToAdd
  329. );
  330. const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
  331. setSelectedBuildpacks((selectedBuildpacks) => [
  332. ...selectedBuildpacks,
  333. buildpack,
  334. ]);
  335. tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
  336. return [...tmpAvailableBuildpacks];
  337. });
  338. };
  339. const handleAddCustomBuildpack = (buildpack: Buildpack) => {
  340. setSelectedBuildpacks((selectedBuildpacks) => [
  341. ...selectedBuildpacks,
  342. buildpack,
  343. ]);
  344. };
  345. if (hide) {
  346. return null;
  347. }
  348. if (!stackOptions?.length || !builderOptions?.length) {
  349. return <Loading />;
  350. }
  351. return (
  352. <BuildpackConfigurationContainer>
  353. <>
  354. <Select
  355. value={selectedStack}
  356. width="300px"
  357. options={stackOptions}
  358. setValue={(option) => {
  359. setSelectedStack(option);
  360. }}
  361. label="Select your builder and stack"
  362. />
  363. {!!selectedBuildpacks?.length && (
  364. <Helper>
  365. The following buildpacks were automatically detected. You can also
  366. manually add/remove buildpacks.
  367. </Helper>
  368. )}
  369. {!!selectedBuildpacks?.length && (
  370. <>{renderBuildpacksList(selectedBuildpacks, "remove")}</>
  371. )}
  372. <Spacer y={1} />
  373. <Button onClick={() => setIsModalOpen(true)}>Add build pack</Button>
  374. {isModalOpen && (
  375. <Modal closeModal={() => setIsModalOpen(false)}>
  376. {renderModalContent()}
  377. </Modal>
  378. )}
  379. </>
  380. </BuildpackConfigurationContainer>
  381. );
  382. };
  383. export const AddCustomBuildpackForm: React.FC<{
  384. onAdd: (buildpack: Buildpack) => void;
  385. }> = ({ onAdd }) => {
  386. const [buildpackUrl, setBuildpackUrl] = useState("");
  387. const [error, setError] = useState(false);
  388. const handleAddCustomBuildpack = () => {
  389. const buildpack: Buildpack = {
  390. buildpack: buildpackUrl,
  391. name: buildpackUrl,
  392. config: null,
  393. };
  394. setBuildpackUrl("");
  395. onAdd(buildpack);
  396. };
  397. return (
  398. <StyledCard marginBottom="0px">
  399. <ContentContainer>
  400. <EventInformation>
  401. <BuildpackInputContainer>
  402. GitHub or ZIP URL
  403. <BuildpackUrlInput
  404. placeholder="https://github.com/custom/buildpack"
  405. type="input"
  406. value={buildpackUrl}
  407. isRequired
  408. setValue={(newUrl) => {
  409. setError(false);
  410. setBuildpackUrl(newUrl as string);
  411. }}
  412. />
  413. <ErrorText hasError={error}>Please enter a valid url</ErrorText>
  414. </BuildpackInputContainer>
  415. </EventInformation>
  416. </ContentContainer>
  417. <ActionContainer>
  418. <ActionButton onClick={() => handleAddCustomBuildpack()}>
  419. <span className="material-icons-outlined">add</span>
  420. </ActionButton>
  421. </ActionContainer>
  422. </StyledCard>
  423. );
  424. };
  425. const ErrorText = styled.span`
  426. color: red;
  427. margin-left: 10px;
  428. display: ${(props: { hasError: boolean }) =>
  429. props.hasError ? "inline-block" : "none"};
  430. `;
  431. const Scrollable = styled.div`
  432. overflow-y: auto;
  433. padding: 0 25px;
  434. width: calc(100% + 50px);
  435. margin-left: -25px;
  436. max-height: calc(100vh - 300px);
  437. `;
  438. const fadeIn = keyframes`
  439. from {
  440. opacity: 0;
  441. }
  442. to {
  443. opacity: 1;
  444. }
  445. `;
  446. const BuildpackUrlInput = styled(InputRow)`
  447. width: auto;
  448. min-width: 300px;
  449. max-width: 600px;
  450. margin: unset;
  451. margin-left: 10px;
  452. display: inline-block;
  453. `;
  454. const BuildpackConfigurationContainer = styled.div`
  455. animation: ${fadeIn} 0.75s;
  456. `;
  457. const StyledCard = styled.div<{ marginBottom?: string }>`
  458. display: flex;
  459. align-items: center;
  460. justify-content: space-between;
  461. border: 1px solid #494b4f;
  462. background: ${({ theme }) => theme.fg};
  463. margin-bottom: ${(props) => props.marginBottom || "30px"};
  464. border-radius: 8px;
  465. padding: 14px;
  466. overflow: hidden;
  467. height: 60px;
  468. font-size: 13px;
  469. animation: ${fadeIn} 0.5s;
  470. `;
  471. const ContentContainer = styled.div`
  472. display: flex;
  473. height: 100%;
  474. width: 100%;
  475. align-items: center;
  476. `;
  477. const Icon = styled.span<{ disableMarginRight: boolean }>`
  478. font-size: 20px;
  479. margin-left: 10px;
  480. ${(props) => {
  481. if (!props.disableMarginRight) {
  482. return "margin-right: 20px";
  483. }
  484. }}
  485. `;
  486. const EventInformation = styled.div`
  487. display: flex;
  488. flex-direction: column;
  489. justify-content: space-around;
  490. height: 100%;
  491. `;
  492. const EventName = styled.div`
  493. font-family: "Work Sans", sans-serif;
  494. font-weight: 500;
  495. color: #ffffff;
  496. `;
  497. const BuildpackInputContainer = styled(EventName)`
  498. padding-left: 15px;
  499. `;
  500. const ActionContainer = styled.div`
  501. display: flex;
  502. align-items: center;
  503. white-space: nowrap;
  504. height: 100%;
  505. `;
  506. const ActionButton = styled.button`
  507. position: relative;
  508. border: none;
  509. background: none;
  510. color: white;
  511. padding: 5px;
  512. display: flex;
  513. justify-content: center;
  514. align-items: center;
  515. border-radius: 50%;
  516. cursor: pointer;
  517. color: #aaaabb;
  518. :hover {
  519. background: #ffffff11;
  520. border: 1px solid #ffffff44;
  521. }
  522. > span {
  523. font-size: 20px;
  524. }
  525. `;
  526. const SaveButton = withStyles({
  527. root: {
  528. backgroundColor: "#8590ff",
  529. color: "white",
  530. marginTop: "24px",
  531. position: "absolute",
  532. bottom: "16px",
  533. right: "16px",
  534. },
  535. })(MuiButton);
  536. const StyledModal = withStyles({
  537. root: {
  538. display: "flex",
  539. alignItems: "center",
  540. justifyContent: "center",
  541. },
  542. })(MuiModal);
  543. const useStyles = makeStyles((theme) => ({
  544. modal: {
  545. display: "flex",
  546. alignItems: "center",
  547. justifyContent: "center",
  548. },
  549. }));