Skip to content

Commit

Permalink
chat support markdown pre raw (#1810)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
FredLL-Avaiga and Fred Lefévère-Laoide authored Sep 20, 2024
1 parent cdbe087 commit cdc9561
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 35 deletions.
3 changes: 2 additions & 1 deletion frontend/taipy-gui/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ module.exports = {
],
coverageReporters: ["json", "html", "text"],
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()
};
39 changes: 27 additions & 12 deletions frontend/taipy-gui/src/components/Taipy/Chat.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import React from "react";
import { render } from "@testing-library/react";
import { render, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import userEvent from "@testing-library/user-event";

Expand All @@ -39,48 +39,63 @@ const searchMsg = messages[valueKey].data[0][1];

describe("Chat Component", () => {
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);
expect(elt.tagName).toBe("DIV");
const input = getByLabelText("message (taipy)");
expect(input.tagName).toBe("INPUT");
});
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);
expect(elt.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement).toHaveClass("taipy-chat");
});
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");
expect(elt.tagName).toBe("IMG");
});
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");
elts.forEach((elt) => expect(elt).toHaveClass("Mui-disabled"));
});
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");
elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
});
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");
elts.forEach((elt) => expect(elt).not.toHaveClass("Mui-disabled"));
});
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");
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 () => {
const dispatch = jest.fn();
const state: TaipyState = INITIAL_STATE;
const { getByLabelText } = render(
<TaipyContext.Provider value={{ state, dispatch }}>
<Chat messages={messages} updateVarName="varname" defaultKey={valueKey} />
<Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
</TaipyContext.Provider>
);
const elt = getByLabelText("message (taipy)");
Expand All @@ -92,7 +107,7 @@ describe("Chat Component", () => {
context: undefined,
payload: {
action: undefined,
args: ["Enter", "varname", "new message", "taipy"],
args: ["Enter", "varName", "new message", "taipy"],
},
});
});
Expand All @@ -101,7 +116,7 @@ describe("Chat Component", () => {
const state: TaipyState = INITIAL_STATE;
const { getByLabelText, getByRole } = render(
<TaipyContext.Provider value={{ state, dispatch }}>
<Chat messages={messages} updateVarName="varname" defaultKey={valueKey} />
<Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
</TaipyContext.Provider>
);
const elt = getByLabelText("message (taipy)");
Expand All @@ -114,7 +129,7 @@ describe("Chat Component", () => {
context: undefined,
payload: {
action: undefined,
args: ["click", "varname", "new message", "taipy"],
args: ["click", "varName", "new message", "taipy"],
},
});
});
Expand Down
65 changes: 44 additions & 21 deletions frontend/taipy-gui/src/components/Taipy/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* 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 Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
Expand All @@ -28,8 +28,6 @@ import Send from "@mui/icons-material/Send";
import ArrowDownward from "@mui/icons-material/ArrowDownward";
import ArrowUpward from "@mui/icons-material/ArrowUpward";

// import InfiniteLoader from "react-window-infinite-loader";

import { createRequestInfiniteTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
Expand All @@ -39,6 +37,8 @@ import { emptyArray, getInitials } from "../../utils";
import { RowType, TableValueType } from "./tableUtils";
import { Stack } from "@mui/material";

const Markdown = lazy(() => import("react-markdown"));

interface ChatProps extends TaipyActiveProps {
messages?: TableValueType;
withInput?: boolean;
Expand All @@ -50,6 +50,7 @@ interface ChatProps extends TaipyActiveProps {
defaultKey?: string; // for testing purposes only
pageSize?: number;
showSender?: boolean;
mode?: string;
}

const ENTER_KEY = "Enter";
Expand All @@ -66,7 +67,13 @@ const gridSx = { pb: "1em", mt: "unset", flex: 1, overflow: "auto" };
const loadMoreSx = { width: "fit-content", marginLeft: "auto", marginRight: "auto" };
const inputSx = { maxWidth: "unset" };
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 = {
pr: `${indicWidth}em`,
pl: `${indicWidth}em`,
Expand Down Expand Up @@ -127,10 +134,11 @@ interface ChatRowProps {
getAvatar: (id: string, sender: boolean) => ReactNode;
index: number;
showSender: boolean;
mode?: string;
}

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 avatar = getAvatar(name, sender);

Expand All @@ -149,14 +157,26 @@ const ChatRow = (props: ChatRowProps) => {
<Stack>
<Box sx={sender ? rightNameSx : leftNameSx}>{name}</Box>
<Paper sx={sender ? senderPaperSx : otherPaperSx} data-idx={index}>
{message}
{mode == "pre" ? (
<pre>{message}</pre>
) : mode == "raw" ? (
message
) : (
<Markdown>{message}</Markdown>
)}
</Paper>
</Stack>
{sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
</Stack>
) : (
<Paper sx={sender ? senderPaperSx : otherPaperSx} data-idx={index}>
{message}
{mode == "pre" ? (
<pre>{message}</pre>
) : mode == "raw" ? (
message
) : (
<Markdown>{message}</Markdown>
)}
</Paper>
)}
</Grid>
Expand Down Expand Up @@ -385,6 +405,7 @@ const Chat = (props: ChatProps) => {
getAvatar={getAvatar}
index={idx}
showSender={showSender}
mode={props.mode}
/>
) : null
)}
Expand All @@ -406,20 +427,22 @@ const Chat = (props: ChatProps) => {
label={`message (${senderId})`}
disabled={!active}
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}
/>
) : null}
Expand Down
12 changes: 11 additions & 1 deletion frontend/taipy-gui/src/components/Taipy/Field.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import React from "react";
import { render } from "@testing-library/react";
import { render, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";

import Field from "./Field";
Expand Down Expand Up @@ -60,4 +60,14 @@ describe("Field Component", () => {
const elt = getByText("titi");
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 change: 1 addition & 0 deletions taipy/gui/_renderers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class _Factory:
("height",),
("page_size", PropertyType.number, 50),
("show_sender", PropertyType.boolean, False),
("mode",),
]
),
"chart": lambda gui, control_type, attrs: _Builder(
Expand Down
6 changes: 6 additions & 0 deletions taipy/gui/viselements.json
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,12 @@
"type": "bool",
"default_value": "False",
"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>"
}
]
}
Expand Down

0 comments on commit cdc9561

Please sign in to comment.