Skip to content

Commit

Permalink
chat control
Browse files Browse the repository at this point in the history
resolves #1044
  • Loading branch information
Fred Lefévère-Laoide authored and Fred Lefévère-Laoide committed Apr 5, 2024
1 parent 260b420 commit 9a5d967
Show file tree
Hide file tree
Showing 11 changed files with 2,388 additions and 1,487 deletions.
117 changes: 117 additions & 0 deletions frontend/taipy-gui/src/components/Taipy/Chat.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright 2021-2024 Avaiga Private Limited
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/

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

import Chat, { Message } from "./Chat";
import { INITIAL_STATE, TaipyState } from "../../context/taipyReducers";
import { TaipyContext } from "../../context/taipyContext";
import { stringIcon } from "../../utils/icon";

const messages: Message[] = [
["1", "msg 1", "Fred"],
["2", "msg From Another unknown User", "Fredo"],
["3", "This from the sender User", "taipy"],
["4", "And from another known one", "Fredi"],
];
const user1: [string, stringIcon] = ["Fred", { path: "/images/favicon.png", text: "Fred.png" }];
const user2: [string, stringIcon] = ["Fredi", { path: "/images/fred.png", text: "Fredi.png" }];
const users = [user1, user2];

const searchMsg = messages[0][1];

