fix(site): fix and improve pending state on template editor UI (#12766)

This commit is contained in:
Bruno Quaresma 2024-03-27 12:42:07 -03:00 committed by GitHub
parent 47fd190064
commit 5d82a78d4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 182 additions and 118 deletions

View File

@ -175,10 +175,10 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
typeof editorValue === "string" ? isBinaryData(editorValue) : false;
// Auto scroll
const buildLogsRef = useRef<HTMLDivElement>(null);
const logsContentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (buildLogsRef.current) {
buildLogsRef.current.scrollTop = buildLogsRef.current.scrollHeight;
if (logsContentRef.current) {
logsContentRef.current.scrollTop = logsContentRef.current.scrollHeight;
}
}, [buildLogs]);
@ -237,9 +237,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
paddingRight: 16,
}}
>
{buildLogs && (
<TemplateVersionStatusBadge version={templateVersion} />
)}
<TemplateVersionStatusBadge version={templateVersion} />
<ButtonGroup
variant="outlined"
@ -575,62 +573,51 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
)}
</div>
<div
ref={buildLogsRef}
css={{
display: selectedTab !== "logs" ? "none" : "flex",
height: selectedTab ? 280 : 0,
flexDirection: "column",
overflowY: "auto",
}}
>
{templateVersion.job.error && (
<div>
<Alert
severity="error"
css={{
borderRadius: 0,
border: 0,
borderBottom: `1px solid ${theme.palette.divider}`,
borderLeft: `2px solid ${theme.palette.error.main}`,
}}
>
<AlertTitle>Error during the build</AlertTitle>
<AlertDetail>{templateVersion.job.error}</AlertDetail>
</Alert>
</div>
)}
{selectedTab === "logs" && (
<div
css={[styles.logs, styles.tabContent]}
ref={logsContentRef}
>
{templateVersion.job.error && (
<div>
<Alert
severity="error"
css={{
borderRadius: 0,
border: 0,
borderBottom: `1px solid ${theme.palette.divider}`,
borderLeft: `2px solid ${theme.palette.error.main}`,
}}
>
<AlertTitle>Error during the build</AlertTitle>
<AlertDetail>{templateVersion.job.error}</AlertDetail>
</Alert>
</div>
)}
{buildLogs && buildLogs.length === 0 && (
<Loader css={{ height: "100%" }} />
)}
{buildLogs && buildLogs.length > 0 ? (
<WorkspaceBuildLogs
css={styles.buildLogs}
hideTimestamps
logs={buildLogs}
/>
) : (
<Loader css={{ height: "100%" }} />
)}
</div>
)}
{buildLogs && buildLogs.length > 0 && (
<WorkspaceBuildLogs
css={styles.buildLogs}
hideTimestamps
logs={buildLogs}
/>
)}
</div>
<div
css={[
{
display: selectedTab !== "resources" ? "none" : undefined,
height: selectedTab ? 280 : 0,
},
styles.resources,
]}
>
{resources && (
<TemplateResourcesTable
resources={resources.filter(
(r) => r.workspace_transition === "start",
)}
/>
)}
</div>
{selectedTab === "resources" && (
<div css={[styles.resources, styles.tabContent]}>
{resources && (
<TemplateResourcesTable
resources={resources.filter(
(r) => r.workspace_transition === "start",
)}
/>
)}
</div>
)}
</div>
</div>
</div>
@ -751,6 +738,17 @@ const styles = {
},
}),
tabContent: {
height: 280,
overflowY: "auto",
},
logs: {
display: "flex",
height: "100%",
flexDirection: "column",
},
buildLogs: {
borderRadius: 0,
border: 0,
@ -780,8 +778,6 @@ const styles = {
},
resources: {
overflowY: "auto",
// Hack to access customize resource-card from here
"& .resource-card": {
borderLeft: 0,

View File

@ -1,5 +1,6 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent, { type UserEvent } from "@testing-library/user-event";
import WS from "jest-websocket-mock";
import { HttpResponse, http } from "msw";
import { QueryClient } from "react-query";
import { RouterProvider, createMemoryRouter } from "react-router-dom";
@ -16,6 +17,7 @@ import {
MockWorkspaceBuildLogs,
} from "testHelpers/entities";
import {
createTestQueryClient,
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
@ -291,30 +293,7 @@ describe.each([
);
}
render(
<AppProviders queryClient={queryClient}>
<RouterProvider
router={createMemoryRouter(
[
{
element: <RequireAuth />,
children: [
{
element: <TemplateVersionEditorPage />,
path: "/templates/:template/versions/:version/edit",
},
],
},
],
{
initialEntries: [
`/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`,
],
},
)}
/>
</AppProviders>,
);
renderEditorPage(queryClient);
await waitForLoaderToBeRemoved();
const dialogSelector = /template variables/i;
@ -326,3 +305,80 @@ describe.each([
});
},
);
test("display pending badge and update it to running when status changes", async () => {
const MockPendingTemplateVersion = {
...MockTemplateVersion,
job: {
...MockTemplateVersion.job,
status: "pending",
},
};
const MockRunningTemplateVersion = {
...MockTemplateVersion,
job: {
...MockTemplateVersion.job,
status: "running",
},
};
let calls = 0;
server.use(
http.get(
"/api/v2/organizations/:org/templates/:template/versions/:version",
() => {
calls += 1;
return HttpResponse.json(
calls > 1 ? MockRunningTemplateVersion : MockPendingTemplateVersion,
);
},
),
);
// Mock the logs when the status is running. This prevents connection errors
// from being thrown in the console during the test.
new WS(
`ws://localhost/api/v2/templateversions/${MockTemplateVersion.name}/logs?follow=true`,
);
renderEditorPage(createTestQueryClient());
const status = await screen.findByRole("status");
expect(status).toHaveTextContent("Pending");
await waitFor(
() => {
expect(status).toHaveTextContent("Running");
},
// Increase the timeout due to the page fetching results every second, which
// may cause delays.
{ timeout: 5_000 },
);
});
function renderEditorPage(queryClient: QueryClient) {
return render(
<AppProviders queryClient={queryClient}>
<RouterProvider
router={createMemoryRouter(
[
{
element: <RequireAuth />,
children: [
{
element: <TemplateVersionEditorPage />,
path: "/templates/:template/versions/:version/edit",
},
],
},
],
{
initialEntries: [
`/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`,
],
},
)}
/>
</AppProviders>,
);
}

View File

@ -43,27 +43,31 @@ export const TemplateVersionEditorPage: FC = () => {
templateName,
versionName,
);
const templateVersionQuery = useQuery({
const activeTemplateVersionQuery = useQuery({
...templateVersionOptions,
keepPreviousData: true,
refetchInterval(data) {
return data?.job.status === "pending" ? 1_000 : false;
},
});
const { data: activeTemplateVersion } = activeTemplateVersionQuery;
const uploadFileMutation = useMutation(uploadFile());
const createTemplateVersionMutation = useMutation(
createTemplateVersion(organizationId),
);
const resourcesQuery = useQuery({
...resources(templateVersionQuery.data?.id ?? ""),
enabled: templateVersionQuery.data?.job.status === "succeeded",
...resources(activeTemplateVersion?.id ?? ""),
enabled: activeTemplateVersion?.job.status === "succeeded",
});
const logs = useWatchVersionLogs(templateVersionQuery.data, {
onDone: templateVersionQuery.refetch,
const logs = useWatchVersionLogs(activeTemplateVersion, {
onDone: activeTemplateVersionQuery.refetch,
});
const { fileTree, tarFile } = useFileTree(templateVersionQuery.data);
const { fileTree, tarFile } = useFileTree(activeTemplateVersion);
const {
missingVariables,
setIsMissingVariablesDialogOpen,
isMissingVariablesDialogOpen,
} = useMissingVariables(templateVersionQuery.data);
} = useMissingVariables(activeTemplateVersion);
// Handle template publishing
const [isPublishingDialogOpen, setIsPublishingDialogOpen] = useState(false);
@ -109,10 +113,10 @@ export const TemplateVersionEditorPage: FC = () => {
Record<string, string>
>({});
useEffect(() => {
if (templateVersionQuery.data?.job.tags) {
setProvisionerTags(templateVersionQuery.data.job.tags);
if (activeTemplateVersion?.job.tags) {
setProvisionerTags(activeTemplateVersion.job.tags);
}
}, [templateVersionQuery.data?.job.tags]);
}, [activeTemplateVersion?.job.tags]);
return (
<>
@ -120,14 +124,14 @@ export const TemplateVersionEditorPage: FC = () => {
<title>{pageTitle(`${templateName} · Template Editor`)}</title>
</Helmet>
{!(templateQuery.data && templateVersionQuery.data && fileTree) ? (
{!(templateQuery.data && activeTemplateVersion && fileTree) ? (
<Loader fullscreen />
) : (
<TemplateVersionEditor
activePath={activePath}
onActivePathChange={onActivePathChange}
template={templateQuery.data}
templateVersion={templateVersionQuery.data}
templateVersion={activeTemplateVersion}
defaultFileTree={fileTree}
onPreview={async (newFileTree) => {
if (!tarFile) {
@ -159,10 +163,10 @@ export const TemplateVersionEditorPage: FC = () => {
await publishVersionMutation.mutateAsync({
isActiveVersion,
data,
version: templateVersionQuery.data,
version: activeTemplateVersion,
});
const publishedVersion = {
...templateVersionQuery.data,
...activeTemplateVersion,
...data,
};
setIsPublishingDialogOpen(false);
@ -190,13 +194,12 @@ export const TemplateVersionEditorPage: FC = () => {
isBuilding={
createTemplateVersionMutation.isLoading ||
uploadFileMutation.isLoading ||
templateVersionQuery.data.job.status === "running" ||
templateVersionQuery.data.job.status === "pending"
activeTemplateVersion.job.status === "running" ||
activeTemplateVersion.job.status === "pending"
}
canPublish={
templateVersionQuery.data.job.status === "succeeded" &&
templateQuery.data.active_version_id !==
templateVersionQuery.data.id
activeTemplateVersion.job.status === "succeeded" &&
templateQuery.data.active_version_id !== activeTemplateVersion.id
}
resources={resourcesQuery.data}
buildLogs={logs}

View File

@ -1,9 +1,11 @@
import CheckIcon from "@mui/icons-material/CheckOutlined";
import ErrorIcon from "@mui/icons-material/ErrorOutline";
import QueuedIcon from "@mui/icons-material/HourglassEmpty";
import type { FC, ReactNode } from "react";
import type { TemplateVersion } from "api/typesGenerated";
import { Pill, PillSpinner } from "components/Pill/Pill";
import type { ThemeRole } from "theme/roles";
import { getPendingStatusLabel } from "utils/provisionerJob";
interface TemplateVersionStatusBadgeProps {
version: TemplateVersion;
@ -14,7 +16,12 @@ export const TemplateVersionStatusBadge: FC<
> = ({ version }) => {
const { text, icon, type } = getStatus(version);
return (
<Pill icon={icon} type={type} title={`Build status is ${text}`}>
<Pill
icon={icon}
type={type}
title={`Build status is ${text}`}
role="status"
>
{text}
</Pill>
);
@ -37,8 +44,8 @@ export const getStatus = (
case "pending":
return {
type: "info",
text: "Pending",
icon: <PillSpinner />,
text: getPendingStatusLabel(version.job),
icon: <QueuedIcon />,
};
case "canceling":
return {

View File

@ -0,0 +1,10 @@
import type { ProvisionerJob } from "api/typesGenerated";
export const getPendingStatusLabel = (
provisionerJob?: ProvisionerJob,
): string => {
if (!provisionerJob || provisionerJob.queue_size === 0) {
return "Pending";
}
return "Position in queue: " + provisionerJob.queue_position;
};

View File

@ -10,6 +10,7 @@ import utc from "dayjs/plugin/utc";
import semver from "semver";
import type * as TypesGen from "api/typesGenerated";
import { PillSpinner } from "components/Pill/Pill";
import { getPendingStatusLabel } from "./provisionerJob";
dayjs.extend(duration);
dayjs.extend(utc);
@ -234,21 +235,12 @@ export const getDisplayWorkspaceStatus = (
case "pending":
return {
type: "active",
text: getPendingWorkspaceStatusText(provisionerJob),
text: getPendingStatusLabel(provisionerJob),
icon: <QueuedIcon />,
} as const;
}
};
const getPendingWorkspaceStatusText = (
provisionerJob?: TypesGen.ProvisionerJob,
): string => {
if (!provisionerJob || provisionerJob.queue_size === 0) {
return "Pending";
}
return "Position in queue: " + provisionerJob.queue_position;
};
export const hasJobError = (workspace: TypesGen.Workspace) => {
return workspace.latest_build.job.error !== undefined;
};