PorterFormContextProvider.tsx 13 KB

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