mirror of https://github.com/coder/coder.git
fix(site): fix and improve pending state on template editor UI (#12766)
This commit is contained in:
parent
47fd190064
commit
5d82a78d4c
|
@ -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,
|
||||
|
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue