diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f8cfd07..6810fb3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,3 +19,6 @@ jobs: - name: Build run: yarn build + + - name: Unit Tests + run: yarn vitest diff --git a/package.json b/package.json index fdebc51..bc4a84f 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,10 @@ "homepage": ".", "type": "module", "dependencies": { + "@codemirror/lang-javascript": "^6.2.1", "@emotion/react": "11.11.1", "@emotion/styled": "11.11.0", - "@graasp/apps-query-client": "3.2.1", + "@graasp/apps-query-client": "github:graasp/graasp-apps-query-client#222-query-setting-by-name", "@graasp/sdk": "3.3.0", "@graasp/ui": "4.1.1", "@mui/icons-material": "5.14.19", @@ -24,11 +25,22 @@ "@types/node": "20.10.4", "@types/react": "18.2.42", "@types/react-dom": "18.2.17", - "i18next": "23.7.8", + "@uiw/react-codemirror": "^4.21.21", + "file-saver": "^2.0.5", + "i18next": "^23.7.9", + "lodash.groupby": "^4.6.0", + "lucide-react": "^0.297.0", + "prism-react-renderer": "^2.3.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-i18next": "13.5.0", + "react-i18next": "^13.5.0", + "react-markdown": "^9.0.1", + "react-mde": "12", + "react-router-dom": "^6.21.0", "react-toastify": "9.1.3", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.0", + "stylis-plugin-rtl": "^2.1.1", "typescript": "5.3.3" }, "scripts": { @@ -58,7 +70,9 @@ "@commitlint/config-conventional": "18.4.3", "@cypress/code-coverage": "3.12.13", "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/file-saver": "^2", "@types/i18n": "0.13.10", + "@types/lodash.groupby": "^4.6.9", "@types/uuid": "9.0.7", "@typescript-eslint/eslint-plugin": "6.13.2", "@typescript-eslint/parser": "6.13.2", @@ -87,7 +101,8 @@ "uuid": "9.0.1", "vite": "^5.0.6", "vite-plugin-checker": "^0.6.2", - "vite-plugin-istanbul": "^5.0.0" + "vite-plugin-istanbul": "^5.0.0", + "vitest": "^1.0.4" }, "browserslist": { "production": [ diff --git a/src/@types/i18next.d.ts b/src/@types/i18next.d.ts deleted file mode 100644 index abde300..0000000 --- a/src/@types/i18next.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defaultNS, resources } from '../config/i18n'; - -declare module 'i18next' { - interface CustomTypeOptions { - defaultNS: typeof defaultNS; - resources: (typeof resources)['en']; - } -} diff --git a/src/config/appActions.ts b/src/config/appActions.ts new file mode 100644 index 0000000..e8e3586 --- /dev/null +++ b/src/config/appActions.ts @@ -0,0 +1,9 @@ +export const AppActionsType = { + // Message actions + Create: 'create_comment', + Edit: 'edit_comment', + Delete: 'delete_comment', + Reply: 'reply_comment', + // chatbot actions + AskChatbot: 'ask_chatbot', +} as const; diff --git a/src/config/appData.ts b/src/config/appData.ts new file mode 100644 index 0000000..ec84117 --- /dev/null +++ b/src/config/appData.ts @@ -0,0 +1,20 @@ +import { AppData } from '@graasp/sdk'; + +export const AppDataTypes = { + UserComment: 'comment', + BotComment: 'bot-comment', +}; + +export const COMMENT_APP_DATA_TYPES: string[] = [ + AppDataTypes.UserComment, + AppDataTypes.BotComment, +]; + +export type VisibilityVariants = 'member' | 'item'; + +export type CommentData = { + content: string; + parent: string | null; + chatbotPromptSettingId?: string; +}; +export type CommentAppData = AppData; diff --git a/src/config/appSetting.ts b/src/config/appSetting.ts new file mode 100644 index 0000000..a604d56 --- /dev/null +++ b/src/config/appSetting.ts @@ -0,0 +1,26 @@ +export const SettingsKeys = { + ChatbotPrompt: 'chatbot-prompt', +} as const; + +export type ChatCompletionMessageRoles = 'system' | 'user' | 'assistant'; + +export type ChatCompletionMessage = { + role: ChatCompletionMessageRoles; + content: string; +}; + +// Chatbot Prompt Setting keys +export enum ChatbotPromptSettingsKeys { + InitialPrompt = 'initialPrompt', + ChatbotCue = 'chatbotCue', + ChatbotName = 'chatbotName', +} + +export type ChatbotPromptSettings = { + [ChatbotPromptSettingsKeys.InitialPrompt]: ChatCompletionMessage[]; + [ChatbotPromptSettingsKeys.ChatbotCue]: string; + [ChatbotPromptSettingsKeys.ChatbotName]: string; + + // used to allow access using settings[settingKey] syntax + // [key: string]: unknown; +}; diff --git a/src/config/selectors.ts b/src/config/selectors.ts index b83f722..dfb7591 100644 --- a/src/config/selectors.ts +++ b/src/config/selectors.ts @@ -140,6 +140,7 @@ export const SETTING_INITIAL_PROMPT_CODE_EDITOR_CY = export const SETTING_CHATBOT_PROMPT_LINE_NUMBER_CY = 'setting_chatbot_prompt_line_number'; export const SETTING_MAX_COMMENT_LENGTH = 'setting_max_comment_length'; +export const SETTING_MAX_THREAD_LENGTH = 'setting_max_thread_length'; export const SETTING_ADD_CHATBOT_PROMPT_CY = 'setting_add_chatbot_prompt'; export const REPL_RUN_CODE_BUTTON_CY = 'repl_run_code_button'; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..e9a94ab --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,6 @@ +export const DEFAULT_BOT_USERNAME = 'Graasp Bot'; +export const ANONYMOUS_USER = 'Anonymous'; + +// UI +export const SMALL_BORDER_RADIUS = '4px'; +export const BIG_BORDER_RADIUS = '8px'; diff --git a/src/langs/en.json b/src/langs/en.json index 8c2e067..2b94acc 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -1,155 +1,158 @@ { - "translations": { - "Welcome to the Graasp App Starter Kit": "Welcome to the Graasp App Starter Kit", - "Cancel": "Cancel", - "Finish": "Finish", - "Save": "Save", - "Stop": "Stop", - "Clear": "Clear", - "Saved": "Saved", - "Send": "Send", - "Reply": "Reply", - "Yes": "Yes", - "Write": "Write", - "Preview": "Preview", - "User": "User", - "Version": "Version", - "Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?", - "View the Users in the Sample Space": "View the Users in the Sample Space", - "Selected Student is:": "Selected Student is:", - "No student selected": "No student selected", - "Image Uri": "Image Uri", - "Name": "Name", - "Settings": "Settings", - "Code": "Code", - "Programming Language": "Programming Language", - "Show Header to Students": "Show Header to Students", - "Allow Comments": "Allow Comments", - "Allow Replies": "Allow Replies", - "Student Comments": "Student Comments", - "Code Review": "Code Review", - "Total Number of Messages": "Total Number of Messages", - "View Chat": "View Chat", - "Select a bot to impersonate": "Select a bot to impersonate", - "Close": "Close", - "Viewing comments from": "Viewing comments from {{user}}", - "Bot Users": "Bot Users", - "Avatar": "Avatar", - "Instructor": "Instructor", - "Actions": "Actions", - "No Bots": "No Bots", - "Add a New Bot User": "Add a New Bot User", - "Warning! You are impersonating a bot. Clear the field above to write comments as yourself.": "Warning! You are impersonating a bot. Clear the field above to write comments as yourself.", - "[DELETED]": "[DELETED]", - "Toggle Visibility": "Toggle Visibility", - "Toggle Fullscreen": "Toggle Fullscreen", - "Fullscreen": "Fullscreen", - "show more": "show more", - "Delete Bot": "Delete Bot", - "Delete All Comments for Bot": "Delete All Comments for Bot", - "Enable Auto Reply": "Enable Auto Reply", - "Enable Automatic Message Seeding": "Enable Automatic Message Seeding", - "Upload File": "Upload File", - "Bot Personality": "Bot Personality", - "Reset Defaults": "Reset Defaults", - "Add Empty Step": "Add Empty Step", - "Show Toolbar to Students": "Show Toolbar to Students", - "Show All Comments": "Show All Comments", - "Hide All Comments": "Hide All Comments", - "Code Settings": "Code Settings", - "Display Settings": "Display Settings", - "Show Version Navigation": "Show Version Navigation", - "Show Code Edit Button": "Show Code Edit Button", - "Show Code Run Button": "Show Code Run Button", - "Show Visibility Toggle": "Show Visibility Toggle", - "Allow students to see other students' code": "Allow students to see other students' code", - "Downloading": "Downloading", - "Download Actions": "Download Actions", - "Edit": "Edit", - "Commit Info": "Commit Info", - "Run": "Run", - "Author": "Author", - "Message": "Message", - "Description": "Description", - "Created": "Created", - "Commit Changes": "Commit Changes", - "Commit Message": "Commit Message", - "Optional Extended Description": "Optional Extended Description", - "Updated Code sample": "Updated Code sample", - "Add an Optional Description": "Add an Optional Description", - "You can reply with": "You can reply with", - "Quick Replies": "Quick Replies", - "Restart interaction": "Restart interaction", - "Default Version": "Default Version", - "Help needed": "Help needed", - "Table View": "Table View", - "Settings View": "Settings", - "Preset View": "Preset View", - "No Comments": "No Comments", - "Define Interaction Mode": "Define Interaction Mode", - "App Customization": "App Customization", - "Delete": "Delete", - "Report": "Report", - "Reason": "Reason", - "Report a comment": "Report a comment", - "Please provide below the reason for reporting this comment": "Please provide below the reason for reporting this comment", - "Type your comment": "Type your comment", - "Respond to this comment": "Respond to this comment", - "LineComment": "Line Comment: {{line}}", - "MultiLineComment": "Multiline Comment: {{start}} - {{end}}", - "Define Review Mode": "Define Review Mode", - "Individual - Each student works in isolation": "Individual - Each student works in isolation", - "Collaborative - Students see other students work": "Collaborative - Students see other students work", - "Remove orphans": "Remove orphans", - "Number of orphan threads": "Orphan threads: {{threads}} ({{totalComments}} total comments)", - "total comments": "total comments", - "Submit Code": "Submit Code", - "User Select": "User Select", - "This is the Code Execution panel": "This is the Code Execution panel", - "Back to Code Review": "Back to Code Review", - "No Commit Message": "~No message~", - "Execute Code": "Execute Code", - "Review Code": "Review Code", - "Collaborate on Code": "Collaborate on Code", - "Header code": "Header code", - "Footer code": "Footer code", - "Code to review": "Code to review", - "Input requested": "Input requested", - "Submit": "Submit", - "Unsaved modifications": "Unsaved modifications", - "APP_VERSION": "version {{ version }}", - "Choose the App Mode": "Choose the App Mode", - "File added": "File added", - "Upload complete": "Upload complete", - "This is just a preview. No files can be uploaded.": "This is just a preview. No files can be uploaded.", - "Data files": "Data files", - "Upload Data Files": "Upload Data Files", - "No files": "No files", - "Supported formats": "Supported formats: {{formats}}", - "Old Code": "Old Code", - "New Code": "New Code", - "Line Offset": "Line Offset", - "Save Setting to Preview": "Save Setting to Preview", - "Show Preview": "Show Preview", - "Pre-loaded libraries": "Preloaded Libraries", - "Stop Execution": "Stop Execution", - "Clear outputs and figures": "Clear Outputs and Figures", - "Run Code": "Run Code", - "Save Code": "Save Code", - "just now": "just now", - "Maximum comment length": "Maximum comment length", - "COMMENT_TEXT_TOO_LONG": "The comment text can not be longer than {{max_length}} characters", - "Add New Chatbot Prompt": "Add New Chatbot Prompt", - "Edit Chatbot Prompt": "Edit Chatbot Prompt", - "Chatbot Prompts": "Chatbot Prompts", - "No Chatbot Prompts": "No Chatbot Prompts", - "Initial Prompt": "Initial Prompt", - "Chatbot Prompt": "Chatbot Prompt", - "Line Number (first line is 0)": "Line Number (first line is 0)", - "Loading": "Loading", - "Optional Text": "Optional Text", - "Download Data": "Download Data", - "Cue": "Cue", - "Prompt": "Prompt" - } + "translations": { + "Welcome to the Graasp App Starter Kit": "Welcome to the Graasp App Starter Kit", + "Cancel": "Cancel", + "Finish": "Finish", + "Save": "Save", + "Stop": "Stop", + "Clear": "Clear", + "Saved": "Saved", + "Send": "Send", + "Reply": "Reply", + "Yes": "Yes", + "Write": "Write", + "Preview": "Preview", + "User": "User", + "Version": "Version", + "Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?", + "View the Users in the Sample Space": "View the Users in the Sample Space", + "Selected Student is:": "Selected Student is:", + "No student selected": "No student selected", + "Image Uri": "Image Uri", + "Name": "Name", + "Settings": "Settings", + "Code": "Code", + "Programming Language": "Programming Language", + "Show Header to Students": "Show Header to Students", + "Allow Comments": "Allow Comments", + "Allow Replies": "Allow Replies", + "Student Comments": "Student Comments", + "Code Review": "Code Review", + "Total Number of Messages": "Total Number of Messages", + "View Chat": "View Chat", + "Select a bot to impersonate": "Select a bot to impersonate", + "Close": "Close", + "Viewing discussion from": "Viewing discussion from {{user}}", + "Bot Users": "Bot Users", + "Avatar": "Avatar", + "Instructor": "Instructor", + "Actions": "Actions", + "No Bots": "No Bots", + "Add a New Bot User": "Add a New Bot User", + "Warning! You are impersonating a bot. Clear the field above to write comments as yourself.": "Warning! You are impersonating a bot. Clear the field above to write comments as yourself.", + "[DELETED]": "[DELETED]", + "Toggle Visibility": "Toggle Visibility", + "Toggle Fullscreen": "Toggle Fullscreen", + "Fullscreen": "Fullscreen", + "show more": "show more", + "Delete Bot": "Delete Bot", + "Delete All Comments for Bot": "Delete All Comments for Bot", + "Enable Auto Reply": "Enable Auto Reply", + "Enable Automatic Message Seeding": "Enable Automatic Message Seeding", + "Upload File": "Upload File", + "Bot Personality": "Bot Personality", + "Reset Defaults": "Reset Defaults", + "Add Empty Step": "Add Empty Step", + "Show Toolbar to Students": "Show Toolbar to Students", + "Show All Comments": "Show All Comments", + "Hide All Comments": "Hide All Comments", + "Code Settings": "Code Settings", + "Display Settings": "Display Settings", + "Show Version Navigation": "Show Version Navigation", + "Show Code Edit Button": "Show Code Edit Button", + "Show Code Run Button": "Show Code Run Button", + "Show Visibility Toggle": "Show Visibility Toggle", + "Allow students to see other students' code": "Allow students to see other students' code", + "Downloading": "Downloading", + "Download Actions": "Download Actions", + "Edit": "Edit", + "Commit Info": "Commit Info", + "Run": "Run", + "Author": "Author", + "Message": "Message", + "Description": "Description", + "Created": "Created", + "Commit Changes": "Commit Changes", + "Commit Message": "Commit Message", + "Optional Extended Description": "Optional Extended Description", + "Updated Code sample": "Updated Code sample", + "Add an Optional Description": "Add an Optional Description", + "You can reply with": "You can reply with", + "Quick Replies": "Quick Replies", + "Restart interaction": "Restart interaction", + "Default Version": "Default Version", + "Help needed": "Help needed", + "Table View": "Table View", + "Settings View": "Settings", + "Preset View": "Preset View", + "No Comments": "No Comments", + "Define Interaction Mode": "Define Interaction Mode", + "App Customization": "App Customization", + "Delete": "Delete", + "Report": "Report", + "Reason": "Reason", + "Report a comment": "Report a comment", + "Please provide below the reason for reporting this comment": "Please provide below the reason for reporting this comment", + "Type your comment": "Type your comment", + "Respond to this comment": "Respond to this comment", + "LineComment": "Line Comment: {{line}}", + "MultiLineComment": "Multiline Comment: {{start}} - {{end}}", + "Define Review Mode": "Define Review Mode", + "Individual - Each student works in isolation": "Individual - Each student works in isolation", + "Collaborative - Students see other students work": "Collaborative - Students see other students work", + "Remove orphans": "Remove orphans", + "Number of orphan threads": "Orphan threads: {{threads}} ({{totalComments}} total comments)", + "total comments": "total comments", + "Submit Code": "Submit Code", + "User Select": "User Select", + "This is the Code Execution panel": "This is the Code Execution panel", + "Back to Code Review": "Back to Code Review", + "No Commit Message": "~No message~", + "Execute Code": "Execute Code", + "Review Code": "Review Code", + "Collaborate on Code": "Collaborate on Code", + "Header code": "Header code", + "Footer code": "Footer code", + "Code to review": "Code to review", + "Input requested": "Input requested", + "Submit": "Submit", + "Unsaved modifications": "Unsaved modifications", + "APP_VERSION": "version {{ version }}", + "Choose the App Mode": "Choose the App Mode", + "File added": "File added", + "Upload complete": "Upload complete", + "This is just a preview. No files can be uploaded.": "This is just a preview. No files can be uploaded.", + "Data files": "Data files", + "Upload Data Files": "Upload Data Files", + "No files": "No files", + "Supported formats": "Supported formats: {{formats}}", + "Old Code": "Old Code", + "New Code": "New Code", + "Line Offset": "Line Offset", + "Save Setting to Preview": "Save Setting to Preview", + "Show Preview": "Show Preview", + "Pre-loaded libraries": "Preloaded Libraries", + "Stop Execution": "Stop Execution", + "Clear outputs and figures": "Clear Outputs and Figures", + "Run Code": "Run Code", + "Save Code": "Save Code", + "just now": "just now", + "MAX_COMMENT_LENGTH_SETTING": "Maximum comment length (default is {{default}} character)", + "MAX_THREAD_LENGTH_SETTING": "Maximum thread length (default is {{default}} messages per user)", + "COMMENT_TEXT_TOO_LONG": "The comment text can not be longer than {{max_length}} characters", + "Add New Chatbot Prompt": "Add New Chatbot Prompt", + "Edit Chatbot Prompt": "Edit Chatbot Prompt", + "Chatbot Prompts": "Chatbot Prompts", + "No Chatbot Prompts": "No Chatbot Prompts", + "Initial Prompt": "Initial Prompt", + "Chatbot Prompt": "Chatbot Prompt", + "Chatbot": "Chatbot", + "Line Number (first line is 0)": "Line Number (first line is 0)", + "Loading": "Loading", + "Optional Text": "Optional Text", + "Download Data": "Download Data", + "Cue": "Cue", + "Chatbot Name": "Chatbot Name", + "Prompt": "Prompt" + } } diff --git a/src/langs/fr.json b/src/langs/fr.json index f9e97c8..37d49ed 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -1,5 +1,5 @@ { "translations": { - "Welcome to the Graasp App Starter Kit": "Bienvenue dans le kit de démarrage de l'application Graasp" + "Chatbot Name": "Nom du chatbot" } } diff --git a/src/modules/App.tsx b/src/modules/App.tsx new file mode 100644 index 0000000..be489e7 --- /dev/null +++ b/src/modules/App.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; + +import { useLocalContext } from '@graasp/apps-query-client'; +import { Context, DEFAULT_LANG } from '@graasp/sdk'; + +import i18n from '../config/i18n'; +import AnalyticsView from './main/AnalyticsView'; +import BuilderView from './main/BuilderView'; +import PlayerView from './main/PlayerView'; + +const App = (): JSX.Element => { + const context = useLocalContext(); + + useEffect(() => { + // handle a change of language + const lang = context?.lang ?? DEFAULT_LANG; + if (i18n.language !== lang) { + i18n.changeLanguage(lang); + } + }, [context]); + + switch (context.context) { + case Context.Builder: + return ; + + case Context.Analytics: + return ; + + case Context.Player: + default: + return ; + } +}; + +export default App; diff --git a/src/modules/Root.tsx b/src/modules/Root.tsx index 9921c05..84bdb4b 100644 --- a/src/modules/Root.tsx +++ b/src/modules/Root.tsx @@ -24,7 +24,7 @@ import { defaultMockContext, mockMembers } from '@/mocks/db'; import Loader from '@/modules/common/Loader'; import { useObjectState } from '@/utils/hooks'; -import App from './main/App'; +import App from './App'; // declare the module to enable theme modification declare module '@mui/material/styles' { @@ -84,7 +84,7 @@ const Root: FC = () => { } + LoadingComponent={Loading Context} useGetLocalContext={hooks.useGetLocalContext} useAutoResize={hooks.useAutoResize} onError={() => { @@ -94,7 +94,7 @@ const Root: FC = () => { }} > } + LoadingComponent={Loading token} useAuthToken={hooks.useAuthToken} onError={() => { console.error( diff --git a/src/modules/common/ChatbotPrompt.tsx b/src/modules/common/ChatbotPrompt.tsx index 84d3f2b..bb71615 100644 --- a/src/modules/common/ChatbotPrompt.tsx +++ b/src/modules/common/ChatbotPrompt.tsx @@ -1,133 +1,141 @@ -import { FC, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { CardContent, CardHeader } from '@mui/material'; -import { APP_ACTIONS_TYPES } from '@/config/appActionsTypes'; -import { APP_DATA_TYPES, COMMENT_APP_DATA_TYPES } from '@/config/appDataTypes'; -import { GENERAL_SETTINGS_NAME } from '@/config/appSettingsTypes'; -import { DEFAULT_BOT_USERNAME, INSTRUCTOR_CODE_ID } from '@/config/constants'; -import { MUTATION_KEYS, useMutation } from '@/config/queryClient'; +import { + ChatbotThreadMessage, + buildPrompt, + useLocalContext, +} from '@graasp/apps-query-client'; +import { UUID } from '@graasp/sdk'; + +import { AppActionsType } from '@/config/appActions'; +import { AppDataTypes, CommentData } from '@/config/appData'; +import { ChatbotPromptSettings, SettingsKeys } from '@/config/appSetting'; +import { hooks, mutations } from '@/config/queryClient'; import { buildChatbotPromptContainerDataCy, buildCommentResponseBoxDataCy, } from '@/config/selectors'; -import { DEFAULT_GENERAL_SETTINGS } from '@/config/settings'; -import { UserDataType, useChatbotApi } from '@/hooks/useChatbotApi'; -import { - ChatCompletionMessage, - ChatCompletionMessageRoles, - GeneralSettingsKeys, -} from '@/interfaces/settings'; - -import { useAppDataContext } from '../context/AppDataContext'; -import { useLoadingIndicator } from '../context/LoadingIndicatorContext'; -import { useSettings } from '../context/SettingsContext'; -import CommentContainer from '../layout/CommentContainer'; -import CustomCommentCard from '../layout/CustomCommentCard'; +import { DEFAULT_BOT_USERNAME } from '@/constants'; + import ChatbotAvatar from './ChatbotAvatar'; import CommentBody from './CommentBody'; import CommentEditor from './CommentEditor'; import ResponseBox from './ResponseBox'; +import CommentContainer from './utils/CommentContainer'; +import CustomCommentCard from './utils/CustomCommentCard'; -const ChatbotPrompt: FC = () => { +type Props = { + id?: UUID; +}; + +const ChatbotPrompt = ({ id }: Props): JSX.Element | null => { const { t } = useTranslation(); - const { postAppDataAsync, appData } = useAppDataContext(); + const { mutateAsync: postAppDataAsync } = mutations.usePostAppData(); + const { mutateAsync: postChatBot } = mutations.usePostChatBot(); + const { data: appData } = hooks.useAppData(); + const { data: chatbotPrompts } = hooks.useAppSettings({ + name: SettingsKeys.ChatbotPrompt, + }); + const chatbotPrompt = chatbotPrompts?.[0]; + let { memberId } = useLocalContext(); + if (id) { + memberId = id; + } const [openEditor, setOpenEditor] = useState(false); - const { mutate: postAction } = useMutation< - unknown, - unknown, - { data: unknown; type: string } - >(MUTATION_KEYS.POST_APP_ACTION); - // if a message already exists with the prompt id we should not display this prompt - const { - chatbotPrompt, - [GENERAL_SETTINGS_NAME]: generalSettings = DEFAULT_GENERAL_SETTINGS, - } = useSettings(); - const { startLoading, stopLoading } = useLoadingIndicator(); - - const { callApi } = useChatbotApi( - (completion: ChatCompletionMessage, data: UserDataType) => { - const newData = { ...data, content: completion }; - // post comment from bot - postAppDataAsync({ - data: newData, - type: APP_DATA_TYPES.BOT_COMMENT, - })?.then(() => stopLoading()); - postAction({ data: newData, type: APP_ACTIONS_TYPES.CREATE_COMMENT }); - }, - ); + const { mutate: postAction } = mutations.usePostAppAction(); - const comments = appData.filter((c) => - COMMENT_APP_DATA_TYPES.includes(c.type), - ); + const comments = appData?.filter((c) => c.creator?.id === memberId); - const realChatbotPromptExists = comments.find( + const realChatbotPromptExists = comments?.find( (c) => c.data.chatbotPromptSettingId !== undefined, ); + const handleNewDiscussion = (newUserComment: string): void => { - const chatbotMessage = chatbotPrompt?.data.chatbotPrompt; - startLoading(); - const newData = { + if (!chatbotPrompt) { + throw new Error( + "unexpected error, chatbot setting is not present, can't sent to API without it", + ); + } + const chatbotMessage = chatbotPrompt.data.chatbotCue; + const newData: CommentData = { parent: null, - codeId: INSTRUCTOR_CODE_ID, content: chatbotMessage, chatbotPromptSettingId: chatbotPrompt?.id, }; // post chatbot comment as app data with async call postAppDataAsync({ data: newData, - type: APP_DATA_TYPES.BOT_COMMENT, + type: AppDataTypes.BotComment, })?.then((botComment) => { const userData = { parent: botComment.id, - codeId: INSTRUCTOR_CODE_ID, content: newUserComment, }; - // post new user comment as appdata with normal call + // post new user comment as appData with normal call postAppDataAsync({ data: userData, - type: APP_DATA_TYPES.COMMENT, + type: AppDataTypes.UserComment, })?.then((userMessage) => { - const fullPrompt = [ - ...(chatbotPrompt?.data.initialPrompt || []), + const threadMessages: ChatbotThreadMessage[] = [ { - role: 'assistant' as ChatCompletionMessageRoles, - content: chatbotMessage, - }, - { - role: 'user' as ChatCompletionMessageRoles, - content: newUserComment, + botDataType: AppDataTypes.BotComment, + msgType: AppDataTypes.BotComment, + data: chatbotMessage, }, ]; - callApi(fullPrompt, { - parent: userMessage.id, - codeId: INSTRUCTOR_CODE_ID, - }); + + const prompt = buildPrompt( + chatbotPrompt.data.chatbotCue, + threadMessages, + newUserComment, + ); + postAction({ - data: { prompt: fullPrompt }, - type: APP_ACTIONS_TYPES.SEND_PROMPT, + data: { prompt }, + type: AppActionsType.AskChatbot, }); + + const actionData = { + parent: userMessage?.id, + content: 'error', + }; + + postChatBot(prompt) + .then((chatBotRes) => { + actionData.content = chatBotRes.completion; + }) + .finally(() => { + // post comment from bot + postAppDataAsync({ + data: actionData, + type: AppDataTypes.BotComment, + }); + postAction({ + data: actionData, + type: AppActionsType.Create, + }); + }); }); - postAction({ data: userData, type: APP_ACTIONS_TYPES.CREATE_COMMENT }); }); - postAction({ data: newData, type: APP_ACTIONS_TYPES.CREATE_COMMENT }); + postAction({ data: newData, type: AppActionsType.Create }); // close editor setOpenEditor(false); }; // display only if real chatbot prompt does not exist yet - if (!realChatbotPromptExists) { - // console.log(chatbotPrompt); - // if (chatbotPrompt?.data?.chatbotPrompt === '') { - // return <>Please configure the chatbot prompt.; - // } + if (!realChatbotPromptExists && chatbotPrompt) { + if (chatbotPrompt?.data?.chatbotCue === '') { + return <>Please configure the chatbot prompt.; + } return ( { avatar={} /> - {chatbotPrompt?.data?.chatbotPrompt} + {chatbotPrompt?.data?.chatbotCue} {openEditor ? ( setOpenEditor(false)} onSend={handleNewDiscussion} diff --git a/src/modules/common/CodeEditor.tsx b/src/modules/common/CodeEditor.tsx index fd13ebc..e7f77ef 100644 --- a/src/modules/common/CodeEditor.tsx +++ b/src/modules/common/CodeEditor.tsx @@ -1,12 +1,12 @@ -import React, { FC } from 'react'; +import { FC } from 'react'; import { Box, Stack, styled, useTheme } from '@mui/material'; import { javascript } from '@codemirror/lang-javascript'; import CodeMirror from '@uiw/react-codemirror'; -import { SMALL_BORDER_RADIUS } from '@/config/layout'; import { CODE_EDITOR_ID_CY } from '@/config/selectors'; +import { SMALL_BORDER_RADIUS } from '@/constants'; const StyledEditorContainer = styled(Box)({ border: 'solid silver 1px', @@ -39,6 +39,7 @@ const CodeEditor: FC = ({ value, readOnly, onChange }) => { basicSetup extensions={[javascript()]} readOnly={readOnly} + unselectable={readOnly ? 'on' : 'off'} /> diff --git a/src/modules/common/Comment.tsx b/src/modules/common/Comment.tsx index ed5661f..d58e998 100644 --- a/src/modules/common/Comment.tsx +++ b/src/modules/common/Comment.tsx @@ -1,4 +1,4 @@ -import React, { FC, ReactElement, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { MoreVert } from '@mui/icons-material'; @@ -13,103 +13,75 @@ import { } from '@mui/material'; import { useLocalContext } from '@graasp/apps-query-client'; +import { formatDate } from '@graasp/sdk'; -import { APP_DATA_TYPES } from '@/config/appDataTypes'; -import { GENERAL_SETTINGS_NAME } from '@/config/appSettingsTypes'; -import { ANONYMOUS_USER, DEFAULT_BOT_USERNAME } from '@/config/constants'; -import { BIG_BORDER_RADIUS } from '@/config/layout'; -import { MUTATION_KEYS, useMutation } from '@/config/queryClient'; +import { AppDataTypes, CommentAppData } from '@/config/appData'; +import { + ChatbotPromptSettings, + ChatbotPromptSettingsKeys, + SettingsKeys, +} from '@/config/appSetting'; +import { hooks, mutations } from '@/config/queryClient'; import { buildCommentContainerDataCy } from '@/config/selectors'; -import { DEFAULT_GENERAL_SETTINGS } from '@/config/settings'; -import { CommentType } from '@/interfaces/comment'; -import { ReportedCommentType } from '@/interfaces/reportedComment'; -import { GeneralSettingsKeys } from '@/interfaces/settings'; -import { getFormattedTime } from '@/utils/datetime'; +import { BIG_BORDER_RADIUS, DEFAULT_BOT_USERNAME } from '@/constants'; -// import { useMembersContext } from '../context/MembersContext'; -import { useSettings } from '../context/SettingsContext'; -import CustomAvatar from '../layout/CustomAvatar'; import ChatbotAvatar from './ChatbotAvatar'; import CommentActions from './CommentActions'; import CommentBody from './CommentBody'; -import ReportCommentDialog from './ReportCommentDialog'; +// import { useMembersContext } from '../context/MembersContext'; +import CustomAvatar from './CustomAvatar'; const CustomCard = styled(Card)({ borderRadius: BIG_BORDER_RADIUS, }); type Props = { - comment: CommentType; + comment: CommentAppData; + onEdit: (id: string) => void; }; -const Comment: FC = ({ comment }) => { +const Comment = ({ comment, onEdit }: Props): JSX.Element => { const { t, i18n } = useTranslation(); // const members = useMembersContext(); - const { [GENERAL_SETTINGS_NAME]: settings = DEFAULT_GENERAL_SETTINGS } = - useSettings(); - const currentMember = useLocalContext().get('memberId'); - const { mutate: postAppData } = useMutation< - ReportedCommentType, - unknown, - unknown, - ReportedCommentType - >(MUTATION_KEYS.POST_APP_DATA); + // const { + // // [GENERAL_SETTINGS_NAME]: settings = DEFAULT_GENERAL_SETTINGS, + // } = useSettings(); + const currentMemberId = useLocalContext().memberId; + const { data: appContext } = hooks.useAppContext(); + const currentMember = appContext?.members.find( + (m) => m.id === currentMemberId, + ); + const { data: chatbotPrompts } = hooks.useAppSettings({ + name: SettingsKeys.ChatbotPrompt, + }); + const chatbotPrompt = chatbotPrompts?.[0]; + const { mutate: postAppData } = mutations.usePostAppData(); - const allowCommentReporting = - settings[GeneralSettingsKeys.AllowCommentsReporting]; + // const allowCommentReporting = true; // settings[GeneralSettingsKeys.AllowCommentsReporting]; const [menuAnchorEl, setMenuAnchorEl] = useState(null); const [openActionsMenu, setOpenActionsMenu] = useState(false); - const [openFlagDialog, setOpenFlagDialog] = useState(false); + // const [openFlagDialog, setOpenFlagDialog] = useState(false); const commentRef = useRef(null); // currently not using members // const member = members.find((u) => u.id === comment.memberId); // const userName = member?.name || ANONYMOUS_USER; - const isBot = comment.type === APP_DATA_TYPES.BOT_COMMENT; + const isBot = comment.type === AppDataTypes.BotComment; - const isEditable = (): boolean => currentMember === comment.creator; + const isEditable = (): boolean => + !!comment.creator && + !!currentMemberId && + comment.creator.id === currentMemberId; const isDeletable = (): boolean => isEditable(); - const sendCommentReport = (reason: string): void => { - postAppData({ - data: { reason, commentId: comment.id }, - type: APP_DATA_TYPES.FLAG, - }); - }; + // const sendCommentReport = (reason: string): void => { + // postAppData({ + // data: { reason, commentId: comment.id }, + // type: AppDataTypes.FLAG, + // }); + // }; - const renderCommentActions = (): ReactElement => ( - <> - - { - setMenuAnchorEl(e.currentTarget); - setOpenActionsMenu(true); - }} - > - - - - setOpenFlagDialog(true)} - onClose={() => { - setMenuAnchorEl(null); - setOpenActionsMenu(false); - }} - /> - - - ); return ( = ({ comment }) => { ref={commentRef} > : } - action={renderCommentActions()} + title={ + isBot + ? chatbotPrompt?.data[ChatbotPromptSettingsKeys.ChatbotName] || + DEFAULT_BOT_USERNAME + : currentMember?.name + } + subheader={formatDate(comment.updatedAt, { locale: i18n.language })} + avatar={ + isBot ? : + } + action={ + <> + + { + setMenuAnchorEl(e.currentTarget); + setOpenActionsMenu(true); + }} + > + + + + setOpenFlagDialog(true)} + onClose={() => { + setMenuAnchorEl(null); + setOpenActionsMenu(false); + }} + onEdit={onEdit} + /> + {/* */} + + } /> {comment.data.content} diff --git a/src/modules/common/CommentActions.tsx b/src/modules/common/CommentActions.tsx index b5f64a4..f162d6a 100644 --- a/src/modules/common/CommentActions.tsx +++ b/src/modules/common/CommentActions.tsx @@ -1,45 +1,39 @@ -import React, { FC } from 'react'; import { useTranslation } from 'react-i18next'; -import { Delete, Edit, Flag } from '@mui/icons-material'; +import { Delete, Edit } from '@mui/icons-material'; import { ListItemIcon, ListItemText, Menu, MenuItem } from '@mui/material'; -import { APP_ACTIONS_TYPES } from '@/config/appActionsTypes'; -import { MUTATION_KEYS, useMutation } from '@/config/queryClient'; - -import { useAppDataContext } from '../context/AppDataContext'; -import { useCommentContext } from '../context/CommentContext'; -import { useReviewContext } from '../context/ReviewContext'; +import { AppActionsType } from '@/config/appActions'; +import { CommentAppData } from '@/config/appData'; +import { mutations } from '@/config/queryClient'; type Props = { open: boolean; menuAnchorEl: null | HTMLElement; onClose: () => void; - onClickFlag?: () => void; + onEdit: (id: string) => void; + // onClickFlag?: () => void; showDelete?: boolean; showEdit?: boolean; - showFlag?: boolean; + // showFlag?: boolean; + comment: CommentAppData; }; -const CommentActions: FC = ({ +const CommentActions = ({ open, menuAnchorEl, onClose, - onClickFlag, + onEdit, + // onClickFlag, showDelete = true, showEdit = true, - showFlag = true, -}) => { + // showFlag = true, + comment, +}: Props): JSX.Element => { const { t } = useTranslation(); - const comment = useCommentContext(); - const { editComment } = useReviewContext(); - const { deleteAppData } = useAppDataContext(); - const { mutate: postAction } = useMutation< - unknown, - unknown, - { data: unknown; type: string } - >(MUTATION_KEYS.POST_APP_ACTION); - + const { mutate: deleteAppData } = mutations.useDeleteAppData(); + const { mutate: postAction } = mutations.usePostAppAction(); + console.debug(open, showEdit, showDelete); return ( = ({ {showEdit && ( { - editComment(comment.id); + // todo: add editing signal + onEdit(comment.id); postAction({ data: { comment }, - type: APP_ACTIONS_TYPES.EDIT_COMMENT, + type: AppActionsType.Edit, }); onClose(); }} @@ -79,7 +74,7 @@ const CommentActions: FC = ({ deleteAppData({ id: comment.id }); postAction({ data: { comment }, - type: APP_ACTIONS_TYPES.DELETE_COMMENT, + type: AppActionsType.Delete, }); onClose(); }} @@ -90,13 +85,13 @@ const CommentActions: FC = ({ {t('Delete')} )} - {showFlag && ( + {/* {showFlag && ( { onClickFlag?.(); postAction({ - data: { comment }, - type: APP_ACTIONS_TYPES.REPORT_COMMENT, + data: { comment: comment.toJS() }, + type: AppActionsType.re, }); onClose(); }} @@ -106,7 +101,7 @@ const CommentActions: FC = ({ {t('Report')} - )} + )} */} ); }; diff --git a/src/modules/common/CommentBody.tsx b/src/modules/common/CommentBody.tsx index 723d6f8..4b0cf1c 100644 --- a/src/modules/common/CommentBody.tsx +++ b/src/modules/common/CommentBody.tsx @@ -1,6 +1,5 @@ -import React, { FC, PropsWithChildren, ReactElement } from 'react'; +import { FC, PropsWithChildren, ReactNode } from 'react'; import ReactMarkdown from 'react-markdown'; -import { CodeProps } from 'react-markdown/lib/ast-to-react'; import { styled } from '@mui/material'; @@ -8,7 +7,7 @@ import { Highlight, themes } from 'prism-react-renderer'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; -import { BIG_BORDER_RADIUS } from '@/config/layout'; +import { BIG_BORDER_RADIUS } from '@/constants'; const StyledReactMarkdown = styled(ReactMarkdown)(({ theme }) => ({ '& .prism-code': { @@ -72,20 +71,17 @@ const StyledReactMarkdown = styled(ReactMarkdown)(({ theme }) => ({ type Props = { children: string; }; - -const renderCode = ({ - inline, - className: classNameInit, - children: codeContent, - ...props -}: CodeProps): ReactElement => { - const match = /language-(\w+)/.exec(classNameInit || ''); - return !inline && match ? ( +function code(props: { + className?: string; + children?: ReactNode; +}): JSX.Element { + const { className: language, children, ...rest } = props; + const match = /language-(\w+)/.exec(language || ''); + return match ? ( {({ className, style, tokens, getLineProps, getTokenProps }) => (
@@ -112,19 +108,16 @@ const renderCode = ({ )} ) : ( - - {codeContent} + + {children} ); -}; +} const CommentBody: FC> = ({ children }) => ( {children} diff --git a/src/modules/common/CommentEditor.tsx b/src/modules/common/CommentEditor.tsx index 1e70283..cbd5a30 100644 --- a/src/modules/common/CommentEditor.tsx +++ b/src/modules/common/CommentEditor.tsx @@ -1,4 +1,4 @@ -import React, { FC, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { boldCommand, @@ -26,7 +26,8 @@ import { import { Button } from '@graasp/ui'; -import { SMALL_BORDER_RADIUS } from '@/config/layout'; +import { CommentAppData } from '@/config/appData'; +// import { DEFAULT_MAX_COMMENT_LENGTH_SETTING } from '@/config/appSetting'; import { COMMENT_EDITOR_BOLD_BUTTON_CYPRESS, COMMENT_EDITOR_CANCEL_BUTTON_CYPRESS, @@ -40,10 +41,9 @@ import { COMMENT_EDITOR_TEXTAREA_CYPRESS, COMMENT_EDITOR_TEXTAREA_HELPER_TEXT_CY, } from '@/config/selectors'; -import { DEFAULT_MAX_COMMENT_LENGTH_SETTING } from '@/config/settings'; -import { CommentType } from '@/interfaces/comment'; +import { SMALL_BORDER_RADIUS } from '@/constants'; -import ToolbarButton from '../layout/ToolbarButton'; +import ToolbarButton from './utils/ToolbarButton'; const TextArea = styled(TextareaAutosize)(({ theme }) => ({ borderRadius: SMALL_BORDER_RADIUS, @@ -68,16 +68,16 @@ const TextArea = styled(TextareaAutosize)(({ theme }) => ({ type Props = { onCancel: () => void; onSend: (comment: string) => void; - comment?: CommentType; + comment?: CommentAppData; maxTextLength?: number; }; -const CommentEditor: FC = ({ +const CommentEditor = ({ onCancel, onSend, comment, - maxTextLength = DEFAULT_MAX_COMMENT_LENGTH_SETTING, -}) => { + maxTextLength = 300, // DEFAULT_MAX_COMMENT_LENGTH_SETTING, +}: Props): JSX.Element => { const { t } = useTranslation(); const [text, setText] = useState(comment?.data.content ?? ''); const [textTooLong, setTextTooLong] = useState(''); diff --git a/src/modules/common/CommentThread.tsx b/src/modules/common/CommentThread.tsx index 08b6e10..8fd57a4 100644 --- a/src/modules/common/CommentThread.tsx +++ b/src/modules/common/CommentThread.tsx @@ -1,217 +1,194 @@ -import { FC, Fragment } from 'react'; +import { FC, Fragment, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { CircularProgress, Stack, Typography } from '@mui/material'; -import { List } from 'immutable'; +import { ChatbotThreadMessage, buildPrompt } from '@graasp/apps-query-client'; -import { APP_ACTIONS_TYPES } from '@/config/appActionsTypes'; -import { APP_DATA_TYPES } from '@/config/appDataTypes'; -import { GENERAL_SETTINGS_NAME } from '@/config/appSettingsTypes'; -import { MAX_CHATBOT_THREAD_LENGTH } from '@/config/constants'; -import { MUTATION_KEYS, useMutation } from '@/config/queryClient'; +import { AppActionsType } from '@/config/appActions'; +import { AppDataTypes, CommentAppData } from '@/config/appData'; +import { ChatbotPromptSettings, SettingsKeys } from '@/config/appSetting'; +// import { DEFAULT_GENERAL_SETTINGS } from '@/config/settings'; +// import { GENERAL_SETTINGS_NAME } from '@/config/appSettings'; +import { hooks, mutations } from '@/config/queryClient'; import { COMMENT_THREAD_CONTAINER_CYPRESS } from '@/config/selectors'; -import { DEFAULT_GENERAL_SETTINGS } from '@/config/settings'; -import { UserDataType, useChatbotApi } from '@/hooks/useChatbotApi'; -import { CommentType } from '@/interfaces/comment'; -import { - ChatCompletionMessage, - ChatCompletionMessageRoles, - GeneralSettingsKeys, -} from '@/interfaces/settings'; import { buildThread } from '@/utils/comments'; -import { useAppDataContext } from '../context/AppDataContext'; -import { CommentProvider } from '../context/CommentContext'; -import { useLoadingIndicator } from '../context/LoadingIndicatorContext'; -import { useReviewContext } from '../context/ReviewContext'; -import { useSettings } from '../context/SettingsContext'; -import CommentContainer from '../layout/CommentContainer'; -import ResponseContainer from '../layout/ResponseContainer'; import Comment from './Comment'; import CommentEditor from './CommentEditor'; import ResponseBox from './ResponseBox'; +import CommentContainer from './utils/CommentContainer'; +import ResponseContainer from './utils/ResponseContainer'; type Props = { - children?: List; + children?: CommentAppData[]; }; const CommentThread: FC = ({ children }) => { const { t } = useTranslation(); + const [replyingId, setReplyingId] = useState(null); + const [editingId, setEditingId] = useState(null); + const { mutate: patchData } = mutations.usePatchAppData(); + const { mutateAsync: postAppDataAsync } = mutations.usePostAppData(); + const { mutate: postAction } = mutations.usePostAppAction(); + const { mutateAsync: postChatbot, isLoading } = mutations.usePostChatBot(); const { - addResponse, - currentRepliedCommentId, - currentEditedCommentId, - closeComment, - closeEditingComment, - } = useReviewContext(); - const { patchAppData, postAppDataAsync } = useAppDataContext(); - const { mutate: postAction } = useMutation< - unknown, - unknown, - { data: unknown; type: string } - >(MUTATION_KEYS.POST_APP_ACTION); - const { - chatbotPrompt, - [GENERAL_SETTINGS_NAME]: generalSettings = DEFAULT_GENERAL_SETTINGS, - } = useSettings(); - const { isLoading, startLoading, stopLoading } = useLoadingIndicator(); - - const { callApi } = useChatbotApi( - (completion: ChatCompletionMessage, data: UserDataType) => { - // post comment from bot - const newData = { ...data, content: completion }; - postAppDataAsync({ - data: newData, - type: APP_DATA_TYPES.BOT_COMMENT, - })?.then(() => stopLoading()); - postAction({ data: newData, type: APP_ACTIONS_TYPES.CREATE_COMMENT }); - }, - ); + data: chatbotPrompts, + // [GENERAL_SETTINGS_NAME]: generalSettings = DEFAULT_GENERAL_SETTINGS, + } = hooks.useAppSettings({ + name: SettingsKeys.ChatbotPrompt, + }); + // todo: add general settings + const chatbotPrompt = chatbotPrompts?.[0]; + const maxThreadLength = 50; // generalSettings[GeneralSettingsKeys.MaxThreadLength]; - const isEdited = (id: string): boolean => id === currentEditedCommentId; - const isReplied = (id: string): boolean => id === currentRepliedCommentId; const allowedChatbotResponse = ( - arr: List, + arr: CommentAppData[], idx: number, commentType: string, ): boolean => - (arr.size < MAX_CHATBOT_THREAD_LENGTH && - commentType === APP_DATA_TYPES.BOT_COMMENT) || + (arr.length < maxThreadLength && commentType === AppDataTypes.BotComment) || // when the comment is a user comment it should not be a response to a chatbot comment - // -> in this case, we want to wait for the cahtbot response - (commentType === APP_DATA_TYPES.COMMENT && - arr.get(idx - 1)?.type !== APP_DATA_TYPES.BOT_COMMENT); - - if (!children || children?.isEmpty()) { + // -> in this case, we want to wait for the chatbot response + (commentType === AppDataTypes.UserComment && + arr[idx - 1]?.type !== AppDataTypes.BotComment); + const addResponse = (id: string): void => { + setReplyingId(id); + }; + const botComment = children?.find( + (c) => c.data.chatbotPromptSettingId === chatbotPrompt?.id, + ); + if (!children || children.length === 0 || !botComment) { return null; } + const commentThread = buildThread(botComment, children); - const threads = children - .filter((c) => !c.data.parent) - .map((parent) => buildThread(parent, children)) - .sortBy((thread) => thread.get(0)?.createdAt); + console.debug(children); + // utility functions + const isReplied = (id: string): boolean => replyingId === id; + const isEdited = (id: string): boolean => editingId === id; return ( - <> - {threads.map((thread) => ( - - {thread.map((c, i, arr) => ( - - - {isEdited(c.id) ? ( - { - closeEditingComment(); - }} - onSend={(content) => { - patchAppData({ - id: c.id, - data: { - ...c.data, + + {commentThread.map((c, i, arr) => { + console.debug(c.id, isEdited(c.id)); + return ( + + {isEdited(c.id) ? ( + { + setEditingId(null); + }} + onSend={(content) => { + patchData({ + id: c.id, + data: { + ...c.data, + content, + }, + }); + setEditingId(null); + }} + comment={c} + /> + ) : ( + setEditingId(id)} /> + )} + { + // show input bar to respond to comment + i + 1 === arr.length && + !isLoading && + !isEdited(c.id) && + !isReplied(c.id) && + allowedChatbotResponse(arr, i, c.type) && ( + + ) + } + {i + 1 === arr.length && isLoading && ( + + + {t('Loading')} + + + + )} + { + // if input bar was clicked, a comment editor opens to compose a response + isReplied(c.id) && ( + setReplyingId(null)} + onSend={(content) => { + const data = { + ...c.data, + parent: c.id, + content, + }; + + postAppDataAsync({ + data, + type: AppDataTypes.UserComment, + })?.then((parent) => { + // when in a chatbot thread, should also post to the api + if (commentThread[0]?.type === AppDataTypes.BotComment) { + const chatbotThread: ChatbotThreadMessage[] = + commentThread.map((botThread) => ({ + botDataType: AppDataTypes.BotComment, + msgType: botThread.type, + data: botThread.data.content, + })); + + const prompt = buildPrompt( + chatbotPrompt?.data.chatbotCue, + chatbotThread, content, - }, - }); - closeEditingComment(); - }} - comment={c} - /> - ) : ( - - )} - - { - // show input bar to respond to comment - i + 1 === arr.size && - !isLoading && - !isEdited(c.id) && - !isReplied(c.id) && - allowedChatbotResponse(arr, i, c.type) && ( - - ) - } - {i + 1 === arr.size && isLoading && ( - - - {t('Loading')} - - - - )} - { - // if input bar was clicked, a comment editor opens to compose a response - isReplied(c.id) && ( - { - startLoading(); - const data = { - ...c.data, - parent: c.id, - content, - }; - - postAppDataAsync({ - data, - type: APP_DATA_TYPES.COMMENT, - })?.then((parent) => { - // post to the api - - const { initialPrompt } = chatbotPrompt.data; + ); - const messages = thread.map((msg) => { - let role: ChatCompletionMessageRoles; - switch (msg.type) { - case APP_DATA_TYPES.BOT_COMMENT: - role = 'assistant'; - break; - case APP_DATA_TYPES.COMMENT: - default: - role = 'user'; - } - - return { role, content: msg.data.content }; - }); - - const fullPrompt = [ - ...initialPrompt, - ...messages, - { - role: 'user' as ChatCompletionMessageRoles, - content, - }, - ]; - - callApi(fullPrompt, { + const newData = { ...data, - parent: parent.id, - }); + parent: parent?.id, + content: 'error', + }; + + postChatbot(prompt) + .then((chatBotRes) => { + newData.content = chatBotRes.completion; + }) + .finally(() => { + postAppDataAsync({ + data: newData, + type: AppDataTypes.BotComment, + }); + postAction({ + data: newData, + type: AppActionsType.Create, + }); + }); + postAction({ - data: { prompt: fullPrompt }, - type: APP_ACTIONS_TYPES.SEND_PROMPT, + data: { prompt }, + type: AppActionsType.AskChatbot, }); - }); - postAction({ - data, - type: APP_ACTIONS_TYPES.RESPOND_COMMENT, - }); - closeComment(); - }} - comment={{ ...c, data: { ...c.data, content: '' } }} - /> - ) - } - - ))} - - ))} - + } + }); + postAction({ + data, + type: AppActionsType.Reply, + }); + setReplyingId(null); + }} + comment={{ ...c, data: { ...c.data, content: '' } }} + /> + ) + } + + ); + })} + ); }; diff --git a/src/modules/common/CustomAvatar.tsx b/src/modules/common/CustomAvatar.tsx new file mode 100644 index 0000000..417cff0 --- /dev/null +++ b/src/modules/common/CustomAvatar.tsx @@ -0,0 +1,52 @@ +import { Avatar } from '@mui/material'; + +import { Member } from '@graasp/sdk'; + +import { ANONYMOUS_USER } from '@/constants'; + +// generate a background color for avatars from userName +const stringToColor = (name: string): string => { + let hash = 0; + let i; + + /* eslint-disable no-bitwise */ + for (i = 0; i < name.length; i += 1) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + + let color = '#'; + + for (i = 0; i < 3; i += 1) { + const value = (hash >> (i * 8)) & 0xff; + color += `00${value.toString(16)}`.slice(-2); + } + /* eslint-enable no-bitwise */ + + return color; +}; + +const getInitials = (name: string): string => + name + .split(/[^a-z]/i) + .map((c) => Array.from(c).filter((l) => l.match(/[a-z]/i))[0]) + .join(''); + +type Props = { + member?: Member; + imgSrc?: string; +}; + +const CustomAvatar = ({ member, imgSrc }: Props): JSX.Element => { + const userName = member?.name || ANONYMOUS_USER; + return ( + + {getInitials(userName)} + + ); +}; + +export default CustomAvatar; diff --git a/src/modules/common/CustomDialog.tsx b/src/modules/common/CustomDialog.tsx new file mode 100644 index 0000000..571d41d --- /dev/null +++ b/src/modules/common/CustomDialog.tsx @@ -0,0 +1,90 @@ +import React, { FC, MutableRefObject, ReactElement, RefObject } from 'react'; + +import { + Breakpoint, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + styled, +} from '@mui/material'; + +import { + CUSTOM_DIALOG_ACTIONS_CYPRESS, + CUSTOM_DIALOG_CONTENT_CY, + CUSTOM_DIALOG_TITLE_CYPRESS, +} from '@/config/selectors'; + +type RefType = + | null + | MutableRefObject + | RefObject; + +const getPlacedModalStyle = ( + anchor: RefType, +): { [key: string]: string | number } => { + if (anchor?.current) { + const { top = 0 } = anchor.current.getBoundingClientRect(); + return { + position: 'fixed', + top: top - 50, + }; + } + return {}; +}; + +const StyledDialogTitle = styled(DialogTitle)({}); + +type Props = { + open: boolean; + title: string | ReactElement; + content: ReactElement | string; + actions?: ReactElement; + onClose?: () => void; + dataCy?: string; + keepMounted?: boolean; + fullScreen?: boolean; + maxWidth?: Breakpoint; + noPadding?: boolean; + anchor?: RefType; +}; + +const CustomDialog: FC = ({ + open, + title, + content, + actions, + onClose, + dataCy, + keepMounted = true, + fullScreen = false, + maxWidth = 'sm', + noPadding = false, + anchor = null, +}) => ( + + + {title} + + + {content} + + + {actions} + + +); + +export default CustomDialog; diff --git a/src/modules/common/Loader.tsx b/src/modules/common/Loader.tsx index d36c874..9c8802c 100644 --- a/src/modules/common/Loader.tsx +++ b/src/modules/common/Loader.tsx @@ -1,11 +1,16 @@ -import React, { FC } from 'react'; - -import { Box } from '@mui/material'; +import { Box, Stack, Typography } from '@mui/material'; import CircularProgress from '@mui/material/CircularProgress'; -const Loader: FC = () => ( +const Loader = ({ + children, +}: { + children: JSX.Element | string; +}): JSX.Element => ( - + + + Loading {children} + ); diff --git a/src/modules/common/ResponseBox.tsx b/src/modules/common/ResponseBox.tsx index 8b26f60..02c3bd0 100644 --- a/src/modules/common/ResponseBox.tsx +++ b/src/modules/common/ResponseBox.tsx @@ -1,11 +1,10 @@ -import { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { TextField, styled } from '@mui/material'; import { COMMENT_RESPONSE_BOX_CY } from '@/config/selectors'; -import ResponseContainer from '../layout/ResponseContainer'; +import ResponseContainer from './utils/ResponseContainer'; const StyledTextField = styled(TextField)(({ theme }) => ({ '& input': { @@ -19,7 +18,7 @@ type Props = { onClick: (id: string) => void; }; -const ResponseBox: FC = ({ onClick, commentId, dataCy }) => { +const ResponseBox = ({ onClick, commentId, dataCy }: Props): JSX.Element => { const { t } = useTranslation(); return ( diff --git a/src/modules/common/utils/CommentContainer.tsx b/src/modules/common/utils/CommentContainer.tsx new file mode 100644 index 0000000..58487ec --- /dev/null +++ b/src/modules/common/utils/CommentContainer.tsx @@ -0,0 +1,11 @@ +import { styled } from '@mui/material'; + +import { BIG_BORDER_RADIUS } from '../../../constants'; + +const CommentContainer = styled('div')(({ theme }) => ({ + backgroundColor: 'white', + border: 'solid silver 1px', + padding: theme.spacing(1, 0), + borderRadius: BIG_BORDER_RADIUS, +})); +export default CommentContainer; diff --git a/src/modules/common/utils/CustomCommentCard.tsx b/src/modules/common/utils/CustomCommentCard.tsx new file mode 100644 index 0000000..c3edcf3 --- /dev/null +++ b/src/modules/common/utils/CustomCommentCard.tsx @@ -0,0 +1,8 @@ +import { Card, CardProps, styled } from '@mui/material'; + +import { BIG_BORDER_RADIUS } from '../../../constants'; + +const CustomCommentCard = styled(Card)({ + borderRadius: BIG_BORDER_RADIUS, +}); +export default CustomCommentCard; diff --git a/src/modules/common/utils/ResponseContainer.tsx b/src/modules/common/utils/ResponseContainer.tsx new file mode 100644 index 0000000..0ab3644 --- /dev/null +++ b/src/modules/common/utils/ResponseContainer.tsx @@ -0,0 +1,8 @@ +import { styled } from '@mui/material'; + +const ResponseContainer = styled('div')(({ theme }) => ({ + padding: theme.spacing(2), + borderBottomLeftRadius: theme.spacing(1), + borderBottomRightRadius: theme.spacing(1), +})); +export default ResponseContainer; diff --git a/src/modules/common/utils/ToolbarButton.tsx b/src/modules/common/utils/ToolbarButton.tsx new file mode 100644 index 0000000..6d47068 --- /dev/null +++ b/src/modules/common/utils/ToolbarButton.tsx @@ -0,0 +1,39 @@ +import { ForwardRefRenderFunction, PropsWithChildren, forwardRef } from 'react'; + +import { Button } from '@mui/material'; + +type Props = { + dataCy?: string; + onClick: () => void; + disabled?: boolean; +}; + +const ToolbarButton: ForwardRefRenderFunction< + HTMLButtonElement, + PropsWithChildren +> = (props, ref) => { + // structure the custom props from the other ones given by the tooltip + const { dataCy, onClick, disabled, ...otherProps } = props; + return ( + - - - - - - - App Data -
{JSON.stringify(appDatas, null, 2)}
-
- - - -
- ); + switch (context.permission) { + // show "teacher view" + case PermissionLevel.Admin: + return ; + case PermissionLevel.Read: + default: + return ; + } }; + export default BuilderView; diff --git a/src/modules/main/ConversationsView.tsx b/src/modules/main/ConversationsView.tsx new file mode 100644 index 0000000..d9bd433 --- /dev/null +++ b/src/modules/main/ConversationsView.tsx @@ -0,0 +1,150 @@ +import { FC, ReactElement, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Close } from '@mui/icons-material'; +import InputIcon from '@mui/icons-material/Input'; +import { + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@mui/material'; + +import groupBy from 'lodash.groupby'; + +import { CommentData } from '@/config/appData'; +import { hooks } from '@/config/queryClient'; +import { + TABLE_NO_COMMENTS_CYPRESS, + TABLE_VIEW_BODY_USERS_CYPRESS, + TABLE_VIEW_NB_COMMENTS_CELL_CYPRESS, + TABLE_VIEW_OPEN_REVIEW_BUTTON_CYPRESS, + TABLE_VIEW_REVIEW_DIALOG_CLOSE_BUTTON_CYPRESS, + TABLE_VIEW_TABLE_CYPRESS, + TABLE_VIEW_USERNAME_CELL_CYPRESS, + TABLE_VIEW_USER_REVIEW_DIALOG_CYPRESS, + TABLE_VIEW_VIEW_COMMENTS_CELL_CYPRESS, + tableRowUserCypress, +} from '@/config/selectors'; +import { ANONYMOUS_USER } from '@/constants'; +import CustomDialog from '@/modules/common/CustomDialog'; +import PlayerView from '@/modules/main/PlayerView'; +import { getOrphans } from '@/utils/comments'; + +import DownloadButtons from '../settings/DownloadButtons'; +import OrphanComments from '../settings/OrphanComments'; + +const DEFAULT_CURRENT_USER = { + name: ANONYMOUS_USER, + id: '', +}; + +const ConversationsView: FC = () => { + const { t } = useTranslation(); + const [openCommentView, setOpenCommentView] = useState(false); + const [currentUser, setCurrentUser] = useState(DEFAULT_CURRENT_USER); + const { data: { members } = { members: [] } } = hooks.useAppContext(); + const { data: comments = [] } = hooks.useAppData(); + + const renderTableBody = (): ReactElement[] | ReactElement | null => { + const orphansId = getOrphans(comments).map((c) => c.id); + const nonOrphanComments = comments?.filter( + (c) => !orphansId.includes(c.id), + ); + // nonOrphanComments is undefined or, is an empty list -> there are not resources to display + if (!nonOrphanComments || nonOrphanComments.length === 0) { + // show that there are no comments available + return ( + + + {t('No Comments')} + + + ); + } + const commentsByUsers = Object.entries( + groupBy(nonOrphanComments, ({ member }) => member.id), + ); + return commentsByUsers.map(([userId, userComments]) => { + const userName = + members.find(({ id }) => id === userId)?.name || ANONYMOUS_USER; + return ( + + + {userName} + + +
{userComments.length}
+
+ + { + setCurrentUser({ + name: userName, + id: userId, + }); + setOpenCommentView(true); + }} + > + + + +
+ ); + }); + }; + + const onCloseDialog = (): void => { + setCurrentUser(DEFAULT_CURRENT_USER); + setOpenCommentView(false); + }; + + // todo: filter app data + const renderDialogContent = (): ReactElement => ( + <> + + + + + + ); + + return ( + <> + + + + + + + {t('Name')} + {t('Total Number of Messages')} + {t('View Chat')} + + + + {renderTableBody()} + +
+
+ + + ); +}; + +export default ConversationsView; diff --git a/src/modules/main/PlayerView.tsx b/src/modules/main/PlayerView.tsx index 7cf9f25..c3f8bf6 100644 --- a/src/modules/main/PlayerView.tsx +++ b/src/modules/main/PlayerView.tsx @@ -1,23 +1,33 @@ -import { Box, Typography } from '@mui/material'; +import { Box } from '@mui/material'; import { useLocalContext } from '@graasp/apps-query-client'; +import { UUID } from '@graasp/sdk'; +import { CommentData } from '@/config/appData'; import { hooks } from '@/config/queryClient'; import { PLAYER_VIEW_CY } from '@/config/selectors'; +import ChatbotPrompt from '@/modules/common/ChatbotPrompt'; +import CommentThread from '@/modules/common/CommentThread'; -const PlayerView = (): JSX.Element => { - const { permission } = useLocalContext(); - const { data: appContext } = hooks.useAppContext(); - const members = appContext?.members; +type Props = { + id?: UUID; +}; + +const PlayerView = ({ id }: Props): JSX.Element => { + const { data: appData } = hooks.useAppData(); + + let { memberId } = useLocalContext(); + if (id) { + memberId = id; + } + + const comments = appData?.filter((res) => res.creator?.id === memberId); return ( -
- Player as {permission} - - Members -
{JSON.stringify(members, null, 2)}
-
-
+ + + {comments} + ); }; export default PlayerView; diff --git a/src/modules/settings/DownloadButtons.tsx b/src/modules/settings/DownloadButtons.tsx new file mode 100644 index 0000000..561152f --- /dev/null +++ b/src/modules/settings/DownloadButtons.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from 'react-i18next'; + +import { LoadingButton } from '@mui/lab'; +import { Stack } from '@mui/material'; + +import { UseQueryResult } from '@tanstack/react-query'; +import { saveAs } from 'file-saver'; +import { DownloadCloud } from 'lucide-react'; + +import { hooks } from '@/config/queryClient'; +import { + DOWNLOAD_ACTIONS_BUTTON_CY, + DOWNLOAD_DATA_BUTTON_CY, +} from '@/config/selectors'; + +const DownloadButtons = (): JSX.Element => { + const { t } = useTranslation(); + + const { refetch: refetchAppActions, isFetching: isFetchingAppActions } = + hooks.useAppActions({ + enabled: false, + }); + const { refetch: refetchAppData, isFetching: isFetchingAppData } = + hooks.useAppData(undefined, { enabled: false }); + + const handleClick = + (refetchFunction: UseQueryResult['refetch'], suffix: string) => + (): void => { + // fetch actions + refetchFunction().then(({ data }) => { + const dataBlob = new Blob([JSON.stringify(data)] || [], { + type: 'text/plain;charset=utf-8', + }); + const fileName = `${new Date().toISOString()}_${suffix}.json`; + saveAs(dataBlob, fileName); + }); + }; + + return ( + + } + variant="outlined" + > + {isFetchingAppActions ? t('Downloading') : t('Download Actions')} + + } + variant="outlined" + > + {isFetchingAppData ? t('Downloading') : t('Download Data')} + + + ); +}; +export default DownloadButtons; diff --git a/src/modules/settings/OrphanComments.tsx b/src/modules/settings/OrphanComments.tsx index 99d2de4..b1c486b 100644 --- a/src/modules/settings/OrphanComments.tsx +++ b/src/modules/settings/OrphanComments.tsx @@ -1,32 +1,29 @@ -import React, { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { FormControlLabel } from '@mui/material'; -import { UUID } from '@graasp/apps-query-client'; +import { UUID } from '@graasp/sdk'; import { Button } from '@graasp/ui'; -import { List } from 'immutable'; - +import { CommentAppData } from '@/config/appData'; +import { mutations } from '@/config/queryClient'; import { ORPHAN_BUTTON_CYPRESS } from '@/config/selectors'; -import { CommentType } from '@/interfaces/comment'; -import { useAppDataContext } from '@/modules/context/AppDataContext'; import { getOrphans, getThreadIdsFromFirstCommentId } from '@/utils/comments'; -type Prop = { - comments: List; +type Props = { + comments: CommentAppData[]; }; -const OrphanComments: FC = ({ comments }) => { +const OrphanComments = ({ comments }: Props): JSX.Element | null => { const { t } = useTranslation(); - const { deleteAppData } = useAppDataContext(); + const { mutate: deleteAppData } = mutations.useDeleteAppData(); - const getOrphanComments = (allComments: List): List => { + const getOrphanComments = (allComments: CommentAppData[]): UUID[][] => { const orphans = getOrphans(allComments); return orphans.map((o) => getThreadIdsFromFirstCommentId(comments, o.id)); }; - const handleOnClickRemoveOrphans = (orphanThreads: List): void => { + const handleOnClickRemoveOrphans = (orphanThreads: UUID[][]): void => { orphanThreads.forEach((thread) => { thread.forEach((id) => { deleteAppData({ id }); @@ -36,7 +33,7 @@ const OrphanComments: FC = ({ comments }) => { const orphanThreads = getOrphanComments(comments); - if (!orphanThreads.size) { + if (!orphanThreads.length) { return null; } @@ -47,7 +44,7 @@ const OrphanComments: FC = ({ comments }) => { color="primary" sx={{ mr: 1 }} onClick={() => handleOnClickRemoveOrphans(orphanThreads)} - disabled={orphanThreads.size === 0} + disabled={orphanThreads.length === 0} > {t('Remove orphans')} @@ -57,7 +54,7 @@ const OrphanComments: FC = ({ comments }) => { 0, ); const buttonLabel = t('Number of orphan threads', { - threads: orphanThreads.size, + threads: orphanThreads.length, totalComments: totalNumberOfOrphanComments, }); diff --git a/src/modules/settings/SettingsView.tsx b/src/modules/settings/SettingsView.tsx index de4e49b..2a5e8bc 100644 --- a/src/modules/settings/SettingsView.tsx +++ b/src/modules/settings/SettingsView.tsx @@ -1,7 +1,217 @@ -import { FC } from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; -import CodeReviewSettings from './CodeReviewSettings'; +import { Edit } from '@mui/icons-material'; +import { + Box, + Button, + Card, + CardContent, + FormLabel, + Stack, + TextField, + TextareaAutosize, + Typography, + styled, +} from '@mui/material'; -const SettingsView: FC = () => ; +import { Undo2 } from 'lucide-react'; +import { ChatbotPromptSettings, SettingsKeys } from '@/config/appSetting'; +import { hooks, mutations } from '@/config/queryClient'; +import { SETTING_CHATBOT_PROMPT_CODE_EDITOR_CY } from '@/config/selectors'; +import { DEFAULT_BOT_USERNAME, SMALL_BORDER_RADIUS } from '@/constants'; + +import CodeEditor from '../common/CodeEditor'; + +const TextArea = styled(TextareaAutosize)(({ theme }) => ({ + borderRadius: SMALL_BORDER_RADIUS, + padding: theme.spacing(2), + fontSize: '1rem', + boxSizing: 'border-box', + resize: 'vertical', + border: 0, + outline: 'solid rgba(80, 80, 210, 0.5) 1px', + // make sure the outline is offset by the same amount that it is wide to not overflow + outlineOffset: '-1px', + width: '100%', + minWidth: '0', + minHeight: `calc(1rem + 2*${theme.spacing(2)})`, + transition: 'outline 250ms ease-in-out', + '&:focus': { + outline: 'solid var(--graasp-primary) 2px !important', + }, + '&:hover': { + outline: 'solid var(--graasp-primary) 1px ', + }, +})); + +const SettingsView = (): JSX.Element => { + const { t } = useTranslation(); + const { mutate: postSetting } = mutations.usePostAppSetting(); + const { mutate: patchSetting } = mutations.usePatchAppSetting(); + const [unsavedChanges, setUnsavedChanges] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const { data: chatbotPromptSettings } = + hooks.useAppSettings({ + name: SettingsKeys.ChatbotPrompt, + }); + const chatbotPrompt = chatbotPromptSettings?.[0]; + const initialPrompt = chatbotPrompt?.data?.initialPrompt || []; + const stringifiedJsonPrompt = JSON.stringify(initialPrompt, null, 2); + const chatbotCue = chatbotPrompt?.data?.chatbotCue || ''; + const chatbotName = chatbotPrompt?.data?.chatbotName || DEFAULT_BOT_USERNAME; + const [newChatbotPrompt, setNewChatbotPrompt] = useState( + stringifiedJsonPrompt, + ); + const [newChatbotCue, setNewChatbotCue] = useState(chatbotCue); + const [newChatbotName, setNewChatbotName] = useState(chatbotName); + + const doneEditing = (): void => { + setIsEditing(false); + }; + + const handleChangeChatbotPrompt = (value: string): void => { + setNewChatbotPrompt(value); + setUnsavedChanges(true); + }; + + const handleChangeChatbotCue = (value: string): void => { + setNewChatbotCue(value); + setUnsavedChanges(true); + }; + + const handleChangeChatbotName = (value: string): void => { + setNewChatbotName(value); + setUnsavedChanges(true); + }; + + const handleCancel = (): void => { + // reset fields + setNewChatbotPrompt(stringifiedJsonPrompt); + setNewChatbotName(chatbotName); + setNewChatbotCue(chatbotCue); + // resume editing + doneEditing(); + }; + + const handleSave = (): void => { + try { + const jsonNewChatbotPrompt = JSON.parse(newChatbotPrompt); + const data: ChatbotPromptSettings = { + initialPrompt: jsonNewChatbotPrompt, + chatbotCue: newChatbotCue, + chatbotName: newChatbotName, + }; + // todo handle saving settings + console.warn('saving setting'); + // setting does not exist + if (!chatbotPrompt) { + postSetting({ + data, + name: SettingsKeys.ChatbotPrompt, + }); + } else { + patchSetting({ + id: chatbotPrompt.id, + data, + }); + } + + doneEditing(); + } catch (e) { + // todo: do something + console.error('Prompt has to be in JSON format.'); + } + }; + + return ( + + + + {t('Chatbot')} + + + + {isEditing ? ( + + + {t('Chatbot Name')} + + handleChangeChatbotName(value) + } + /> + + + {t('Prompt')} + handleChangeChatbotPrompt(value)} + /> + + + {t('Cue')} +