crane-cloud/frontend

View on GitHub
src/pages/AppSettingsPage/index.jsx

Summary

Maintainability
C
1 day
Test Coverage
F
0%
import React, { useEffect, useState, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useParams, useHistory } from "react-router-dom";
import DomainInstructionsContent from "../../components/DomainInstructionsContent";
import updateApp, { clearUpdateAppState } from "../../redux/actions/updateApp";
import RollbackConfirmationContent from "../../components/RollbackContent";
import DashboardLayout from "../../components/Layouts/DashboardLayout";
import deleteApp, { clearState } from "../../redux/actions/deleteApp";
import EnvironmentAndPortsTab from "../../components/EnvironmentsTab";
import GeneralDetailsTab from "../../components/GeneralDetailsTab";
import SettingsActionRow from "../../components/SettingsActionRow";
import DisableModalContent from "../../components/DisableModalContent";
import ImageSettingsTab from "../../components/ImageSettingsTab";
import DeleteAppContent from "../../components/DeleteAppContent";
import DomainAndUrlsTab from "../../components/DomainAndUrlsTab";
import SettingsModal from "../../components/SettingsModal";
import RevisionsList from "../../components/RevisionList";
import Pagination from "../../components/Pagination";
import usePaginator from "../../hooks/usePaginator";
import Feedback from "../../components/Feedback";
import Spinner from "../../components/Spinner";
import TabItem from "../../components/TabItem";
import getSingleApp, {
  clearFetchAppState,
} from "../../redux/actions/getSingleApp";
import getAppRevisions, {
  clearFetchAppRevisionsState,
} from "../../redux/actions/getRevisions";
import {
  handleGetRequest,
  handlePostRequestWithOutDataObject,
} from "../../apis/apis";
import "./AppSettingsPage.css";
import { validateDomain } from "../../helpers/validation";
import revertUrl, { clearUrlRevertState } from "../../redux/actions/revertUrl";

