Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chat support markdown pre raw #1810

Merged
merged 6 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading