Jelajahi Sumber

Implemented image upload on Chat (#2078)

* Implemented image upload fuctionality

* Fix existed test cases

* Add handle image upload test

* Update image handling logic

* Implement max_file_size property

* Update handle image upload

* Add test for maxFileSize

* Add example file for chat and update the existing chat examples

* Separate imports

* Update max_file_size to 1MB

* Update the example script using_show_sender

* Update sender name to taipy

* Add a cleanup function with URL.revokeObjectURL()

* Fix styling issue with image and short message

* Fix typeerror in handle image upload
Satoshi S. 6 bulan lalu
induk
melakukan
53a3a947e1

+ 5 - 4
doc/gui/examples/controls/chat_calculator.py

@@ -16,19 +16,20 @@
 # Human-computer dialog UI based on the chat control.
 # -----------------------------------------------------------------------------------------
 from math import cos, pi, sin, sqrt, tan  # noqa: F401
+from typing import Optional
 
 from taipy.gui import Gui
 
 # The user interacts with the Python interpreter
 users = ["human", "Result"]
-messages: list[tuple[str, str, str]] = []
+messages: list[tuple[str, str, str, Optional[str]]] = []
 
 
 def evaluate(state, var_name: str, payload: dict):
     # Retrieve the callback parameters
-    (_, _, expression, sender_id) = payload.get("args", [])
+    (_, _, expression, sender_id, image_url) = payload.get("args", [])
     # Add the input content as a sent message
-    messages.append((f"{len(messages)}", expression, sender_id))
+    messages.append((f"{len(messages)}", expression, sender_id, image_url))
     # Default message used if evaluation fails
     result = "Invalid expression"
     try:
@@ -37,7 +38,7 @@ def evaluate(state, var_name: str, payload: dict):
     except Exception:
         pass
     # Add the result as an incoming message
-    messages.append((f"{len(messages)}", result, users[1]))
+    messages.append((f"{len(messages)}", result, users[1], None))
     state.messages = messages
 
 

+ 4 - 4
doc/gui/examples/controls/chat_discuss.py

@@ -19,14 +19,14 @@
 # incognito windows so a given user's context is not reused.
 # -----------------------------------------------------------------------------------------
 from os import path
-from typing import Union
+from typing import Optional, Union
 
 from taipy.gui import Gui, Icon
 from taipy.gui.gui_actions import navigate, notify
 
 username = ""
 users: list[Union[str, Icon]] = []
-messages: list[tuple[str, str, str]] = []
+messages: list[tuple[str, str, str, Optional[str]]] = []
 
 Gui.add_shared_variables("messages", "users")
 
@@ -62,8 +62,8 @@ def register(state):
 
 
 def send(state, _: str, payload: dict):
-    (_, _, message, sender_id) = payload.get("args", [])
-    messages.append((f"{len(messages)}", message, sender_id))
+    (_, _, message, sender_id, image_url) = payload.get("args", [])
+    messages.append((f"{len(messages)}", message, sender_id, image_url))
     state.messages = messages
 
 

+ 46 - 0
doc/gui/examples/controls/chat_messages.py

@@ -0,0 +1,46 @@
+# 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.
+
+from taipy.gui import Gui, Icon
+
+msgs = [
+    ["1", "msg 1", "Alice", None],
+    ["2", "msg From Another unknown User", "Charles", None],
+    ["3", "This from the sender User", "taipy", "./sample.jpeg"],
+    ["4", "And from another known one", "Alice", None],
+]
+users = [
+    ["Alice", Icon("./alice-avatar.png", "Alice avatar")],
+    ["Charles", Icon("./charles-avatar.png", "Charles avatar")],
+    ["taipy", Icon("./beatrix-avatar.png", "Beatrix avatar")],
+]
+
+
+def on_action(state, var_name: str, payload: dict):
+    args = payload.get("args", [])
+    msgs.append([f"{len(msgs) +1 }", args[2], args[3], args[4]])
+    state.msgs = msgs
+
+
+Gui(
+    """
+<|toggle|theme|>
+# Test Chat
+<|1 1 1|layout|
+<|{msgs}|chat|users={users}|show_sender={True}|>
+
+<|part|>
+
+<|{msgs}|chat|users={users}|show_sender={True}|not with_input|>
+|>
+
+""",
+).run()

TEMPAT SAMPAH
doc/gui/examples/controls/sample.jpeg


+ 72 - 4
frontend/taipy-gui/src/components/Taipy/Chat.spec.tsx

@@ -12,7 +12,7 @@
  */
 
 import React from "react";
-import { render, waitFor } from "@testing-library/react";
+import { render, waitFor, fireEvent } from "@testing-library/react";
 import "@testing-library/jest-dom";
 import userEvent from "@testing-library/user-event";
 
@@ -108,7 +108,7 @@ describe("Chat Component", () => {
             context: undefined,
             payload: {
                 action: undefined,
-                args: ["Enter", "varName", "new message", "taipy"],
+                args: ["Enter", "varName", "new message", "taipy",null],
             },
         });
     });
