Skip to content

feat(site): warn user if they leave the editor without publishing #12406

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions site/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { QueryClient, QueryClientProvider } from "react-query";
import { type FC, type ReactNode, useEffect, useState } from "react";
import { HelmetProvider } from "react-helmet-async";
import { AppRouter } from "./AppRouter";
import { router } from "./router";
import { ThemeProvider } from "./contexts/ThemeProvider";
import { AuthProvider } from "./contexts/auth/AuthProvider";
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary";
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar";
import "./theme/globalFonts";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { RouterProvider } from "react-router-dom";

const defaultQueryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -61,7 +62,7 @@ export const App: FC = () => {
return (
<AppProviders>
<ErrorBoundary>
<AppRouter />
<RouterProvider router={router} />
</ErrorBoundary>
</AppProviders>
);
Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/TemplateVersionEditorPage/MonacoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { MONOSPACE_FONT_FAMILY } from "theme/constants";

loader.config({ monaco });

interface MonacoEditorProps {
export interface MonacoEditorProps {
value?: string;
path?: string;
onChange?: (value: string) => void;
Expand Down
57 changes: 48 additions & 9 deletions site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import CreateIcon from "@mui/icons-material/AddOutlined";
import { Link as RouterLink } from "react-router-dom";
import {
Link as RouterLink,
unstable_usePrompt as usePrompt,
} from "react-router-dom";
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import AlertTitle from "@mui/material/AlertTitle";
Expand Down Expand Up @@ -65,8 +68,8 @@ export interface TemplateVersionEditorProps {
defaultFileTree: FileTree;
buildLogs?: ProvisionerJobLog[];
resources?: WorkspaceResource[];
disablePreview?: boolean;
disableUpdate?: boolean;
isBuilding: boolean;
canPublish: boolean;
onPreview: (files: FileTree) => Promise<void>;
onPublish: () => void;
onConfirmPublish: (data: PublishVersionData) => void;
Expand All @@ -88,8 +91,8 @@ export interface TemplateVersionEditorProps {
}

export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
disablePreview,
disableUpdate,
isBuilding,
canPublish,
template,
templateVersion,
defaultFileTree,
Expand Down Expand Up @@ -179,6 +182,10 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
}
}, [buildLogs]);

useLeaveSiteWarning(canPublish);

const canBuild = !isBuilding && dirty;

return (
<>
<div css={{ height: "100%", display: "flex", flexDirection: "column" }}>
Expand Down Expand Up @@ -242,7 +249,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
borderLeft: "1px solid #FFF",
},
}}
disabled={disablePreview}
disabled={!canBuild}
>
<TopbarButton
startIcon={
Expand All @@ -251,7 +258,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
/>
}
title="Build template (Ctrl + Enter)"
disabled={disablePreview}
disabled={!canBuild}
onClick={async () => {
await triggerPreview();
}}
Expand All @@ -276,7 +283,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({

<TopbarButton
variant="contained"
disabled={dirty || disableUpdate}
disabled={dirty || !canPublish}
onClick={onPublish}
>
Publish
Expand Down Expand Up @@ -540,7 +547,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
</button>

<button
disabled={disableUpdate}
disabled={!canPublish}
css={styles.tab}
className={selectedTab === "resources" ? "active" : ""}
onClick={() => {
Expand Down Expand Up @@ -649,6 +656,38 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
);
};

const useLeaveSiteWarning = (enabled: boolean) => {
const MESSAGE =
"You have unpublished changes. Are you sure you want to leave?";

// This works for regular browser actions like close tab and back button
useEffect(() => {
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (enabled) {
e.preventDefault();
return MESSAGE;
}
};

window.addEventListener("beforeunload", onBeforeUnload);

return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [enabled]);

// This is used for react router navigation that is not triggered by the
// browser
usePrompt({
message: MESSAGE,
when: ({ nextLocation }) => {
// We need to check the path because we change the URL when new template
// version is created during builds
return enabled && !nextLocation.pathname.endsWith("/edit");
},
});
};

const styles = {
tab: (theme) => ({
"&:not(:disabled)": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { server } from "testHelpers/server";
import { rest } from "msw";
import { AppProviders } from "App";
import { TemplateVersion } from "api/typesGenerated";
import { MonacoEditorProps } from "./MonacoEditor";

// For some reason this component in Jest is throwing a MUI style warning so,
// since we don't need it for this test, we can mock it out
Expand All @@ -35,7 +36,15 @@ jest.mock(
// Occasionally, Jest encounters HTML5 canvas errors. As the MonacoEditor is not
// required for these tests, we can safely mock it.
jest.mock("pages/TemplateVersionEditorPage/MonacoEditor", () => ({
MonacoEditor: () => <div />,
MonacoEditor: (props: MonacoEditorProps) => (
<textarea
data-testid="monaco-editor"
value={props.value}
onChange={(e) => {
props.onChange?.(e.target.value);
}}
/>
),
}));

const renderTemplateEditorPage = () => {
Expand All @@ -51,6 +60,11 @@ const renderTemplateEditorPage = () => {
});
};

const typeOnEditor = async (value: string, user: UserEvent) => {
const editor = await screen.findByTestId("monaco-editor");
await user.type(editor, value);
};

const buildTemplateVersion = async (
templateVersion: TemplateVersion,
user: UserEvent,
Expand Down Expand Up @@ -94,6 +108,8 @@ test("Use custom name, message and set it as active when publishing", async () =
id: "new-version-id",
name: "new-version",
};

await typeOnEditor("new content", user);
await buildTemplateVersion(newTemplateVersion, user, topbar);

// Publish
Expand Down Expand Up @@ -138,6 +154,8 @@ test("Do not mark as active if promote is not checked", async () => {
id: "new-version-id",
name: "new-version",
};

await typeOnEditor("new content", user);
await buildTemplateVersion(newTemplateVersion, user, topbar);

// Publish
Expand Down Expand Up @@ -181,6 +199,8 @@ test("Patch request is not send when there are no changes", async () => {
name: "new-version",
message: "",
};

await typeOnEditor("new content", user);
await buildTemplateVersion(newTemplateVersion, user, topbar);

// Publish
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
createTemplateVersion,
resources,
templateByName,
templateByNameKey,
templateVersionByName,
templateVersionVariables,
} from "api/queries/templates";
Expand Down Expand Up @@ -68,6 +69,11 @@ export const TemplateVersionEditorPage: FC = () => {
const [isPublishingDialogOpen, setIsPublishingDialogOpen] = useState(false);
const publishVersionMutation = useMutation({
mutationFn: publishVersion,
onSuccess: async () => {
await queryClient.invalidateQueries(
templateByNameKey(orgId, templateName),
);
},
});
const [lastSuccessfulPublishedVersion, setLastSuccessfulPublishedVersion] =
useState<TemplateVersion>();
Expand Down Expand Up @@ -179,16 +185,16 @@ export const TemplateVersionEditorPage: FC = () => {
`/templates/${templateName}/workspace?${params.toString()}`,
);
}}
disablePreview={
templateVersionQuery.data.job.status === "running" ||
templateVersionQuery.data.job.status === "pending" ||
isBuilding={
createTemplateVersionMutation.isLoading ||
uploadFileMutation.isLoading
uploadFileMutation.isLoading ||
templateVersionQuery.data.job.status === "running" ||
templateVersionQuery.data.job.status === "pending"
}
disableUpdate={
templateVersionQuery.data.job.status !== "succeeded" ||
templateVersionQuery.data.name ===
lastSuccessfulPublishedVersion?.name
canPublish={
templateVersionQuery.data.job.status === "succeeded" &&
templateQuery.data.active_version_id !==
templateVersionQuery.data.id
}
resources={resourcesQuery.data}
buildLogs={logs}
Expand Down
Loading