BuildpackStack.tsx 15 KB

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