1
0
Эх сурвалжийг харах

chat support markdown pre raw (#1810)

* support markdown pre in chat messages

* test

* test lazy markdown

* jest with react-markdown

* do not ignore react-markdown take 2

* test with react-markdown

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Fred Lefévère-Laoide 8 сар өмнө
parent
commit
cdc9561fe4

+ 2 - 1
frontend/taipy-gui/jest.config.js

@@ -26,6 +26,7 @@ module.exports = {
     ],
     ],
     coverageReporters: ["json", "html", "text"],
     coverageReporters: ["json", "html", "text"],
     modulePathIgnorePatterns: ["<rootDir>/packaging/"],
     modulePathIgnorePatterns: ["<rootDir>/packaging/"],
-    transformIgnorePatterns: ["<rootDir>/node_modules/(?!react-jsx-parser/)"],
+    moduleNameMapper: {"react-markdown": "<rootDir>/node_modules/react-markdown/react-markdown.min.js"},
+    transformIgnorePatterns: ["<rootDir>/node_modules/(?!react-jsx-parser|react-markdown/)"],
     ...createJsWithTsPreset()
     ...createJsWithTsPreset()
 };
 };

+ 27 - 12
frontend/taipy-gui/src/components/Taipy/Chat.spec.tsx

@@ -12,7 +12,7 @@
  */
  */
 
 
 import React from "react";
 import React from "react";
-import { render } from "@testing-library/react";
+import { render, waitFor } from "@testing-library/react";
 import "@testing-library/jest-dom";
 import "@testing-library/jest-dom";
 import userEvent from "@testing-library/user-event";
 import userEvent from "@testing-library/user-event";
 
 
@@ -39,48 +39,63 @@ const searchMsg = messages[valueKey].data[0][1];
 
 
 describe("Chat Component", () => {
 describe("Chat Component", () => {
     it("renders", async () => {
     it("renders", async () => {
-        const { getByText, getByLabelText } = render(<Chat messages={messages} defaultKey={valueKey} />);
+        const { getByText, getByLabelText } = render(<Chat messages={messages} defaultKey={valueKey} mode="raw" />);
         const elt = getByText(searchMsg);
         const elt = getByText(searchMsg);
         expect(elt.tagName).toBe("DIV");
         expect(elt.tagName).toBe("DIV");
         const input = getByLabelText("message (taipy)");
         const input = getByLabelText("message (taipy)");
         expect(input.tagName).toBe("INPUT");
         expect(input.tagName).toBe("INPUT");
     });
     });
     it("uses the class", async () => {
     it("uses the class", async () => {
-        const { getByText } = render(<Chat messages={messages} className="taipy-chat" defaultKey={valueKey} />);
+        const { getByText } = render(<Chat messages={messages} className="taipy-chat" defaultKey={valueKey} mode="raw" />);
         const elt = getByText(searchMsg);
         const elt = getByText(searchMsg);
         expect(elt.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement).toHaveClass("taipy-chat");
         expect(elt.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement).toHaveClass("taipy-chat");
     });
     });
     it("can display an avatar", async () => {
     it("can display an avatar", async () => {
-        const { getByAltText } = render(<Chat messages={messages} users={users} defaultKey={valueKey} />);
+        const { getByAltText } = render(<Chat messages={messages} users={users} defaultKey={valueKey} mode="raw"/>);
         const elt = getByAltText("Fred.png");
         const elt = getByAltText("Fred.png");
         expect(elt.tagName).toBe("IMG");
         expect(elt.tagName).toBe("IMG");
     });
     });
     it("is disabled", async () => {
     it("is disabled", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} active={false} defaultKey={valueKey} />);
+        const { getAllByRole } = render(<Chat messages={messages} active={false} defaultKey={valueKey} mode="raw"/>);
         const elts = getAllByRole("button");
         const elts = getAllByRole("button");
         elts.forEach((elt) => expect(elt).toHaveClass("Mui-disabled"));
         elts.forEach((elt) => expect(elt).toHaveClass("Mui-disabled"));
     });
     });
     it("is enabled by default", async () => {
     it("is enabled by default", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} defaultKey={valueKey} />);
+        const { getAllByRole } = render(<Chat messages={messages} defaultKey={valueKey} mode="raw"/>);
         const elts = getAllByRole("button");
         const elts = getAllByRole("button");
         elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
         elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
     });
     });
     it("is enabled by active", async () => {
     it("is enabled by active", async () => {
-        const { getAllByRole } = render(<Chat messages={messages} active={true} defaultKey={valueKey} />);
+        const { getAllByRole } = render(<Chat messages={messages} active={true} defaultKey={valueKey} mode="raw"/>);
         const elts = getAllByRole("button");
         const elts = getAllByRole("button");
         elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
         elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
     });
     });
     it("can hide input", async () => {
     it("can hide input", async () => {
-        render(<Chat messages={messages} withInput={false} className="taipy-chat" defaultKey={valueKey} />);
+        render(<Chat messages={messages} withInput={false} className="taipy-chat" defaultKey={valueKey} mode="raw"/>);
         const elt = document.querySelector(".taipy-chat input");
         const elt = document.querySelector(".taipy-chat input");
         expect(elt).toBeNull();
         expect(elt).toBeNull();
     });
     });