const AppSettingsPage = () => {
  const dispatch = useDispatch();
  const history = useHistory();
  const { appID } = useParams();

  const loggedInUser = useSelector((state) => state.user);
  const { app, isRetrieving, isFetched } = useSelector(
    (state) => state.singleAppReducer
  );
  const { isUpdating, errorMessage } = useSelector(
    (state) => state.updateAppReducer
  );
  const { isDeleting, isFailed, message } = useSelector(
    (state) => state.deleteAppReducer
  );
  const { isReverting } = useSelector((state) => state.revertUrlReducer);
  const { revisions, isFetching, pagination } = useSelector(
    (state) => state.appRevisionsReducer
  );

  const tabNames = [
    "General Details",
    "Image Settings",
    "Environment and Ports",
    "Domain and URLs",
  ];

  const replicaOptions = [
    { id: 1, name: "1" },
    { id: 2, name: "2" },
    { id: 3, name: "3" },
    { id: 4, name: "4" },
  ];

  const [activeTab, setActiveTab] = useState("General Details");
  const [currentPage, handleChangePage] = usePaginator();
  const [parentProject, setParentProject] = useState("");
  const [openDeleteAlert, setOpenDeleteAlert] = useState(false);
  const [error, setError] = useState("");
  const [submitMessage, setSubmitMessage] = useState("");
  const [envError, setEnvError] = useState("");
  const [portError, setPortError] = useState("");
  const [commandError, setCommandError] = useState("");
  const [disableDelete, setDisableDelete] = useState(true);
  const [ConfirmAppname, setConfirmAppname] = useState("");
  const [domainModal, setDomainModal] = useState(false);
  const [newImage, setNewImage] = useState("");
  const [urlChecked, setUrlChecked] = useState(false);
  const [internalUrlChecked, setInternalUrlChecked] = useState(false);
  const [isPrivateImage, setIsPrivateImage] = useState(false);
  const [isCustomDomain, setIsCustomDomain] = useState(false);
  const [domainName, setDomainName] = useState("");
  const [varName, setVarName] = useState("");
  const [varValue, setVarValue] = useState("");
  const [envVars, setEnvVars] = useState({});
  const [entryCommand, setEntryCommand] = useState("");
  const [port, setPort] = useState("");
  const [replicas, setReplicas] = useState("");
  const [revisionId, setRevisionId] = useState("");
  const [rollBackConfirmationModal, setRollBackConfirmationModal] =
    useState(false);
  const [revisingApp, setRevisingApp] = useState(false);
  const [revisingAppError, setRevisingAppError] = useState("");
  const [loadingIndex, setLoadingIndex] = useState(null);
  const [showAppDisableModal, setShowAppDisableModal] = useState(false);
  const [appDisableProgress, setAppDisableProgress] = useState(false);
  const [dockerCredentials, setDockerCredentials] = useState({
    username: "",
    email: "",
    password: "",
    server: "",
    error: "",
  });

  const applicationRevisions = useCallback(
    () => dispatch(getAppRevisions(appID, currentPage)),
    [dispatch, appID, currentPage]
  );

  useEffect(() => {
    if (appID) {
      // Clear the state before fetching new app data and revisions
      dispatch(clearFetchAppState());
      dispatch(getSingleApp(appID));
    }
    // The cleanup function here is called when the component unmounts
    return () => {
      dispatch(clearFetchAppState());
    };
  }, [appID, dispatch]);

  useEffect(() => {
    applicationRevisions();
    // The cleanup function here is called when the component unmounts
    return () => {
      dispatch(clearFetchAppRevisionsState());
    };
  }, [applicationRevisions, dispatch]);

  useEffect(() => {
    dispatch(getAppRevisions(appID, currentPage));
    // The cleanup function here is called when the component unmounts
    return () => {
      dispatch(clearFetchAppRevisionsState());
    };
  }, [appID, currentPage, dispatch]);

  useEffect(() => {
    if (app.command) {
      setEntryCommand(app.command);
    }
    if (app.port) {
      setPort(app.port);
    }
  }, [app?.command, app?.port]);

  const fetchProjectDetails = useCallback(() => {
    handleGetRequest(`/projects/${app?.project_id}`)
      .then((response) => {
        setParentProject(response.data.data.project);
      })
      .catch((error) => {
        console.error(error);
      });
  }, [app?.project_id]);

  useEffect(() => {
    fetchProjectDetails();
  }, [fetchProjectDetails]);

  useEffect(() => {
    setEnvVars(app?.env_vars || {});
  }, [app?.env_vars]);

  // Handlers
  const handlePageChange = (currentPage) => {
    handleChangePage(currentPage);
    applicationRevisions();
  };

  const handleDeleteApp = (e, appId) => {
    e.preventDefault();

    dispatch(deleteApp(appId))
      .then(() => {
        dispatch(clearState());
        // redirect user to the project apps dashboard page
        const projectID = app.project_id;
        window.location.href = `/projects/${projectID}/dashboard`;
      })
      .catch((error) => {
        console.error(error);
      })
      .finally(() => {
        dispatch(clearState());
      });
  };

  const handleImageChange = (e) => {
    setNewImage(e.target.value);
  };

  const handleSelectReplicas = (selectedOption) => {
    setReplicas(selectedOption.id);
  };

  const handleDockerCredentialsChange = (e) => {
    const { name, value } = e.target;
    setDockerCredentials((prevCredentials) => ({
      ...prevCredentials,
      [name]: value,
    }));
  };

  const handleImageSectionSubmit = () => {
    const projectID = app.project_id;
    let updatePayload = {
      ...(newImage !== app?.image && { image: newImage }),
      ...(replicas !== app?.replicas && { replicas }),
      ...(isPrivateImage && { privateImage: isPrivateImage }),
      ...dockerCredentials,
    };

    // Remove keys with empty string values
    updatePayload = Object.entries(updatePayload).reduce(
      (acc, [key, value]) => {
        if (value !== "") {
          acc[key] = value;
        }
        return acc;
      },
      {}
    );

    // Check if the payload is empty (no changes)
    if (Object.keys(updatePayload).length === 0) {
      setSubmitMessage("No changes to submit.");
    } else {
      dispatch(updateApp(appID, updatePayload))
        .then(() => {
          dispatch(clearUpdateAppState());
          setSubmitMessage("Update successful.");
          window.location.href = `/projects/${projectID}/apps/${appID}/settings`;
        })
        .catch((error) => {
          dispatch(clearUpdateAppState());
          console.error("Update failed:", error);
          setSubmitMessage("Update failed, please try again.");
        });
    }
  };

  const rollbackApp = (revisionId) => {
    const projectID = app.project_id;
    setRevisingApp(true);

    handlePostRequestWithOutDataObject(
      revisionId,
      `/apps/${appID}/revise/${revisionId}`
    )
      .then(() => {
        window.location.href = `/projects/${projectID}/apps/${appID}/settings`;
        setRevisingApp(false);
      })
      .catch((error) => {
        setError(error);
        setRevisingApp(false);
      });
  };

  const handleEnvChange = (e) => {
    const { name, value } = e.target;

    if (name === "varName") {
      setVarName(value);
    } else if (name === "varValue") {
      setVarValue(value);
    } else if (name === "entryCommand") {
      setEntryCommand(value);
    } else if (name === "port") {
      setPort(value);
    }
  };

  const handleEnvSubmit = () => {
    const projectID = app?.project_id;
    let updatePayload = {};
    if (port !== "" && port.toString() !== app.port.toString()) {
      updatePayload = { ...updatePayload, port: parseInt(port, 10) };
    }

    if (haveEnvVarsChanged(envVars, app?.env_vars)) {
      updatePayload = { ...updatePayload, env_vars: envVars };
    }

    // Remove keys with empty string values
    updatePayload = Object.entries(updatePayload).reduce(
      (acc, [key, value]) => {
        if (value !== "" && value !== null) {
          acc[key] = value;
        }
        return acc;
      },
      {}
    );
    //since command can be empty

    if (entryCommand !== app.command) {
      updatePayload = { ...updatePayload, command: entryCommand };
    }

    // Check if the payload is empty (no changes)
    if (Object.keys(updatePayload).length === 0) {
      setSubmitMessage("No changes to submit.");
    } else {
      // Proceed with the update operation
      dispatch(updateApp(appID, updatePayload))
        .then(() => {
          dispatch(clearUpdateAppState());
          setSubmitMessage("Update successful.");
          window.location.href = `/projects/${projectID}/apps/${appID}/settings`;
        })
        .catch((error) => {
          dispatch(clearUpdateAppState());
          console.error("Update failed:", error);
          setSubmitMessage("Update failed, please try again.");
        });
    }
  };

  const haveEnvVarsChanged = (newEnvVars, originalEnvVars) => {
    return Object.keys(newEnvVars).some(
      (key) => newEnvVars[key] !== (originalEnvVars?.[key] ?? undefined)
    );
  };

  const addEnvVar = () => {
    if (varName.trim() && varValue.trim()) {
      setEnvVars((prevEnvVars) => ({
        ...prevEnvVars,
        [varName.trim()]: varValue.trim(),
      }));
      setVarName("");
      setVarValue("");
    } else {
      setEnvError("Provide an environment variable key and value.");
    }
  };

  const removeExistingEnvVar = async (index) => {
    const projectID = app?.project_id;
    setLoadingIndex(index);
    const keyToRemove = Object.keys(envVars)[index];
    console.log(keyToRemove)

    if (keyToRemove !== null) {
      const updatePayload = { delete_env_vars: [keyToRemove] };

      await dispatch(updateApp(appID, updatePayload))
        .then(() => {
          dispatch(clearUpdateAppState());

          // update state with new env vars
          const newEnvVars = Object.keys(envVars).reduce((object, key) => {
            if (key !== keyToRemove) {
              object[key] = envVars[key];
            }
            return object;
          }, {});
          setEnvVars(newEnvVars);

          // retrieve updated application details here
          setSubmitMessage("Update successful.");
          dispatch(getSingleApp(appID));
          window.location.href = `/projects/${projectID}/apps/${appID}/settings`;
        })
        .catch((error) => {
          dispatch(clearUpdateAppState());
          console.error("Update failed:", error);
          setSubmitMessage("Update failed, please try again.");
        });
    }
  };

  const handleEnableButtonClick = async () => {
    setAppDisableProgress(true);
    setError("");

    try {
      const path = `/apps/${appID}/${app.disabled ? "enable" : "disable"}`;
      await handlePostRequestWithOutDataObject(appID, path);
      window.location.reload();
    } catch (error) {
      setError("Request failed, please try again later");
    } finally {
      setAppDisableProgress(false);
    }
  };

  const handleCIClick = () => {
    history.push(`/apps/${appID}/webhook`);
  };

  const handleAppName = (e) => {
    setConfirmAppname(e.target.value);
    if (openDeleteAlert && e.target.value === app.name) {
      setDisableDelete(false);
    } else if (openDeleteAlert && e.target.value !== app.name) {
      setDisableDelete(true);
    }
  };

  const handleDomainChange = (e) => {
    const { name, value } = e.target;

    if (name === "domainName") {
      setDomainName(value);
    }
  };

  const handleDomainSubmit = () => {
    const projectID = app.project_id;

    if (!domainName) {
      setSubmitMessage("Provide a domain name.");
      return;
    }

    let updatePayload = { ...{ custom_domain: domainName } };

    let error = validateDomain(domainName.toLowerCase());

    if (error) {
      setSubmitMessage(error);
      return;
    } else {
      if (Object.keys(updatePayload).length === 0) {
        setSubmitMessage("Provide a domain name.");
        return;
      } else {
        dispatch(updateApp(appID, updatePayload))
          .then(() => {
            dispatch(clearUpdateAppState());
            setSubmitMessage("Update successful.");
            window.location.href = `/projects/${projectID}/apps/${appID}/settings`;
          })
          .catch((error) => {
            dispatch(clearUpdateAppState());
            console.error("Update failed:", error);
            setSubmitMessage("Update failed, please try again.");
          });
      }
    }
  };

  const revertAppUrl = () => {
    const projectID = app.project_id;

    if (!app.has_custom_domain) {
      setSubmitMessage("Can't revert. Application has no custom domain.");
      return;
    }

    dispatch(revertUrl(appID))
      .then(() => {
        dispatch(clearUrlRevertState());
        setSubmitMessage("Application Url reverted successfully.");
        window.location.href = `/projects/${projectID}/apps/${appID}/settings`;
      })
      .catch((error) => {
        dispatch(clearUrlRevertState());
        console.error("Failed to revert url:", error);
        setSubmitMessage("Failed to revert url, please try again.");
      });
  };

  // Alerts
  const showDeleteAlert = () => {
    setOpenDeleteAlert(true);
  };
  const showDomainModal = () => {
    setDomainModal(true);
  };
  const showDisableModal = () => {
    setShowAppDisableModal(true);
  };
  const showAppRevisionModal = (revisionId) => {
    setRevisionId(revisionId);
    setRollBackConfirmationModal(true);
    setRevisingAppError("");
  };
  const hideAppRevisionModal = () => {
    setRollBackConfirmationModal(false);
  };

  // Toggles
  const togglePrivateImage = () => {
    setIsPrivateImage((prevIsPrivateImage) => !prevIsPrivateImage);
  };
  const toggleCustomDomain = () => {
    setIsCustomDomain((prevIsCustomDomain) => !prevIsCustomDomain);
  };
  const urlOnClick = () => {
    navigator.clipboard.writeText(app.url);
    setUrlChecked(true);
  };
  const internalUrlOnClick = () => {
    navigator.clipboard.writeText(app.internal_url);
    setInternalUrlChecked(true);
  };

  return (
    <DashboardLayout name={app?.name} header="Application Settings" short>
      <div className="SettingsContainer">
        {parentProject === "" && isRetrieving ? (
          <div className="NoResourcesMessage">
            <div className="SpinnerWrapper">
              <Spinner size="big" />
            </div>
          </div>
        ) : isFetched && !isRetrieving ? (
          <>
            <div className="AppPageLayout">
              <div className="APPSections">
                <div className="SectionTitle">Application Information</div>

                <div className="ApplicationTabsContainer">
                  <div className="ApplicationTabs">
                    {tabNames.map((tabName) => (
                      <TabItem
                        key={tabName}
                        tabName={tabName}
                        activeTab={activeTab}
                        setActiveTab={setActiveTab}
                      />
                    ))}
                  </div>
                  <div className="tab-content">
                    {activeTab === "General Details" && (
                      <GeneralDetailsTab
                        app={app}
                        parentProject={parentProject}
                      />
                    )}
                    {activeTab === "Image Settings" && (
                      <>
                        <ImageSettingsTab
                          app={app}
                          error={error}
                          newImage={newImage}
                          loading={isUpdating}
                          replicaOptions={replicaOptions}
                          isPrivateImage={isPrivateImage}
                          handleImageChange={handleImageChange}
                          togglePrivateImage={togglePrivateImage}
                          handleSelectReplicas={handleSelectReplicas}
                          handleImageSectionSubmit={handleImageSectionSubmit}
                          handleDockerCredentialsChange={(e) => {
                            handleDockerCredentialsChange(e);
                          }}
                        />
                      </>
                    )}
                    {activeTab === "Environment and Ports" && (
                      <EnvironmentAndPortsTab
                        app={app}
                        port={port}
                        error={error}
                        envVars={envVars}
                        varName={varName}
                        varValue={varValue}
                        envError={envError}
                        loading={isUpdating}
                        loadingIndex={loadingIndex}
                        addEnvVar={addEnvVar}
                        portError={portError}
                        entryCommand={entryCommand}
                        commandError={commandError}
                        handleEnvChange={handleEnvChange}
                        handleEnvSubmit={handleEnvSubmit}
                        removeExistingEnvVar={removeExistingEnvVar}
                        clearEnvError={() => setEnvError("")}
                        clearCommandError={() => {
                          setCommandError("");
                        }}
                        clearPortError={() => {
                          setPortError("");
                        }}
                      />
                    )}
                    {activeTab === "Domain and URLs" && (
                      <DomainAndUrlsTab
                        app={app}
                        updating={isUpdating}
                        reverting={isReverting}
                        urlOnClick={urlOnClick}
                        domainName={domainName}
                        urlChecked={urlChecked}
                        revertAppUrl={revertAppUrl}
                        loggedInUser={loggedInUser}
                        isCustomDomain={isCustomDomain}
                        showDomainModal={showDomainModal}
                        handleDomainChange={handleDomainChange}
                        handleDomainSubmit={handleDomainSubmit}
                        internalUrlChecked={internalUrlChecked}
                        toggleCustomDomain={toggleCustomDomain}
                        internalUrlOnClick={internalUrlOnClick}
                      />
                    )}
                  </div>
                </div>
              </div>

              {submitMessage && (
                <div className="SuccessMessageArea">
                  <Feedback type="success" message={submitMessage} />
                </div>
              )}

              <div className="APPSections">
                <div className="SectionTitle">Application Revisions</div>
                <div className={`AppRevisionsDetails BigCard`}>
                  {isFetching ? (
                    <div className="ResourceSpinnerWrapper">
                      <Spinner size="big" />
                    </div>
                  ) : (
                    <RevisionsList
                      revisions={revisions}
                      onRollbackClick={showAppRevisionModal}
                    />
                  )}
                </div>

                {pagination?.pages > 1 && (
                  <div className="AdminPaginationSection">
                    <Pagination
                      total={pagination.pages}
                      current={currentPage}
                      onPageChange={handlePageChange}
                    />
                  </div>
                )}
              </div>

              <>
                <div className="SectionTitle">Manage Application</div>
                <div className="ProjectInstructions">
                  <div className="MemberBody">
                    <SettingsActionRow
                      title="Setup CI/CD"
                      content="Setup continuous integration for your application"
                      buttonLabel="Set up CI/CD"
                      buttonColor="primary"
                      onButtonClick={handleCIClick}
                    />
                    <SettingsActionRow
                      title={`${
                        app?.disabled ? "Enable" : "Disable"
                      } Application`}
                      content={
                        app?.disabled
                          ? "This will allow access to application and its resources"
                          : "This will temporarily disable your application and its resources"
                      }
                      buttonLabel={app?.disabled ? "Enable" : "Disable"}
                      buttonColor={app?.disabled ? "primary" : "red"}
                      onButtonClick={showDisableModal}
                    />
                    <SettingsActionRow
                      title="Delete Application"
                      content="This will permanently delete your application"
                      buttonLabel="Delete App"
                      buttonColor="red"
                      onButtonClick={showDeleteAlert}
                    />
                  </div>
                </div>
              </>

              {/* Rollback Modal */}
              <SettingsModal
                showModal={rollBackConfirmationModal}
                onClickAway={hideAppRevisionModal}
              >
                <RollbackConfirmationContent
                  onConfirm={() => rollbackApp(revisionId)}
                  onCancel={hideAppRevisionModal}
                  loading={revisingApp}
                  error={revisingAppError}
                />
              </SettingsModal>

              {/* Delete App Modal */}
              <SettingsModal
                showModal={openDeleteAlert}
                onClickAway={() => setOpenDeleteAlert(false)}
              >
                <DeleteAppContent
                  app={app}
                  verifiedName={ConfirmAppname}
                  handleAppName={handleAppName}
                  loading={isDeleting}
                  error={message}
                  disableDelete={disableDelete}
                  onConfirm={(e) => handleDeleteApp(e, appID)}
                  onCancel={() => setOpenDeleteAlert(false)}
                />
              </SettingsModal>

              {/* Disable App Modal */}
              <SettingsModal
                showModal={showAppDisableModal}
                onClickAway={() => setShowAppDisableModal(false)}
              >
                <DisableModalContent
                  item={{
                    name: app?.name,
                    disabled: app?.disabled,
                    type: "app",
                  }}
                  disableProgress={appDisableProgress}
                  handleDisableButtonClick={handleEnableButtonClick}
                  hideDisableAlert={() => setShowAppDisableModal(false)}
                  message={message}
                  isFailed={isFailed}
                />
              </SettingsModal>

              {/* Domain Instructions Modal */}
              <SettingsModal
                showModal={domainModal}
                onClickAway={() => setDomainModal(false)}
              >
                <DomainInstructionsContent
                  onClose={() => setDomainModal(false)}
                />
              </SettingsModal>
            </div>
          </>
        ) : (
          !isRetrieving &&
          !isFetched && (
            <div className="NoResourcesMessage">
              {errorMessage
                ? errorMessage
                : "Oops! Something went wrong! Failed to retrieve app information."}
            </div>
          )
        )}
      </div>
    </DashboardLayout>
  );
};

export default AppSettingsPage;