@@ -123,15 +123,83 @@ describe("Chat Component", () => {
         const elt = getByLabelText("message (taipy)");
         await userEvent.click(elt);
         await userEvent.keyboard("new message");
-        await userEvent.click(getByRole("button"))
+        await userEvent.click(getByRole("button",{ name: /send message/i }))
         expect(dispatch).toHaveBeenCalledWith({
             type: "SEND_ACTION_ACTION",
             name: "",
             context: undefined,
             payload: {
                 action: undefined,
-                args: ["click", "varName", "new message", "taipy"],
+                args: ["click", "varName", "new message", "taipy",null],
             },
         });
     });
+    it("handle image upload",async()=>{
+        const dispatch = jest.fn();
+        const state: TaipyState = INITIAL_STATE;
+        const { getByLabelText,getByText,getByAltText,queryByText,getByRole } = render(
+            <TaipyContext.Provider value={{ state, dispatch }}>
+                <Chat messages={messages} updateVarName="varName" defaultKey={valueKey} mode="raw"/>
+            </TaipyContext.Provider>
+        );
+        const file = new File(['(⌐□_□)'], 'test.png', { type: 'image/png' });
+        URL.createObjectURL = jest.fn(() => 'mocked-url');
+        URL.revokeObjectURL = jest.fn();
+
+        const attachButton = getByLabelText('upload image');
+        expect(attachButton).toBeInTheDocument();
+
+
+        const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+        expect(fileInput).toBeInTheDocument();
+        fireEvent.change(fileInput, { target: { files: [file] } });
+
+        await waitFor(() => {
+            const chipWithImage = getByText('test.png');
+            expect(chipWithImage).toBeInTheDocument();
+            const previewImg = getByAltText('Image preview');
+            expect(previewImg).toBeInTheDocument();
+            expect(previewImg).toHaveAttribute('src', 'mocked-url');
+          });
+
+          const elt = getByLabelText("message (taipy)");
+          await userEvent.click(elt);
+          await userEvent.keyboard("Test message with image");
+          await userEvent.click(getByRole("button",{ name: /send message/i }))
+
+          expect(dispatch).toHaveBeenNthCalledWith(2,
+            expect.objectContaining({
+              type: 'SEND_ACTION_ACTION',
+              payload: expect.objectContaining({
+                args: ['click', 'varName', 'Test message with image', 'taipy', 'mocked-url']
+              })
+            })
+          );
+          await waitFor(() => {
+            const chipWithImage = queryByText('test.png');
+            expect(chipWithImage).not.toBeInTheDocument();
+          });
+          jest.restoreAllMocks()
+    })
+    it("Not upload image over a file size limit",async()=>{
+        const dispatch = jest.fn();
+        const state: TaipyState = INITIAL_STATE;
+        const { getByText,getByAltText } = render(
+            <TaipyContext.Provider value={{ state, dispatch }}>
+                <Chat messages={messages} updateVarName="varName" maxFileSize={0} defaultKey={valueKey} mode="raw"/>
+            </TaipyContext.Provider>
+        );
+        const file = new File(['(⌐□_□)'], 'test.png', { type: 'image/png' });
+        URL.createObjectURL = jest.fn(() => 'mocked-url');
+
+        const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+        expect(fileInput).toBeInTheDocument();
+        fireEvent.change(fileInput, { target: { files: [file] } });
+
+        await waitFor(() => {
+            expect(() =>getByText('test.png')).toThrow()
+            expect(()=>getByAltText('Image preview')).toThrow();
+          });
+          jest.restoreAllMocks()
+    })
 });

+ 92 - 7
frontend/taipy-gui/src/components/Taipy/Chat.tsx

@@ -35,6 +35,7 @@ import Popper from "@mui/material/Popper";
 import TextField from "@mui/material/TextField";
 import Tooltip from "@mui/material/Tooltip";
 import Send from "@mui/icons-material/Send";
+import AttachFile from "@mui/icons-material/AttachFile";
 import ArrowDownward from "@mui/icons-material/ArrowDownward";
 import ArrowUpward from "@mui/icons-material/ArrowUpward";
 