+    it("renders markdown by default", async () => {
+        render(<Chat messages={messages} className="taipy-chat" defaultKey={valueKey} />);
+        const elt = document.querySelector(".taipy-chat .taipy-chat-received .MuiPaper-root");
+        await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
+    });
+    it("can render pre", async () => {
+        render(<Chat messages={messages} className="taipy-chat" defaultKey={valueKey} mode="pre" />);
+        const elt = document.querySelector(".taipy-chat .taipy-chat-received .MuiPaper-root pre");
+        expect(elt).toBeInTheDocument();
+    });
+    it("can render raw", async () => {
+        render(<Chat messages={messages} className="taipy-chat" defaultKey={valueKey} mode="raw" />);
+        const elt = document.querySelector(".taipy-chat .taipy-chat-received div.MuiPaper-root");
+        expect(elt).toBeInTheDocument();
+    });
     it("dispatch a well formed message by Keyboard", async () => {
     it("dispatch a well formed message by Keyboard", async () => {
         const dispatch = jest.fn();
         const dispatch = jest.fn();
         const state: TaipyState = INITIAL_STATE;
         const state: TaipyState = INITIAL_STATE;
         const { getByLabelText } = render(
         const { getByLabelText } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varname" defaultKey={valueKey} />
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
             </TaipyContext.Provider>
             </TaipyContext.Provider>
         );
         );
         const elt = getByLabelText("message (taipy)");
         const elt = getByLabelText("message (taipy)");
@@ -92,7 +107,7 @@ describe("Chat Component", () => {
             context: undefined,
             context: undefined,
             payload: {
             payload: {
                 action: undefined,
                 action: undefined,
-                args: ["Enter", "varname", "new message", "taipy"],
+                args: ["Enter", "varName", "new message", "taipy"],
             },
             },
         });
         });
     });
     });
@@ -101,7 +116,7 @@ describe("Chat Component", () => {
         const state: TaipyState = INITIAL_STATE;
         const state: TaipyState = INITIAL_STATE;
         const { getByLabelText, getByRole } = render(
         const { getByLabelText, getByRole } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
             <TaipyContext.Provider value={{ state, dispatch }}>
-                <Chat messages={messages} updateVarName="varname" defaultKey={valueKey} />
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
             </TaipyContext.Provider>
             </TaipyContext.Provider>
         );
         );
         const elt = getByLabelText("message (taipy)");
         const elt = getByLabelText("message (taipy)");
@@ -114,7 +129,7 @@ describe("Chat Component", () => {
             context: undefined,
             context: undefined,
             payload: {
             payload: {
                 action: undefined,
                 action: undefined,
-                args: ["click", "varname", "new message", "taipy"],
+                args: ["click", "varName", "new message", "taipy"],
             },
             },
         });
         });
     });
     });

+ 44 - 21
frontend/taipy-gui/src/components/Taipy/Chat.tsx

@@ -11,7 +11,7 @@
  * specific language governing permissions and limitations under the License.
  * specific language governing permissions and limitations under the License.
  */
  */
 
 
-import React, { useMemo, useCallback, KeyboardEvent, MouseEvent, useState, useRef, useEffect, ReactNode } from "react";
+import React, { useMemo, useCallback, KeyboardEvent, MouseEvent, useState, useRef, useEffect, ReactNode, lazy } from "react";
 import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
 import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
 import Avatar from "@mui/material/Avatar";
 import Avatar from "@mui/material/Avatar";
 import Box from "@mui/material/Box";
 import Box from "@mui/material/Box";
@@ -28,8 +28,6 @@ import Send from "@mui/icons-material/Send";
 import ArrowDownward from "@mui/icons-material/ArrowDownward";
 import ArrowDownward from "@mui/icons-material/ArrowDownward";
 import ArrowUpward from "@mui/icons-material/ArrowUpward";
 import ArrowUpward from "@mui/icons-material/ArrowUpward";
 
 
