diff --git a/dashboard/src/lib/hooks/useAppValidation.ts b/dashboard/src/lib/hooks/useAppValidation.ts index 8b677a67b9..3ad2b8ab0c 100644 --- a/dashboard/src/lib/hooks/useAppValidation.ts +++ b/dashboard/src/lib/hooks/useAppValidation.ts @@ -10,6 +10,12 @@ import api from "shared/api"; import { match } from "ts-pattern"; import { z } from "zod"; +export type AppValidationResult = { + validatedAppProto: PorterApp; + variables: Record; + secrets: Record; +}; + export const useAppValidation = ({ deploymentTargetID, creating = false, @@ -19,13 +25,6 @@ export const useAppValidation = ({ }) => { const { currentProject, currentCluster } = useContext(Context); - const removedEnvKeys = ( - current: Record, - previous: Record - ) => { - return Object.keys(previous).filter((key) => !current[key]); - }; - const getBranchHead = async ({ projectID, source, @@ -62,7 +61,10 @@ export const useAppValidation = ({ }; const validateApp = useCallback( - async (data: PorterAppFormData, prevRevision?: PorterApp) => { + async ( + data: PorterAppFormData, + prevRevision?: PorterApp + ): Promise => { if (!currentProject || !currentCluster) { throw new Error("No project or cluster selected"); } diff --git a/dashboard/src/lib/porter-apps/index.ts b/dashboard/src/lib/porter-apps/index.ts index e5bee91bef..d0caa3b27f 100644 --- a/dashboard/src/lib/porter-apps/index.ts +++ b/dashboard/src/lib/porter-apps/index.ts @@ -81,6 +81,7 @@ export const porterAppFormValidator = z.object({ app: clientAppValidator, source: sourceValidator, deletions: deletionValidator, + redeployOnSave: z.boolean().default(false), }); export type PorterAppFormData = z.infer; diff --git a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx b/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx index 66f763068e..36745b1f18 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { PorterAppFormData, @@ -13,7 +13,10 @@ import TabSelector from "components/TabSelector"; import { useHistory } from "react-router"; import { match } from "ts-pattern"; import Overview from "./tabs/Overview"; -import { useAppValidation } from "lib/hooks/useAppValidation"; +import { + AppValidationResult, + useAppValidation, +} from "lib/hooks/useAppValidation"; import api from "shared/api"; import { useQueryClient } from "@tanstack/react-query"; import Settings from "./tabs/Settings"; @@ -32,6 +35,7 @@ import EventFocusView from "./tabs/activity-feed/events/focus-views/EventFocusVi import { z } from "zod"; import { PorterApp } from "@porter-dev/api-contracts"; import JobsTab from "./tabs/JobsTab"; +import ConfirmRedeployModal from "./ConfirmRedeployModal"; // commented out tabs are not yet implemented // will be included as support is available based on data from app revisions rather than helm releases @@ -58,7 +62,7 @@ type AppDataContainerProps = { const AppDataContainer: React.FC = ({ tabParam }) => { const history = useHistory(); const queryClient = useQueryClient(); - const [redeployOnSave, setRedeployOnSave] = useState(false); + const [confirmDeployModalOpen, setConfirmDeployModalOpen] = useState(false); const { porterApp, @@ -158,13 +162,26 @@ const AppDataContainer: React.FC = ({ tabParam }) => { return dirty.every((f) => f === "expanded" || f === "id"); }, [isDirty, JSON.stringify(dirtyFields)]); + const buildIsDirty = useMemo(() => { + if (!isDirty) return false; + + // get all entries in entire dirtyFields object that are true + const dirty = getAllDirtyFields(dirtyFields.app?.build ?? {}); + return dirty.some((f) => f); + }, [isDirty, JSON.stringify(dirtyFields)]); + const onSubmit = handleSubmit(async (data) => { try { - const { validatedAppProto, variables, secrets } = await validateApp( + const { variables, secrets, validatedAppProto } = await validateApp( data, latestProto ); + if (buildIsDirty && !data.redeployOnSave) { + setConfirmDeployModalOpen(true); + return; + } + // updates the default env group associated with this app to store app specific env vars const res = await api.updateEnvironmentGroupV2( "", @@ -213,11 +230,7 @@ const AppDataContainer: React.FC = ({ tabParam }) => { } ); - if ( - redeployOnSave && - latestSource.type === "github" && - dirtyFields.app?.build - ) { + if (latestSource.type === "github" && buildIsDirty) { const res = await api.reRunGHWorkflow( "", {}, @@ -235,8 +248,6 @@ const AppDataContainer: React.FC = ({ tabParam }) => { if (res.data != null) { window.open(res.data, "_blank", "noreferrer"); } - - setRedeployOnSave(false); } await queryClient.invalidateQueries([ @@ -260,6 +271,31 @@ const AppDataContainer: React.FC = ({ tabParam }) => { } catch (err) {} }); + const cancelRedeploy = useCallback(() => { + reset({ + app: clientAppFromProto({ + proto: previewRevision + ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto)) + : latestProto, + overrides: servicesFromYaml, + variables: appEnv?.variables, + secrets: appEnv?.secret_variables, + }), + source: latestSource, + deletions: { + envGroupNames: [], + serviceNames: [], + }, + redeployOnSave: false, + }); + setConfirmDeployModalOpen(false); + }, [previewRevision, latestProto, servicesFromYaml, appEnv, latestSource]); + + const finalizeDeploy = useCallback(() => { + setConfirmDeployModalOpen(false); + onSubmit(); + }, [onSubmit, setConfirmDeployModalOpen]); + useEffect(() => { reset({ app: clientAppFromProto({ @@ -275,6 +311,7 @@ const AppDataContainer: React.FC = ({ tabParam }) => { envGroupNames: [], serviceNames: [], }, + redeployOnSave: false, }); }, [ servicesFromYaml, @@ -306,7 +343,12 @@ const AppDataContainer: React.FC = ({ tabParam }) => { loadingText={"Updating..."} height={"10px"} status={isSubmitting ? "loading" : ""} - disabled={isSubmitting} + disabled={ + isSubmitting || + latestRevision.status === "CREATED" || + latestRevision.status === "AWAITING_BUILD_ARTIFACT" + } + disabledTooltipMessage="Please wait for the build to complete before updating the app" > @@ -353,12 +395,7 @@ const AppDataContainer: React.FC = ({ tabParam }) => { {match(currentTab) .with("activity", () => ) .with("overview", () => ) - .with("build-settings", () => ( - - )) + .with("build-settings", () => ) .with("environment", () => ( )) @@ -370,6 +407,13 @@ const AppDataContainer: React.FC = ({ tabParam }) => { .otherwise(() => null)} + {confirmDeployModalOpen ? ( + + ) : null} ); }; diff --git a/dashboard/src/main/home/app-dashboard/app-view/ConfirmRedeployModal.tsx b/dashboard/src/main/home/app-dashboard/app-view/ConfirmRedeployModal.tsx new file mode 100644 index 0000000000..225c1e39c7 --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/app-view/ConfirmRedeployModal.tsx @@ -0,0 +1,63 @@ +import Button from "components/porter/Button"; +import Modal from "components/porter/Modal"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { PorterAppFormData } from "lib/porter-apps"; +import React, { Dispatch, SetStateAction } from "react"; +import { useFormContext } from "react-hook-form"; +import styled from "styled-components"; + +type Props = { + cancelRedeploy: () => void; + setOpen: Dispatch>; + finalizeDeploy: () => void; +}; + +const ConfirmRedeployModal: React.FC = ({ + cancelRedeploy, + setOpen, + finalizeDeploy, +}) => { + const { setValue } = useFormContext(); + + return ( + setOpen(false)}> + Confirm deploy + + + A change to your application's build settings has been detected. + Confirming this change will trigger a rerun of your application's CI + pipeline. + + + + + + + + + ); +}; + +export default ConfirmRedeployModal; + +const ButtonContainer = styled.div` + display: flex; + justify-content: flex-end; + column-gap: 0.5rem; +`; diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx index 37ac7dac1e..5b20ff0397 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx @@ -1,23 +1,13 @@ -import React, { Dispatch, SetStateAction, useMemo } from "react"; +import React, { useMemo } from "react"; import RepoSettings from "../../create-app/RepoSettings"; import { useFormContext } from "react-hook-form"; import { PorterAppFormData } from "lib/porter-apps"; import { useLatestRevision } from "../LatestRevisionContext"; import Spacer from "components/porter/Spacer"; -import Checkbox from "components/porter/Checkbox"; -import Text from "components/porter/Text"; import Button from "components/porter/Button"; import Error from "components/porter/Error"; -type Props = { - redeployOnSave: boolean; - setRedeployOnSave: Dispatch>; -}; - -const BuildSettings: React.FC = ({ - redeployOnSave, - setRedeployOnSave, -}) => { +const BuildSettings: React.FC = () => { const { watch, formState: { isSubmitting, errors }, @@ -52,13 +42,6 @@ const BuildSettings: React.FC = ({ appExists /> - setRedeployOnSave(!redeployOnSave)} - > - Re-run build and deploy on save - - diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx index fae073c5e5..6a0804d987 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx @@ -87,6 +87,7 @@ const Environment: React.FC = ({ latestSource }) => { latestRevision.status === "CREATED" || latestRevision.status === "AWAITING_BUILD_ARTIFACT" } + disabledTooltipMessage="Please wait for the build to complete before updating environment variables" > Update app diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx index dc86c47fb8..9577292738 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx @@ -16,7 +16,14 @@ import { useAppStatus } from "lib/hooks/useAppStatus"; const Overview: React.FC = () => { const { formState } = useFormContext(); - const { porterApp, latestProto, latestRevision, projectId, clusterId, deploymentTarget } = useLatestRevision(); + const { + porterApp, + latestProto, + latestRevision, + projectId, + clusterId, + deploymentTarget, + } = useLatestRevision(); const { serviceVersionStatus } = useAppStatus({ projectId, @@ -77,6 +84,7 @@ const Overview: React.FC = () => { latestRevision.status === "CREATED" || latestRevision.status === "AWAITING_BUILD_ARTIFACT" } + disabledTooltipMessage="Please wait for the build to complete before updating services" > Update app diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx index cfacce6f73..3cca7bcf1e 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx @@ -36,6 +36,9 @@ const BuildpackSettings: React.FC = ({ source, autoDetectionDisabled, }) => { + const [enableAutoDetection, setEnableAutoDetection] = useState( + !autoDetectionDisabled + ); const [stackOptions, setStackOptions] = useState< { label: string; value: string }[] >([]); @@ -80,7 +83,7 @@ const BuildpackSettings: React.FC = ({ return detectedBuildpacks; }, { - enabled: !autoDetectionDisabled, + enabled: enableAutoDetection, } ); @@ -93,7 +96,7 @@ const BuildpackSettings: React.FC = ({ ); useEffect(() => { - if (autoDetectionDisabled) { + if (!enableAutoDetection) { // in this case, we are not detecting buildpacks, so we just populate based on the DB if (build.builder) { setValue("app.build.builder", build.builder); @@ -154,7 +157,7 @@ const BuildpackSettings: React.FC = ({ detectedBuilder = defaultBuilder.builders[0]; } - if (!autoDetectionDisabled) { + if (enableAutoDetection) { setValue("app.build.builder", detectedBuilder); replace( defaultBuilder.detected.map((bp) => ({ @@ -193,7 +196,7 @@ const BuildpackSettings: React.FC = ({ /> )} - {!autoDetectionDisabled && status === "error" && ( + {enableAutoDetection && status === "error" && ( <> = ({