diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx index 936e93034c705..2600c272f416c 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -104,6 +104,8 @@ interface MultiSelectComboboxProps { >; /** hide or show the button that clears all the selected options. */ hideClearAllButton?: boolean; + /** Test ID for testing purposes */ + "data-testid"?: string; } interface MultiSelectComboboxRef { @@ -205,6 +207,7 @@ export const MultiSelectCombobox = forwardRef< commandProps, inputProps, hideClearAllButton = false, + "data-testid": dataTestId, }: MultiSelectComboboxProps, ref, ) => { @@ -454,6 +457,7 @@ export const MultiSelectCombobox = forwardRef< { handleKeyDown(e); commandProps?.onKeyDown?.(e); diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index ac0df20355205..181de033d2bac 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -506,7 +506,10 @@ const ParameterField: FC = ({ return ( { diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.test.tsx new file mode 100644 index 0000000000000..3aafddf7f813a --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.test.tsx @@ -0,0 +1,849 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { API } from "api/api"; +import type { DynamicParametersResponse } from "api/typesGenerated"; +import { + MockPermissions, + MockTemplate, + MockTemplateVersionExternalAuthGithub, + MockTemplateVersionExternalAuthGithubAuthenticated, + MockUserOwner, + MockWorkspace, + mockDropdownParameter, + mockMultiSelectParameter, + mockSliderParameter, + mockSwitchParameter, + mockTagSelectParameter, + validationParameter, +} from "testHelpers/entities"; +import { + renderWithAuth, + waitForLoaderToBeRemoved, +} from "testHelpers/renderHelpers"; +import CreateWorkspacePageExperimental from "./CreateWorkspacePageExperimental"; + +type MockPublisher = Readonly<{ + publishMessage: (event: MessageEvent) => void; + publishError: (event: ErrorEvent) => void; + publishClose: (event: CloseEvent) => void; + publishOpen: (event: Event) => void; +}>; + +type MockWebSocket = Omit & { + readyState: number; +}; + +function createMockWebSocket( + url: string, + protocols?: string | string[], +): readonly [WebSocket, MockPublisher] { + type CallbackStore = { + [K in keyof WebSocketEventMap]: ((event: WebSocketEventMap[K]) => void)[]; + }; + + let activeProtocol: string; + if (Array.isArray(protocols)) { + activeProtocol = protocols[0] ?? ""; + } else if (typeof protocols === "string") { + activeProtocol = protocols; + } else { + activeProtocol = ""; + } + + let closed = false; + const store: CallbackStore = { + message: [], + error: [], + close: [], + open: [], + }; + + const mockSocket: MockWebSocket = { + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3, + + url, + protocol: activeProtocol, + readyState: 1, + binaryType: "blob", + bufferedAmount: 0, + extensions: "", + onclose: null, + onerror: null, + onmessage: null, + onopen: null, + send: jest.fn(), + dispatchEvent: jest.fn(), + + addEventListener: ( + eventType: E, + cb: (event: WebSocketEventMap[E]) => void, + ) => { + if (closed) { + return; + } + + const subscribers = store[eventType]; + if (!subscribers.includes(cb)) { + subscribers.push(cb); + } + }, + + removeEventListener: ( + eventType: E, + cb: (event: WebSocketEventMap[E]) => void, + ) => { + if (closed) { + return; + } + + const updated = store[eventType].filter((c) => c !== cb); + store[eventType] = updated as unknown as CallbackStore[E]; + }, + + close: () => { + if (!closed) { + closed = true; + publisher.publishClose(new CloseEvent("close")); + } + }, + }; + + const publisher: MockPublisher = { + publishOpen: (event) => { + if (closed) { + return; + } + for (const sub of store.open) { + sub(event); + } + mockSocket.onopen?.(event); + }, + + publishError: (event) => { + if (closed) { + return; + } + for (const sub of store.error) { + sub(event); + } + mockSocket.onerror?.(event); + }, + + publishMessage: (event) => { + if (closed) { + return; + } + for (const sub of store.message) { + sub(event); + } + mockSocket.onmessage?.(event); + }, + + publishClose: (event) => { + if (closed) { + return; + } + mockSocket.readyState = 3; // CLOSED + for (const sub of store.close) { + sub(event); + } + mockSocket.onclose?.(event); + }, + }; + + return [mockSocket, publisher] as const; +} + +const mockDynamicParametersResponse: DynamicParametersResponse = { + id: 1, + parameters: [ + mockDropdownParameter, + mockSliderParameter, + mockSwitchParameter, + mockTagSelectParameter, + mockMultiSelectParameter, + ], + diagnostics: [], +}; + +const mockDynamicParametersResponseWithError: DynamicParametersResponse = { + id: 2, + parameters: [mockDropdownParameter], + diagnostics: [ + { + severity: "error", + summary: "Validation failed", + detail: "The selected instance type is not available in this region", + extra: { + code: "", + }, + }, + ], +}; + +describe("CreateWorkspacePageExperimental", () => { + let mockWebSocket: WebSocket; + let publisher: MockPublisher; + + const renderCreateWorkspacePageExperimental = ( + route = `/templates/${MockTemplate.name}/workspace`, + ) => { + return renderWithAuth(, { + route, + path: "/templates/:template/workspace", + extraRoutes: [ + { + path: "/:username/:workspace", + element:
Workspace Page
, + }, + ], + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); + jest.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); + jest.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([]); + jest.spyOn(API, "createWorkspace").mockResolvedValue(MockWorkspace); + jest.spyOn(API, "checkAuthorization").mockResolvedValue(MockPermissions); + + jest + .spyOn(API, "templateVersionDynamicParameters") + .mockImplementation((versionId, _ownerId, callbacks) => { + const [socket, pub] = createMockWebSocket(`ws://test/${versionId}`); + mockWebSocket = socket; + publisher = pub; + + socket.addEventListener("message", (event) => { + callbacks.onMessage(JSON.parse(event.data)); + }); + socket.addEventListener("error", (event) => { + callbacks.onError((event as ErrorEvent).error); + }); + socket.addEventListener("close", () => { + callbacks.onClose(); + }); + + publisher.publishOpen(new Event("open")); + publisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify(mockDynamicParametersResponse), + }), + ); + + return mockWebSocket; + }); + }); + + afterEach(() => { + mockWebSocket?.close(); + jest.restoreAllMocks(); + }); + + describe("WebSocket Integration", () => { + it("establishes WebSocket connection and receives initial parameters", async () => { + renderCreateWorkspacePageExperimental(); + + await waitForLoaderToBeRemoved(); + + expect(API.templateVersionDynamicParameters).toHaveBeenCalledWith( + MockTemplate.active_version_id, + MockUserOwner.id, + expect.objectContaining({ + onMessage: expect.any(Function), + onError: expect.any(Function), + onClose: expect.any(Function), + }), + ); + + await waitFor(() => { + expect(screen.getByText(/instance type/i)).toBeInTheDocument(); + expect(screen.getByText("CPU Count")).toBeInTheDocument(); + expect(screen.getByText("Enable Monitoring")).toBeInTheDocument(); + expect(screen.getByText("Tags")).toBeInTheDocument(); + }); + }); + + it("sends parameter updates via WebSocket when form values change", async () => { + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + expect(screen.getByText(/instance type/i)).toBeInTheDocument(); + + expect(mockWebSocket.send).toBeDefined(); + + const instanceTypeSelect = screen.getByRole("combobox", { + name: /instance type/i, + }); + expect(instanceTypeSelect).toBeInTheDocument(); + + await waitFor(async () => { + await userEvent.click(instanceTypeSelect); + }); + + let mediumOption: Element | null = null; + await waitFor(() => { + mediumOption = screen.queryByRole("option", { name: /t3\.medium/i }); + expect(mediumOption).toBeTruthy(); + }); + + await waitFor(async () => { + await userEvent.click(mediumOption!); + }); + + expect(mockWebSocket.send).toHaveBeenCalledWith( + expect.stringContaining('"instance_type":"t3.medium"'), + ); + }); + + it("handles WebSocket error gracefully", async () => { + jest + .spyOn(API, "templateVersionDynamicParameters") + .mockImplementation((versionId, _ownerId, callbacks) => { + const [socket, pub] = createMockWebSocket(`ws://test/${versionId}`); + mockWebSocket = socket; + publisher = pub; + + socket.addEventListener("error", (event) => { + callbacks.onError((event as ErrorEvent).error); + }); + + // Simulate error + setTimeout(() => { + publisher.publishError( + new ErrorEvent("error", { + error: new Error("Connection failed"), + }), + ); + }, 10); + + return mockWebSocket; + }); + + renderCreateWorkspacePageExperimental(); + + await waitFor(() => { + expect(screen.getByText(/connection failed/i)).toBeInTheDocument(); + }); + }); + + it("handles WebSocket close event", async () => { + jest + .spyOn(API, "templateVersionDynamicParameters") + .mockImplementation((versionId, _ownerId, callbacks) => { + const [socket, pub] = createMockWebSocket(`ws://test/${versionId}`); + mockWebSocket = socket; + publisher = pub; + + socket.addEventListener("close", () => { + callbacks.onClose(); + }); + + setTimeout(() => { + publisher.publishClose(new CloseEvent("close")); + }, 10); + + return mockWebSocket; + }); + + renderCreateWorkspacePageExperimental(); + + await waitFor(() => { + expect( + screen.getByText(/websocket connection.*unexpectedly closed/i), + ).toBeInTheDocument(); + }); + }); + + it("only parameters from latest response are displayed", async () => { + jest + .spyOn(API, "templateVersionDynamicParameters") + .mockImplementation((versionId, _ownerId, callbacks) => { + const [socket, pub] = createMockWebSocket(`ws://test/${versionId}`); + mockWebSocket = socket; + publisher = pub; + + socket.addEventListener("message", (event) => { + callbacks.onMessage(JSON.parse(event.data)); + }); + + publisher.publishOpen(new Event("open")); + publisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify({ + id: 0, + parameters: [mockDropdownParameter], + diagnostics: [], + }), + }), + ); + + return mockWebSocket; + }); + + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + const response1: DynamicParametersResponse = { + id: 1, + parameters: [mockDropdownParameter], + diagnostics: [], + }; + const response2: DynamicParametersResponse = { + id: 4, + parameters: [mockSliderParameter], + diagnostics: [], + }; + + await waitFor(() => { + publisher.publishMessage( + new MessageEvent("message", { data: JSON.stringify(response1) }), + ); + + publisher.publishMessage( + new MessageEvent("message", { data: JSON.stringify(response2) }), + ); + }); + + expect(screen.queryByText("CPU Count")).toBeInTheDocument(); + expect(screen.queryByText("Instance Type")).not.toBeInTheDocument(); + }); + }); + + describe("Dynamic Parameter Types", () => { + it("renders dropdown parameter with options", async () => { + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + expect(screen.getByText(/instance type/i)).toBeInTheDocument(); + + const select = screen.getByRole("combobox", { name: /instance type/i }); + + await waitFor(async () => { + await userEvent.click(select); + }); + + // Each option appears in both the trigger and the dropdown + expect(screen.getAllByText(/t3\.micro/i)).toHaveLength(2); + expect(screen.getAllByText(/t3\.small/i)).toHaveLength(2); + expect(screen.getAllByText(/t3\.medium/i)).toHaveLength(2); + }); + + it("renders number parameter with slider", async () => { + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + await waitFor(() => { + expect(screen.getByText("CPU Count")).toBeInTheDocument(); + }); + + await waitFor(() => { + const numberInput = screen.getByDisplayValue("2"); + expect(numberInput).toBeInTheDocument(); + }); + }); + + it("renders boolean parameter with switch", async () => { + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + await waitFor(() => { + expect(screen.getByText("Enable Monitoring")).toBeInTheDocument(); + expect( + screen.getByRole("switch", { name: /enable monitoring/i }), + ).toBeInTheDocument(); + }); + }); + + it("renders list parameter with tag input", async () => { + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + await waitFor(() => { + expect(screen.getByText("Tags")).toBeInTheDocument(); + expect( + screen.getByRole("textbox", { name: /tags/i }), + ).toBeInTheDocument(); + }); + }); + + it("renders multi-select parameter", async () => { + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + await waitFor(() => { + expect(screen.getByText("IDEs")).toBeInTheDocument(); + }); + + const multiSelect = screen.getByTestId("multiselect-ides"); + expect(multiSelect).toBeInTheDocument(); + + const select = multiSelect.querySelector('[role="combobox"]'); + expect(select).toBeInTheDocument(); + + await waitFor(async () => { + await userEvent.click(select!); + }); + + expect( + screen.getByRole("option", { name: /vscode/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: /cursor/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: /goland/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: /windsurf/i }), + ).toBeInTheDocument(); + }); + + it("displays parameter validation errors", async () => { + jest + .spyOn(API, "templateVersionDynamicParameters") + .mockImplementation((versionId, _ownerId, callbacks) => { + const [socket, pub] = createMockWebSocket(`ws://test/${versionId}`); + mockWebSocket = socket; + publisher = pub; + + socket.addEventListener("message", (event) => { + callbacks.onMessage(JSON.parse(event.data)); + }); + + publisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify(mockDynamicParametersResponseWithError), + }), + ); + + return mockWebSocket; + }); + + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + await waitFor(() => { + expect(screen.getByText("Validation failed")).toBeInTheDocument(); + expect( + screen.getByText( + "The selected instance type is not available in this region", + ), + ).toBeInTheDocument(); + }); + }); + + it("displays parameter validation errors for min/max constraints", async () => { + const mockResponseInitial: DynamicParametersResponse = { + id: 1, + parameters: [validationParameter], + diagnostics: [], + }; + + const mockResponseWithError: DynamicParametersResponse = { + id: 2, + parameters: [ + { + ...validationParameter, + value: { value: "200", valid: false }, + diagnostics: [ + { + severity: "error", + summary: + "Invalid parameter value according to 'validation' block", + detail: "value 200 is more than the maximum 100", + extra: { + code: "", + }, + }, + ], + }, + ], + diagnostics: [], + }; + + jest + .spyOn(API, "templateVersionDynamicParameters") + .mockImplementation((versionId, _ownerId, callbacks) => { + const [socket, pub] = createMockWebSocket(`ws://test/${versionId}`); + mockWebSocket = socket; + publisher = pub; + + socket.addEventListener("message", (event) => { + callbacks.onMessage(JSON.parse(event.data)); + }); + + publisher.publishOpen(new Event("open")); + + publisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify(mockResponseInitial), + }), + ); + + const originalSend = socket.send; + socket.send = jest.fn((data) => { + originalSend.call(socket, data); + + if (typeof data === "string" && data.includes('"200"')) { + publisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify(mockResponseWithError), + }), + ); + } + }); + + return mockWebSocket; + }); + + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + await waitFor(() => { + expect(screen.getByText("Invalid Parameter")).toBeInTheDocument(); + }); + + const numberInput = screen.getByDisplayValue("50"); + expect(numberInput).toBeInTheDocument(); + + await waitFor(async () => { + await userEvent.clear(numberInput); + await userEvent.type(numberInput, "200"); + }); + + await waitFor(() => { + expect(screen.getByDisplayValue("200")).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByText( + "Invalid parameter value according to 'validation' block", + ), + ).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByText("value 200 is more than the maximum 100"), + ).toBeInTheDocument(); + }); + + const errorElement = screen.getByText( + "value 200 is more than the maximum 100", + ); + expect(errorElement.closest("div")).toHaveClass( + "text-content-destructive", + ); + }); + }); + + describe("External Authentication", () => { + it("displays external auth providers", async () => { + jest + .spyOn(API, "getTemplateVersionExternalAuth") + .mockResolvedValue([MockTemplateVersionExternalAuthGithub]); + + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + await waitFor(() => { + expect(screen.getByText("GitHub")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /login with github/i }), + ).toBeInTheDocument(); + }); + }); + + it("shows authenticated state for connected providers", async () => { + jest + .spyOn(API, "getTemplateVersionExternalAuth") + .mockResolvedValue([ + MockTemplateVersionExternalAuthGithubAuthenticated, + ]); + + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + await waitFor(() => { + expect(screen.getByText("GitHub")).toBeInTheDocument(); + expect(screen.getByText(/authenticated/i)).toBeInTheDocument(); + }); + }); + + it("prevents auto-creation when required external auth is missing", async () => { + jest + .spyOn(API, "getTemplateVersionExternalAuth") + .mockResolvedValue([MockTemplateVersionExternalAuthGithub]); + + renderCreateWorkspacePageExperimental( + `/templates/${MockTemplate.name}/workspace?mode=auto`, + ); + await waitForLoaderToBeRemoved(); + + await waitFor(() => { + expect( + screen.getByText( + /external authentication providers that are not connected/i, + ), + ).toBeInTheDocument(); + expect( + screen.getByText(/auto-creation has been disabled/i), + ).toBeInTheDocument(); + }); + }); + }); + + describe("Auto-creation Mode", () => { + it("falls back to form mode when auto-creation fails", async () => { + jest + .spyOn(API, "getTemplateVersionExternalAuth") + .mockResolvedValue([ + MockTemplateVersionExternalAuthGithubAuthenticated, + ]); + jest + .spyOn(API, "createWorkspace") + .mockRejectedValue(new Error("Auto-creation failed")); + + renderCreateWorkspacePageExperimental( + `/templates/${MockTemplate.name}/workspace?mode=auto`, + ); + + await waitForLoaderToBeRemoved(); + + expect(screen.getByText(/instance type/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText("Create workspace")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /create workspace/i }), + ).toBeInTheDocument(); + }); + }); + }); + + describe("Form Submission", () => { + it("creates workspace with correct parameters", async () => { + renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + expect(screen.getByText(/instance type/i)).toBeInTheDocument(); + + const nameInput = screen.getByRole("textbox", { + name: /workspace name/i, + }); + await waitFor(async () => { + await userEvent.clear(nameInput); + await userEvent.type(nameInput, "my-test-workspace"); + }); + + const createButton = screen.getByRole("button", { + name: /create workspace/i, + }); + await waitFor(async () => { + await userEvent.click(createButton); + }); + + await waitFor(() => { + expect(API.createWorkspace).toHaveBeenCalledWith( + "test-user", + expect.objectContaining({ + name: "my-test-workspace", + template_version_id: MockTemplate.active_version_id, + template_id: undefined, + rich_parameter_values: [ + expect.objectContaining({ name: "instance_type", value: "" }), + expect.objectContaining({ name: "cpu_count", value: "2" }), + expect.objectContaining({ + name: "enable_monitoring", + value: "true", + }), + expect.objectContaining({ name: "tags", value: "[]" }), + expect.objectContaining({ name: "ides", value: "[]" }), + ], + }), + ); + }); + }); + }); + + describe("URL Parameters", () => { + it("pre-fills parameters from URL", async () => { + renderCreateWorkspacePageExperimental( + `/templates/${MockTemplate.name}/workspace?param.instance_type=t3.large¶m.cpu_count=4`, + ); + await waitForLoaderToBeRemoved(); + + expect(screen.getByText(/instance type/i)).toBeInTheDocument(); + expect(screen.getByText("CPU Count")).toBeInTheDocument(); + }); + + it("uses custom template version when specified", async () => { + const customVersionId = "custom-version-123"; + + renderCreateWorkspacePageExperimental( + `/templates/${MockTemplate.name}/workspace?version=${customVersionId}`, + ); + + await waitFor(() => { + expect(API.templateVersionDynamicParameters).toHaveBeenCalledWith( + customVersionId, + MockUserOwner.id, + expect.any(Object), + ); + }); + }); + + it("pre-fills workspace name from URL", async () => { + const workspaceName = "my-custom-workspace"; + + renderCreateWorkspacePageExperimental( + `/templates/${MockTemplate.name}/workspace?name=${workspaceName}`, + ); + await waitForLoaderToBeRemoved(); + + await waitFor(() => { + const nameInput = screen.getByRole("textbox", { + name: /workspace name/i, + }); + expect(nameInput).toHaveValue(workspaceName); + }); + }); + }); + + describe("Navigation", () => { + it("navigates to workspace after successful creation", async () => { + const { router } = renderCreateWorkspacePageExperimental(); + await waitForLoaderToBeRemoved(); + + const nameInput = screen.getByRole("textbox", { + name: /workspace name/i, + }); + + await waitFor(async () => { + await userEvent.clear(nameInput); + await userEvent.type(nameInput, "my-test-workspace"); + }); + + // Submit form + const createButton = screen.getByRole("button", { + name: /create workspace/i, + }); + await waitFor(async () => { + await userEvent.click(createButton); + }); + + await waitFor(() => { + expect(router.state.location.pathname).toBe( + `/@${MockWorkspace.owner_name}/${MockWorkspace.name}`, + ); + }); + }); + }); +}); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 2e39c5625a6cb..d4b1c762ff6a5 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -271,16 +271,19 @@ const CreateWorkspacePageExperimental: FC = () => { return [...latestResponse.parameters].sort((a, b) => a.order - b.order); }, [latestResponse?.parameters]); + const shouldShowLoader = + !templateQuery.data || + isLoadingFormData || + isLoadingExternalAuth || + autoCreateReady || + (!latestResponse && !wsError); + return ( <> {pageTitle(title)} - {!latestResponse || - !templateQuery.data || - isLoadingFormData || - isLoadingExternalAuth || - autoCreateReady ? ( + {shouldShowLoader ? ( ) : ( pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy