FormWrapper.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. import React, { Component } from "react";
  2. import styled from "styled-components";
  3. import _ from "lodash";
  4. import { Section, FormElement } from "shared/types";
  5. import { Context } from "shared/Context";
  6. import TabRegion from "components/TabRegion";
  7. import ValuesForm from "components/values-form/ValuesForm";
  8. import SaveButton from "../SaveButton";
  9. type PropsType = {
  10. formData: any;
  11. onSubmit?: (formValues: any) => void;
  12. saveValuesStatus?: string | null;
  13. saveButtonText?: string | null;
  14. // Handle additional non-form tabs
  15. // TODO: find cleaner way to share submitValues w/ rerun jobs button
  16. renderTabContents?: (currentTab: string, submitValues?: any) => any;
  17. tabOptions?: any[];
  18. tabOptionsOnly?: boolean;
  19. // Allow external control of state
  20. valuesToOverride?: any;
  21. clearValuesToOverride?: () => void;
  22. // External values made available to all child components
  23. externalValues?: any;
  24. // Display and debugger settings
  25. isInModal?: boolean;
  26. isReadOnly?: boolean;
  27. showStateDebugger?: boolean;
  28. // TabRegion props to pass through
  29. color?: string;
  30. addendum?: any;
  31. };
  32. type StateType = {
  33. metaState: any;
  34. requiredFields: string[];
  35. currentTab: string;
  36. tabOptions: { value: string; label: string }[];
  37. };
  38. /**
  39. * Renders from raw JSON form data and manages form state.
  40. *
  41. * To control values using external state prop in "valuesToOverride" (refer to
  42. * FormDebugger or LaunchTemplate for example usage).
  43. *
  44. * TODO: Handle passing in valuesToOverride at same time as formData
  45. */
  46. export default class FormWrapper extends Component<PropsType, StateType> {
  47. state = {
  48. metaState: {} as any,
  49. requiredFields: [] as string[],
  50. currentTab: "",
  51. tabOptions: [] as { value: string; label: string }[],
  52. };
  53. updateTabs = (resetState?: boolean, callback?: any) => {
  54. if (resetState) {
  55. let tabOptions = [] as { value: string; label: string }[];
  56. let tabs = this.props.formData?.tabs;
  57. let requiredFields = [] as string[];
  58. let metaState: any = {
  59. "currentCluster.service.is_gcp": {
  60. value: this.context.currentCluster.service == "gke",
  61. },
  62. "currentCluster.service.is_aws": {
  63. value: this.context.currentCluster.service == "eks",
  64. },
  65. "currentCluster.service.is_do": {
  66. value: this.context.currentCluster.service == "doks",
  67. },
  68. };
  69. tabs?.forEach((tab: any, i: number) => {
  70. // Exclude value if omitFromLaunch is set
  71. let omit =
  72. tab.settings?.omitFromLaunch && this.props.externalValues?.isLaunch;
  73. if (tab?.name && tab.label && !omit) {
  74. // If a tab is valid, extract state
  75. tab.sections?.forEach((section: Section, i: number) => {
  76. section?.contents?.forEach((item: FormElement, i: number) => {
  77. if (item === null || item === undefined) {
  78. return;
  79. }
  80. if (
  81. item.type === "variable" &&
  82. item.variable &&
  83. item.settings?.default
  84. ) {
  85. metaState[item.variable] = { value: item.settings.default };
  86. return;
  87. }
  88. // If no name is assigned use values.yaml variable as identifier
  89. let key = item.name || item.variable;
  90. let def =
  91. item.settings &&
  92. item.settings.unit &&
  93. !item.settings.omitUnitFromValue
  94. ? `${item.settings.default}${item.settings.unit}`
  95. : item.settings?.default;
  96. def = (item.value && item.value[0]) || def;
  97. if (item.type === "checkbox") {
  98. def = item.value && item.value[0];
  99. }
  100. // Handle add to list of required fields
  101. if (item.required && key) {
  102. requiredFields.push(key);
  103. }
  104. let value: any = def;
  105. switch (item.type) {
  106. case "checkbox":
  107. value = def || false;
  108. break;
  109. case "string-input":
  110. value = def || "";
  111. break;
  112. case "string-input-password":
  113. value = def || item.settings.default;
  114. case "array-input":
  115. value = def || [];
  116. break;
  117. case "env-key-value-array":
  118. value = def || {};
  119. break;
  120. case "key-value-array":
  121. value = def || {};
  122. break;
  123. case "number-input":
  124. value = def?.toString() ? def : "";
  125. break;
  126. case "select":
  127. value = def || item.settings.options[0].value;
  128. break;
  129. case "provider-select":
  130. let providerMap: any = {
  131. gke: "gcp",
  132. eks: "aws",
  133. doks: "do",
  134. };
  135. def = providerMap[this.context.currentCluster.service];
  136. value = def || "aws";
  137. break;
  138. case "base-64":
  139. value = def || "";
  140. case "base-64-password":
  141. value = def || "";
  142. default:
  143. }
  144. if (value !== null && value !== undefined) {
  145. metaState[key] = { value };
  146. }
  147. });
  148. });
  149. if (!this.props.tabOptionsOnly) {
  150. tabOptions.push({ value: tab.name, label: tab.label });
  151. }
  152. }
  153. });
  154. if (this.props.tabOptions?.length > 0) {
  155. let prependTabs = [] as { value: string; label: string }[];
  156. let appendTabs = [] as { value: string; label: string }[];
  157. this.props.tabOptions.forEach(
  158. (tab: { value: string; label: string }) => {
  159. if (tab.value === "status" || tab.value === "metrics") {
  160. prependTabs.push(tab);
  161. } else {
  162. appendTabs.push(tab);
  163. }
  164. }
  165. );
  166. tabOptions = prependTabs.concat(tabOptions.concat(appendTabs));
  167. }
  168. if (tabOptions.length > 0) {
  169. this.setState(
  170. {
  171. tabOptions: tabOptions,
  172. currentTab:
  173. this.state.currentTab === ""
  174. ? tabOptions[0].value
  175. : this.state.currentTab,
  176. metaState,
  177. requiredFields: requiredFields,
  178. },
  179. callback
  180. );
  181. } else {
  182. this.setState({ tabOptions }, callback);
  183. }
  184. } else {
  185. // TODO: refactor by consolidating w/ above
  186. // Handle change only to external tabs (e.g. DevOps mode toggle)
  187. let tabOptions = [] as { value: string; label: string }[];
  188. let tabs = this.props.formData?.tabs;
  189. tabs?.forEach((tab: any, i: number) => {
  190. if (tab?.name && tab.label) {
  191. tabOptions.push({ value: tab.name, label: tab.label });
  192. }
  193. });
  194. if (this.props.tabOptions?.length > 0) {
  195. let prependTabs = [] as { value: string; label: string }[];
  196. let appendTabs = [] as { value: string; label: string }[];
  197. this.props.tabOptions.forEach(
  198. (tab: { value: string; label: string }) => {
  199. if (tab.value === "status" || tab.value === "metrics") {
  200. prependTabs.push(tab);
  201. } else {
  202. appendTabs.push(tab);
  203. }
  204. }
  205. );
  206. tabOptions = prependTabs.concat(tabOptions.concat(appendTabs));
  207. }
  208. this.setState({ tabOptions }, callback);
  209. }
  210. };
  211. componentDidMount() {
  212. this.updateTabs(true, () => {
  213. this.setState(
  214. {
  215. metaState: {
  216. ...this.state.metaState,
  217. ...this.props.valuesToOverride,
  218. },
  219. },
  220. () => {
  221. this.props.clearValuesToOverride &&
  222. this.props.clearValuesToOverride();
  223. }
  224. );
  225. });
  226. }
  227. componentDidUpdate(prevProps: any) {
  228. // Override metaState values set from outside FormWrapper
  229. if (
  230. this.props.valuesToOverride &&
  231. !_.isEmpty(this.props.valuesToOverride) &&
  232. !_.isEqual(prevProps.valuesToOverride, this.props.valuesToOverride)
  233. ) {
  234. this.setState(
  235. {
  236. metaState: {
  237. ...this.state.metaState,
  238. ...this.props.valuesToOverride,
  239. },
  240. },
  241. () => {
  242. // Seems redundant with below but need to ensure no leaked state updates
  243. if (
  244. !_.isEqual(prevProps.tabOptions, this.props.tabOptions) ||
  245. !_.isEqual(prevProps.formData, this.props.formData)
  246. ) {
  247. let formHasChanged = !_.isEqual(
  248. prevProps.formData,
  249. this.props.formData
  250. );
  251. this.updateTabs(formHasChanged);
  252. }
  253. this.props.clearValuesToOverride &&
  254. this.props.clearValuesToOverride();
  255. }
  256. );
  257. } else if (
  258. !_.isEqual(prevProps.tabOptions, this.props.tabOptions) ||
  259. !_.isEqual(prevProps.formData, this.props.formData)
  260. ) {
  261. if (
  262. prevProps.tabOptions?.length === 0 &&
  263. !_.isEqual(prevProps.tabOptions, this.props.tabOptions)
  264. ) {
  265. this.setState({ currentTab: "status" });
  266. }
  267. let formHasChanged = !_.isEqual(prevProps.formData, this.props.formData);
  268. this.updateTabs(formHasChanged);
  269. }
  270. }
  271. isSet = (value: any) => {
  272. if (
  273. value === null ||
  274. value === undefined ||
  275. value === "" ||
  276. value === false
  277. ) {
  278. return false;
  279. }
  280. return true;
  281. };
  282. isDisabled = () => {
  283. if (this.props.saveValuesStatus == "loading") {
  284. return true;
  285. }
  286. let requiredMissing = false;
  287. this.state.requiredFields?.forEach((requiredKey: string, i: number) => {
  288. if (!this.isSet(this.state.metaState[requiredKey]?.value)) {
  289. requiredMissing = true;
  290. }
  291. });
  292. return requiredMissing;
  293. };
  294. renderTabContents = () => {
  295. let tabs = this.props.formData?.tabs;
  296. if (tabs) {
  297. let matchedTab = null as any;
  298. tabs.forEach((tab: any, i: number) => {
  299. if (tab?.name === this.state.currentTab) {
  300. matchedTab = tab;
  301. }
  302. });
  303. if (matchedTab) {
  304. return (
  305. <ValuesForm
  306. externalValues={this.props.externalValues}
  307. disabled={this.props.isReadOnly}
  308. metaState={this.state.metaState}
  309. setMetaState={(key: string, value: any) => {
  310. let metaState: any = this.state.metaState;
  311. metaState[key] = { value };
  312. this.setState({ metaState });
  313. }}
  314. sections={matchedTab.sections}
  315. />
  316. );
  317. }
  318. }
  319. // If no form tabs match, check against external tabs
  320. if (this.props.renderTabContents) {
  321. // TODO: find a cleaner way to share submissionValues w/ rerun button
  322. let submissionValues: any = {};
  323. Object.keys(this.state.metaState)?.forEach((key: string, i: number) => {
  324. submissionValues[key] = this.state.metaState[key]?.value;
  325. });
  326. return this.props.renderTabContents(
  327. this.state.currentTab,
  328. submissionValues
  329. );
  330. }
  331. return <div>No matched tabs found.</div>;
  332. };
  333. renderStateDebugger = () => {
  334. if (this.props.showStateDebugger) {
  335. return (
  336. <>
  337. <StateDisplay>
  338. <Header>FormWrapper State</Header>
  339. <ScrollWrapper>
  340. {JSON.stringify(this.state.metaState, undefined, 2)}
  341. </ScrollWrapper>
  342. </StateDisplay>
  343. </>
  344. );
  345. }
  346. };
  347. handleSubmit = () => {
  348. // Extract metaState values
  349. let submissionValues: any = {};
  350. Object.keys(this.state.metaState)?.forEach((key: string, i: number) => {
  351. submissionValues[key] = this.state.metaState[key]?.value;
  352. });
  353. this.props.onSubmit && this.props.onSubmit(submissionValues);
  354. };
  355. showSaveButton = (): boolean => {
  356. if (this.props.isReadOnly || this.state.tabOptions?.length === 0) {
  357. return false;
  358. }
  359. let tabs = this.props.formData?.tabs;
  360. if (tabs) {
  361. let matchedTab = null as any;
  362. tabs.forEach((tab: any, i: number) => {
  363. if (tab?.name === this.state.currentTab) {
  364. matchedTab = tab;
  365. }
  366. });
  367. if (matchedTab) {
  368. return true;
  369. }
  370. }
  371. // Check if current tab is among non-form tab options
  372. let nonFormTabValues = this.props.tabOptions?.map((tab: any, i: number) => {
  373. return tab.value;
  374. });
  375. if (nonFormTabValues && nonFormTabValues.includes(this.state.currentTab)) {
  376. return false;
  377. }
  378. return true;
  379. };
  380. renderContents = (showSave: boolean) => {
  381. return (
  382. <>
  383. <TabRegion
  384. options={this.state.tabOptions}
  385. currentTab={this.state.currentTab}
  386. setCurrentTab={(x: string) => this.setState({ currentTab: x })}
  387. addendum={this.props.addendum}
  388. color={this.props.color}
  389. >
  390. {this.renderTabContents()}
  391. </TabRegion>
  392. {showSave && (
  393. <SaveButton
  394. disabled={this.isDisabled()}
  395. text={this.props.saveButtonText || "Deploy"}
  396. onClick={this.handleSubmit}
  397. status={
  398. this.isDisabled() && this.props.saveValuesStatus != "loading"
  399. ? "Missing required fields"
  400. : this.props.saveValuesStatus
  401. }
  402. makeFlush={!this.props.isInModal}
  403. />
  404. )}
  405. {this.renderStateDebugger()}
  406. </>
  407. );
  408. };
  409. render() {
  410. let showSave = this.showSaveButton();
  411. return (
  412. <>
  413. {this.props.isInModal || !showSave ? (
  414. <StyledValuesWrapper showSave={showSave}>
  415. {this.renderContents(showSave)}
  416. </StyledValuesWrapper>
  417. ) : (
  418. <PaddedWrapper>
  419. <StyledValuesWrapper showSave={showSave}>
  420. {this.renderContents(showSave)}
  421. </StyledValuesWrapper>
  422. </PaddedWrapper>
  423. )}
  424. </>
  425. );
  426. }
  427. }
  428. FormWrapper.contextType = Context;
  429. const Spacer = styled.div`
  430. width: 100%;
  431. height: 200px;
  432. background: red;
  433. position: relative;
  434. `;
  435. const TabWrapper = styled.div`
  436. min-height: 100px;
  437. display: flex;
  438. align-items: center;
  439. justify-content: center;
  440. `;
  441. const ScrollWrapper = styled.div`
  442. padding: 20px;
  443. overflow-y: auto;
  444. max-height: 300px;
  445. padding-top: 15px;
  446. `;
  447. const Header = styled.div`
  448. width: 100%;
  449. height: 40px;
  450. color: #ffffff;
  451. font-weight: 500;
  452. padding-left: 17px;
  453. background: #00000022;
  454. display: flex;
  455. align-items: center;
  456. `;
  457. const StateDisplay = styled.pre`
  458. width: 100%;
  459. font-size: 13px;
  460. display:
  461. overflow: hidden;
  462. border-radius: 5px;
  463. position: relative;
  464. line-height: 1.5em;
  465. color: #aaaabb;
  466. background: #ffffff11;
  467. `;
  468. const StyledValuesWrapper = styled.div<{ showSave: boolean }>`
  469. width: 100%;
  470. padding: 0;
  471. height: ${(props) => (props.showSave ? "calc(100% - 55px)" : "100%")};
  472. `;
  473. const PaddedWrapper = styled.div`
  474. padding-bottom: 65px;
  475. position: relative;
  476. `;