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

This commit is contained in:
Bruno Quaresma 2024-03-05 16:55:23 -03:00 committed by GitHub
parent 61bd341a36
commit bc30c9c013
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 273 additions and 217 deletions

View File

@ -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: {
@ -61,7 +62,7 @@ export const App: FC = () => {
return (
<AppProviders>
<ErrorBoundary>
<AppRouter />
<RouterProvider router={router} />
</ErrorBoundary>
</AppProviders>
);

View File

@ -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;

View File

@ -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";
@ -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;
@ -88,8 +91,8 @@ export interface TemplateVersionEditorProps {
}
export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
disablePreview,
disableUpdate,
isBuilding,
canPublish,
template,
templateVersion,
defaultFileTree,
@ -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" }}>
@ -242,7 +249,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
borderLeft: "1px solid #FFF",
},
}}
disabled={disablePreview}
disabled={!canBuild}
>
<TopbarButton
startIcon={
@ -251,7 +258,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
/>
}
title="Build template (Ctrl + Enter)"
disabled={disablePreview}
disabled={!canBuild}
onClick={async () => {
await triggerPreview();
}}
@ -276,7 +283,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
<TopbarButton
variant="contained"
disabled={dirty || disableUpdate}
disabled={dirty || !canPublish}
onClick={onPublish}
>
Publish
@ -540,7 +547,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
</button>
<button
disabled={disableUpdate}
disabled={!canPublish}
css={styles.tab}
className={selectedTab === "resources" ? "active" : ""}
onClick={() => {
@ -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)": {

View File

@ -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
@ -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 = () => {
@ -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,
@ -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
@ -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
@ -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

View File

@ -14,6 +14,7 @@ import {
createTemplateVersion,
resources,
templateByName,
templateByNameKey,
templateVersionByName,
templateVersionVariables,
} from "api/queries/templates";
@ -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>();
@ -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}

View File

@ -1,13 +1,13 @@
import { type FC, lazy, Suspense } from "react";
import { Suspense, lazy } from "react";
import {
Route,
Routes,
BrowserRouter as Router,
createBrowserRouter,
Navigate,
createRoutesFromChildren,
Outlet,
} from "react-router-dom";
import { DashboardLayout } from "./modules/dashboard/DashboardLayout";
import { RequireAuth } from "./contexts/auth/RequireAuth";
import { FullScreenLoader } from "./components/Loader/FullScreenLoader";
import AuditPage from "./pages/AuditPage/AuditPage";
import { DeploySettingsLayout } from "./pages/DeploySettingsPage/DeploySettingsLayout";
import LoginPage from "./pages/LoginPage/LoginPage";
@ -21,6 +21,7 @@ import WorkspacesPage from "./pages/WorkspacesPage/WorkspacesPage";
import UserSettingsLayout from "./pages/UserSettingsPage/Layout";
import { TemplateSettingsLayout } from "./pages/TemplateSettingsPage/TemplateSettingsLayout";
import { WorkspaceSettingsLayout } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
// Lazy load pages
// - Pages that are secondary, not in the main navigation or not usually accessed
@ -240,200 +241,189 @@ const ProvisionerDaemonsHealthPage = lazy(
() => import("./pages/HealthPage/ProvisionerDaemonsPage"),
);
export const AppRouter: FC = () => {
const RoutesWithSuspense = () => {
return (
<Suspense fallback={<FullScreenLoader />}>
<Router>
<Routes>
<Route path="login" element={<LoginPage />} />
<Route path="setup" element={<SetupPage />} />
{/* Dashboard routes */}
<Route element={<RequireAuth />}>
<Route element={<DashboardLayout />}>
<Route index element={<Navigate to="/workspaces" replace />} />
<Route
path="/external-auth/:provider"
element={<ExternalAuthPage />}
/>
<Route path="/workspaces" element={<WorkspacesPage />} />
<Route path="/starter-templates">
<Route index element={<StarterTemplatesPage />} />
<Route path=":exampleId" element={<StarterTemplatePage />} />
</Route>
<Route path="/templates">
<Route index element={<TemplatesPage />} />
<Route path="new" element={<CreateTemplatePage />} />
<Route path=":template">
<Route element={<TemplateLayout />}>
<Route index element={<TemplateSummaryPage />} />
<Route path="docs" element={<TemplateDocsPage />} />
<Route path="files" element={<TemplateFilesPage />} />
<Route path="versions" element={<TemplateVersionsPage />} />
<Route path="embed" element={<TemplateEmbedPage />} />
<Route path="insights" element={<TemplateInsightsPage />} />
</Route>
<Route path="workspace" element={<CreateWorkspacePage />} />
<Route path="settings" element={<TemplateSettingsLayout />}>
<Route index element={<TemplateSettingsPage />} />
<Route
path="permissions"
element={<TemplatePermissionsPage />}
/>
<Route
path="variables"
element={<TemplateVariablesPage />}
/>
<Route path="schedule" element={<TemplateSchedulePage />} />
</Route>
<Route path="versions">
<Route path=":version">
<Route index element={<TemplateVersionPage />} />
</Route>
</Route>
</Route>
</Route>
<Route path="/users">
<Route element={<UsersLayout />}>
<Route index element={<UsersPage />} />
</Route>
<Route path="create" element={<CreateUserPage />} />
</Route>
<Route path="/groups">
<Route element={<UsersLayout />}>
<Route index element={<GroupsPage />} />
</Route>
<Route path="create" element={<CreateGroupPage />} />
<Route path=":groupId" element={<GroupPage />} />
<Route
path=":groupId/settings"
element={<SettingsGroupPage />}
/>
</Route>
<Route path="/audit" element={<AuditPage />} />
<Route path="/deployment" element={<DeploySettingsLayout />}>
<Route path="general" element={<GeneralSettingsPage />} />
<Route path="licenses" element={<LicensesSettingsPage />} />
<Route path="licenses/add" element={<AddNewLicensePage />} />
<Route path="security" element={<SecuritySettingsPage />} />
<Route
path="observability"
element={<ObservabilitySettingsPage />}
/>
<Route path="appearance" element={<AppearanceSettingsPage />} />
<Route path="network" element={<NetworkSettingsPage />} />
<Route path="userauth" element={<UserAuthSettingsPage />} />
<Route
path="external-auth"
element={<ExternalAuthSettingsPage />}
/>
<Route path="oauth2-provider">
<Route index element={<NotFoundPage />} />
<Route path="apps">
<Route index element={<OAuth2AppsSettingsPage />} />
<Route path="add" element={<CreateOAuth2AppPage />} />
<Route path=":appId" element={<EditOAuth2AppPage />} />
</Route>
</Route>
<Route
path="workspace-proxies"
element={<WorkspaceProxyPage />}
/>
</Route>
<Route path="/settings" element={<UserSettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="appearance" element={<AppearancePage />} />
<Route path="schedule" element={<SchedulePage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route
path="external-auth"
element={<UserExternalAuthSettingsPage />}
/>
<Route
path="oauth2-provider"
element={<UserOAuth2ProviderSettingsPage />}
/>
<Route path="tokens">
<Route index element={<TokensPage />} />
<Route path="new" element={<CreateTokenPage />} />
</Route>
</Route>
{/* In order for the 404 page to work properly the routes that start with
top level parameter must be fully qualified. */}
<Route
path="/:username/:workspace/builds/:buildNumber"
element={<WorkspaceBuildPage />}
/>
<Route
path="/:username/:workspace/settings"
element={<WorkspaceSettingsLayout />}
>
<Route index element={<WorkspaceSettingsPage />} />
<Route
path="parameters"
element={<WorkspaceParametersPage />}
/>
<Route path="schedule" element={<WorkspaceSchedulePage />} />
</Route>
<Route path="/health" element={<HealthLayout />}>
<Route index element={<Navigate to="access-url" replace />} />
<Route path="access-url" element={<AccessURLPage />} />
<Route path="database" element={<DatabasePage />} />
<Route path="derp" element={<DERPPage />} />
<Route
path="derp/regions/:regionId"
element={<DERPRegionPage />}
/>
<Route path="websocket" element={<WebsocketPage />} />
<Route
path="workspace-proxy"
element={<WorkspaceProxyHealthPage />}
/>
<Route
path="provisioner-daemons"
element={<ProvisionerDaemonsHealthPage />}
/>
</Route>
{/* Using path="*"" means "match anything", so this route
acts like a catch-all for URLs that we don't have explicit
routes for. */}
<Route path="*" element={<NotFoundPage />} />
</Route>
{/* Pages that don't have the dashboard layout */}
<Route path="/:username/:workspace" element={<WorkspacePage />} />
<Route
path="/templates/:template/versions/:version/edit"
element={<TemplateVersionEditorPage />}
/>
<Route
path="/:username/:workspace/terminal"
element={<TerminalPage />}
/>
<Route path="/cli-auth" element={<CliAuthenticationPage />} />
<Route path="/icons" element={<IconsPage />} />
</Route>
</Routes>
</Router>
<Outlet />
</Suspense>
);
};
export const router = createBrowserRouter(
createRoutesFromChildren(
<Route element={<RoutesWithSuspense />}>
<Route path="login" element={<LoginPage />} />
<Route path="setup" element={<SetupPage />} />
{/* Dashboard routes */}
<Route element={<RequireAuth />}>
<Route element={<DashboardLayout />}>
<Route index element={<Navigate to="/workspaces" replace />} />
<Route
path="/external-auth/:provider"
element={<ExternalAuthPage />}
/>
<Route path="/workspaces" element={<WorkspacesPage />} />
<Route path="/starter-templates">
<Route index element={<StarterTemplatesPage />} />
<Route path=":exampleId" element={<StarterTemplatePage />} />
</Route>
<Route path="/templates">
<Route index element={<TemplatesPage />} />
<Route path="new" element={<CreateTemplatePage />} />
<Route path=":template">
<Route element={<TemplateLayout />}>
<Route index element={<TemplateSummaryPage />} />
<Route path="docs" element={<TemplateDocsPage />} />
<Route path="files" element={<TemplateFilesPage />} />
<Route path="versions" element={<TemplateVersionsPage />} />
<Route path="embed" element={<TemplateEmbedPage />} />
<Route path="insights" element={<TemplateInsightsPage />} />
</Route>
<Route path="workspace" element={<CreateWorkspacePage />} />
<Route path="settings" element={<TemplateSettingsLayout />}>
<Route index element={<TemplateSettingsPage />} />
<Route
path="permissions"
element={<TemplatePermissionsPage />}
/>
<Route path="variables" element={<TemplateVariablesPage />} />
<Route path="schedule" element={<TemplateSchedulePage />} />
</Route>
<Route path="versions">
<Route path=":version">
<Route index element={<TemplateVersionPage />} />
</Route>
</Route>
</Route>
</Route>
<Route path="/users">
<Route element={<UsersLayout />}>
<Route index element={<UsersPage />} />
</Route>
<Route path="create" element={<CreateUserPage />} />
</Route>
<Route path="/groups">
<Route element={<UsersLayout />}>
<Route index element={<GroupsPage />} />
</Route>
<Route path="create" element={<CreateGroupPage />} />
<Route path=":groupId" element={<GroupPage />} />
<Route path=":groupId/settings" element={<SettingsGroupPage />} />
</Route>
<Route path="/audit" element={<AuditPage />} />
<Route path="/deployment" element={<DeploySettingsLayout />}>
<Route path="general" element={<GeneralSettingsPage />} />
<Route path="licenses" element={<LicensesSettingsPage />} />
<Route path="licenses/add" element={<AddNewLicensePage />} />
<Route path="security" element={<SecuritySettingsPage />} />
<Route
path="observability"
element={<ObservabilitySettingsPage />}
/>
<Route path="appearance" element={<AppearanceSettingsPage />} />
<Route path="network" element={<NetworkSettingsPage />} />
<Route path="userauth" element={<UserAuthSettingsPage />} />
<Route
path="external-auth"
element={<ExternalAuthSettingsPage />}
/>
<Route path="oauth2-provider">
<Route index element={<NotFoundPage />} />
<Route path="apps">
<Route index element={<OAuth2AppsSettingsPage />} />
<Route path="add" element={<CreateOAuth2AppPage />} />
<Route path=":appId" element={<EditOAuth2AppPage />} />
</Route>
</Route>
<Route path="workspace-proxies" element={<WorkspaceProxyPage />} />
</Route>
<Route path="/settings" element={<UserSettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="appearance" element={<AppearancePage />} />
<Route path="schedule" element={<SchedulePage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route
path="external-auth"
element={<UserExternalAuthSettingsPage />}
/>
<Route
path="oauth2-provider"
element={<UserOAuth2ProviderSettingsPage />}
/>
<Route path="tokens">
<Route index element={<TokensPage />} />
<Route path="new" element={<CreateTokenPage />} />
</Route>
</Route>
{/* In order for the 404 page to work properly the routes that start with
top level parameter must be fully qualified. */}
<Route
path="/:username/:workspace/builds/:buildNumber"
element={<WorkspaceBuildPage />}
/>
<Route
path="/:username/:workspace/settings"
element={<WorkspaceSettingsLayout />}
>
<Route index element={<WorkspaceSettingsPage />} />
<Route path="parameters" element={<WorkspaceParametersPage />} />
<Route path="schedule" element={<WorkspaceSchedulePage />} />
</Route>
<Route path="/health" element={<HealthLayout />}>
<Route index element={<Navigate to="access-url" replace />} />
<Route path="access-url" element={<AccessURLPage />} />
<Route path="database" element={<DatabasePage />} />
<Route path="derp" element={<DERPPage />} />
<Route path="derp/regions/:regionId" element={<DERPRegionPage />} />
<Route path="websocket" element={<WebsocketPage />} />
<Route
path="workspace-proxy"
element={<WorkspaceProxyHealthPage />}
/>
<Route
path="provisioner-daemons"
element={<ProvisionerDaemonsHealthPage />}
/>
</Route>
{/* Using path="*"" means "match anything", so this route
acts like a catch-all for URLs that we don't have explicit
routes for. */}
<Route path="*" element={<NotFoundPage />} />
</Route>
{/* Pages that don't have the dashboard layout */}
<Route path="/:username/:workspace" element={<WorkspacePage />} />
<Route
path="/templates/:template/versions/:version/edit"
element={<TemplateVersionEditorPage />}
/>
<Route
path="/:username/:workspace/terminal"
element={<TerminalPage />}
/>
<Route path="/cli-auth" element={<CliAuthenticationPage />} />
<Route path="/icons" element={<IconsPage />} />
</Route>
</Route>,
),
);