github.com/replicatedhq/ship@v0.55.0/web/init/src/components/kustomize/kustomize_overlay/KustomizeOverlay.jsx (about) 1 import React from "react"; 2 import classNames from "classnames"; 3 import AceEditor from "react-ace"; 4 import ReactTooltip from "react-tooltip" 5 import * as yaml from "js-yaml"; 6 import isEmpty from "lodash/isEmpty"; 7 import sortBy from "lodash/sortBy"; 8 import pick from "lodash/pick"; 9 import keyBy from "lodash/keyBy"; 10 import find from "lodash/find"; 11 import trim from "lodash/trim"; 12 import findIndex from "lodash/findIndex"; 13 import map from "lodash/map"; 14 import defaultTo from "lodash/defaultTo"; 15 16 import FileTree from "./FileTree"; 17 import KustomizeModal from "./KustomizeModal"; 18 import Loader from "../../shared/Loader"; 19 import { AceEditorHOC, PATCH_TOKEN } from "./AceEditorHOC"; 20 import DiffEditor from "../../shared/DiffEditor"; 21 22 import "../../../../node_modules/brace/mode/yaml"; 23 import "../../../../node_modules/brace/theme/chrome"; 24 25 export const PATCH_OVERLAY = "PATCH"; 26 export const BASE_OVERLAY = "BASE"; 27 export const RESOURCE_OVERLAY = "RESOURCE"; 28 29 export default class KustomizeOverlay extends React.Component { 30 constructor() { 31 super(); 32 this.state = { 33 fileTree: [], 34 fileTreeBasePath: "", 35 selectedFile: "", 36 fileContents: {}, 37 fileLoadErr: false, 38 fileLoadErrMessage: "", 39 savePatchErr: false, 40 savePatchErrorMessage: "", 41 applyPatchErr: false, 42 applyPatchErrorMessage: "", 43 viewDiff: false, 44 markers: [], 45 patch: "", 46 savingFinalize: false, 47 displayConfirmModal: false, 48 overlayToDelete: "", 49 addingNewResource: false, 50 newResourceName: "", 51 lastSavedPatch: null, 52 displayConfirmModalMessage: "", 53 displayConfirmModalDiscardMessage: "", 54 displayConfirmModalSubMessage: "", 55 modalAction: this.discardOverlay, 56 }; 57 this.addResourceWrapper = React.createRef(); 58 this.addResourceInput = React.createRef(); 59 } 60 61 toggleModal = (overlayPath, overlayType) => { 62 const displayConfirmModalSubMessage = "It will not be applied to the kustomization.yaml file that is generated for you."; 63 let displayConfirmModalMessage = "Are you sure you want to discard this patch?"; 64 let displayConfirmModalDiscardMessage = "Discard patch"; 65 66 if (overlayType === BASE_OVERLAY) { 67 displayConfirmModalMessage = "Are you sure you want to discard this base resource?"; 68 displayConfirmModalDiscardMessage = "Discard base"; 69 } else if (overlayType === RESOURCE_OVERLAY) { 70 displayConfirmModalMessage = "Are you sure you want to discard this resource?"; 71 displayConfirmModalDiscardMessage = "Discard resource"; 72 } 73 74 this.setState({ 75 displayConfirmModal: !this.state.displayConfirmModal, 76 overlayToDelete: this.state.displayConfirmModal ? "" : overlayPath, 77 displayConfirmModalMessage, 78 displayConfirmModalDiscardMessage, 79 displayConfirmModalSubMessage, 80 modalAction: () => (this.discardOverlay(overlayType)), 81 }); 82 } 83 84 toggleModalForExcludedBase = (basePath) => { 85 this.setState({ 86 displayConfirmModal: !this.state.displayConfirmModal, 87 displayConfirmModalMessage: "Are you sure you want to include this base resource?", 88 displayConfirmModalDiscardMessage: "Include base", 89 displayConfirmModalSubMessage: "It will be included in the kustomization.yaml file that is generated for you.", 90 modalAction: () => (this.includeBase(basePath)), 91 }); 92 } 93 94 includeBase = async(basePath) => { 95 await this.props.includeBase(basePath); 96 this.setState({ displayConfirmModal: false }); 97 } 98 99 componentDidUpdate(lastProps, lastState) { 100 const { currentStep } = this.props; 101 this.rebuildTooltip(); 102 if (this.props.currentStep !== lastProps.currentStep && !isEmpty(this.props.currentStep)) { 103 this.setFileTree(currentStep); 104 } 105 if (this.props.fileContents !== lastProps.fileContents && !isEmpty(this.props.fileContents)) { 106 this.setState({ fileContents: keyBy(this.props.fileContents, "key") }); 107 } 108 if ( 109 (this.state.viewDiff !== lastState.viewDiff) || 110 (this.state.patch !== lastState.patch) || 111 (this.state.selectedFile !== lastState.selectedFile) 112 ) { 113 this.aceEditorOverlay.editor.resize(); 114 } 115 if (this.props.patch !== lastProps.patch) { 116 this.setState({ 117 lastSavedPatch: this.state.lastSavedPatch !== null ? this.state.lastSavedPatch : this.props.patch, 118 patch: this.props.patch 119 }); 120 } 121 } 122 123 componentDidMount() { 124 const { currentStep } = this.props; 125 if (currentStep && !isEmpty(currentStep)) { 126 this.setFileTree(currentStep); 127 } 128 if (this.props.fileContents && !isEmpty(this.props.fileContents)) { 129 this.setState({ fileContents: keyBy(this.props.fileContents, "key") }); 130 } 131 } 132 133 handleApplyPatch = async () => { 134 const { selectedFile, fileTreeBasePath } = this.state; 135 const contents = this.aceEditorOverlay.editor.getValue(); 136 137 const applyPayload = { 138 resource: `${fileTreeBasePath}${selectedFile}`, 139 patch: contents, 140 }; 141 await this.props.applyPatch(applyPayload) 142 .catch((err) => { 143 this.setState({ 144 applyPatchErr: true, 145 applyPatchErrorMessage: err.message 146 }); 147 148 setTimeout(() => { 149 this.setState({ 150 applyPatchErr: false, 151 applyPatchErrorMessage: "" 152 }); 153 }, 3000); 154 }); 155 } 156 157 toggleDiff = async () => { 158 const { patch, modified } = this.props; 159 const hasPatchButNoModified = patch.length > 0 && modified.length === 0; 160 if (hasPatchButNoModified) { 161 await this.handleApplyPatch().catch(); 162 } 163 164 this.setState({ viewDiff: !this.state.viewDiff }); 165 } 166 167 createOverlay = () => { 168 const { selectedFile } = this.state; 169 let file = find(this.props.fileContents, ["key", selectedFile]); 170 if (!file) return; 171 const files = yaml.safeLoadAll(file.baseContent); 172 let overlayFields = map(files, (file) => { 173 return pick(file, "apiVersion", "kind", "metadata.name") 174 }); 175 if (files.length === 1) { 176 overlayFields = overlayFields[0]; 177 } 178 const overlay = yaml.safeDump(overlayFields); 179 this.setState({ patch: `--- \n${overlay}` }); 180 } 181 182 setSelectedFile = async (path) => { 183 const { lastSavedPatch, patch } = this.state; 184 185 let canChangeFile = !lastSavedPatch || patch === lastSavedPatch || confirm("You have unsaved changes in the patch. If you proceed, you will lose any of the changes you've made."); 186 if (canChangeFile) { 187 this.setState({ selectedFile: path, lastSavedPatch: null }); 188 await this.props.getFileContent(path).then(() => { 189 // set state with new file content 190 this.setState({ 191 fileContents: keyBy(this.props.fileContents, "key"), 192 }); 193 }); 194 } 195 } 196 197 handleFinalize = async () => { 198 const { 199 finalizeKustomizeOverlay, 200 finalizeStep, 201 history, 202 isNavcycle, 203 actions, 204 startPoll, 205 routeId, 206 pollCallback 207 } = this.props; 208 209 if (isNavcycle) { 210 await finalizeStep({ action: actions[0] }); 211 startPoll(routeId, pollCallback); 212 } else { 213 await finalizeKustomizeOverlay() 214 .then(() => { 215 this.setState({ savingFinalize: false }); 216 history.push("/"); 217 }).catch(); 218 } 219 } 220 221 discardOverlay = async (overlayType) => { 222 const { overlayToDelete } = this.state; 223 await this.deleteOverlay(overlayToDelete, overlayType); 224 this.setState({ 225 patch: "", 226 displayConfirmModal: false, 227 lastSavedPatch: null 228 }); 229 } 230 231 deleteOverlay = async (path, overlayType) => { 232 const { fileTree, selectedFile } = this.state; 233 const isResource = overlayType === RESOURCE_OVERLAY; 234 const isBase = overlayType === BASE_OVERLAY; 235 const overlays = find(fileTree, { name: "overlays" }); 236 const overlayExists = overlays && findIndex(overlays.children, { path }) > -1; 237 238 if (isResource) { 239 await this.props.deleteOverlay(path, "resource"); 240 return; 241 } 242 243 if (isBase) { 244 if (selectedFile === path) { 245 this.setState({ selectedFile: "" }); 246 } 247 await this.props.deleteOverlay(path, "base"); 248 return; 249 } 250 251 if (overlayExists) { 252 await this.props.deleteOverlay(path, "patch"); 253 return; 254 } 255 } 256 257 handleKustomizeSave = async (finalize) => { 258 const { selectedFile, fileContents } = this.state; 259 const { isResource } = fileContents[selectedFile]; 260 const contents = this.aceEditorOverlay.editor.getValue(); 261 this.setState({ patch: contents }); 262 263 const payload = { 264 path: selectedFile, 265 contents, 266 isResource 267 }; 268 269 if (!isResource) await this.handleApplyPatch(); 270 await this.props.saveKustomizeOverlay(payload) 271 .then(() => { 272 this.setState({ lastSavedPatch: null }); 273 }) 274 .catch((err) => { 275 this.setState({ 276 savePatchErr: true, 277 savePatchErrorMessage: err.message 278 }); 279 280 setTimeout(() => { 281 this.setState({ 282 savePatchErr: false, 283 savePatchErrorMessage: "" 284 }); 285 }, 3000); 286 }); 287 await this.props.getCurrentStep(); 288 if (finalize) { 289 this.setState({ savingFinalize: true, addingNewResource: false }); 290 this.handleFinalize(); 291 } 292 } 293 294 handleCreateResource = async () => { 295 const { newResourceName } = this.state; 296 const contents = "\n"; // Cannot be empty 297 this.setState({ patch: contents }); 298 299 const payload = { 300 path: `/${newResourceName}`, 301 contents, 302 isResource: true 303 }; 304 305 await this.props.saveKustomizeOverlay(payload) 306 .then(() => { 307 this.setSelectedFile(`/${newResourceName}`); 308 this.setState({ addingNewResource: false, newResourceName: "" }) 309 }) 310 .catch((err) => { 311 this.setState({ 312 savePatchErr: true, 313 savePatchErrorMessage: err.message 314 }); 315 316 setTimeout(() => { 317 this.setState({ 318 savePatchErr: false, 319 savePatchErrorMessage: "" 320 }); 321 }, 3000); 322 }); 323 await this.props.getCurrentStep(); 324 } 325 326 handleGeneratePatch = async (path) => { 327 const current = this.aceEditorOverlay.editor.getValue(); 328 const { selectedFile, fileTreeBasePath } = this.state; 329 this.setState({ lastSavedPatch: null }) 330 const payload = { 331 original: selectedFile, 332 current, 333 path, 334 resource: `${fileTreeBasePath}${selectedFile}`, 335 }; 336 await this.props.generatePatch(payload); 337 338 const position = this.aceEditorOverlay.editor.find(PATCH_TOKEN); // Find text for position 339 if(position) { 340 this.aceEditorOverlay.editor.focus(); 341 this.aceEditorOverlay.editor.gotoLine(position.start.row + 1, position.start.column); 342 this.aceEditorOverlay.editor.find(PATCH_TOKEN); // Have to find text again to auto focus text 343 } 344 } 345 346 rebuildTooltip = () => { 347 // We need to rebuild these because...well I dunno why but if you don't the tooltips will not be visible after toggling the overlay editor. 348 ReactTooltip.rebuild(); 349 ReactTooltip.hide(); 350 } 351 352 setFileTree = ({ kustomize }) => { 353 if (!kustomize.tree) return; 354 const sortedTree = sortBy(kustomize.tree.children, (dir) => { 355 dir.children ? dir.children.length : 0 356 }); 357 358 this.setState({ 359 fileTree: sortedTree, 360 fileTreeBasePath: kustomize.basePath 361 }); 362 } 363 364 setAceEditor = (editor) => { 365 this.aceEditorOverlay = editor; 366 } 367 368 updateModifiedPatch = (patch, isResource) => { 369 // We already circumvent React's lifecycle state system for updates 370 // Set the current patch state to the changed value to avoid 371 // React re-rendering the ACE Editor 372 if (!isResource) { 373 this.state.patch = patch; // eslint-disable-line 374 } 375 }; 376 377 handleAddResourceClick = async () => { 378 // Ref input won't focus until state has been set 379 await this.setState({ addingNewResource: true }); 380 this.addResourceInput.current.focus(); 381 window.addEventListener("click", this.handleClickOutsideResourceInput); 382 } 383 384 handleClickOutsideResourceInput = (e) => { 385 const { addingNewResource } = this.state; 386 if (addingNewResource && !this.addResourceWrapper.current.contains(e.target)) { 387 this.setState({ addingNewResource: false, newResourceName: "" }); 388 window.removeEventListener("click", this.handleClickOutsideResourceInput); 389 } 390 } 391 392 handleCreateNewResource = (e) => { 393 if (e.charCode === 13) { 394 this.handleCreateResource() 395 } 396 } 397 398 render() { 399 const { dataLoading, modified, firstRoute, goBack } = this.props; 400 const { 401 fileTree, 402 selectedFile, 403 fileLoadErr, 404 fileLoadErrMessage, 405 patch, 406 savingFinalize, 407 fileContents, 408 addingNewResource, 409 newResourceName, 410 modalAction, 411 applyPatchErr, 412 applyPatchErrorMessage, 413 savePatchErr, 414 savePatchErrorMessage 415 } = this.state; 416 const fileToView = defaultTo(find(fileContents, ["key", selectedFile]), {}); 417 const showOverlay = patch.length; 418 const showBase = !fileToView.isResource; 419 420 return ( 421 <div className="flex flex1"> 422 <div className="u-minHeight--full u-minWidth--full flex-column flex1 u-position--relative"> 423 <div className="flex flex1 u-minHeight--full u-height--full"> 424 <div className="flex-column flex1 Sidebar-wrapper u-overflow--hidden"> 425 <div className="flex-column flex1"> 426 <div className="flex1 u-overflow--auto u-background--biscay"> 427 <div className="flex1 dirtree-wrapper u-overflow--hidden flex-column"> 428 {fileTree.map((tree, i) => { 429 const id = `sub-dir-${tree.name}-${tree.children.length}-${tree.path}-${i}`; 430 return ( 431 <div 432 key={id} 433 className={classNames("u-overflow--auto FileTree-wrapper u-position--relative dirtree", { 434 "flex-auto has-border": i > 0, 435 "flex-0-auto": i <= 0 436 })}> 437 <input 438 type="checkbox" 439 name={id} 440 id={id} 441 defaultChecked={true} 442 /> 443 <label htmlFor={id}> 444 {tree.name === "/" ? "base" : tree.name} 445 </label> 446 <FileTree 447 files={tree.children} 448 basePath={tree.name} 449 handleFileSelect={(path) => this.setSelectedFile(path)} 450 handleDeleteOverlay={this.toggleModal} 451 handleClickExcludedBase={this.toggleModalForExcludedBase} 452 selectedFile={this.state.selectedFile} 453 isOverlayTree={tree.name === "overlays"} 454 isResourceTree={tree.name === "resources"} 455 isBaseTree={tree.name === "/"} 456 /> 457 </div> 458 ); 459 })} 460 <div className="add-new-resource u-position--relative" ref={this.addResourceWrapper}> 461 <input 462 type="text" 463 className={`Input add-resource-name-input u-position--absolute ${!addingNewResource ? "u-visibility--hidden" : ""}`} 464 name="new-resource" 465 placeholder="filename.yaml" 466 onChange={(e) => { this.setState({ newResourceName: e.target.value }) }} 467 onKeyPress={(e) => { this.handleCreateNewResource(e) }} 468 value={newResourceName} 469 ref={this.addResourceInput} 470 /> 471 <p 472 className={`add-resource-link u-position--absolute u-marginTop--small u-marginLeft--normal u-cursor--pointer u-fontSize--small u-color--silverSand u-fontWeight--bold ${addingNewResource ? "u-visibility--hidden" : ""}`} 473 onClick={this.handleAddResourceClick} 474 >+ Add Resource 475 </p> 476 </div> 477 </div> 478 </div> 479 </div> 480 </div> 481 <div className="flex-column flex1 u-height--auto u-overflow--hidden LayoutContent-wrapper u-position--relative"> 482 <div className="flex flex1 u-position--relative"> 483 484 <div className={`flex-column flex1 base-editor-wrapper ${showOverlay && "u-paddingRight--15"} ${showBase ? "visible" : ""}`}> 485 <div className="flex1 flex-column u-position--relative"> 486 {fileLoadErr ? 487 <div className="flex-column flex1 alignItems--center justifyContent--center"> 488 <p className="u-color--chestnut u-fontSize--normal u-fontWeight--medium">Oops, we ran into a probelm getting that file, <span className="u-fontWeight--bold">{fileLoadErrMessage}</span></p> 489 </div> 490 : dataLoading.fileContentLoading ? 491 <div className="flex-column flex1 alignItems--center justifyContent--center"> 492 <Loader size="50" color="#337AB7" /> 493 </div> 494 : 495 <div className="flex1 flex-column"> 496 <div className="u-paddingLeft--20 u-paddingRight--20 u-paddingTop--20"> 497 <p className="u-marginBottom--normal u-fontSize--large u-color--tuna u-fontWeight--bold">Base YAML</p> 498 <p className="u-fontSize--small u-lineHeight--more u-fontWeight--medium u-color--doveGray">Select a file to be used as the base YAML. You can then click the edit icon on the top right to create a patch for that file.</p> 499 </div> 500 {selectedFile !== "" ? 501 <div className="flex1 file-contents-wrapper AceEditor--wrapper"> 502 {!showOverlay && 503 <div data-tip="create-overlay-tooltip" data-for="create-overlay-tooltip" className="overlay-toggle u-cursor--pointer" onClick={this.createOverlay}> 504 <span className="icon clickable u-overlayCreateIcon"></span> 505 </div> 506 } 507 <ReactTooltip id="create-overlay-tooltip" effect="solid" className="replicated-tooltip">Create patch</ReactTooltip> 508 <AceEditorHOC 509 handleGeneratePatch={this.handleGeneratePatch} 510 handleApplyPatch={this.handleApplyPatch} 511 fileToView={fileToView} 512 diffOpen={this.state.viewDiff} 513 overlayOpen={showOverlay} 514 /> 515 </div> 516 : 517 <div className="flex1 flex-column empty-file-wrapper alignItems--center justifyContent--center"> 518 <p className="u-fontSize--small u-fontWeight--medium u-color--dustyGray">No file selected.</p> 519 </div> 520 } 521 </div> 522 } 523 </div> 524 </div> 525 526 <div className={`flex-column flex1 overlays-editor-wrapper ${showOverlay ? "visible" : ""}`}> 527 <div className="u-paddingLeft--20 u-paddingRight--20 u-paddingTop--20"> 528 <p className="u-marginBottom--normal u-fontSize--large u-color--tuna u-fontWeight--bold">{showBase ? "Patch" : "Resource"}</p> 529 <p className="u-fontSize--small u-lineHeight--more u-fontWeight--medium u-color--doveGray">This file will be applied as a patch to the base manifest. Edit the values that you want patched. The current file you're editing will be automatically saved when you open a new file.</p> 530 </div> 531 <div className="flex1 flex-column file-contents-wrapper u-position--relative"> 532 <div className="flex1 AceEditor--wrapper"> 533 {showOverlay && showBase ? <span data-tip="close-overlay-tooltip" data-for="close-overlay-tooltip" className="icon clickable u-closeOverlayIcon" onClick={() => this.toggleModal(this.state.selectedFile, PATCH_OVERLAY)}></span> : null} 534 <ReactTooltip id="close-overlay-tooltip" effect="solid" className="replicated-tooltip">Discard patch</ReactTooltip> 535 <AceEditor 536 ref={this.setAceEditor} 537 mode="yaml" 538 theme="chrome" 539 className="flex1 flex acePatchEditor" 540 value={trim(patch)} 541 height="100%" 542 width="100%" 543 editorProps={{ 544 $blockScrolling: Infinity, 545 useSoftTabs: true, 546 tabSize: 2, 547 }} 548 debounceChangePeriod={1000} 549 setOptions={{ 550 scrollPastEnd: false 551 }} 552 onChange={(patch) => this.updateModifiedPatch(patch, fileToView.isResource)} 553 /> 554 </div> 555 </div> 556 </div> 557 </div> 558 559 {showOverlay && showBase ? 560 <div className={`${this.state.viewDiff ? "flex1" : "flex-auto"} flex-column`}> 561 <div className="diff-viewer-wrapper flex-column flex1"> 562 <span className="diff-toggle" onClick={this.toggleDiff}>{this.state.viewDiff ? "Hide diff" : "Show diff"}</span> 563 {this.state.viewDiff && 564 <DiffEditor 565 diffTitle="Diff YAML" 566 diffSubCopy="Here you can see the diff of the base YAML, and the finalized version with the overlay applied." 567 original={fileToView.baseContent} 568 updated={this.props.modified} 569 /> 570 } 571 </div> 572 </div> 573 : null} 574 575 <div className="flex-auto flex layout-footer-actions less-padding"> 576 <div className="flex flex1"> 577 {firstRoute ? null : 578 <div className="flex-auto u-marginRight--normal"> 579 <button className="btn secondary" onClick={() => goBack()}>Back</button> 580 </div> 581 } 582 <div className="flex-column flex-verticalCenter"> 583 <p className="u-margin--none u-marginRight--30 u-fontSize--small u-color--dustyGray u-fontWeight--normal">Contributed by <a target="_blank" rel="noopener noreferrer" href="https://replicated.com" className="u-fontWeight--medium u-color--astral u-textDecoration--underlineOnHover">Replicated</a></p> 584 </div> 585 </div> 586 <div className="flex1 flex alignItems--center justifyContent--flexEnd"> 587 {selectedFile === "" ? 588 <button type="button" onClick={this.props.skipKustomize} className="btn primary">Continue</button> 589 : 590 <div className="flex"> 591 {applyPatchErr && <span className="flex flex1 u-fontSize--small u-fontWeight--medium u-color--chestnut u-marginRight--20 alignItems--center">{ applyPatchErrorMessage }</span>} 592 {savePatchErr && <span className="flex flex1 u-fontSize--small u-fontWeight--medium u-color--chestnut u-marginRight--20 alignItems--center">{ savePatchErrorMessage }</span>} 593 <button type="button" disabled={dataLoading.saveKustomizeLoading || patch === "" || savingFinalize} onClick={() => this.handleKustomizeSave(false)} className="btn primary save-btn u-marginRight--normal">{dataLoading.saveKustomizeLoading && !savingFinalize ? "Saving patch" : "Save patch"}</button> 594 {patch === "" ? 595 <button type="button" onClick={this.props.skipKustomize} className="btn primary">Continue</button> 596 : 597 <button type="button" disabled={dataLoading.saveKustomizeLoading || savingFinalize} onClick={() => this.handleKustomizeSave(true)} className="btn secondary finalize-btn">{savingFinalize ? "Finalizing overlay" : "Save & continue"}</button> 598 } 599 </div> 600 } 601 </div> 602 </div> 603 604 </div> 605 </div> 606 </div> 607 <KustomizeModal 608 isOpen={this.state.displayConfirmModal} 609 onRequestClose={this.toggleModal} 610 discardOverlay={modalAction} 611 message={this.state.displayConfirmModalMessage} 612 subMessage={this.state.displayConfirmModalSubMessage} 613 discardMessage={this.state.displayConfirmModalDiscardMessage} 614 /> 615 </div> 616 ); 617 } 618 }