BuildpackStack.tsx 17 KB

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