feat(site): refactor template version editor layout (#10912)

This commit is contained in:
Bruno Quaresma
2023-11-28 16:42:31 -03:00
committed by GitHub
parent afbda2235c
commit e9c12c30cf
10 changed files with 389 additions and 252 deletions
+5 -5
View File
@@ -259,10 +259,6 @@ export const AppRouter: FC = () => {
<Route path="versions">
<Route path=":version">
<Route index element={<TemplateVersionPage />} />
<Route
path="edit"
element={<TemplateVersionEditorPage />}
/>
</Route>
</Route>
</Route>
@@ -346,13 +342,17 @@ export const AppRouter: FC = () => {
</Route>
</Route>
{/* Terminal and CLI auth pages don't have the dashboard layout */}
{/* Pages that don't have the dashboard layout */}
<Route
path="/:username/:workspace/terminal"
element={<TerminalPage />}
/>
<Route path="/cli-auth" element={<CliAuthenticationPage />} />
<Route path="/icons" element={<IconsPage />} />
<Route
path="/templates/:template/versions/:version/edit"
element={<TemplateVersionEditorPage />}
/>
</Route>
{/* Using path="*"" means "match anything", so this route
@@ -24,10 +24,14 @@ export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
className = "",
}) => {
return (
<div css={styles.root} className={className}>
<div css={styles.root} className={`${className} logs-container`}>
<div css={{ minWidth: "fit-content" }}>
{lines.map((line, idx) => (
<div css={styles.line} className={line.level} key={idx}>
<div
css={[styles.line]}
className={`${line.level} logs-line`}
key={idx}
>
{!hideTimestamps && (
<>
<span css={styles.time}>
@@ -77,7 +77,10 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
return (
<Fragment key={stage}>
<div css={[styles.header, sticky && styles.sticky]}>
<div
css={[styles.header, sticky && styles.sticky]}
className="logs-header"
>
<div>{stage}</div>
{shouldDisplayDuration && (
<div css={styles.duration}>
@@ -233,7 +233,6 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
<TextField
{...getFieldHelpers("deprecation_message")}
disabled={isSubmitting || !accessControlEnabled}
autoFocus
fullWidth
label="Deprecation Message"
/>
@@ -62,6 +62,7 @@ export const FileTreeView: FC<{
css={(theme) => css`
overflow: hidden;
user-select: none;
height: 32px;
&:focus:not(.active) > .MuiTreeItem-content {
background: ${theme.palette.action.hover};
@@ -92,7 +93,7 @@ export const FileTreeView: FC<{
&.active {
& > .MuiTreeItem-content {
color: ${theme.palette.text.primary};
background: ${colors.gray[13]};
background: ${colors.gray[14]};
pointer-events: none;
}
}
@@ -1,9 +1,8 @@
import { useTheme } from "@emotion/react";
import Editor, { loader } from "@monaco-editor/react";
import * as monaco from "monaco-editor";
import { FC, useLayoutEffect, useMemo, useState } from "react";
import { FC, useMemo } from "react";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
import type { editor } from "monaco-editor";
loader.config({ monaco });
@@ -13,22 +12,6 @@ export const MonacoEditor: FC<{
onChange?: (value: string) => void;
}> = ({ onChange, value, path }) => {
const theme = useTheme();
const [editor, setEditor] = useState<editor.IStandaloneCodeEditor>();
useLayoutEffect(() => {
if (!editor) {
return;
}
const resizeListener = () => {
editor.layout({
height: 0,
width: 0,
});
};
window.addEventListener("resize", resizeListener);
return () => {
window.removeEventListener("resize", resizeListener);
};
}, [editor]);
const language = useMemo(() => {
if (path?.endsWith(".tf")) {
@@ -56,7 +39,7 @@ export const MonacoEditor: FC<{
options={{
automaticLayout: true,
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 16,
fontSize: 14,
wordWrap: "on",
padding: {
top: 16,
@@ -81,8 +64,6 @@ export const MonacoEditor: FC<{
},
);
setEditor(editor);
document.fonts.ready
.then(() => {
// Ensures that all text is measured properly.
@@ -124,7 +105,7 @@ export const MonacoEditor: FC<{
],
colors: {
"editor.foreground": theme.palette.text.primary,
"editor.background": theme.palette.background.default,
"editor.background": theme.palette.background.paper,
},
});
editor.updateOptions({
@@ -68,44 +68,45 @@ export const PublishTemplateVersionDialog: FC<
confirmText="Publish"
title="Publish new version"
description={
<Stack>
<p>You are about to publish a new version of this template.</p>
<FormFields>
<TextField
{...getFieldHelpers("name")}
label={Language.versionNameLabel}
autoFocus
disabled={isPublishing}
/>
<form id="publish-version" onSubmit={form.handleSubmit}>
<Stack>
<p>You are about to publish a new version of this template.</p>
<FormFields>
<TextField
{...getFieldHelpers("name")}
label={Language.versionNameLabel}
autoFocus
disabled={isPublishing}
/>
<TextField
{...getFieldHelpers("message")}
label="Message"
placeholder={Language.messagePlaceholder}
autoFocus
disabled={isPublishing}
multiline
rows={5}
/>
<TextField
{...getFieldHelpers("message")}
label="Message"
placeholder={Language.messagePlaceholder}
disabled={isPublishing}
multiline
rows={5}
/>
<FormControlLabel
label={Language.defaultCheckboxLabel}
control={
<Checkbox
size="small"
checked={form.values.isActiveVersion}
onChange={async (e) => {
await form.setFieldValue(
"isActiveVersion",
e.target.checked,
);
}}
name="isActiveVersion"
/>
}
/>
</FormFields>
</Stack>
<FormControlLabel
label={Language.defaultCheckboxLabel}
control={
<Checkbox
size="small"
checked={form.values.isActiveVersion}
onChange={async (e) => {
await form.setFieldValue(
"isActiveVersion",
e.target.checked,
);
}}
name="isActiveVersion"
/>
}
/>
</FormFields>
</Stack>
</form>
}
/>
);
@@ -1,10 +1,7 @@
import Button from "@mui/material/Button";
import Button, { ButtonProps } from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Link from "@mui/material/Link";
import Tooltip from "@mui/material/Tooltip";
import CreateIcon from "@mui/icons-material/AddOutlined";
import BuildIcon from "@mui/icons-material/BuildOutlined";
import PreviewIcon from "@mui/icons-material/VisibilityOutlined";
import {
ProvisionerJobLog,
Template,
@@ -16,11 +13,11 @@ import {
import { Link as RouterLink } from "react-router-dom";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/AvatarData/AvatarData";
import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable";
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs";
import { PublishVersionData } from "pages/TemplateVersionEditorPage/types";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
import {
createFile,
existsFile,
@@ -41,13 +38,13 @@ import { FileTreeView } from "./FileTreeView";
import { MissingTemplateVariablesDialog } from "./MissingTemplateVariablesDialog";
import { MonacoEditor } from "./MonacoEditor";
import { PublishTemplateVersionDialog } from "./PublishTemplateVersionDialog";
import {
getStatus,
TemplateVersionStatusBadge,
} from "./TemplateVersionStatusBadge";
import { TemplateVersionStatusBadge } from "./TemplateVersionStatusBadge";
import AlertTitle from "@mui/material/AlertTitle";
import { DashboardFullPage } from "components/Dashboard/DashboardLayout";
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
import CloseOutlined from "@mui/icons-material/CloseOutlined";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
import { Loader } from "components/Loader/Loader";
type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab
export interface TemplateVersionEditorProps {
@@ -74,8 +71,6 @@ export interface TemplateVersionEditorProps {
defaultTab?: Tab;
}
const topbarHeight = 80;
const findInitialFile = (fileTree: FileTree): string | undefined => {
let initialFile: string | undefined;
@@ -162,90 +157,196 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
["running", "pending"].includes(previousVersion.current.job.status) &&
templateVersion.job.status === "succeeded"
) {
setSelectedTab("resources");
setDirty(false);
}
previousVersion.current = templateVersion;
}, [templateVersion]);
const hasIcon = template.icon && template.icon !== "";
const showBuildLogs = Boolean(buildLogs);
const editorValue = getFileContent(activePath ?? "", fileTree) as string;
// Auto scroll
const buildLogsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
window.dispatchEvent(new Event("resize"));
}, [showBuildLogs]);
if (buildLogsRef.current) {
buildLogsRef.current.scrollTop = buildLogsRef.current.scrollHeight;
}
}, [buildLogs]);
return (
<>
<DashboardFullPage
css={{
background: theme.palette.background.default,
}}
>
<div css={styles.topbar} data-testid="topbar">
<div css={styles.topbarSides}>
<Link
component={RouterLink}
underline="none"
to={`/templates/${template.name}`}
>
<AvatarData
title={template.display_name || template.name}
subtitle={template.description}
avatar={
hasIcon && (
<Avatar src={template.icon} variant="square" fitImage />
)
}
/>
</Link>
<div css={{ height: "100%", display: "flex", flexDirection: "column" }}>
<div
css={{
height: 48,
borderBottom: `1px solid ${theme.palette.divider}`,
display: "grid",
gridTemplateColumns: "1fr 2fr 1fr",
alignItems: "center",
}}
data-testid="topbar"
>
<div>
<Tooltip title="Back to the template">
<IconButton
component={RouterLink}
to={`/templates/${template.name}`}
size="small"
css={{
padding: "0 16px",
borderRadius: 0,
height: 48,
}}
>
<ArrowBackOutlined css={{ width: 20, height: 20 }} />
</IconButton>
</Tooltip>
</div>
{publishedVersion && (
<Alert
severity="success"
dismissible
actions={
<Button variant="text" size="small" onClick={onCreateWorkspace}>
Create a workspace
</Button>
}
>
Successfully published {publishedVersion.name}!
</Alert>
)}
<div
css={{
fontSize: 13,
display: "flex",
gap: 8,
alignItems: "center",
justifyContent: "center",
}}
>
<Avatar
src={template.icon}
variant="square"
fitImage
css={{ width: 16, height: 16 }}
/>
<RouterLink
to={`/templates/${template.name}`}
css={{
color: theme.palette.text.primary,
textDecoration: "none",
<div css={styles.topbarSides}>
"&:hover": {
textDecoration: "underline",
},
}}
>
{template.display_name || template.name}
</RouterLink>
<span css={{ color: theme.palette.divider }}>/</span>
<span css={{ color: theme.palette.text.secondary }}>
{templateVersion.name}
</span>
</div>
<div
css={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 8,
paddingRight: 16,
}}
>
{buildLogs && (
<TemplateVersionStatusBadge version={templateVersion} />
)}
<Button
<TopbarButton
startIcon={
<PlayArrowOutlined
css={{ color: theme.palette.success.light }}
/>
}
title="Build template (Ctrl + Enter)"
disabled={disablePreview}
onClick={() => {
triggerPreview();
}}
>
Build template
</Button>
Build
</TopbarButton>
<Button
<TopbarButton
variant="contained"
disabled={dirty || disableUpdate}
onClick={onPublish}
>
Publish version
</Button>
Publish
</TopbarButton>
</div>
</div>
<div css={styles.sidebarAndEditor}>
<div css={styles.sidebar}>
<div css={styles.sidebarTitle}>
Template files
<div css={styles.sidebarActions}>
<div
css={{
display: "flex",
flex: 1,
flexBasis: 0,
overflow: "hidden",
position: "relative",
}}
>
{publishedVersion && (
<div
// We need this to reset the dismissable state of the component
// when the published version changes
key={publishedVersion.id}
css={{
position: "absolute",
width: "100%",
display: "flex",
justifyContent: "center",
padding: 12,
zIndex: 10,
}}
>
<Alert
severity="success"
dismissible
actions={
<Button
variant="text"
size="small"
onClick={onCreateWorkspace}
>
Create a workspace
</Button>
}
>
Successfully published {publishedVersion.name}!
</Alert>
</div>
)}
<div
css={{
width: 240,
borderRight: `1px solid ${theme.palette.divider}`,
flexShrink: 0,
}}
>
<div
css={{
height: 42,
padding: "0 8px 0 16px",
display: "flex",
alignItems: "center",
}}
>
<span
css={{
color: theme.palette.text.primary,
fontSize: 13,
}}
>
Files
</span>
<div
css={{
marginLeft: "auto",
"& svg": {
fill: theme.palette.text.primary,
},
}}
>
<Tooltip title="Create File" placement="top">
<IconButton
aria-label="Create File"
@@ -254,7 +355,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
event.currentTarget.blur();
}}
>
<CreateIcon />
<CreateIcon css={{ width: 16, height: 16 }} />
</IconButton>
</Tooltip>
</div>
@@ -326,14 +427,14 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
<div
css={{
display: "grid",
display: "flex",
flexDirection: "column",
width: "100%",
gridTemplateColumns: showBuildLogs ? "1fr 1fr" : "1fr 0fr",
minHeight: "100%",
overflow: "hidden",
}}
>
<div css={styles.editor} data-chromatic="ignore">
<div css={{ flex: 1, overflowY: "auto" }} data-chromatic="ignore">
{activePath ? (
<MonacoEditor
value={editorValue}
@@ -353,45 +454,84 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
)}
</div>
<div css={styles.panelWrapper}>
<div css={styles.tabs}>
<button
css={styles.tab}
className={selectedTab === "logs" ? "active" : ""}
onClick={() => {
setSelectedTab("logs");
<div
css={{
borderTop: `1px solid ${theme.palette.divider}`,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<div
css={{
display: "flex",
alignItems: "center",
borderBottom: selectedTab
? `1px solid ${theme.palette.divider}`
: 0,
}}
>
<div
css={{
display: "flex",
"& .MuiTab-root": {
padding: 0,
fontSize: 14,
textTransform: "none",
letterSpacing: "unset",
},
}}
>
{templateVersion.job.status !== "succeeded" ? (
getStatus(templateVersion).icon
) : (
<BuildIcon />
)}
Build Log
</button>
{!disableUpdate && (
<button
disabled={!buildLogs}
css={styles.tab}
className={selectedTab === "logs" ? "active" : ""}
onClick={() => {
setSelectedTab("logs");
}}
>
Output
</button>
<button
disabled={disableUpdate}
css={styles.tab}
className={selectedTab === "resources" ? "active" : ""}
onClick={() => {
setSelectedTab("resources");
}}
>
<PreviewIcon />
Workspace Preview
Resources
</button>
</div>
{selectedTab && (
<IconButton
onClick={() => {
setSelectedTab(undefined);
}}
css={{
marginLeft: "auto",
width: 36,
height: 36,
borderRadius: 0,
}}
>
<CloseOutlined css={{ width: 16, height: 16 }} />
</IconButton>
)}
</div>
<div
css={[
styles.panel,
{
display: selectedTab !== "logs" ? "none" : "flex",
flexDirection: "column",
},
]}
ref={buildLogsRef}
css={{
display: selectedTab !== "logs" ? "none" : "flex",
flexDirection: "column",
overflowY: "auto",
height: selectedTab ? 280 : 0,
}}
>
{templateVersion.job.error && (
<div>
@@ -412,9 +552,39 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
</div>
)}
{buildLogs && buildLogs.length === 0 && (
<Loader css={{ height: "100%" }} />
)}
{buildLogs && buildLogs.length > 0 && (
<WorkspaceBuildLogs
sx={{ borderRadius: 0, border: 0 }}
css={{
borderRadius: 0,
border: 0,
// Hack to update logs header and lines
"& .logs-header": {
border: 0,
padding: "0 16px",
fontFamily: MONOSPACE_FONT_FAMILY,
"&:first-child": {
paddingTop: 16,
},
"&:last-child": {
paddingBottom: 16,
},
},
"& .logs-line": {
paddingLeft: 16,
},
"& .logs-container": {
border: "0 !important",
},
}}
hideTimestamps
logs={buildLogs}
/>
@@ -422,13 +592,25 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
</div>
<div
css={[
styles.panel,
{
paddingBottom: 16,
display: selectedTab !== "resources" ? "none" : undefined,
css={{
display: selectedTab !== "resources" ? "none" : undefined,
overflowY: "auto",
height: selectedTab ? 280 : 0,
// Hack to access customize resource-card from here
"& .resource-card": {
borderLeft: 0,
borderRight: 0,
"&:first-child": {
borderTop: 0,
},
"&:last-child": {
borderBottom: 0,
},
},
]}
}}
>
{resources && (
<TemplateResourcesTable
@@ -441,7 +623,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
</div>
</div>
</div>
</DashboardFullPage>
</div>
<PublishTemplateVersionDialog
key={templateVersion.name}
@@ -463,84 +645,30 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
);
};
const TopbarButton = (props: ButtonProps) => {
return (
<Button
{...props}
css={{
height: 28,
fontSize: 13,
borderRadius: 4,
padding: "0 12px",
}}
/>
);
};
const styles = {
topbar: (theme) => ({
padding: 16,
borderBottom: `1px solid ${theme.palette.divider}`,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
height: topbarHeight,
}),
topbarSides: {
display: "flex",
alignItems: "center",
gap: 16,
},
sidebarAndEditor: {
display: "flex",
flex: 1,
flexBasis: 0,
overflow: "hidden",
},
sidebar: (theme) => ({
minWidth: 256,
borderRight: `1px solid ${theme.palette.divider}`,
}),
sidebarTitle: (theme) => ({
fontSize: 10,
textTransform: "uppercase",
padding: "8px 16px",
color: theme.palette.text.primary,
fontWeight: 500,
letterSpacing: "0.5px",
display: "flex",
alignItems: "center",
}),
sidebarActions: (theme) => ({
marginLeft: "auto",
"& svg": {
fill: theme.palette.text.primary,
},
}),
editor: {
flex: 1,
},
panelWrapper: (theme) => ({
flex: 1,
borderLeft: `1px solid ${theme.palette.divider}`,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}),
panel: {
overflowY: "auto",
height: "100%",
// Hack to access customize resource-card from here
"& .resource-card": {
border: 0,
},
},
tabs: (theme) => ({
borderBottom: `1px solid ${theme.palette.divider}`,
display: "flex",
boxShadow: "#000000 0 6px 6px -6px inset",
"& .MuiTab-root": {
padding: 0,
fontSize: 14,
textTransform: "none",
letterSpacing: "unset",
},
}),
tab: (theme) => ({
cursor: "pointer",
"&:not(:disabled)": {
cursor: "pointer",
},
padding: 12,
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.5px",
fontWeight: 600,
fontWeight: 500,
background: "transparent",
fontFamily: "inherit",
border: 0,
@@ -564,15 +692,19 @@ const styles = {
display: "block",
width: "100%",
height: 1,
backgroundColor: theme.palette.text.primary,
backgroundColor: theme.palette.primary.main,
bottom: -1,
position: "absolute",
},
},
"&:hover": {
"&:not(:disabled):hover": {
color: theme.palette.text.primary,
},
"&:disabled": {
color: theme.palette.text.disabled,
},
}),
tabBar: (theme) => ({
padding: "8px 16px",
@@ -57,7 +57,7 @@ test("Use custom name, message and set it as active when publishing", async () =
return jest.fn() as never;
});
const buildButton = within(topbar).getByRole("button", {
name: "Build template",
name: "Build",
});
await user.click(buildButton);
@@ -70,7 +70,7 @@ test("Use custom name, message and set it as active when publishing", async () =
.mockResolvedValue({ message: "" });
await within(topbar).findByText("Success");
const publishButton = within(topbar).getByRole("button", {
name: "Publish version",
name: "Publish",
});
await user.click(publishButton);
const publishDialog = await screen.findByTestId("dialog");
@@ -120,7 +120,7 @@ test("Do not mark as active if promote is not checked", async () => {
return jest.fn() as never;
});
const buildButton = within(topbar).getByRole("button", {
name: "Build template",
name: "Build",
});
await user.click(buildButton);
@@ -133,7 +133,7 @@ test("Do not mark as active if promote is not checked", async () => {
.mockResolvedValue({ message: "" });
await within(topbar).findByText("Success");
const publishButton = within(topbar).getByRole("button", {
name: "Publish version",
name: "Publish",
});
await user.click(publishButton);
const publishDialog = await screen.findByTestId("dialog");
@@ -185,7 +185,7 @@ test("Patch request is not send when there are no changes", async () => {
return jest.fn() as never;
});
const buildButton = within(topbar).getByRole("button", {
name: "Build template",
name: "Build",
});
await user.click(buildButton);
@@ -195,11 +195,11 @@ test("Patch request is not send when there are no changes", async () => {
.mockResolvedValue(MockTemplateVersionWithEmptyMessage);
await within(topbar).findByText("Success");
const publishButton = within(topbar).getByRole("button", {
name: "Publish version",
name: "Publish",
});
await user.click(publishButton);
const publishDialog = await screen.findByTestId("dialog");
// It is using the name from the template version
// It is using the name from the template
const nameField = within(publishDialog).getByLabelText("Version name");
expect(nameField).toHaveValue(MockTemplateVersionWithEmptyMessage.name);
// Publish
@@ -30,6 +30,7 @@ import {
TemplateVersion,
} from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
type Params = {
version: string;
@@ -107,7 +108,7 @@ export const TemplateVersionEditorPage: FC = () => {
<title>{pageTitle(`${templateName} · Template Editor`)}</title>
</Helmet>
{templateQuery.data && templateVersionQuery.data && fileTree && (
{templateQuery.data && templateVersionQuery.data && fileTree ? (
<TemplateVersionEditor
template={templateQuery.data}
templateVersion={templateVersionQuery.data}
@@ -144,8 +145,21 @@ export const TemplateVersionEditorPage: FC = () => {
data,
version: templateVersionQuery.data,
});
const publishedVersion = {
...templateVersionQuery.data,
...data,
};
setCurrentVersionName(publishedVersion.name);
setIsPublishingDialogOpen(false);
setLastSuccessfulPublishedVersion(templateVersionQuery.data);
setLastSuccessfulPublishedVersion(publishedVersion);
queryClient.setQueryData(
templateVersionOptions.queryKey,
publishedVersion,
);
navigate(
`/templates/${templateName}/versions/${publishedVersion.name}/edit`,
{ replace: true },
);
}}
isAskingPublishParameters={isPublishingDialogOpen}
isPublishing={publishVersionMutation.isLoading}
@@ -197,6 +211,8 @@ export const TemplateVersionEditorPage: FC = () => {
setIsMissingVariablesDialogOpen(false);
}}
/>
) : (
<FullScreenLoader />
)}
</>
);