PorterFormContextProvider.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. import React, { createContext, useContext, useReducer } from "react";
  2. import {
  3. type GetFinalVariablesFunction,
  4. type GetMetadataFunction,
  5. type PorterFormAction,
  6. type PorterFormData,
  7. type PorterFormState,
  8. type PorterFormValidationInfo,
  9. type PorterFormVariableList,
  10. } from "./types";
  11. import {
  12. type ShowIf,
  13. type ShowIfAnd,
  14. type ShowIfIs,
  15. type ShowIfNot,
  16. type ShowIfOr,
  17. } from "../../shared/types";
  18. import { getFinalVariablesForStringInput } from "./field-components/Input";
  19. import {
  20. getFinalVariablesForKeyValueArray,
  21. getMetadata as getMetadataForKeyValueArray,
  22. } from "./field-components/KeyValueArray";
  23. import { Context } from "../../shared/Context";
  24. import { getFinalVariablesForArrayInput } from "./field-components/ArrayInput";
  25. import { getFinalVariablesForCheckbox } from "./field-components/Checkbox";
  26. import { getFinalVariablesForSelect } from "./field-components/Select";
  27. export type BaseProps = {
  28. rawFormData: PorterFormData;
  29. initialVariables?: PorterFormVariableList;
  30. overrideVariables?: PorterFormVariableList;
  31. includeHiddenFields?: boolean;
  32. isReadOnly?: boolean;
  33. doDebug?: boolean;
  34. }
  35. export type PropsWithMetadata = {
  36. onSubmit: (
  37. data: { vars: PorterFormVariableList; metadata: PorterFormVariableList },
  38. cb?: () => void
  39. ) => void;
  40. includeMetadata: true;
  41. } & BaseProps
  42. export type PropsWithoutMetadata = {
  43. onSubmit: (vars: PorterFormVariableList, cb?: () => void) => void;
  44. includeMetadata: false;
  45. } & BaseProps
  46. export type Props = PropsWithMetadata | PropsWithoutMetadata;
  47. type ContextProps = {
  48. formData: PorterFormData;
  49. formState: PorterFormState;
  50. onSubmit: (cb?: () => void) => void;
  51. dispatchAction: (event: PorterFormAction) => void;
  52. validationInfo: PorterFormValidationInfo;
  53. getSubmitValues: () => PorterFormVariableList;
  54. isReadOnly?: boolean;
  55. }
  56. export const PorterFormContext = createContext<ContextProps | undefined>(
  57. undefined
  58. );
  59. const { Provider } = PorterFormContext;
  60. export const PorterFormContextProvider: React.FC<Props> = (props) => {
  61. const context = useContext(Context);
  62. const handleAction = (
  63. state: PorterFormState,
  64. action: PorterFormAction
  65. ): PorterFormState => {
  66. switch (action?.type) {
  67. case "init-field":
  68. if (!(action.id in state.components)) {
  69. return {
  70. ...state,
  71. variables: {
  72. ...state.variables,
  73. ...action.initVars,
  74. },
  75. components: {
  76. ...state.components,
  77. [action.id]: {
  78. state: action.initValue,
  79. },
  80. },
  81. validation: {
  82. ...state.validation,
  83. [action.id]: {
  84. ...{
  85. validated: false,
  86. },
  87. ...action.initValidation,
  88. },
  89. },
  90. };
  91. }
  92. break;
  93. case "update-field":
  94. return {
  95. ...state,
  96. variables: {
  97. ...state.variables,
  98. ...props.overrideVariables,
  99. },
  100. components: {
  101. ...state.components,
  102. [action.id]: {
  103. ...state.components[action.id],
  104. state: {
  105. ...state.components[action.id].state,
  106. ...action.updateFunc(state.components[action.id].state),
  107. },
  108. },
  109. },
  110. };
  111. case "update-validation":
  112. return {
  113. ...state,
  114. components: {
  115. ...state.components,
  116. [action.id]: {
  117. ...state.components[action.id],
  118. },
  119. },
  120. validation: {
  121. ...state.validation,
  122. [action.id]: {
  123. ...action.updateFunc(state.validation[action.id]),
  124. },
  125. },
  126. };
  127. case "mutate-vars":
  128. return {
  129. ...state,
  130. variables: {
  131. ...state.variables,
  132. ...action.mutateFunc(state.variables),
  133. ...props.overrideVariables,
  134. },
  135. };
  136. }
  137. return state;
  138. };
  139. // get variables initiated by variable field
  140. const getInitialVariables = (data: PorterFormData) => {
  141. const ret: Record<string, any> = {};
  142. data?.tabs?.map((tab) =>
  143. tab.sections?.map((section) =>
  144. section.contents?.map((field) => {
  145. if (field?.type == "variable") {
  146. ret[field.variable] = field.settings?.default;
  147. }
  148. })
  149. )
  150. );
  151. let scopedVars = {};
  152. if (data?.isClusterScoped) {
  153. scopedVars = {
  154. "currentCluster.service.is_gcp":
  155. context.currentCluster?.service == "gke",
  156. "currentCluster.service.is_aws":
  157. context.currentCluster?.service == "eks",
  158. "currentCluster.service.is_do":
  159. context.currentCluster?.service == "doks",
  160. };
  161. }
  162. return {
  163. ...ret,
  164. ...scopedVars,
  165. };
  166. };
  167. const getInitialValidation = (data: PorterFormData) => {
  168. const ret: Record<string, any> = {};
  169. data?.tabs?.map((tab, i) =>
  170. tab.sections?.map((section, j) =>
  171. section.contents?.map((field, k) => {
  172. if (
  173. field?.type == "heading" ||
  174. field?.type == "subtitle" ||
  175. field?.type == "resource-list" ||
  176. field?.type == "service-ip-list" ||
  177. field?.type == "velero-create-backup"
  178. )
  179. return;
  180. if (
  181. field.required &&
  182. (field.settings?.default || (field.value?.[0]))
  183. ) {
  184. ret[`${i}-${j}-${k}`] = {
  185. validated: true,
  186. };
  187. }
  188. })
  189. )
  190. );
  191. return ret;
  192. };
  193. const [state, dispatch] = useReducer(handleAction, {
  194. components: {},
  195. validation: getInitialValidation(props.rawFormData),
  196. variables: {
  197. ...props.initialVariables,
  198. ...getInitialVariables(props.rawFormData),
  199. ...props.overrideVariables,
  200. },
  201. });
  202. const evalShowIf = (
  203. vals: ShowIf,
  204. variables: PorterFormVariableList
  205. ): boolean => {
  206. if (!vals) {
  207. return false;
  208. }
  209. if (typeof vals === "string") {
  210. return !!variables[vals];
  211. }
  212. if ((vals as ShowIfIs).is) {
  213. vals = vals as ShowIfIs;
  214. return vals.is == variables[vals.variable];
  215. }
  216. if ((vals as ShowIfOr).or) {
  217. vals = vals as ShowIfOr;
  218. for (let i = 0; i < vals.or?.length; i++) {
  219. if (evalShowIf(vals.or[i], variables)) {
  220. return true;
  221. }
  222. }
  223. return false;
  224. }
  225. if ((vals as ShowIfAnd).and) {
  226. vals = vals as ShowIfAnd;
  227. for (let i = 0; i < vals.and?.length; i++) {
  228. if (!evalShowIf(vals.and[i], variables)) {
  229. return false;
  230. }
  231. }
  232. return true;
  233. }
  234. if ((vals as ShowIfNot).not) {
  235. vals = vals as ShowIfNot;
  236. return !evalShowIf(vals.not, variables);
  237. }
  238. return false;
  239. };
  240. /*
  241. Takes in old form data and changes it to use newer fields and assigns ids
  242. For example, number-input becomes input with a setting that makes it
  243. a number input
  244. */
  245. const restructureToNewFields = (data: PorterFormData) => {
  246. return {
  247. ...data,
  248. tabs: data?.tabs?.map((tab, i) => {
  249. return {
  250. ...tab,
  251. sections: tab.sections?.map((section, j) => {
  252. return {
  253. ...section,
  254. contents: section.contents
  255. ?.map((field: any, k) => {
  256. const id = `${i}-${j}-${k}`;
  257. if (field?.type == "number-input") {
  258. return {
  259. id,
  260. ...field,
  261. type: "input",
  262. settings: {
  263. ...field.settings,
  264. type: "number",
  265. },
  266. };
  267. }
  268. if (field?.type == "string-input") {
  269. return {
  270. id,
  271. ...field,
  272. type: "input",
  273. settings: {
  274. ...field.settings,
  275. type: "string",
  276. },
  277. };
  278. }
  279. if (field?.type == "string-input-password") {
  280. return {
  281. id,
  282. ...field,
  283. type: "input",
  284. settings: {
  285. ...field.settings,
  286. type: "password",
  287. },
  288. };
  289. }
  290. if (field?.type == "provider-select") {
  291. return {
  292. id,
  293. ...field,
  294. type: "select",
  295. settings: {
  296. ...field.settings,
  297. type: "provider",
  298. },
  299. };
  300. }
  301. if (field?.type == "env-key-value-array") {
  302. return {
  303. id,
  304. ...field,
  305. type: "key-value-array",
  306. secretOption: true,
  307. envLoader: true,
  308. fileUpload: true,
  309. settings: {
  310. ...(field.settings || {}),
  311. type: "env",
  312. },
  313. };
  314. }
  315. if (field?.type == "variable") return null;
  316. return {
  317. id,
  318. ...field,
  319. };
  320. })
  321. .filter((x) => x != null),
  322. };
  323. }),
  324. };
  325. }),
  326. };
  327. };
  328. /*
  329. We don't want to have the actual <PorterForm> component to do as little form
  330. logic as possible, so this structures the form object based on show_if statements
  331. and assigns a unique id to each field
  332. This computed structure also later lets us figure out which fields should be required
  333. */
  334. const computeFormStructure = (
  335. data: PorterFormData,
  336. variables: PorterFormVariableList
  337. ) => {
  338. return {
  339. ...data,
  340. tabs: data?.tabs?.map((tab, i) => {
  341. return {
  342. ...tab,
  343. sections: tab.sections
  344. ?.map((section, j) => {
  345. return {
  346. ...section,
  347. contents: section.contents?.map((field, k) => {
  348. return {
  349. ...field,
  350. };
  351. }),
  352. };
  353. })
  354. .filter((section) => {
  355. return !section.show_if || evalShowIf(section.show_if, variables);
  356. }),
  357. };
  358. }),
  359. };
  360. };
  361. /*
  362. compute a list of field ids who's input is required and a map from a variable value
  363. to a list of fields that set it
  364. */
  365. const computeRequiredVariables = (
  366. data: PorterFormData
  367. ): [string[], Record<string, string[]>] => {
  368. const requiredIds: string[] = [];
  369. const mapping: Record<string, string[]> = {};
  370. data?.tabs?.map((tab) =>
  371. tab.sections?.map((section) =>
  372. section.contents?.map((field) => {
  373. if (
  374. field?.type == "heading" ||
  375. field?.type == "subtitle" ||
  376. field?.type == "resource-list" ||
  377. field?.type == "service-ip-list" ||
  378. field?.type == "velero-create-backup"
  379. )
  380. return;
  381. // fields that have defaults can't be required since we can always
  382. // compute their value
  383. if (field.required) {
  384. requiredIds.push(field.id);
  385. }
  386. if (!mapping[field.variable]) {
  387. mapping[field.variable] = [];
  388. }
  389. mapping[field.variable].push(field.id);
  390. })
  391. )
  392. );
  393. return [requiredIds, mapping];
  394. };
  395. /*
  396. Validate the form based on a list of required ids
  397. */
  398. const doValidation = (requiredIds: string[]) =>
  399. requiredIds?.map((id) => state.validation[id]?.validated).every((x) => x);
  400. const formData = computeFormStructure(
  401. restructureToNewFields(props.rawFormData),
  402. state.variables
  403. );
  404. const [requiredIds, varMapping] = computeRequiredVariables(formData);
  405. const isValidated = doValidation(requiredIds);
  406. /*
  407. Handle submit
  408. This involves going through all the (currently active) fields in the form and
  409. using functions for each input to finalize the variables
  410. This can take care of things like appending units to strings
  411. */
  412. const getSubmitValues = () => {
  413. // we start off with a base list of the current variables for fields
  414. // that don't need any processing on top (for example: checkbox)
  415. // the assign here is important because that way state.variable isn't mutated
  416. const varList: PorterFormVariableList[] = [
  417. Object.assign({}, state.variables),
  418. ];
  419. const finalFunctions: Record<string, GetFinalVariablesFunction> = {
  420. input: getFinalVariablesForStringInput,
  421. "array-input": getFinalVariablesForArrayInput,
  422. checkbox: getFinalVariablesForCheckbox,
  423. "key-value-array": getFinalVariablesForKeyValueArray,
  424. select: getFinalVariablesForSelect,
  425. };
  426. const data = props.includeHiddenFields
  427. ? restructureToNewFields(props.rawFormData)
  428. : props.rawFormData.includeHiddenFields
  429. ? restructureToNewFields(props.rawFormData)
  430. : formData;
  431. data?.tabs?.map((tab) =>
  432. tab.sections?.map((section) =>
  433. section.contents?.map((field) => {
  434. if (finalFunctions[field?.type]) {
  435. varList.push(
  436. finalFunctions[field?.type](
  437. state.variables,
  438. field,
  439. state.components[field.id]?.state,
  440. context
  441. )
  442. );
  443. }
  444. })
  445. )
  446. );
  447. if (props.includeMetadata) {
  448. const metadataFunctions: Record<string, GetMetadataFunction> = {
  449. "key-value-array": getMetadataForKeyValueArray,
  450. };
  451. const metadataList: PorterFormVariableList[] = [];
  452. data?.tabs?.map((tab) =>
  453. tab.sections?.map((section) =>
  454. section.contents?.map((field) => {
  455. if (metadataFunctions[field?.type]) {
  456. metadataList.push(
  457. metadataFunctions[field?.type](
  458. state.variables,
  459. field,
  460. state.components[field.id]?.state,
  461. context
  462. )
  463. );
  464. }
  465. })
  466. )
  467. );
  468. if (props.doDebug)
  469. console.log({
  470. values: Object.assign.apply({}, varList),
  471. metadata: Object.assign.apply({}, metadataList),
  472. });
  473. if (!metadataList.length) {
  474. return {
  475. values: Object.assign.apply({}, varList),
  476. metadata: {},
  477. };
  478. }
  479. return {
  480. values: Object.assign.apply({}, varList),
  481. metadata: Object.assign.apply({}, metadataList),
  482. };
  483. }
  484. if (props.doDebug) console.log(Object.assign.apply({}, varList));
  485. return Object.assign.apply({}, varList);
  486. };
  487. const onSubmitWrapper = (cb?: () => void) => {
  488. props.onSubmit(getSubmitValues(), cb);
  489. };
  490. if (props.doDebug) {
  491. console.group("Validation Info:");
  492. console.log(requiredIds);
  493. console.log(varMapping);
  494. console.log(isValidated);
  495. console.groupEnd();
  496. }
  497. return (
  498. <Provider
  499. value={{
  500. formData,
  501. formState: state,
  502. dispatchAction: dispatch,
  503. isReadOnly: props.isReadOnly,
  504. validationInfo: {
  505. validated: isValidated,
  506. error: isValidated ? null : "Missing required fields",
  507. },
  508. onSubmit: onSubmitWrapper,
  509. getSubmitValues,
  510. }}
  511. >
  512. {props.children}
  513. </Provider>
  514. );
  515. };