-// import InfiniteLoader from "react-window-infinite-loader";
-
 import { createRequestInfiniteTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
 import { createRequestInfiniteTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
 import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
 import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
 import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
 import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
@@ -39,6 +37,8 @@ import { emptyArray, getInitials } from "../../utils";
 import { RowType, TableValueType } from "./tableUtils";
 import { RowType, TableValueType } from "./tableUtils";
 import { Stack } from "@mui/material";
 import { Stack } from "@mui/material";
 
 
+const Markdown = lazy(() => import("react-markdown"));
+
 interface ChatProps extends TaipyActiveProps {
 interface ChatProps extends TaipyActiveProps {
     messages?: TableValueType;
     messages?: TableValueType;
     withInput?: boolean;
     withInput?: boolean;
@@ -50,6 +50,7 @@ interface ChatProps extends TaipyActiveProps {
     defaultKey?: string; // for testing purposes only
     defaultKey?: string; // for testing purposes only
     pageSize?: number;
     pageSize?: number;
     showSender?: boolean;
     showSender?: boolean;
+    mode?: string;
 }
 }
 
 
 const ENTER_KEY = "Enter";
 const ENTER_KEY = "Enter";
@@ -66,7 +67,13 @@ const gridSx = { pb: "1em", mt: "unset", flex: 1, overflow: "auto" };
 const loadMoreSx = { width: "fit-content", marginLeft: "auto", marginRight: "auto" };
 const loadMoreSx = { width: "fit-content", marginLeft: "auto", marginRight: "auto" };
 const inputSx = { maxWidth: "unset" };
 const inputSx = { maxWidth: "unset" };
 const leftNameSx = { fontSize: "0.6em", fontWeight: "bolder", pl: `${indicWidth}em` };
 const leftNameSx = { fontSize: "0.6em", fontWeight: "bolder", pl: `${indicWidth}em` };
-const rightNameSx: SxProps = { ...leftNameSx, pr: `${2 * indicWidth}em`, width: "100%", display: "flex", justifyContent: "flex-end" };
+const rightNameSx: SxProps = {
+    ...leftNameSx,
+    pr: `${2 * indicWidth}em`,
+    width: "100%",
+    display: "flex",
+    justifyContent: "flex-end",
+};
 const senderPaperSx = {
 const senderPaperSx = {
     pr: `${indicWidth}em`,
     pr: `${indicWidth}em`,
     pl: `${indicWidth}em`,
     pl: `${indicWidth}em`,
@@ -127,10 +134,11 @@ interface ChatRowProps {
     getAvatar: (id: string, sender: boolean) => ReactNode;
     getAvatar: (id: string, sender: boolean) => ReactNode;
     index: number;
     index: number;
     showSender: boolean;
     showSender: boolean;
+    mode?: string;
 }
 }
 
 
 const ChatRow = (props: ChatRowProps) => {
 const ChatRow = (props: ChatRowProps) => {
-    const { senderId, message, name, className, getAvatar, index, showSender } = props;
+    const { senderId, message, name, className, getAvatar, index, showSender, mode } = props;
     const sender = senderId == name;
     const sender = senderId == name;
     const avatar = getAvatar(name, sender);
     const avatar = getAvatar(name, sender);
 
 
@@ -149,14 +157,26 @@ const ChatRow = (props: ChatRowProps) => {
                         <Stack>
                         <Stack>
                             <Box sx={sender ? rightNameSx : leftNameSx}>{name}</Box>
                             <Box sx={sender ? rightNameSx : leftNameSx}>{name}</Box>
                             <Paper sx={sender ? senderPaperSx : otherPaperSx} data-idx={index}>
                             <Paper sx={sender ? senderPaperSx : otherPaperSx} data-idx={index}>
-                                {message}
+                                {mode == "pre" ? (
+                                    <pre>{message}</pre>
+                                ) : mode == "raw" ? (
+                                    message
+                                ) : (
+                                    <Markdown>{message}</Markdown>
+                                )}
                             </Paper>
                             </Paper>
                         </Stack>
                         </Stack>
                         {sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
                         {sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
                     </Stack>
                     </Stack>
                 ) : (
                 ) : (
                     <Paper sx={sender ? senderPaperSx : otherPaperSx} data-idx={index}>
                     <Paper sx={sender ? senderPaperSx : otherPaperSx} data-idx={index}>
-                        {message}
+                        {mode == "pre" ? (
+                            <pre>{message}</pre>
+                        ) : mode == "raw" ? (
+                            message
+                        ) : (
+                            <Markdown>{message}</Markdown>
+                        )}
                     </Paper>
                     </Paper>
                 )}
                 )}
             </Grid>
             </Grid>
@@ -385,6 +405,7 @@ const Chat = (props: ChatProps) => {
                                 getAvatar={getAvatar}
                                 getAvatar={getAvatar}
                                 index={idx}
                                 index={idx}
                                 showSender={showSender}
                                 showSender={showSender}
+                                mode={props.mode}
                             />
                             />
                         ) : null
                         ) : null
                     )}
                     )}
