Skip to content

Commit c83f253

Browse files
Refactor AIAssistant and Chat components with FontAwesome integration
- Replaced AI icon in AIAssistantModal and ChatContainer with FontAwesome's robot icon. - Updated styles for the AI assistant title in AIAssistantModal.scss and ChatPage.scss for better alignment and visibility. - Adjusted ChatMessage layout to use flex-row for improved message display. - Enhanced CheckboxInput tests to use data attributes for checked state verification. - Mocked IonCheckbox in tests for better simulation of behavior.
1 parent ef2a9fe commit c83f253

File tree

11 files changed

+168
-65
lines changed

11 files changed

+168
-65
lines changed

frontend/src/common/components/AIAssistant/AIAssistantModal.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747

4848
.ai-assistant-title-icon {
4949
height: 2.5rem;
50+
width: 2.5rem;
51+
font-size: 2rem;
52+
margin-right: 0.75rem;
53+
color: var(--ion-color-primary);
5054
}
5155

5256
.ai-assistant-title-text {

frontend/src/common/components/AIAssistant/AIAssistantModal.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import {
88
IonToolbar,
99
IonFooter,
1010
} from '@ionic/react';
11-
import { useState, useRef } from 'react';
11+
import { useState, useRef, useEffect } from 'react';
1212
import { closeOutline, expandOutline, contractOutline } from 'ionicons/icons';
13-
import aiIcon from '../../../assets/img/ai-icon.svg';
13+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
14+
import { faRobot } from '@fortawesome/free-solid-svg-icons';
1415
import ChatContainer from '../Chat/ChatContainer';
1516
import ChatInput from '../Chat/ChatInput';
1617
import { chatService } from '../../services/ChatService';
@@ -31,19 +32,25 @@ const AIAssistantModal: React.FC<AIAssistantModalProps> = ({
3132
const [isExpanded, setIsExpanded] = useState<boolean>(false);
3233
const [messages, setMessages] = useState<ChatMessageData[]>([]);
3334
const modalRef = useRef<HTMLIonModalElement>(null);
35+
36+
// Reset expanded state whenever modal opens
37+
useEffect(() => {
38+
if (isOpen) {
39+
setIsExpanded(false);
40+
}
41+
}, [isOpen]);
3442

3543
const handleClose = () => {
3644
setIsOpen(false);
37-
setIsExpanded(false);
3845
};
3946

4047
const handleExpand = () => {
4148
setIsExpanded(!isExpanded);
4249
};
4350

4451
const handleSendMessage = async (text: string) => {
45-
// If this is the first message, expand the modal automatically
46-
if (messages.length === 0 && !isExpanded) {
52+
// Always expand the modal on any message
53+
if (!isExpanded) {
4754
setIsExpanded(true);
4855
}
4956

@@ -73,7 +80,7 @@ const AIAssistantModal: React.FC<AIAssistantModalProps> = ({
7380
<IonHeader className="ai-assistant-header">
7481
<IonToolbar className="ai-assistant-toolbar">
7582
<div className="ai-assistant-title-container">
76-
<img src={aiIcon} alt="AI Assistant Icon" className="ai-assistant-title-icon" />
83+
<FontAwesomeIcon icon={faRobot} className="ai-assistant-title-icon" />
7784
<span className="ai-assistant-title-text">AI Assistant</span>
7885
</div>
7986
<IonButtons slot="end">
@@ -98,7 +105,7 @@ const AIAssistantModal: React.FC<AIAssistantModalProps> = ({
98105
<IonContent className="ai-assistant-content">
99106
<ChatContainer
100107
messages={messages}
101-
aiIconSrc={aiIcon}
108+
robotIcon={faRobot}
102109
testid={`${testid}-chat-container`}
103110
/>
104111
</IonContent>

frontend/src/common/components/Chat/ChatContainer.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,9 @@
1616
text-align: center;
1717
padding: 0 2rem;
1818
}
19+
20+
/* Force all direct children to align left */
21+
& > * {
22+
align-self: flex-start;
23+
}
1924
}

frontend/src/common/components/Chat/ChatContainer.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import React, { useEffect, useRef } from 'react';
22
import { useTranslation } from 'react-i18next';
3+
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
34
import ChatMessage, { ChatMessageData } from './ChatMessage';
45
import './ChatContainer.scss';
56

67
interface ChatContainerProps {
78
messages: ChatMessageData[];
8-
aiIconSrc: string;
9+
aiIconSrc?: string;
10+
robotIcon?: IconDefinition;
911
testid?: string;
1012
className?: string;
1113
}
@@ -17,6 +19,7 @@ interface ChatContainerProps {
1719
const ChatContainer: React.FC<ChatContainerProps> = ({
1820
messages,
1921
aiIconSrc,
22+
robotIcon,
2023
testid = 'chat-container',
2124
className = '',
2225
}) => {
@@ -48,6 +51,7 @@ const ChatContainer: React.FC<ChatContainerProps> = ({
4851
key={message.id}
4952
message={message}
5053
aiIconSrc={aiIconSrc}
54+
robotIcon={robotIcon}
5155
testid={`${testid}-message`}
5256
/>
5357
))

frontend/src/common/components/Chat/ChatMessage.scss

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
.chat-message {
22
display: flex;
3-
flex-direction: column;
3+
flex-direction: row;
44
max-width: 90%;
55
margin-bottom: 0.75rem;
6+
align-self: flex-start;
67

78
.message-content {
89
padding: 0.75rem 1rem;
@@ -16,22 +17,16 @@
1617
}
1718

1819
&.user-message {
19-
align-self: flex-end;
20-
2120
.message-content {
22-
background-color: var(--ion-color-primary);
23-
color: var(--ion-color-primary-contrast);
24-
border-bottom-right-radius: 0.25rem;
21+
background-color: #EBECFD;
22+
color: var(--ion-color-dark);
2523
}
2624
}
2725

2826
&.assistant-message {
29-
align-self: flex-start;
30-
3127
.message-content {
32-
background-color: var(--ion-color-light);
28+
background-color: #FFFFFF;
3329
color: var(--ion-color-dark);
34-
border-bottom-left-radius: 0.25rem;
3530
}
3631
}
3732

@@ -40,26 +35,31 @@
4035
width: 1.5rem;
4136
height: 1.5rem;
4237
border-radius: 50%;
43-
margin-bottom: 0.25rem;
38+
margin-right: 0.5rem;
39+
align-self: flex-start;
4440

4541
&.user-avatar {
46-
align-self: flex-end;
47-
margin-right: 0.25rem;
42+
display: flex;
43+
align-items: center;
44+
justify-content: center;
45+
46+
ion-icon {
47+
width: 1.5rem;
48+
height: 1.5rem;
49+
}
50+
4851
}
4952

5053
&.assistant-avatar {
51-
align-self: flex-start;
52-
margin-left: 0.25rem;
53-
background-color: var(--ion-color-primary);
5454
display: flex;
5555
align-items: center;
5656
justify-content: center;
5757

58-
svg, img {
59-
width: 1rem;
60-
height: 1rem;
61-
filter: brightness(0) invert(1);
58+
svg, img, ion-icon {
59+
width: 1.5rem;
60+
height: 1.5rem;
6261
}
62+
6363
}
6464
}
6565
}

frontend/src/common/components/Chat/ChatMessage.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { IonIcon } from '@ionic/react';
22
import { personCircleOutline } from 'ionicons/icons';
33
import React from 'react';
4+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5+
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
46
import './ChatMessage.scss';
57

68
export interface ChatMessageData {
@@ -12,7 +14,8 @@ export interface ChatMessageData {
1214

1315
interface ChatMessageProps {
1416
message: ChatMessageData;
15-
aiIconSrc: string;
17+
aiIconSrc?: string;
18+
robotIcon?: IconDefinition;
1619
testid?: string;
1720
}
1821

@@ -23,6 +26,7 @@ interface ChatMessageProps {
2326
const ChatMessage: React.FC<ChatMessageProps> = ({
2427
message,
2528
aiIconSrc,
29+
robotIcon,
2630
testid = 'chat-message'
2731
}) => {
2832
const isUser = message.sender === 'user';
@@ -42,7 +46,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
4246

4347
{!isUser && (
4448
<div className="message-avatar assistant-avatar" data-testid={`${messageTestId}-avatar`}>
45-
<img src={aiIconSrc} alt="AI" aria-hidden="true" />
49+
{robotIcon ? (
50+
<FontAwesomeIcon icon={robotIcon} aria-hidden="true" />
51+
) : aiIconSrc ? (
52+
<img src={aiIconSrc} alt="AI" aria-hidden="true" />
53+
) : (
54+
<IonIcon icon={personCircleOutline} aria-hidden="true" />
55+
)}
4656
</div>
4757
)}
4858

frontend/src/common/components/Input/__tests__/CheckboxInput.test.tsx

Lines changed: 80 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,62 @@
11
import { describe, expect, it, vi } from 'vitest';
22
import userEvent from '@testing-library/user-event';
33
import { Form, Formik } from 'formik';
4+
import { waitFor } from '@testing-library/react';
5+
import { ReactNode } from 'react';
46

57
import { render, screen } from 'test/test-utils';
68

9+
// Mock IonCheckbox to better simulate its behavior in tests
10+
vi.mock('@ionic/react', async () => {
11+
const actual = await vi.importActual('@ionic/react');
12+
return {
13+
...actual,
14+
IonCheckbox: ({
15+
children,
16+
onIonChange,
17+
checked,
18+
value,
19+
'data-testid': testId,
20+
className,
21+
name
22+
}: {
23+
children?: ReactNode;
24+
onIonChange?: (event: { detail: { checked: boolean; value?: string } }) => void;
25+
checked?: boolean | string;
26+
value?: string;
27+
'data-testid'?: string;
28+
className?: string;
29+
name?: string;
30+
}) => {
31+
const handleClick = () => {
32+
const newChecked = typeof checked === 'string' ? checked === 'false' : !checked;
33+
const detailObj = {
34+
checked: newChecked,
35+
value
36+
};
37+
onIonChange?.({ detail: detailObj });
38+
};
39+
40+
const checkedValue = String(checked);
41+
const ariaChecked = checkedValue === 'true' ? 'true' : 'false';
42+
43+
return (
44+
<div
45+
onClick={handleClick}
46+
className={`ion-checkbox ${className || ''}`}
47+
data-testid={testId}
48+
aria-checked={ariaChecked as 'false' | 'true'}
49+
data-checked={checkedValue}
50+
data-name={name}
51+
data-value={value}
52+
>
53+
{children}
54+
</div>
55+
);
56+
}
57+
};
58+
});
59+
760
import CheckboxInput from '../CheckboxInput';
861

962
describe('CheckboxInput', () => {
@@ -39,7 +92,7 @@ describe('CheckboxInput', () => {
3992

4093
// ASSERT
4194
expect(screen.getByTestId('input')).toBeDefined();
42-
expect(screen.getByTestId('input')).toHaveAttribute('checked', 'false');
95+
expect(screen.getByTestId('input')).toHaveAttribute('data-checked', 'false');
4396
});
4497

4598
it('should be checked', async () => {
@@ -57,7 +110,7 @@ describe('CheckboxInput', () => {
57110

58111
// ASSERT
59112
expect(screen.getByTestId('input')).toBeDefined();
60-
expect(screen.getByTestId('input')).toHaveAttribute('checked', 'true');
113+
expect(screen.getByTestId('input')).toHaveAttribute('data-checked', 'true');
61114
});
62115

63116
it('should change boolean value', async () => {
@@ -73,14 +126,16 @@ describe('CheckboxInput', () => {
73126
</Formik>,
74127
);
75128
await screen.findByTestId('input');
76-
expect(screen.getByTestId('input')).toHaveAttribute('checked', 'false');
129+
expect(screen.getByTestId('input')).toHaveAttribute('data-checked', 'false');
77130

78131
// ACT
79-
await user.click(screen.getByText('MyCheckbox'));
132+
await user.click(screen.getByTestId('input'));
80133

81134
// ASSERT
82-
expect(screen.getByTestId('input')).toBeDefined();
83-
expect(screen.getByTestId('input')).toHaveAttribute('checked', 'true');
135+
await waitFor(() => {
136+
expect(screen.getByTestId('input')).toBeDefined();
137+
expect(screen.getByTestId('input')).toHaveAttribute('data-checked', 'true');
138+
}, { timeout: 1000 });
84139
});
85140

86141
it('should change array value', async () => {
@@ -99,26 +154,29 @@ describe('CheckboxInput', () => {
99154
</Formik>,
100155
);
101156
await screen.findByTestId('one');
102-
expect(screen.getByTestId('one')).toHaveAttribute('checked', 'false');
103-
expect(screen.getByTestId('two')).toHaveAttribute('checked', 'false');
157+
expect(screen.getByTestId('one')).toHaveAttribute('data-checked', 'false');
158+
expect(screen.getByTestId('two')).toHaveAttribute('data-checked', 'false');
104159

105160
// ACT
106-
await user.click(screen.getByText('CheckboxOne'));
161+
await user.click(screen.getByTestId('one'));
107162

108163
// ASSERT
109-
expect(screen.getByTestId('one')).toBeDefined();
110-
expect(screen.getByTestId('one')).toHaveAttribute('checked', 'true');
111-
expect(screen.getByTestId('two')).toHaveAttribute('checked', 'false');
164+
await waitFor(() => {
165+
expect(screen.getByTestId('one')).toHaveAttribute('data-checked', 'true');
166+
expect(screen.getByTestId('two')).toHaveAttribute('data-checked', 'false');
167+
}, { timeout: 1000 });
112168

113169
// ACT
114-
await user.click(screen.getByText('CheckboxOne'));
170+
await user.click(screen.getByTestId('one'));
115171

116172
// ASSERT
117-
expect(screen.getByTestId('one')).toHaveAttribute('checked', 'false');
118-
expect(screen.getByTestId('two')).toHaveAttribute('checked', 'false');
173+
await waitFor(() => {
174+
expect(screen.getByTestId('one')).toHaveAttribute('data-checked', 'false');
175+
expect(screen.getByTestId('two')).toHaveAttribute('data-checked', 'false');
176+
}, { timeout: 1000 });
119177
});
120178

121-
it.skip('should call onChange function', async () => {
179+
it('should call onChange function', async () => {
122180
// ARRANGE
123181
const user = userEvent.setup();
124182
const onChange = vi.fn();
@@ -132,13 +190,15 @@ describe('CheckboxInput', () => {
132190
</Form>
133191
</Formik>,
134192
);
135-
await screen.findByText(/MyCheckbox/i);
193+
await screen.findByTestId('input');
136194

137195
// ACT
138-
await user.click(screen.getByText(/MyCheckbox/i));
196+
await user.click(screen.getByTestId('input'));
139197

140198
// ASSERT
141-
expect(onChange).toHaveBeenCalledTimes(1);
142-
expect(screen.getByTestId('input')).toHaveAttribute('checked', 'true');
199+
await waitFor(() => {
200+
expect(onChange).toHaveBeenCalledTimes(1);
201+
expect(screen.getByTestId('input')).toHaveAttribute('data-checked', 'true');
202+
}, { timeout: 1000 });
143203
});
144204
});

0 commit comments

Comments
 (0)