describe("Chat Component", () => {
it("renders", async () => {
const { getByText, getByLabelText } = render(<Chat messages={messages} />);
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" />);
const elt = getByText(searchMsg);
expect(elt.parentElement?.parentElement?.parentElement?.parentElement).toHaveClass("taipy-chat");
});
it("can display an avatar", async () => {
const { getByAltText } = render(<Chat messages={messages} users={users} />);
const elt = getByAltText("Fred.png");
expect(elt.tagName).toBe("IMG");
});
it("is disabled", async () => {
const { getAllByRole } = render(<Chat messages={messages} active={false} />);
const elts = getAllByRole("button");
elts.forEach((elt) => expect(elt).toHaveClass("Mui-disabled"));
});
it("is enabled by default", async () => {
const { getAllByRole } = render(<Chat messages={messages} />);
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} />);
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" />);
const elt = document.querySelector(".taipy-chat input");
expect(elt).toBeNull();
});
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" />
</TaipyContext.Provider>
);
const elt = getByLabelText("message (taipy)");
await userEvent.click(elt);
await userEvent.keyboard("new message{Enter}");
expect(dispatch).toHaveBeenCalledWith({
type: "SEND_ACTION_ACTION",
name: "",
context: undefined,
payload: {
action: undefined,
args: ["Enter", "varname", "new message", "taipy"],
},
});
});
it("dispatch a well formed message by button", async () => {
const dispatch = jest.fn();
const state: TaipyState = INITIAL_STATE;
const { getByLabelText, getByRole } = render(
<TaipyContext.Provider value={{ state, dispatch }}>
<Chat messages={messages} updateVarName="varname" />
</TaipyContext.Provider>
);
const elt = getByLabelText("message (taipy)");
await userEvent.click(elt);
await userEvent.keyboard("new message");
await userEvent.click(getByRole("button"))
expect(dispatch).toHaveBeenCalledWith({
type: "SEND_ACTION_ACTION",
name: "",
context: undefined,
payload: {
action: undefined,
args: ["click", "varname", "new message", "taipy"],
},
});
});
});
245 changes: 245 additions & 0 deletions frontend/taipy-gui/src/components/Taipy/Chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
/*
* Copyright 2021-2024 Avaiga Private Limited
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/

import React, { useMemo, useCallback, KeyboardEvent, MouseEvent } from "react";
import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import Paper from "@mui/material/Paper";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Send from "@mui/icons-material/Send";

import { createSendActionNameAction } from "../../context/taipyReducers";
import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
import {
useClassNames,
useDispatch,
useDispatchRequestUpdateOnFirstRender,
useDynamicProperty,
useModule,
} from "../../utils/hooks";
import { LoVElt, useLovListMemo } from "./lovUtils";
import { IconAvatar, avatarSx } from "../../utils/icon";
import { getInitials } from "../../utils";

export type Message = [string, string, string];

interface ChatProps extends TaipyActiveProps {
messages?: Message[];
withInput?: boolean;
users?: LoVElt[];
defaultUsers?: string;
onAction?: string;
senderId?: string;
height?: string;
}

const ENTER_KEY = "Enter";
const NoMessages: Message[] = [];

const indicWidth = 0.7;
const avatarWidth = 24;
const chatAvatarSx = { ...avatarSx, width: avatarWidth, height: avatarWidth };
const avatarColSx = { width: 1.5 * avatarWidth };
const senderMsgSx = { width: "fit-content", maxWidth: "80%", marginLeft: "auto" };
const gridSx = { pb: "1em" };
const inputSx = { maxWidth: "unset" };
const nameSx = { fontSize: "0.6em", fontWeight: "bolder" };
const senderPaperSx = {
pr: `${indicWidth}em`,
pl: `${indicWidth}em`,
mr: `${indicWidth}em`,
position: "relative",
"&:before": {
content: "''",
position: "absolute",
width: "0",
height: "0",
borderTopWidth: `${indicWidth}em`,
borderTopStyle: "solid",
borderTopColor: (theme: Theme) => theme.palette.background.paper,
borderLeft: `${indicWidth}em solid transparent`,
borderRight: `${indicWidth}em solid transparent`,
top: "0",
right: `-${indicWidth}em`,
},
} as SxProps<Theme>;
const otherPaperSx = {
position: "relative",
pl: `${indicWidth}em`,
pr: `${indicWidth}em`,
"&:before": {
content: "''",
position: "absolute",
width: "0",
height: "0",
borderTopWidth: `${indicWidth}em`,
borderTopStyle: "solid",
borderTopColor: (theme: Theme) => theme.palette.background.paper,
borderLeft: `${indicWidth}em solid transparent`,
borderRight: `${indicWidth}em solid transparent`,
top: "0",
left: `-${indicWidth}em`,
},
} as SxProps<Theme>;
const defaultBoxSx = {
pl: `${indicWidth}em`,
pr: `${indicWidth}em`,
backgroundColor: (theme: Theme) =>
theme.palette.mode == "dark"
? lighten(theme.palette.background.paper, 0.05)

Check warning on line 104 in frontend/taipy-gui/src/components/Taipy/Chat.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
: darken(theme.palette.background.paper, 0.15),
} as SxProps<Theme>;

const Chat = (props: ChatProps) => {
const { id, updateVarName, senderId = "taipy", onAction, messages = NoMessages, withInput = true } = props;

Check warning on line 109 in frontend/taipy-gui/src/components/Taipy/Chat.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
const dispatch = useDispatch();
const module = useModule();

const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
const active = useDynamicProperty(props.active, props.defaultActive, true);
const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
const users = useLovListMemo(props.users, props.defaultUsers || "");

useDispatchRequestUpdateOnFirstRender(dispatch, id, module, undefined, updateVarName);

const boxSx = useMemo(
() => (props.height ? { ...defaultBoxSx, maxHeight: props.height } : defaultBoxSx),

Check warning on line 121 in frontend/taipy-gui/src/components/Taipy/Chat.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
[props.height]
);
const handleAction = useCallback(
(evt: KeyboardEvent<HTMLDivElement>) => {
if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && ENTER_KEY == evt.key) {
const elt = evt.currentTarget.querySelector("input");
if (elt?.value) {

Check warning on line 128 in frontend/taipy-gui/src/components/Taipy/Chat.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
dispatch(
createSendActionNameAction(id, module, onAction, evt.key, updateVarName, elt?.value, senderId)

Check warning on line 130 in frontend/taipy-gui/src/components/Taipy/Chat.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
);
elt.value = "";
}
evt.preventDefault();
}
},
[updateVarName, onAction, senderId, id, dispatch, module]
);

const handleClick = useCallback(
(evt: MouseEvent<HTMLButtonElement>) => {
const elt = evt.currentTarget.parentElement?.parentElement?.querySelector("input");

Check warning on line 142 in frontend/taipy-gui/src/components/Taipy/Chat.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

Check warning on line 142 in frontend/taipy-gui/src/components/Taipy/Chat.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
if (elt?.value) {

Check warning on line 143 in frontend/taipy-gui/src/components/Taipy/Chat.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
dispatch(
createSendActionNameAction(id, module, onAction, "click", updateVarName, elt?.value, senderId)

Check warning on line 145 in frontend/taipy-gui/src/components/Taipy/Chat.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
);
elt.value = "";
}
evt.preventDefault();
},
[updateVarName, onAction, senderId, id, dispatch, module]
);

const avatars = useMemo(() => {
return users.reduce((pv, elt) => {
if (elt.id) {
pv[elt.id] =
typeof elt.item == "string" ? (
<Tooltip title={elt.item}>
<Avatar sx={chatAvatarSx}>{getInitials(elt.item)}</Avatar>

Check warning on line 160 in frontend/taipy-gui/src/components/Taipy/Chat.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
</Tooltip>
) : (
<IconAvatar img={elt.item} sx={chatAvatarSx} />
);
}
return pv;
}, {} as Record<string, React.ReactNode>);
}, [users]);

const getAvatar = useCallback(
(id: string) =>
avatars[id] || (
<Tooltip title={id}>
<Avatar sx={chatAvatarSx}>{getInitials(id)}</Avatar>
</Tooltip>
),
[avatars]
);

return (
<Tooltip title={hover || ""}>
<Paper className={className} sx={boxSx} id={id}>
<Grid container rowSpacing={2} sx={gridSx}>
{messages.map((msg) =>
senderId == msg[2] ? (
<Grid item key={msg[0]} className={getSuffixedClassNames(className, "-sent")} xs={12}>
<Box sx={senderMsgSx}>
<Paper sx={senderPaperSx}>{msg[1]}</Paper>
</Box>
</Grid>
) : (
<Grid
item
container
key={msg[0]}
className={getSuffixedClassNames(className, "-received")}
rowSpacing={0.2}
columnSpacing={1}
>
<Grid item sx={avatarColSx}></Grid>
<Grid item sx={nameSx}>
{msg[2]}
</Grid>
<Box width="100%" />
<Grid item sx={avatarColSx}>
{getAvatar(msg[2])}
</Grid>
<Grid item>
<Paper sx={otherPaperSx}>{msg[1]}</Paper>
</Grid>
</Grid>
)
)}
</Grid>
{withInput ? (
<TextField
margin="dense"
fullWidth
className={getSuffixedClassNames(className, "-input")}
label={`message (${senderId})`}
disabled={!active}
onKeyDown={handleAction}
InputProps={{
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}
</Paper>
</Tooltip>
);
};

export default Chat;
2 changes: 2 additions & 0 deletions frontend/taipy-gui/src/components/Taipy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import { ComponentType } from "react";
import Button from "./Button";
import Chat from "./Chat";
import Chart from "./Chart";
import DateRange from "./DateRange";
import DateSelector from "./DateSelector";
Expand Down Expand Up @@ -46,6 +47,7 @@ export const getRegisteredComponents = () => {
Object.entries({
a: Link,
Button: Button,
Chat: Chat,
Chart: Chart,
DateRange: DateRange,
DateSelector: DateSelector,
Expand Down
2 changes: 2 additions & 0 deletions frontend/taipy-gui/src/components/Taipy/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,5 @@ export const getSuffixedClassNames = (names: string | undefined, suffix: string)
.join(" ");

export const emptyStyle = {} as CSSProperties;

export const disableColor = <T>(color: T, disabled: boolean) => (disabled ? ("disabled" as T) : color);

0 comments on commit 9a5d967

Please sign in to comment.