@@ -406,20 +427,22 @@ const Chat = (props: ChatProps) => {
                         label={`message (${senderId})`}
                         label={`message (${senderId})`}
                         disabled={!active}
                         disabled={!active}
                         onKeyDown={handleAction}
                         onKeyDown={handleAction}
-                        slotProps={{input: {
-                            endAdornment: (
-                                <InputAdornment position="end">
-                                    <IconButton
-                                        aria-label="send message"
-                                        onClick={handleClick}
-                                        edge="end"
-                                        disabled={!active}
-                                    >
-                                        <Send color={disableColor("primary", !active)} />
-                                    </IconButton>
-                                </InputAdornment>
-                            ),
-                        }}}
+                        slotProps={{
+                            input: {
+                                endAdornment: (
+                                    <InputAdornment position="end">
+                                        <IconButton
+                                            aria-label="send message"
+                                            onClick={handleClick}
+                                            edge="end"
+                                            disabled={!active}
+                                        >
+                                            <Send color={disableColor("primary", !active)} />
+                                        </IconButton>
+                                    </InputAdornment>
+                                ),
+                            },
+                        }}
                         sx={inputSx}
                         sx={inputSx}
                     />
                     />
                 ) : null}
                 ) : null}

+ 11 - 1
frontend/taipy-gui/src/components/Taipy/Field.spec.tsx

@@ -12,7 +12,7 @@
  */
  */
 
 
 import React from "react";
 import React from "react";
-import { render } from "@testing-library/react";
+import { render, waitFor } from "@testing-library/react";
 import "@testing-library/jest-dom";
 import "@testing-library/jest-dom";
 
 
 import Field from "./Field";
 import Field from "./Field";
@@ -60,4 +60,14 @@ describe("Field Component", () => {
         const elt = getByText("titi");
         const elt = getByText("titi");
         expect(elt).toHaveStyle("width: 500px");
         expect(elt).toHaveStyle("width: 500px");
     });
     });
+    it("can render markdown", async () => {
+        render(<Field value="titi" className="taipy-text" mode="md" />);
+        const elt = document.querySelector(".taipy-text");
+        await waitFor(() => expect(elt?.querySelector("p")).not.toBeNull());
+    });
+    it("can render pre", async () => {
+        render(<Field value="titi" className="taipy-text" mode="pre" />);
+        const elt = document.querySelector("pre.taipy-text");
+        expect(elt).toBeInTheDocument();
+    });
 });
 });

+ 1 - 0
taipy/gui/_renderers/factory.py

@@ -99,6 +99,7 @@ class _Factory:
                 ("height",),
                 ("height",),
                 ("page_size", PropertyType.number, 50),
                 ("page_size", PropertyType.number, 50),
                 ("show_sender", PropertyType.boolean, False),
                 ("show_sender", PropertyType.boolean, False),
+                ("mode",),
             ]
             ]
         ),
         ),
         "chart": lambda gui, control_type, attrs: _Builder(
         "chart": lambda gui, control_type, attrs: _Builder(

+ 6 - 0
taipy/gui/viselements.json

@@ -1661,6 +1661,12 @@
                         "type": "bool",
                         "type": "bool",
                         "default_value": "False",
                         "default_value": "False",
                         "doc": "If True, the sender avatar and name are displayed."
                         "doc": "If True, the sender avatar and name are displayed."
+                    },
+                    {
+                        "name": "mode",
+                        "type": "str",
+                        "default_value": "\"markdown\"",
+                        "doc": "Define the way the messages are processed:\n<ul><li>&quot;raw&quot; no processing</li><li>&quot;pre&quot;: keeps spaces and new lines</li><li>&quot;markdown&quot; or &quot;md&quot;: basic support for Markdown.</li></ul>"
                     }
                     }
                 ]
                 ]
             }
             }