Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

import { renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import type { ReactNode } from "react";
import { usePostConversationMessages } from "@api/usePostConversationMessages";

const mockLogger = { debug: vi.fn() };
vi.mock("@hooks/useLogger", () => ({ useLogger: () => mockLogger }));

const mockAuthFetch = vi.fn();
vi.mock("@context/AuthApiContext", () => ({
useAuthApiClient: () => mockAuthFetch,
}));

const mockUseAsgardeo = vi.fn(() => ({
isSignedIn: true,
isLoading: false,
}));
vi.mock("@asgardeo/react", () => ({
useAsgardeo: () => mockUseAsgardeo(),
}));

describe("usePostConversationMessages", () => {
let queryClient: QueryClient;
const originalConfig = window.config;

const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

const requestParams = {
projectId: "proj-1",
conversationId: "conv-1",
message: "Follow-up question",
envProducts: { QA: ["WSO2 IS 6.1"], Production: ["WSO2 AM 4.2"] },
region: "EU",
tier: "Tier 1",
};

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
vi.clearAllMocks();
window.config = {
CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com",
} as typeof window.config;
mockAuthFetch.mockResolvedValue(
new Response(JSON.stringify({ message: "AI response", conversationId: "conv-1" }), {
status: 200,
}),
);
});

afterEach(() => {
window.config = originalConfig;
});

it("should POST to messages endpoint and return response", async () => {
const { result } = renderHook(() => usePostConversationMessages(), {
wrapper,
});

const response = await result.current.mutateAsync(requestParams);

expect(mockAuthFetch).toHaveBeenCalledWith(
"https://api.example.com/projects/proj-1/conversations/conv-1/messages",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
message: requestParams.message,
envProducts: requestParams.envProducts,
region: requestParams.region,
tier: requestParams.tier,
}),
}),
);
expect(response).toEqual({
message: "AI response",
conversationId: "conv-1",
});
});

it("should throw when base URL is not configured", async () => {
window.config = {} as typeof window.config;
const { result } = renderHook(() => usePostConversationMessages(), {
wrapper,
});

await expect(result.current.mutateAsync(requestParams)).rejects.toThrow(
"CUSTOMER_PORTAL_BACKEND_BASE_URL is not configured",
);
});

it("should throw when user is not signed in", async () => {
mockUseAsgardeo.mockReturnValueOnce({
isSignedIn: false,
isLoading: false,
});
const { result } = renderHook(() => usePostConversationMessages(), {
wrapper,
});

await expect(result.current.mutateAsync(requestParams)).rejects.toThrow(
"User must be signed in to send messages",
);
});

it("should throw when auth is loading", async () => {
mockUseAsgardeo.mockReturnValueOnce({
isSignedIn: false,
isLoading: true,
});
const { result } = renderHook(() => usePostConversationMessages(), {
wrapper,
});

await expect(result.current.mutateAsync(requestParams)).rejects.toThrow(
"User must be signed in to send messages",
);
});

it("should throw when API returns error response", async () => {
mockAuthFetch.mockResolvedValueOnce(
new Response("Internal Server Error", {
status: 500,
statusText: "Internal Server Error",
}),
);
const { result } = renderHook(() => usePostConversationMessages(), {
wrapper,
});

await expect(result.current.mutateAsync(requestParams)).rejects.toThrow(
"Conversation messages API error: 500 Internal Server Error",
);
});
});
83 changes: 83 additions & 0 deletions apps/customer-portal/webapp/src/api/usePostConversationMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

import { useMutation, type UseMutationResult } from "@tanstack/react-query";
import { useAsgardeo } from "@asgardeo/react";
import { useLogger } from "@hooks/useLogger";
import { useAuthApiClient } from "@context/AuthApiContext";
import type { ConversationRequest } from "@models/requests";
import type { ConversationResponse } from "@models/responses";

