Skip to content

Commit ab25824

Browse files
authored
feat: conserve text keyword case in keyword or chatbot views (#114)
* test: add tests to validate the keywords - checks that only complete words are matched - checks that matching keywords is case insensitive - checks that the clicked keyword conserve its case in keyword info view
1 parent 8b72335 commit ab25824

File tree

13 files changed

+247
-25
lines changed

13 files changed

+247
-25
lines changed

cypress/e2e/builder/enterSettings.cy.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
TITLE_INPUT_FIELD_CY,
1717
USE_CHATBOT_DATA_CY,
1818
buildDataCy,
19+
buildKeywordNotExistWarningCy,
1920
} from '../../../src/config/selectors';
2021
import {
2122
MOCK_APP_SETTINGS,
@@ -102,6 +103,55 @@ describe('Enter Settings', () => {
102103
cy.get(buildDataCy(KEYWORD_LIST_ITEM_CY)).should('not.exist');
103104
});
104105

106+
// Detected incomplete keywords in the text.
107+
// 'wef' was found incomplete in 'wefwef hello'.
108+
// Check that only complete words are detected in text.
109+
it('only detect complete keywords', () => {
110+
const PRBLEMATIC_TEXT = 'wefwef hello';
111+
const PROBLEMATIC_KEYWORDS = ['wef', 'he'];
112+
113+
cy.get(buildDataCy(TEXT_INPUT_FIELD_CY))
114+
.should('be.visible')
115+
.type(PRBLEMATIC_TEXT);
116+
117+
PROBLEMATIC_KEYWORDS.forEach((k) => {
118+
cy.get(buildDataCy(ENTER_KEYWORD_FIELD_CY)).should('be.visible').type(k);
119+
120+
cy.get(buildDataCy(ADD_KEYWORD_BUTTON_CY))
121+
.should('be.visible')
122+
.should('not.be.disabled')
123+
.click()
124+
.should('be.disabled');
125+
126+
cy.get(buildDataCy(buildKeywordNotExistWarningCy(k))).should(
127+
'be.visible',
128+
);
129+
});
130+
131+
cy.get(buildDataCy(SETTINGS_SAVE_BUTTON_CY)).should('be.disabled');
132+
});
133+
134+
it('detect keywords case insensitive', () => {
135+
const TEXT = 'hello this is a Test';
136+
const KEYWORDS = ['Hello', 'test'];
137+
138+
cy.get(buildDataCy(TEXT_INPUT_FIELD_CY)).should('be.visible').type(TEXT);
139+
140+
KEYWORDS.forEach((k) => {
141+
cy.get(buildDataCy(ENTER_KEYWORD_FIELD_CY)).should('be.visible').type(k);
142+
143+
cy.get(buildDataCy(ADD_KEYWORD_BUTTON_CY))
144+
.should('be.visible')
145+
.should('not.be.disabled')
146+
.click()
147+
.should('be.disabled');
148+
149+
cy.get(buildDataCy(buildKeywordNotExistWarningCy(k))).should('not.exist');
150+
});
151+
152+
cy.get(buildDataCy(SETTINGS_SAVE_BUTTON_CY)).should('be.disabled');
153+
});
154+
105155
it('does not use chatbot (by default)', () => {
106156
cy.get(buildDataCy(USE_CHATBOT_DATA_CY)).should('not.be.checked');
107157
cy.get(buildDataCy(CHATBOT_CONTAINER_CY)).should('not.exist');

cypress/e2e/player/read/showApp.cy.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@ import { Context, PermissionLevel } from '@graasp/sdk';
33
import { DEFAULT_TEXT_RESOURCE_SETTING } from '../../../../src/config/appSettings';
44
import {
55
BANNER_CY,
6+
CHATBOT_MODE_CY,
7+
DICTIONNARY_MODE_CY,
68
PLAYER_VIEW_CY,
79
SHOW_KEYWORDS_BUTTON_CY,
810
TEXT_DISPLAY_FIELD_CY,
911
buildAllKeywordsButtonDataCy,
1012
buildDataCy,
13+
keywordDataCy,
1114
} from '../../../../src/config/selectors';
1215
import {
1316
MOCK_APP_SETTINGS,
1417
MOCK_TEXT_RESOURCE,
18+
buildMock,
1519
} from '../../../fixtures/appSettings';
1620

1721
describe('Empty App Settings', () => {
@@ -88,3 +92,128 @@ describe('With App Setting', () => {
8892
);
8993
});
9094
});
95+
96+
describe('Check incomplete keywords and case insensitive', () => {
97+
const TEXT = 'wefwef hello Test';
98+
const KEYWORD_INSENSITIVE = { word: 'test', def: '' };
99+
const INCOMPLETE_KEYWORDS = [
100+
{ word: 'wef', def: '' },
101+
{ word: 'he', def: '' },
102+
];
103+
const KEYWORDS = [KEYWORD_INSENSITIVE, ...INCOMPLETE_KEYWORDS];
104+
105+
beforeEach(() => {
106+
cy.setUpApi({
107+
database: {
108+
appData: [],
109+
appSettings: buildMock(TEXT, KEYWORDS),
110+
},
111+
appContext: {
112+
context: Context.Player,
113+
permission: PermissionLevel.Read,
114+
},
115+
});
116+
cy.visit('/');
117+
});
118+
119+
it('show keywords case insensitive', () => {
120+
// check that the show keywords button is visible and active
121+
cy.get(buildDataCy(SHOW_KEYWORDS_BUTTON_CY))
122+
.should('be.visible')
123+
.and('not.be.disabled')
124+
.click();
125+
126+
cy.get(buildDataCy(keywordDataCy(KEYWORD_INSENSITIVE.word))).should(
127+
'be.visible',
128+
);
129+
});
130+
131+
it('incomplete words should not match as prefix in the text', () => {
132+
// check that the show keywords button is visible and active
133+
cy.get(buildDataCy(SHOW_KEYWORDS_BUTTON_CY))
134+
.should('be.visible')
135+
.and('not.be.disabled')
136+
.click();
137+
138+
INCOMPLETE_KEYWORDS.forEach((k) => {
139+
cy.get(buildDataCy(keywordDataCy(k.word))).should('not.exist');
140+
});
141+
});
142+
});
143+
144+
describe('Keywords should keep same case as in the text', () => {
145+
const WORD_CAPITALIZE = 'Test';
146+
const WORD_LOWER = 'docker';
147+
const TEXT = `${WORD_CAPITALIZE} ${WORD_LOWER}`;
148+
const KEYWORDS = [
149+
{ word: WORD_CAPITALIZE.toLowerCase(), def: '' },
150+
{ word: WORD_LOWER, def: '' },
151+
];
152+
153+
const capitalize = (word: string): string =>
154+
word.slice(0, 1).toUpperCase() + word.slice(1);
155+
156+
const validateKeywordCase = (
157+
dataCy: string,
158+
textWord: string,
159+
isLowerCase: boolean,
160+
): void => {
161+
// Because we use the word displayed in the text, it should be lowercase for keywordDataCy.
162+
cy.get(buildDataCy(keywordDataCy(textWord.toLowerCase()))).click();
163+
cy.get(buildDataCy(dataCy)).should(
164+
'contain',
165+
isLowerCase ? textWord.toLowerCase() : textWord,
166+
);
167+
// This one is just to check that contain is case sensitive.
168+
cy.get(buildDataCy(dataCy)).should(
169+
'not.contain',
170+
isLowerCase ? capitalize(textWord) : textWord.toLowerCase(),
171+
);
172+
};
173+
174+
it('display keyword case as in text in keyword mode', () => {
175+
cy.setUpApi({
176+
database: {
177+
appData: [],
178+
appSettings: buildMock(TEXT, KEYWORDS),
179+
},
180+
appContext: {
181+
context: Context.Player,
182+
permission: PermissionLevel.Read,
183+
},
184+
});
185+
cy.visit('/');
186+
187+
// check that the show keywords button is visible and active
188+
cy.get(buildDataCy(SHOW_KEYWORDS_BUTTON_CY))
189+
.should('be.visible')
190+
.and('not.be.disabled')
191+
.click();
192+
193+
validateKeywordCase(DICTIONNARY_MODE_CY, WORD_CAPITALIZE, false);
194+
validateKeywordCase(DICTIONNARY_MODE_CY, WORD_LOWER, true);
195+
});
196+
197+
it('display keyword case as in text in chatbot mode', () => {
198+
cy.setUpApi({
199+
database: {
200+
appData: [],
201+
appSettings: buildMock(TEXT, KEYWORDS, true),
202+
},
203+
appContext: {
204+
context: Context.Player,
205+
permission: PermissionLevel.Read,
206+
},
207+
});
208+
cy.visit('/');
209+
210+
// check that the show keywords button is visible and active
211+
cy.get(buildDataCy(SHOW_KEYWORDS_BUTTON_CY))
212+
.should('be.visible')
213+
.and('not.be.disabled')
214+
.click();
215+
216+
validateKeywordCase(CHATBOT_MODE_CY, WORD_CAPITALIZE, false);
217+
validateKeywordCase(CHATBOT_MODE_CY, WORD_LOWER, true);
218+
});
219+
});

cypress/fixtures/appSettings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,13 @@ export const MOCK_APP_SETTINGS_USING_CHATBOT = [
8989
...MOCK_APP_SETTINGS,
9090
MOCK_USE_CHATBOT_SETTING,
9191
];
92+
93+
export const buildMock = (
94+
text: string,
95+
keywords: Keyword[],
96+
useChatbot = false,
97+
): AppSetting[] => [
98+
{ ...MOCK_TEXT_RESOURCE_SETTING, data: { text } },
99+
{ ...MOCK_KEYWORDS_SETTING, data: { keywords } },
100+
...(useChatbot ? [MOCK_USE_CHATBOT_SETTING] : []),
101+
];

src/components/common/chat/ChatBox.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import {
1313
import { Alert, Box, Stack, styled } from '@mui/material';
1414

1515
import { TEXT_ANALYSIS } from '@/langs/constants';
16+
import { replaceWordCaseInsensitive } from '@/utils/keywords';
1617

1718
import { APP_DATA_TYPES, ChatAppData } from '../../../config/appDataTypes';
1819
import {
1920
INITIAL_PROMPT_SETTING_KEY,
21+
KeywordWithLabel,
2022
TextResourceData,
2123
} from '../../../config/appSettingTypes';
2224
import { DEFAULT_INITIAL_PROMPT } from '../../../config/appSettings';
@@ -71,7 +73,7 @@ const StyledReactMarkdown = styled(ReactMarkdown)(({ theme }) => ({
7173
},
7274
}));
7375

74-
type Prop = { focusWord: string; isOpen: boolean };
76+
type Prop = { focusWord: KeywordWithLabel; isOpen: boolean };
7577

7678
const ChatBox: FC<Prop> = ({ focusWord, isOpen }) => {
7779
const { t } = useTranslation();
@@ -87,21 +89,21 @@ const ChatBox: FC<Prop> = ({ focusWord, isOpen }) => {
8789
const initialPrompt = (
8890
(appSettingArray.find((s) => s.name === INITIAL_PROMPT_SETTING_KEY)?.data ||
8991
DEFAULT_INITIAL_PROMPT) as TextResourceData
90-
).text.replaceAll('{{keyword}}', focusWord);
92+
).text.replaceAll('{{keyword}}', focusWord.label);
9193

9294
const chatAppData = appDataArray
9395
.filter(
9496
(data) =>
9597
(data.type === APP_DATA_TYPES.BOT_COMMENT ||
9698
data.type === APP_DATA_TYPES.STUDENT_COMMENT) &&
97-
data.data.keyword === focusWord,
99+
data.data.keyword === focusWord.word,
98100
)
99101
.sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1)) as ChatAppData[];
100102

101103
const onSend = (input: string): void => {
102104
if (input.trim() !== '') {
103105
postAppDataAsync({
104-
data: { message: input, keyword: focusWord },
106+
data: { message: input, keyword: focusWord.word },
105107
type: APP_DATA_TYPES.STUDENT_COMMENT,
106108
})?.then(() => {
107109
const thread: ChatbotThreadMessage[] = chatAppData.map((data) => ({
@@ -116,7 +118,7 @@ const ChatBox: FC<Prop> = ({ focusWord, isOpen }) => {
116118

117119
const appData = {
118120
message: t(TEXT_ANALYSIS.CHAT_BOT_ERROR_MESSAGE),
119-
keyword: focusWord,
121+
keyword: focusWord.word,
120122
};
121123

122124
postChatBot(prompt)
@@ -143,7 +145,7 @@ const ChatBox: FC<Prop> = ({ focusWord, isOpen }) => {
143145
<InputBar onSend={(input) => onSend(input)} />
144146
);
145147

146-
const renderedMesssages = chatAppData.map((msg) =>
148+
const renderedMesssages = chatAppData.map((msg, idx) =>
147149
msg.type === APP_DATA_TYPES.STUDENT_COMMENT ? (
148150
<UserBox data-cy={messagesDataCy(msg.id)} key={msg.id} initial={initial}>
149151
<StyledUserMessage key={msg.id} alignSelf="flex-end">
@@ -155,9 +157,19 @@ const ChatBox: FC<Prop> = ({ focusWord, isOpen }) => {
155157
<StyledBotMessage
156158
key={msg.id}
157159
alignSelf="flex-start"
158-
wordLowerCase={focusWord.toLowerCase()}
160+
wordLowerCase={focusWord.word.toLowerCase()}
159161
>
160-
<StyledReactMarkdown>{msg.data.message}</StyledReactMarkdown>
162+
<StyledReactMarkdown>
163+
{/* If it is the first chatbot message, replace all keywords by the label, to keep case as in the text. */}
164+
{/* It is replaced here too, to handle AppData with keyword in lower case. */}
165+
{idx === 0
166+
? replaceWordCaseInsensitive(
167+
msg.data.message,
168+
focusWord.word,
169+
focusWord.label,
170+
)
171+
: msg.data.message}
172+
</StyledReactMarkdown>
161173
</StyledBotMessage>
162174
</ChatbotBox>
163175
),
@@ -192,7 +204,7 @@ const ChatBox: FC<Prop> = ({ focusWord, isOpen }) => {
192204
<ChatbotBox>
193205
<StyledBotMessage
194206
alignSelf="flex-start"
195-
wordLowerCase={focusWord.toLowerCase()}
207+
wordLowerCase={focusWord.word.toLowerCase()}
196208
>
197209
...
198210
</StyledBotMessage>

src/components/common/chat/ChatbotWindow.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import CloseIcon from '@mui/icons-material/Close';
44
import DeleteIcon from '@mui/icons-material/Delete';
55
import { Box, IconButton, Tooltip, Typography } from '@mui/material';
66

7-
import { Keyword } from '../../../config/appSettingTypes';
7+
import { KeywordWithLabel } from '../../../config/appSettingTypes';
88
import { CHAT_WINDOW_CY, DICTIONNARY_MODE_CY } from '../../../config/selectors';
99
import {
1010
DEFAULT_BORDER_RADIUS,
@@ -16,7 +16,7 @@ import ChatBox from './ChatBox';
1616

1717
type Prop = {
1818
closeChatbot: () => void;
19-
focusWord: Keyword;
19+
focusWord: KeywordWithLabel;
2020
useChatbot: boolean;
2121
isOpen: boolean;
2222
onDelete: () => void;
@@ -30,15 +30,15 @@ const ChatbotWindow: FC<Prop> = ({
3030
onDelete,
3131
}) => {
3232
const renderWindow = useChatbot ? (
33-
<ChatBox focusWord={focusWord.word} isOpen={isOpen} />
33+
<ChatBox focusWord={focusWord} isOpen={isOpen} />
3434
) : (
3535
<Typography
3636
data-cy={DICTIONNARY_MODE_CY}
3737
margin={DEFAULT_MARGIN}
3838
marginTop="0px"
3939
sx={{ flex: 2 }}
4040
>
41-
<strong>{focusWord.word}: </strong>
41+
<strong>{focusWord.label}: </strong>
4242
{focusWord.def}
4343
</Typography>
4444
);

src/components/common/display/Highlighted.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ import { styled } from '@mui/material';
77

88
import { isKeywordPresent } from '@/utils/keywords';
99

10-
import { Keyword } from '../../../config/appSettingTypes';
10+
import { Keyword, KeywordWithLabel } from '../../../config/appSettingTypes';
1111
import { DEFAULT_KEYWORD } from '../../../config/appSettings';
1212
import KeywordButton from './KeywordButton';
1313

1414
type Prop = {
1515
text: string;
1616
words: Keyword[];
1717
highlight: boolean;
18-
openChatbot: (word: Keyword) => void;
18+
openChatbot: (word: KeywordWithLabel) => void;
1919
};
2020

2121
const StyledReactMarkdown = styled(ReactMarkdown)(({ theme }) => ({

0 commit comments

Comments
 (0)