diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index ea8b1d0c4ccbc..b7fbd0c7d76b2 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -61,7 +61,15 @@ export const mapApiErrorToFieldErrors = ( export const getErrorMessage = ( error: unknown, defaultMessage: string, -): string => (isApiError(error) ? error.response.data.message : defaultMessage); +): string => { + if (isApiError(error)) { + return error.response.data.message; + } + if (typeof error === "string") { + return error; + } + return defaultMessage; +}; /** * diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 85d33b7921baa..7bb34b74df124 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -1,6 +1,13 @@ import * as API from "api/api"; -import { type Template, type AuthorizationResponse } from "api/typesGenerated"; -import { type QueryOptions } from "@tanstack/react-query"; +import { + type Template, + type AuthorizationResponse, + type CreateTemplateVersionRequest, + type ProvisionerJobStatus, + type TemplateVersion, +} from "api/typesGenerated"; +import { type QueryClient, type QueryOptions } from "@tanstack/react-query"; +import { delay } from "utils/delay"; export const templateByNameKey = (orgId: string, name: string) => [ orgId, @@ -63,3 +70,53 @@ export const templateVersions = (templateId: string) => { queryFn: () => API.getTemplateVersions(templateId), }; }; + +export const templateVersionVariables = (versionId: string) => { + return { + queryKey: ["templateVersion", versionId, "variables"], + queryFn: () => API.getTemplateVersionVariables(versionId), + }; +}; + +export const createAndBuildTemplateVersion = (orgId: string) => { + return { + mutationFn: async ( + request: CreateTemplateVersionRequest, + ): Promise => { + const newVersion = await API.createTemplateVersion(orgId, request); + + let data: TemplateVersion; + let jobStatus: ProvisionerJobStatus; + do { + await delay(1000); + data = await API.getTemplateVersion(newVersion.id); + jobStatus = data.job.status; + + if (jobStatus === "succeeded") { + return newVersion.id; + } + } while (jobStatus === "pending" || jobStatus === "running"); + + // No longer pending/running, but didn't succeed + throw data.job.error; + }, + }; +}; + +export const updateActiveTemplateVersion = ( + template: Template, + queryClient: QueryClient, +) => { + return { + mutationFn: (versionId: string) => + API.updateActiveTemplateVersion(template.id, { + id: versionId, + }), + onSuccess: async () => { + // invalidated because of `active_version_id` + await queryClient.invalidateQueries( + templateByNameKey(template.organization_id, template.name), + ); + }, + }; +}; diff --git a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.test.tsx index 39adf1c02bd82..2ce13b21fbf10 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.test.tsx @@ -13,16 +13,14 @@ import { MockTemplateVersionVariable1, MockTemplateVersionVariable2, MockTemplateVersion2, - MockTemplateVersionVariable5, } from "testHelpers/entities"; +import { delay } from "utils/delay"; const validFormValues = { first_variable: "Hello world", second_variable: "123", }; -const validationRequiredField = "Variable is required."; - const renderTemplateVariablesPage = async () => { renderWithTemplateSettingsLayout(, { route: `/templates/${MockTemplate.name}/variables`, @@ -62,7 +60,7 @@ describe("TemplateVariablesPage", () => { jest.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate); jest .spyOn(API, "getTemplateVersion") - .mockResolvedValueOnce(MockTemplateVersion); + .mockResolvedValue(MockTemplateVersion); jest .spyOn(API, "getTemplateVersionVariables") .mockResolvedValueOnce([ @@ -106,49 +104,9 @@ describe("TemplateVariablesPage", () => { FooterFormLanguage.defaultSubmitLabel, ); await userEvent.click(submitButton); - // Wait for the success message - await screen.findByText("Template updated successfully"); - }); + await delay(1500); - it("user forgets to fill the required field", async () => { - jest.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate); - jest - .spyOn(API, "getTemplateVersion") - .mockResolvedValueOnce(MockTemplateVersion); - jest - .spyOn(API, "getTemplateVersionVariables") - .mockResolvedValueOnce([ - MockTemplateVersionVariable1, - MockTemplateVersionVariable5, - ]); - jest - .spyOn(API, "createTemplateVersion") - .mockResolvedValueOnce(MockTemplateVersion2); - jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({ - message: "done", - }); - - await renderTemplateVariablesPage(); - - const firstVariable = await screen.findByLabelText( - MockTemplateVersionVariable1.name, - ); - expect(firstVariable).toBeDefined(); - - const fifthVariable = await screen.findByLabelText( - MockTemplateVersionVariable5.name, - ); - expect(fifthVariable).toBeDefined(); - - // Submit the form - const submitButton = await screen.findByText( - FooterFormLanguage.defaultSubmitLabel, - ); - await userEvent.click(submitButton); - - // Check validation error - const validationError = await screen.findByText(validationRequiredField); - expect(validationError).toBeDefined(); + await screen.findByText("Template updated successfully"); }); }); diff --git a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx index d7b61022d6275..db02b3f10b46e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx @@ -1,4 +1,3 @@ -import { useMachine } from "@xstate/react"; import { CreateTemplateVersionRequest, TemplateVersionVariable, @@ -6,40 +5,84 @@ import { } from "api/typesGenerated"; import { displaySuccess } from "components/GlobalSnackbar/utils"; import { useOrganizationId } from "hooks/useOrganizationId"; -import { FC } from "react"; +import { useCallback, type FC } from "react"; import { Helmet } from "react-helmet-async"; import { useNavigate, useParams } from "react-router-dom"; -import { templateVariablesMachine } from "xServices/template/templateVariablesXService"; -import { pageTitle } from "../../../utils/page"; +import { pageTitle } from "utils/page"; import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplateVariablesPageView } from "./TemplateVariablesPageView"; +import { + createAndBuildTemplateVersion, + templateVersion, + templateVersionVariables, + updateActiveTemplateVersion, +} from "api/queries/templates"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Loader } from "components/Loader/Loader"; export const TemplateVariablesPage: FC = () => { const { template: templateName } = useParams() as { organization: string; template: string; }; - const organizationId = useOrganizationId(); + const orgId = useOrganizationId(); const { template } = useTemplateSettings(); const navigate = useNavigate(); - const [state, send] = useMachine(templateVariablesMachine, { - context: { - organizationId, - template, - }, - actions: { - onUpdateTemplate: () => { - displaySuccess("Template updated successfully"); - }, - }, + const queryClient = useQueryClient(); + const versionId = template.active_version_id; + + const { + data: version, + error: versionError, + isLoading: isVersionLoading, + } = useQuery({ ...templateVersion(versionId), keepPreviousData: true }); + const { + data: variables, + error: variablesError, + isLoading: isVariablesLoading, + } = useQuery({ + ...templateVersionVariables(versionId), + keepPreviousData: true, }); + + const { + mutateAsync: sendCreateAndBuildTemplateVersion, + error: buildError, + isLoading: isBuilding, + } = useMutation(createAndBuildTemplateVersion(orgId)); const { - activeTemplateVersion, - templateVariables, - getTemplateDataError, - updateTemplateError, - jobError, - } = state.context; + mutateAsync: sendUpdateActiveTemplateVersion, + error: publishError, + isLoading: isPublishing, + } = useMutation(updateActiveTemplateVersion(template, queryClient)); + + const publishVersion = useCallback( + async (versionId: string) => { + await sendUpdateActiveTemplateVersion(versionId); + displaySuccess("Template updated successfully"); + }, + [sendUpdateActiveTemplateVersion], + ); + + const buildVersion = useCallback( + async (req: CreateTemplateVersionRequest) => { + const newVersionId = await sendCreateAndBuildTemplateVersion(req); + await publishVersion(newVersionId); + }, + [sendCreateAndBuildTemplateVersion, publishVersion], + ); + + const isSubmitting = Boolean(isBuilding || isPublishing); + + const error = versionError ?? variablesError; + if (error) { + return ; + } + + if (isVersionLoading || isVariablesLoading) { + return ; + } return ( <> @@ -48,23 +91,19 @@ export const TemplateVariablesPage: FC = () => { { navigate(`/templates/${templateName}`); }} - onSubmit={(formData) => { - const request = filterEmptySensitiveVariables( - formData, - templateVariables, - ); - send({ type: "UPDATE_TEMPLATE_EVENT", request: request }); + onSubmit={async (formData) => { + const request = filterEmptySensitiveVariables(formData, variables); + await buildVersion(request); }} /> diff --git a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx index dfd0baf83e2b6..e52d07e5713f3 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx @@ -49,7 +49,7 @@ export const RequiredVariable: Story = { }, }; -export const WithUpdateTemplateError: Story = { +export const WithErrors: Story = { args: { templateVersion: MockTemplateVersion, templateVariables: [ @@ -59,25 +59,20 @@ export const WithUpdateTemplateError: Story = { MockTemplateVersionVariable4, ], errors: { - updateTemplateError: mockApiError({ - message: "Something went wrong.", + buildError: mockApiError({ + message: "buildError", + validations: [ + { + field: `user_variable_values[0].value`, + detail: "Variable is required.", + }, + ], }), + publishError: mockApiError({ message: "publishError" }), }, - }, -}; -export const WithJobError: Story = { - args: { - templateVersion: MockTemplateVersion, - templateVariables: [ - MockTemplateVersionVariable1, - MockTemplateVersionVariable2, - MockTemplateVersionVariable3, - MockTemplateVersionVariable4, - ], - errors: { - jobError: - "template import provision for start: recv import provision: plan terraform: terraform plan: exit status 1", + initialTouched: { + user_variable_values: true, }, }, }; diff --git a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.tsx index 19b1c8d015966..2c3cd8d5100df 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPageView.tsx @@ -4,12 +4,12 @@ import { TemplateVersionVariable, } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; -import { Loader } from "components/Loader/Loader"; import { ComponentProps, FC } from "react"; import { TemplateVariablesForm } from "./TemplateVariablesForm"; import { makeStyles } from "@mui/styles"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Stack } from "components/Stack/Stack"; export interface TemplateVariablesPageViewProps { templateVersion?: TemplateVersion; @@ -18,9 +18,14 @@ export interface TemplateVariablesPageViewProps { onCancel: () => void; isSubmitting: boolean; errors?: { - getTemplateDataError?: unknown; - updateTemplateError?: unknown; - jobError?: TemplateVersion["job"]["error"]; + /** + * Failed to build a new template version + */ + buildError?: unknown; + /** + * New version was created successfully, but publishing it failed + */ + publishError?: unknown; }; initialTouched?: ComponentProps< typeof TemplateVariablesForm @@ -37,29 +42,23 @@ export const TemplateVariablesPageView: FC = ({ initialTouched, }) => { const classes = useStyles(); - const isLoading = - !templateVersion && - !templateVariables && - !errors.getTemplateDataError && - !errors.updateTemplateError; const hasError = Object.values(errors).some((error) => Boolean(error)); + return ( <> Template variables {hasError && ( -
- {Boolean(errors.getTemplateDataError) && ( - + + {Boolean(errors.buildError) && ( + )} - {Boolean(errors.updateTemplateError) && ( - + {Boolean(errors.publishError) && ( + )} - {Boolean(errors.jobError) && } -
+ )} - {isLoading && } {templateVersion && templateVariables && templateVariables.length > 0 && ( = ({ templateVariables={templateVariables} onSubmit={onSubmit} onCancel={onCancel} - error={errors.updateTemplateError} + error={errors.buildError} /> )} {templateVariables && templateVariables.length === 0 && ( diff --git a/site/src/xServices/template/templateVariablesXService.ts b/site/src/xServices/template/templateVariablesXService.ts deleted file mode 100644 index f2920d804eb58..0000000000000 --- a/site/src/xServices/template/templateVariablesXService.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { - createTemplateVersion, - getTemplateVersion, - getTemplateVersionVariables, - updateActiveTemplateVersion, -} from "api/api"; -import { - CreateTemplateVersionRequest, - Response, - Template, - TemplateVersion, - TemplateVersionVariable, -} from "api/typesGenerated"; -import { assign, createMachine } from "xstate"; -import { delay } from "utils/delay"; - -type TemplateVariablesContext = { - organizationId: string; - - template: Template; - activeTemplateVersion?: TemplateVersion; - templateVariables?: TemplateVersionVariable[]; - - createTemplateVersionRequest?: CreateTemplateVersionRequest; - newTemplateVersion?: TemplateVersion; - - getTemplateDataError?: unknown; - updateTemplateError?: unknown; - - jobError?: TemplateVersion["job"]["error"]; -}; - -type UpdateTemplateEvent = { - type: "UPDATE_TEMPLATE_EVENT"; - request: CreateTemplateVersionRequest; -}; - -export const templateVariablesMachine = createMachine( - { - id: "templateVariablesState", - predictableActionArguments: true, - tsTypes: {} as import("./templateVariablesXService.typegen").Typegen0, - schema: { - context: {} as TemplateVariablesContext, - events: {} as UpdateTemplateEvent, - services: {} as { - getActiveTemplateVersion: { - data: TemplateVersion; - }; - getTemplateVariables: { - data: TemplateVersionVariable[]; - }; - createNewTemplateVersion: { - data: TemplateVersion; - }; - waitForJobToBeCompleted: { - data: TemplateVersion; - }; - updateTemplate: { - data: Response; - }; - }, - }, - initial: "gettingActiveTemplateVersion", - states: { - gettingActiveTemplateVersion: { - entry: "clearGetTemplateDataError", - invoke: { - src: "getActiveTemplateVersion", - onDone: [ - { - actions: ["assignActiveTemplateVersion"], - target: "gettingTemplateVariables", - }, - ], - onError: { - actions: ["assignGetTemplateDataError"], - target: "error", - }, - }, - }, - gettingTemplateVariables: { - entry: "clearGetTemplateDataError", - invoke: { - src: "getTemplateVariables", - onDone: [ - { - actions: ["assignTemplateVariables"], - target: "fillingParams", - }, - ], - onError: { - actions: ["assignGetTemplateDataError"], - target: "error", - }, - }, - }, - fillingParams: { - on: { - UPDATE_TEMPLATE_EVENT: { - actions: ["assignCreateTemplateVersionRequest", "clearJobError"], - target: "creatingTemplateVersion", - }, - }, - }, - creatingTemplateVersion: { - entry: "clearUpdateTemplateError", - invoke: { - src: "createNewTemplateVersion", - onDone: { - actions: ["assignNewTemplateVersion"], - target: "waitingForJobToBeCompleted", - }, - onError: { - actions: ["assignGetTemplateDataError"], - target: "fillingParams", - }, - }, - tags: ["submitting"], - }, - waitingForJobToBeCompleted: { - invoke: { - src: "waitForJobToBeCompleted", - onDone: [ - { - target: "fillingParams", - cond: "hasJobError", - actions: ["assignJobError"], - }, - { - actions: ["assignNewTemplateVersion"], - target: "updatingTemplate", - }, - ], - onError: { - actions: ["assignUpdateTemplateError"], - target: "fillingParams", - }, - }, - tags: ["submitting"], - }, - updatingTemplate: { - invoke: { - src: "updateTemplate", - onDone: { - target: "updated", - actions: ["onUpdateTemplate"], - }, - onError: { - actions: ["assignUpdateTemplateError"], - target: "fillingParams", - }, - }, - tags: ["submitting"], - }, - updated: { - entry: "onUpdateTemplate", - type: "final", - }, - error: {}, - }, - }, - { - services: { - getActiveTemplateVersion: ({ template }) => { - return getTemplateVersion(template.active_version_id); - }, - getTemplateVariables: ({ template }) => { - return getTemplateVersionVariables(template.active_version_id); - }, - createNewTemplateVersion: ({ - organizationId, - createTemplateVersionRequest, - }) => { - if (!createTemplateVersionRequest) { - throw new Error("Missing request body"); - } - return createTemplateVersion( - organizationId, - createTemplateVersionRequest, - ); - }, - waitForJobToBeCompleted: async ({ newTemplateVersion }) => { - if (!newTemplateVersion) { - throw new Error("Template version is undefined"); - } - - let status = newTemplateVersion.job.status; - while (["pending", "running"].includes(status)) { - newTemplateVersion = await getTemplateVersion(newTemplateVersion.id); - status = newTemplateVersion.job.status; - await delay(2_000); - } - return newTemplateVersion; - }, - updateTemplate: ({ template, newTemplateVersion }) => { - if (!newTemplateVersion) { - throw new Error("New template version is undefined"); - } - - return updateActiveTemplateVersion(template.id, { - id: newTemplateVersion.id, - }); - }, - }, - actions: { - assignActiveTemplateVersion: assign({ - activeTemplateVersion: (_, event) => event.data, - }), - assignTemplateVariables: assign({ - templateVariables: (_, event) => event.data, - }), - assignCreateTemplateVersionRequest: assign({ - createTemplateVersionRequest: (_, event) => event.request, - }), - assignNewTemplateVersion: assign({ - newTemplateVersion: (_, event) => event.data, - }), - assignGetTemplateDataError: assign({ - getTemplateDataError: (_, event) => event.data, - }), - clearGetTemplateDataError: assign({ - getTemplateDataError: (_) => undefined, - }), - assignUpdateTemplateError: assign({ - updateTemplateError: (_, event) => event.data, - }), - clearUpdateTemplateError: assign({ - updateTemplateError: (_) => undefined, - }), - assignJobError: assign({ - jobError: (_, event) => event.data.job.error, - }), - clearJobError: assign({ - jobError: (_) => undefined, - }), - }, - guards: { - hasJobError: (_, { data }) => { - return Boolean(data.job.error); - }, - }, - }, -);