/**
* Posts a follow-up message to an existing conversation.
* Called after usePostConversations when user sends additional messages in chat.
*
* @returns {UseMutationResult<ConversationResponse, Error, { projectId: string; conversationId: string } & ConversationRequest>} Mutation result.
*/
export function usePostConversationMessages(): UseMutationResult<
ConversationResponse,
Error,
{ projectId: string; conversationId: string } & ConversationRequest
> {
const logger = useLogger();
const { isSignedIn, isLoading: isAuthLoading } = useAsgardeo();
const fetchFn = useAuthApiClient();

return useMutation<
ConversationResponse,
Error,
{ projectId: string; conversationId: string } & ConversationRequest
>({
mutationFn: async (
params: { projectId: string; conversationId: string } & ConversationRequest,
): Promise<ConversationResponse> => {
const { projectId, conversationId, message, envProducts, region, tier } =
params;

if (!isSignedIn || isAuthLoading) {
throw new Error("User must be signed in to send messages");
}

logger.debug("[usePostConversationMessages] Request:", {
projectId,
conversationId,
messageLength: message.length,
});

const baseUrl = window.config?.CUSTOMER_PORTAL_BACKEND_BASE_URL;
if (!baseUrl) {
throw new Error(
"CUSTOMER_PORTAL_BACKEND_BASE_URL is not configured",
);
}

const requestUrl = `${baseUrl}/projects/${projectId}/conversations/${conversationId}/messages`;
const response = await fetchFn(requestUrl, {
method: "POST",
body: JSON.stringify({ message, envProducts, region, tier }),
});

if (!response.ok) {
throw new Error(
`Conversation messages API error: ${response.status} ${response.statusText}`,
);
}

const data: ConversationResponse = await response.json();
logger.debug("[usePostConversationMessages] Data received");
return data;
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
// specific language governing permissions and limitations
// under the License.

import { Box, CircularProgress, IconButton } from "@wso2/oxygen-ui";
import { Box, CircularProgress, IconButton, Typography } from "@wso2/oxygen-ui";
import { Send } from "@wso2/oxygen-ui-icons-react";
import { useState } from "react";
import { usePostComment } from "@api/usePostComment";
Expand Down Expand Up @@ -87,7 +87,7 @@ export default function ActivityCommentInput({
resetTrigger={resetTrigger}
minHeight={40}
showToolbar={true}
placeholder="Write a comment... (Enter to send, Shift+Enter for new line)"
placeholder="Write a comment..."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
onSubmitKeyDown={handleSend}
/>
<Box
Expand Down Expand Up @@ -121,6 +121,13 @@ export default function ActivityCommentInput({
</IconButton>
</Box>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.5 }}
>
Press Enter to send, Shift+Enter for newline
</Typography>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export default function NoveraChatBanner(): JSX.Element {
<Button
variant="contained"
color="warning"
onClick={() => navigate("chat")}
onClick={() => navigate("chat/describe-issue")}
>
Start New Chat
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,12 @@ describe("NoveraChatBanner", () => {
expect(screen.getByTestId("icon-bot")).toBeInTheDocument();
});

it("should navigate to chat page when clicking 'Start New Chat'", () => {
it("should navigate to describe-issue page when clicking 'Start New Chat'", () => {
render(<NoveraChatBanner />);

const button = screen.getByRole("button", { name: /Start New Chat/i });
fireEvent.click(button);

expect(mockNavigate).toHaveBeenCalledWith("chat");
expect(mockNavigate).toHaveBeenCalledWith("chat/describe-issue");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,65 +14,60 @@
// specific language governing permissions and limitations
// under the License.

import { Box, IconButton, TextField } from "@wso2/oxygen-ui";
import { Box, IconButton } from "@wso2/oxygen-ui";
import { Send } from "@wso2/oxygen-ui-icons-react";
import { type JSX } from "react";
import EscalationBanner from "@components/support/novera-ai-assistant/novera-chat-page/EscalationBanner";
import Editor from "@components/common/rich-text-editor/Editor";
import { htmlToPlainText } from "@utils/richTextEditor";

interface ChatInputProps {
inputValue: string;
setInputValue: (value: string) => void;
onSend: () => void;
showEscalationBanner: boolean;
onCreateCase: () => void;
isCreateCaseLoading?: boolean;
isCreateCaseDisabled?: boolean;
isSending?: boolean;
resetTrigger?: number;
}

const CHAT_PLACEHOLDER = "Type your message...";

/**
* Renders the input area for the Novera Chat page.
*
* Handles message input, sending messages, and optionally displays
* an escalation banner for creating support cases.
* Renders the input area for the Novera Chat page with rich text editor.
* Single line by default, extends on Shift+Enter. Same toolbar as describe-issue.
*
* @returns The ChatInput JSX element.
*/
export default function ChatInput({
inputValue,
setInputValue,
onSend,
showEscalationBanner,
onCreateCase,
isCreateCaseLoading,
isCreateCaseDisabled,
isSending = false,
resetTrigger = 0,
}: ChatInputProps): JSX.Element {
const plainText = htmlToPlainText(inputValue).trim();
const isSendDisabled = !plainText || isSending;

return (
<Box sx={{ p: 2, bgcolor: "background.paper", flexShrink: 0 }}>
<EscalationBanner
visible={showEscalationBanner}
onCreateCase={onCreateCase}
isLoading={isCreateCaseLoading}
isCreateCaseDisabled={isCreateCaseDisabled}
/>
<Box sx={{ display: "flex", gap: 1 }}>
<TextField
fullWidth
placeholder="Type your message..."
size="small"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (!inputValue.trim()) return;
e.preventDefault();
onSend();
}
}}
/>
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-end" }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Editor
id="novera-chat-input-editor"
value={inputValue}
onChange={setInputValue}
placeholder={CHAT_PLACEHOLDER}
minHeight={40}
showToolbar
toolbarVariant="describeIssue"
onSubmitKeyDown={() => !isSendDisabled && onSend()}
disabled={isSending}
resetTrigger={resetTrigger}
/>
</Box>
<IconButton
disabled={!inputValue.trim()}
disabled={isSendDisabled}
onClick={onSend}
color="warning"
sx={{ flexShrink: 0 }}
>
<Send size={18} />
</IconButton>
Expand Down
Loading