@@ -47,11 +48,13 @@ import { emptyArray, getInitials } from "../../utils";
 import { RowType, TableValueType } from "./tableUtils";
 import { Stack } from "@mui/material";
 import { getComponentClassName } from "./TaipyStyle";
+import { noDisplayStyle } from "./utils";
 
 const Markdown = lazy(() => import("react-markdown"));
 
 interface ChatProps extends TaipyActiveProps {
     messages?: TableValueType;
+    maxFileSize?: number;
     withInput?: boolean;
     users?: LoVElt[];
     defaultUsers?: string;
@@ -132,7 +135,7 @@ const defaultBoxSx = {
 } as SxProps<Theme>;
 const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps<Theme>;
 const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps<Theme>;
-
+const imageSx = {width:3/5, height:"auto"}
 interface key2Rows {
     key: string;
 }
@@ -140,6 +143,7 @@ interface key2Rows {
 interface ChatRowProps {
     senderId: string;
     message: string;
+    image?: string;
     name: string;
     className?: string;
     getAvatar: (id: string, sender: boolean) => ReactNode;
@@ -149,7 +153,7 @@ interface ChatRowProps {
 }
 
 const ChatRow = (props: ChatRowProps) => {
-    const { senderId, message, name, className, getAvatar, index, showSender, mode } = props;
+    const { senderId, message, image, name, className, getAvatar, index, showSender, mode } = props;
     const sender = senderId == name;
     const avatar = getAvatar(name, sender);
 
@@ -162,8 +166,18 @@ const ChatRow = (props: ChatRowProps) => {
             justifyContent={sender ? "flex-end" : undefined}
         >
             <Grid sx={sender ? senderMsgSx : undefined}>
+            {image?(
+                <Grid container justifyContent={sender ? "flex-end" : undefined}>
+                <Box
+                                component="img"
+                                sx={imageSx}
+                                alt="Uploaded image"
+                                src={image}
+                            />
+                </Grid>
+                            ):null}
                 {(!sender || showSender) && avatar ? (
-                    <Stack direction="row" gap={1}>
+                    <Stack direction="row" gap={1} justifyContent={sender ? "flex-end" : undefined}>
                         {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
                         <Stack>
                             <Box sx={sender ? rightNameSx : leftNameSx}>{name}</Box>
@@ -213,6 +227,7 @@ const Chat = (props: ChatProps) => {
         onAction,
         withInput = true,
         defaultKey = "",
+        maxFileSize= 1 * 1024 * 1024, // 1 MB
         pageSize = 50,
         showSender = false,
     } = props;
@@ -227,6 +242,10 @@ const Chat = (props: ChatProps) => {
     const isAnchorDivVisible = useElementVisible(anchorDivRef);
     const [showMessage, setShowMessage] = useState(false);
     const [anchorPopup, setAnchorPopup] = useState<HTMLDivElement | null>(null);
+    const [selectedFile, setSelectedFile] = useState<File | null>(null);
+    const [imagePreview, setImagePreview] = useState<string | null>(null);
+    const [objectURLs, setObjectURLs] = useState<string[]>([]);
+    const fileInputRef = useRef<HTMLInputElement>(null);
 
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
@@ -256,14 +275,16 @@ const Chat = (props: ChatProps) => {
                 const elt = evt.currentTarget.querySelector("input");
                 if (elt?.value) {
                     dispatch(
-                        createSendActionNameAction(id, module, onAction, evt.key, updateVarName, elt?.value, senderId)
+                        createSendActionNameAction(id, module, onAction, evt.key, updateVarName, elt?.value,senderId, imagePreview)
                     );
                     elt.value = "";
+                    setSelectedFile(null);
+                    setImagePreview(null);
                 }
                 evt.preventDefault();
             }
         },
-        [updateVarName, onAction, senderId, id, dispatch, module]
+        [imagePreview, updateVarName, onAction, senderId, id, dispatch, module]
     );
 
     const handleClick = useCallback(
@@ -271,15 +292,39 @@ const Chat = (props: ChatProps) => {
             const elt = evt.currentTarget.parentElement?.parentElement?.querySelector("input");
             if (elt?.value) {
                 dispatch(
-                    createSendActionNameAction(id, module, onAction, "click", updateVarName, elt?.value, senderId)
+                    createSendActionNameAction(id, module, onAction, "click", updateVarName, elt?.value,senderId,imagePreview)
                 );
                 elt.value = "";
+                setSelectedFile(null);
+                setImagePreview(null);
             }
             evt.preventDefault();
         },
-        [updateVarName, onAction, senderId, id, dispatch, module]
+        [imagePreview,updateVarName, onAction, senderId, id, dispatch, module]
     );
 
+    const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+            const file = event.target.files ? event.target.files[0] : null;
+            if (file) {
+                if (file.type.startsWith("image/") && file.size <= maxFileSize) {
+                    setSelectedFile(file);
+                    const newImagePreview = URL.createObjectURL(file);
+                    setImagePreview(newImagePreview);
+                    setObjectURLs((prevURLs) => [...prevURLs, newImagePreview]);
+                } else {
+                    setSelectedFile(null);
+                    setImagePreview(null);
+                }
+            }
+        };
+
+    const handleAttachClick = useCallback(() => {
+        if (fileInputRef.current) {
+            fileInputRef.current.click();
+        }
+    }, [fileInputRef]);
+
+
     const avatars = useMemo(() => {
         return users.reduce((pv, elt) => {
             if (elt.id) {
@@ -390,6 +435,14 @@ const Chat = (props: ChatProps) => {
         loadMoreItems(0);
     }, [loadMoreItems]);
 
+    useEffect(() => {
+        return () => {
+            for (const objectURL of objectURLs) {
+                URL.revokeObjectURL(objectURL);
+            }
+        };
+    }, [objectURLs]);
+
     const loadOlder = useCallback(
         (evt: MouseEvent<HTMLElement>) => {
             const { start } = evt.currentTarget.dataset;
@@ -424,6 +477,7 @@ const Chat = (props: ChatProps) => {
                                 senderId={senderId}
                                 message={`${row[columns[1]]}`}
                                 name={columns[2] ? `${row[columns[2]]}` : "Unknown"}
+                                image={columns[3] && columns[3] != "_tp_index" && row[columns[3]] ? `${row[columns[3]]}` : undefined}
                                 className={className}
                                 getAvatar={getAvatar}
                                 index={idx}
@@ -443,6 +497,25 @@ const Chat = (props: ChatProps) => {
                     />
                 </Popper>
                 {withInput ? (
+                    <>
+                    {imagePreview && selectedFile && (
+                            <Box mb={1}>
+                                <Chip
+                                    label={selectedFile.name}
+                                    avatar={<Avatar alt="Image preview" src={imagePreview}/>}
+                                    onDelete={() => setSelectedFile(null)}
+                                    variant="outlined"
+                                />
+                            </Box>
+                        )}
+                    <input
+                            type="file"
+                            ref={fileInputRef}
+                            style={noDisplayStyle}
+                            onChange={(e) => handleFileSelect(e)}
+                            accept="image/*"
+                        />
+
                     <TextField
                         margin="dense"
                         fullWidth
@@ -452,6 +525,17 @@ const Chat = (props: ChatProps) => {
                         onKeyDown={handleAction}
                         slotProps={{
                             input: {
+                                startAdornment: (<InputAdornment position="start">
+                                    <IconButton
+                                            aria-label="upload image"
+                                            onClick={handleAttachClick}
+                                            edge="start"
+                                            disabled={!active}
+                                        >
+                                       <AttachFile color={disableColor("primary", !active)} />
+                                    </IconButton>
+
+                                </InputAdornment>),
                                 endAdornment: (
                                     <InputAdornment position="end">
                                         <IconButton
@@ -468,6 +552,7 @@ const Chat = (props: ChatProps) => {
                         }}
                         sx={inputSx}
                     />
+                    </>
                 ) : null}
                 {props.children}
             </Paper>

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

@@ -99,6 +99,7 @@ class _Factory:
                 ("sender_id",),
                 ("height",),
                 ("page_size", PropertyType.number, 50),
+                ("max_file_size", PropertyType.number, 1 * 1024 * 1024),
                 ("show_sender", PropertyType.boolean, False),
                 ("mode",),
             ]

+ 8 - 1
taipy/gui/viselements.json

@@ -1696,7 +1696,14 @@
                         "default_property": true,
                         "required": true,
                         "type": "dynamic(list[str])",
-                        "doc": "The list of messages. Each item of this list must consist of a list of three strings: a message identifier, a message content, and a user identifier."
+                        "doc": "The list of messages. Each item of this list must consist of a list of three strings: a message identifier, a message content, a user identifier, and an image URL."
+                    },
+                    {
+                        "name": "max_file_size",
+                        "type": "int",
+                        "default_value": "1 * 1024 * 1024 (1MB)",
+                        "doc": "The maximum file size can be uploaded to a chat message."
+
                     },
                     {
                         "name": "users",