Skip to content

Commit bc30c9c

Browse files
feat(site): warn user if they leave the editor without publishing (#12406)
1 parent 61bd341 commit bc30c9c

File tree

6 files changed

+247
-191
lines changed

6 files changed

+247
-191
lines changed

site/src/App.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { QueryClient, QueryClientProvider } from "react-query";
22
import { type FC, type ReactNode, useEffect, useState } from "react";
33
import { HelmetProvider } from "react-helmet-async";
4-
import { AppRouter } from "./AppRouter";
4+
import { router } from "./router";
55
import { ThemeProvider } from "./contexts/ThemeProvider";
66
import { AuthProvider } from "./contexts/auth/AuthProvider";
77
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary";
88
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar";
99
import "./theme/globalFonts";
1010
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
11+
import { RouterProvider } from "react-router-dom";
1112

1213
const defaultQueryClient = new QueryClient({
1314
defaultOptions: {
@@ -61,7 +62,7 @@ export const App: FC = () => {
6162
return (
6263
<AppProviders>
6364
<ErrorBoundary>
64-
<AppRouter />
65+
<RouterProvider router={router} />
6566
</ErrorBoundary>
6667
</AppProviders>
6768
);

site/src/pages/TemplateVersionEditorPage/MonacoEditor.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { MONOSPACE_FONT_FAMILY } from "theme/constants";
66

77
loader.config({ monaco });
88

9-
interface MonacoEditorProps {
9+
export interface MonacoEditorProps {
1010
value?: string;
1111
path?: string;
1212
onChange?: (value: string) => void;

site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx

+48-9
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import Button from "@mui/material/Button";
22
import IconButton from "@mui/material/IconButton";
33
import Tooltip from "@mui/material/Tooltip";
44
import CreateIcon from "@mui/icons-material/AddOutlined";
5-
import { Link as RouterLink } from "react-router-dom";
5+
import {
6+
Link as RouterLink,
7+
unstable_usePrompt as usePrompt,
8+
} from "react-router-dom";
69
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
710
import { type FC, useCallback, useEffect, useRef, useState } from "react";
811
import AlertTitle from "@mui/material/AlertTitle";
@@ -65,8 +68,8 @@ export interface TemplateVersionEditorProps {
6568
defaultFileTree: FileTree;
6669
buildLogs?: ProvisionerJobLog[];
6770
resources?: WorkspaceResource[];
68-
disablePreview?: boolean;
69-
disableUpdate?: boolean;
71+
isBuilding: boolean;
72+
canPublish: boolean;
7073
onPreview: (files: FileTree) => Promise<void>;
7174
onPublish: () => void;
7275
onConfirmPublish: (data: PublishVersionData) => void;
@@ -88,8 +91,8 @@ export interface TemplateVersionEditorProps {
8891
}
8992

9093
export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
91-
disablePreview,
92-
disableUpdate,
94+
isBuilding,
95+
canPublish,
9396
template,
9497
templateVersion,
9598
defaultFileTree,
@@ -179,6 +182,10 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
179182
}
180183
}, [buildLogs]);
181184

185+
useLeaveSiteWarning(canPublish);
186+
187+
const canBuild = !isBuilding && dirty;
188+
182189
return (
183190
<>
184191
<div css={{ height: "100%", display: "flex", flexDirection: "column" }}>
@@ -242,7 +249,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
242249
borderLeft: "1px solid #FFF",
243250
},
244251
}}
245-
disabled={disablePreview}
252+
disabled={!canBuild}
246253
>
247254
<TopbarButton
248255
startIcon={
@@ -251,7 +258,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
251258
/>
252259
}
253260
title="Build template (Ctrl + Enter)"
254-
disabled={disablePreview}
261+
disabled={!canBuild}
255262
onClick={async () => {
256263
await triggerPreview();
257264
}}
@@ -276,7 +283,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
276283

277284
<TopbarButton
278285
variant="contained"
279-
disabled={dirty || disableUpdate}
286+
disabled={dirty || !canPublish}
280287
onClick={onPublish}
281288
>
282289
Publish
@@ -540,7 +547,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
540547
</button>
541548

542549
<button
543-
disabled={disableUpdate}
550+
disabled={!canPublish}
544551
css={styles.tab}
545552
className={selectedTab === "resources" ? "active" : ""}
546553
onClick={() => {
@@ -649,6 +656,38 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
649656
);
650657
};
651658

659+
const useLeaveSiteWarning = (enabled: boolean) => {
660+
const MESSAGE =
661+
"You have unpublished changes. Are you sure you want to leave?";
662+
663+
// This works for regular browser actions like close tab and back button
664+
useEffect(() => {
665+
const onBeforeUnload = (e: BeforeUnloadEvent) => {
666+
if (enabled) {
667+
e.preventDefault();
668+
return MESSAGE;
669+
}
670+
};
671+
672+
window.addEventListener("beforeunload", onBeforeUnload);
673+
674+
return () => {
675+
window.removeEventListener("beforeunload", onBeforeUnload);
676+
};
677+
}, [enabled]);
678+
679+
// This is used for react router navigation that is not triggered by the
680+
// browser
681+
usePrompt({
682+
message: MESSAGE,
683+
when: ({ nextLocation }) => {
684+
// We need to check the path because we change the URL when new template
685+
// version is created during builds
686+
return enabled && !nextLocation.pathname.endsWith("/edit");
687+
},
688+
});
689+
};
690+
652691
const styles = {
653692
tab: (theme) => ({
654693
"&:not(:disabled)": {

site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx

+21-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { server } from "testHelpers/server";
2222
import { rest } from "msw";
2323
import { AppProviders } from "App";
2424
import { TemplateVersion } from "api/typesGenerated";
25+
import { MonacoEditorProps } from "./MonacoEditor";
2526

2627
// For some reason this component in Jest is throwing a MUI style warning so,
2728
// since we don't need it for this test, we can mock it out
@@ -35,7 +36,15 @@ jest.mock(
3536
// Occasionally, Jest encounters HTML5 canvas errors. As the MonacoEditor is not
3637
// required for these tests, we can safely mock it.
3738
jest.mock("pages/TemplateVersionEditorPage/MonacoEditor", () => ({
38-
MonacoEditor: () => <div />,
39+
MonacoEditor: (props: MonacoEditorProps) => (
40+
<textarea
41+
data-testid="monaco-editor"
42+
value={props.value}
43+
onChange={(e) => {
44+
props.onChange?.(e.target.value);
45+
}}
46+
/>
47+
),
3948
}));
4049

4150
const renderTemplateEditorPage = () => {
@@ -51,6 +60,11 @@ const renderTemplateEditorPage = () => {
5160
});
5261
};
5362

63+
const typeOnEditor = async (value: string, user: UserEvent) => {
64+
const editor = await screen.findByTestId("monaco-editor");
65+
await user.type(editor, value);
66+
};
67+
5468
const buildTemplateVersion = async (
5569
templateVersion: TemplateVersion,
5670
user: UserEvent,
@@ -94,6 +108,8 @@ test("Use custom name, message and set it as active when publishing", async () =
94108
id: "new-version-id",
95109
name: "new-version",
96110
};
111+
112+
await typeOnEditor("new content", user);
97113
await buildTemplateVersion(newTemplateVersion, user, topbar);
98114

99115
// Publish
@@ -138,6 +154,8 @@ test("Do not mark as active if promote is not checked", async () => {
138154
id: "new-version-id",
139155
name: "new-version",
140156
};
157+
158+
await typeOnEditor("new content", user);
141159
await buildTemplateVersion(newTemplateVersion, user, topbar);
142160

143161
// Publish
@@ -181,6 +199,8 @@ test("Patch request is not send when there are no changes", async () => {
181199
name: "new-version",
182200
message: "",
183201
};
202+
203+
await typeOnEditor("new content", user);
184204
await buildTemplateVersion(newTemplateVersion, user, topbar);
185205

186206
// Publish

site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx

+14-8
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
createTemplateVersion,
1515
resources,
1616
templateByName,
17+
templateByNameKey,
1718
templateVersionByName,
1819
templateVersionVariables,
1920
} from "api/queries/templates";
@@ -68,6 +69,11 @@ export const TemplateVersionEditorPage: FC = () => {
6869
const [isPublishingDialogOpen, setIsPublishingDialogOpen] = useState(false);
6970
const publishVersionMutation = useMutation({
7071
mutationFn: publishVersion,
72+
onSuccess: async () => {
73+
await queryClient.invalidateQueries(
74+
templateByNameKey(orgId, templateName),
75+
);
76+
},
7177
});
7278
const [lastSuccessfulPublishedVersion, setLastSuccessfulPublishedVersion] =
7379
useState<TemplateVersion>();
@@ -179,16 +185,16 @@ export const TemplateVersionEditorPage: FC = () => {
179185
`/templates/${templateName}/workspace?${params.toString()}`,
180186
);
181187
}}
182-
disablePreview={
183-
templateVersionQuery.data.job.status === "running" ||
184-
templateVersionQuery.data.job.status === "pending" ||
188+
isBuilding={
185189
createTemplateVersionMutation.isLoading ||
186-
uploadFileMutation.isLoading
190+
uploadFileMutation.isLoading ||
191+
templateVersionQuery.data.job.status === "running" ||
192+
templateVersionQuery.data.job.status === "pending"
187193
}
188-
disableUpdate={
189-
templateVersionQuery.data.job.status !== "succeeded" ||
190-
templateVersionQuery.data.name ===
191-
lastSuccessfulPublishedVersion?.name
194+
canPublish={
195+
templateVersionQuery.data.job.status === "succeeded" &&
196+
templateQuery.data.active_version_id !==
197+
templateVersionQuery.data.id
192198
}
193199
resources={resourcesQuery.data}
194200
buildLogs={logs}

0 commit comments

Comments
 (0)