我遇到了這個奇怪的問題,這是我第一次遇到它。我創建了一個使用 Redux 工具包處理應用程式建立的按鈕。根據 UI 設計,按鈕應該會在如下所示的頁面上出現兩次。反白的按鈕是同一組件。
如果我嘗試創建一個應用程序,它會顯示兩個 toast 訊息:
我注意到,如果我刪除“創建應用程式”按鈕之一併保留一個,然後我嘗試創建一個僅顯示一條 Toast 消息的應用程序,如預期的那樣。
建立 2 個單獨的按鈕來處理一項功能是理想的最佳做法嗎?
這是 CreateAnApp 按鈕:
import React, { useState, useEffect } from "react"; import { Box, Button, Checkbox, FormControl, FormLabel, Flex, Input, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, Spinner, Text, ModalBody, ModalCloseButton, Wrap, Select, Textarea } from "@chakra-ui/react"; import { Select as Select1 } from "chakra-react-select"; import { useToast } from "@chakra-ui/react"; import { useDropzone } from "react-dropzone"; import "./style.css"; import { AiOutlineCloudUpload } from "react-icons/ai"; import { useDispatch, useSelector } from "react-redux"; import { createApp, reset } from "../../features/apps/appSlice"; export const CreateAnApp = (props) => { const { isOpen, onOpen, onClose } = useDisclosure(); const { variant, bg, textColor, fontSize, fontWeight, leftIcon, hover, children, ...rest } = props; const { isAppLoading, isError, isAppSuccess, message } = useSelector( (state) => state.app ); const toast = useToast(); const [formData, setFormData] = useState({ name: "", displayName: "", reason: "", product: "", environment: "", }); const { name, displayName, reason, product, environment } = formData; const [icon, setIcon] = useState([]); const { getRootProps, getInputProps } = useDropzone({ accept: "image/*", onDrop: (acceptedFiles) => { setIcon( acceptedFiles.map((file) => Object.assign(file, { preview: URL.createObjectURL(file), }) ) ); }, }); // const [product, setProduct] = useState([]); const [scopes, setScopes] = useState([]); const [institutionScope, setInstitutionScope] = useState([]); // const [environment, setEnvironment] = useState([]); const images = icon.map((file) => ( <img key={file.name} src={file.preview} alt="image" style={{ width: "50%", height: "50%" }} /> )); const onChange = (e) => { setFormData((prevState) => ({ ...prevState, [e.target.name]: e.target.value, })); }; const onCheckBoxChange = (event) => { if (event.target.checked) { setFormData((prevState) => ({ ...prevState, displayName: prevState.name, })); } } // handle onChange event of the dropdown const handleScopes = (e) => { setScopes(Array.isArray(e) ? e.map((x) => x.value) : []); }; const handleInstitutionScope = (e) => { setInstitutionScope(Array.isArray(e) ? e.map((x) => x.value) : []); }; // const handleEnvironment = (e) => { // setEnvironment(Array.isArray(e) ? e.map((x) => x.value) : []); // }; const scopesOptions = [ { label: "Transactions", value: "Transactions", }, { label: "Accounts", value: "Accounts", }, ]; const institutionScopeOptions = [ { label: "Neobanks", value: "Neobanks", }, { label: "DeFi/CeFi", value: "DeFi/CeFi", }, { label: "Personal finance", value: "Personal finance", }, { label: "Investments", value: "Investments", }, { label: "Wallets", value: "Wallets", }, ]; const dispatch = useDispatch(); useEffect(() => { if (isError) { toast({ title: "Error", description: message, status: "error", position: "top-right", duration: 5000, isClosable: true, }); dispatch(reset()); } if (isAppSuccess) { toast({ title: "App created", description: "Refreshing page", status: "success", position: "top-right", duration: 5000, isClosable: true, }); dispatch(reset()); onClose(); } }, [isAppSuccess, reset]); const onSubmit = async (e) => { e.preventDefault(); const appData = { name, displayName, product, // icon, scopes, reason, institutionScope, environment, }; dispatch(createApp(appData)); }; function SubmitButton() { if ( name?.length && displayName?.length && scopes?.length && environment?.length && reason?.length > 8 && institutionScope?.length > 0 ) { return ( <Button fontSize={{ sm: "12px", md: "14px" }} type="submit" borderRadius="md" color="white" bg="#002C8A" _hover={{ bg: "#002C6A" }} width={{ sm: "300px", md: "400px" }} > {isAppLoading ? <Spinner /> : "Create app"} </Button> ); } else { return ( <Button fontSize={{ sm: "12px", md: "14px" }} type="submit" borderRadius="md" color="white" bg="#002C8A" _hover={{ bg: "#002C6A" }} width={{ sm: "300px", md: "400px" }} isDisabled > {isAppLoading ? <Spinner /> : "Create app"} </Button> ); } } return ( <div> <Button {...rest} leftIcon={leftIcon} onClick={onOpen} bg={bg} textColor={textColor} borderRadius="lg" variant="solid" fontSize={fontSize} _hover={_hover} fontWeight={fontWeight} > Create an app </Button> <Modal size="lg" closeOnOverlayClick={false} isOpen={isOpen} onClose={onClose} > <ModalOverlay /> <ModalContent mt={1}> <ModalHeader textAlign="center" fontSize="md" color="#002c8a"> Create an app </ModalHeader> <ModalCloseButton /> <form onSubmit={onSubmit}> <ModalBody pb={6}> <Flex flexDirection={{ sm: "column", md: "row" }}> <Box> <Box mb={6}> <FormControl> <FormLabel fontSize="sm" fontWeight="semibold"> Add a logo to personalize your app </FormLabel> <Box width={{ sm: "340px", md: "450px" }} className="dropArea" {...getRootProps()} > <input {...getInputProps()} /> <Flex className="text" width={{ sm: "340px", md: "450px" }} > {images?.length > 0 && ( <> <div>{images}</div> </> )} {images?.length === 0 && ( <> <Box> <AiOutlineCloudUpload size={30} /> </Box> <Box> <Text fontSize="sm"> Drop app icon here or{" "} <Button variant="link" fontSize="sm" color="#002c8a" > browse </Button> </Text> </Box> </> )} </Flex> </Box> </FormControl> </Box> <Flex mt={6}> <Box> <FormControl> <FormLabel fontSize="sm" fontWeight="semibold"> App Name </FormLabel> <Input fontSize="14" width={{ sm: "165px", md: "225px" }} name="name" type="name" value={name} onChange={onChange} /> </FormControl> <Checkbox mt={1} onChange={onCheckBoxChange} size='sm' css={` > span:first-of-type { box-shadow: unset; } `}><Text fontSize="10.9px">Use as display name</Text></Checkbox> </Box> <Box ml={2}> <FormControl> <FormLabel fontSize="sm" fontWeight="semibold"> Display Name </FormLabel> <Input fontSize="14" width={{ sm: "165px", md: "225px" }} name="displayName" type="name" value={displayName} onChange={onChange} /> </FormControl> </Box> </Flex> <Box mt={3}> <FormLabel fontSize="sm" fontWeight="semibold"> Product </FormLabel> <Select name="product" placeholder=" " fontSize="14" value={product} onChange={onChange} > <option value="Connect">Connect</option> <option value="Directpay">Directpay</option> </Select> </Box> <Box w="100%" mt={3}> <FormLabel fontSize="sm" fontWeight="semibold"> Account </FormLabel> <Select1 useBasicStyles isMulti name="scopes" colorScheme="blue" placeholder=" " options={scopesOptions} closeMenuOnSelect={false} value={scopesOptions?.filter((obj) => scopes?.includes(obj.value) )} // set selected values onChange={handleScopes} /> </Box> <Box w="100%" mt={3}> <FormLabel fontSize="sm" fontWeight="semibold"> Institution </FormLabel> <Select1 useBasicStyles isMulti name="institution" colorScheme="blue" placeholder=" " _placeholder={{ color: "red" }} options={institutionScopeOptions} closeMenuOnSelect={false} value={institutionScopeOptions?.filter((obj) => institutionScope?.includes(obj.value) )} // set selected values onChange={handleInstitutionScope} /> </Box> <Box w="100%" mt={3}> <FormLabel fontSize="sm" fontWeight="semibold"> Environment </FormLabel> <Select name="environment" placeholder=" " fontSize="14" color="black" value={environment} onChange={onChange} > <option value="Sandbox">Sandbox</option> <option value="Production">Production</option></Select> {/*<FormLabel fontSize="sm" fontWeight="semibold"> Environment </FormLabel> <Select1 useBasicStyles name="environment" isMulti placeholder=" " options={environmentOptions} closeMenuOnSelect={true} color="black" value={environmentOptions?.filter((obj) => environment?.includes(obj.value) )} // set selected values onChange={handleEnvironment} />*/} </Box> <Box w="100%" mt={4}> <Textarea placeholder="Reason for data access" fontSize="sm" value={reason} name="reason" type="string" onChange={onChange} colorScheme="blue" /> </Box> </Box> </Flex> </ModalBody> <Wrap mb={6} justify="center"> <SubmitButton /> </Wrap> </form> </ModalContent> </Modal> </div> ); };
這是應用程式頁面:
import { Box, Button, Flex, Spacer, Center, Skeleton, SkeletonCircle, SkeletonText, Text, VStack, Image, Spinner, SimpleGrid, HStack, Avatar, Stack, Select, Hide, Tag, } from "@chakra-ui/react"; import React, { useState, useEffect } from "react"; import { MdFilterList } from "react-icons/md"; import { IoIosApps } from "react-icons/io"; import { ArrowLeftIcon, ArrowRightIcon, SpinnerIcon } from "@chakra-ui/icons"; import { CreateAnApp } from "../../../../components/Buttons/CreateAnApp"; import { useDispatch, useSelector } from "react-redux"; import { getAllApps } from "../../../../features/apps/appSlice"; import moment from "moment"; import { Link, useNavigate } from "react-router-dom"; import Card from "#components/Card/Card"; import CardBody from "#components/Card/CardBody"; import transaction_blue from "#assets/svg/transaction_blue.svg"; import { BsPlusCircleFill } from "react-icons/bs"; import useLocalStorage from "use-local-storage"; const Apps = () => { const dispatch = useDispatch(); const { apps, isLoading, isAppSuccess, meta } = useSelector( (state) => state.app ); const [mode] = useLocalStorage("apiEnv", false); const [loading, setLoading] = useState(true); useEffect(() => { setTimeout(() => { setLoading(false); }, 2000); }, [loading]); const fetchApps = () => { dispatch(getAllApps()); }; useEffect(() => { fetchApps(); }, [isAppSuccess]); return ( <> <Flex alignItems="center" mt={-3} ml={-4} p="5px" mb="10px"> <Spacer /> <Flex> <Box> <Skeleton borderRadius="lg" isLoaded={!loading}> <Button leftIcon={<MdFilterList size={20} />} variant="outline" textColor="black" borderRadius="lg" fontSize={{ sm: "xs", md: "sm" }} fontWeight="normal" > Filter </Button> </Skeleton> </Box> <Box ml={4}> <Skeleton borderRadius="lg" isLoaded={!loading}> <CreateAnApp bg="#002C8A" textColor="white" fontSize={{ sm: "xs", md: "sm" }} _hover={{ bg: "#002C6A" }} leftIcon={<BsPlusCircleFill size={16} />} fontWeight="normal" /> </Skeleton> </Box> </Flex> </Flex> {isLoading ? ( <Center> <Spinner mt={20} /> </Center> ) : ( <SimpleGrid mt={10} minChildWidth="360px" spacing="40px"> {isLoading ? ( <Center> <Spinner mt={20} /> </Center> ) : apps && apps?.length > 0 ? ( apps && apps?.map((app) => { return ( <Skeleton borderRadius="lg" isLoaded={!loading}> <Box _hover={{ bg: "white" }} h="150px" as="button" shadow="lg" p={2} w={{ sm: "85%", md: "350px" }} bg="#f5f5f5" borderRadius="lg" > <Link to={`/admin/viewapp/${app.uid}`}> <Box> {app.environment === "Sandbox" && ( <Box align="right" mt={-4}> <Tag variant="solid" borderRadius="10px" size="sm" colorScheme="orange" fontSize="xs" textTransform="uppercase" > Sandbox </Tag> </Box> )} {app.environment === "Production" && ( <Box align="right" mt={-4}> <Tag variant="solid" borderRadius="10px" colorScheme="green" size="sm" fontSize="xs" textTransform="uppercase" > Production </Tag> </Box> )} <HStack> <Box mt={3} ml={6}> <Avatar bg="black" color="white" name={app.name} /> </Box> <Box> <Stack ml={2}> <Box mt={-1}> <Text color="orange" textTransform="uppercase" fontSize="12px" > {app.product} </Text> </Box> <Box> <Text fontSize={{ sm: "sm", md: "lg" }} fontWeight="bold" > <SkeletonText isLoaded={!loading}> {app.displayName} </SkeletonText> </Text> </Box> </Stack> </Box> </HStack> <Text fontSize="sm"> Created on {moment(app.createdAt).format("LL")} </Text> </Box> </Link> </Box> </Skeleton> ); }) ) : ( <Center ml={{ sm: "0", md: -32 }}> <VStack spacing={4} align="stretch"> <Box> <Center> <SkeletonCircle isLoaded={!loading}> <IoIosApps size={45} /> </SkeletonCircle> </Center> </Box> <Box> <Text fontSize="30px" fontWeight={700}> <SkeletonText isLoaded={!loading}> No apps yet </SkeletonText> </Text> <Text mb={4}> <SkeletonText noOfLines={1} mt={2} isLoaded={!loading}> Create an app to get started </SkeletonText> </Text> <Skeleton borderRadius="lg" isLoaded={!loading}> <CreateAnApp w="200px" h="50px" leftIcon={ <BsPlusCircleFill className="bg-[#002C8A] hover: none text-white" size={30} /> } bg="#002C8A" _hover={{ bg: "#002C6A" }} color="white" /> </Skeleton> </Box> <Box></Box> </VStack> </Center> )} {apps && apps?.length > 1 && ( <Skeleton borderRadius="lg" isLoaded={!loading}> <CreateAnApp h="150px" ml={{ sm: 0, md: -20 }} shadow="lg" leftIcon={ <BsPlusCircleFill className="bg-[#f5f5f5] text-blue-800" size={30} /> } bg="#f5f5f5" textColor="black" border="2px" borderColor="gray.400" borderStyle="dashed" fontSize={{ sm: "sm", md: "2xl" }} fontWeight="bold" p={2} w={{ sm: "85%", md: "350px" }} /> </Skeleton> )} </SimpleGrid> )} </Box> </> ); }; export default Apps;
還有我的 appSlice:
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; import appService from "./appService"; const initialState = { apps: [], app: [], isLoading: false, isAppLoading: false, isError: false, isAppSuccess: false, isSuccess: false, message: "", }; // Create new app export const createApp = createAsyncThunk( "app/createApp", async (appData, thunkAPI) => { try { const token = sessionStorage.getItem("token"); return await appService.createApp(appData, token); } catch (error) { const message = (error.response && error.response.data && error.response.data.message) || error.message || error.toString(); return thunkAPI.rejectWithValue(message); } } ); // Get all apps export const getAllApps = createAsyncThunk( "app/getAllApps", async (_, thunkAPI) => { try { const token = sessionStorage.getItem("token"); return await appService.getAllApps(token); } catch (error) { const message = (error.response && error.response.data && error.response.data.message) || error.message || error.toString(); return thunkAPI.rejectWithValue(message); } } ); export const appSlice = createSlice({ name: "app", initialState, reducers: { reset: (state) => { (state.isLoading = false), (state.isAppSuccess= false), (state.isAppLoading = false), (state.isSuccess = false), (state.isError = false), (state.message = ""); }, }, extraReducers: (builder) => { builder .addCase(createApp.pending, (state) => { state.isAppLoading = true; state.isError = false; }) .addCase(createApp.fulfilled, (state, action) => { state.isAppLoading = false; state.isAppSuccess = true; state.app = action.payload; }) .addCase(createApp.rejected, (state, action) => { state.isAppLoading = false; state.isError = true; state.message = action.payload; }) .addCase(getAllApps.pending, (state) => { state.isLoading = true; state.isError = false; }) .addCase(getAllApps.fulfilled, (state, action) => { state.isLoading = false; state.apps = action.payload.payload.data; }) .addCase(getAllApps.rejected, (state, action) => { state.isLoading = false; state.isError = true; state.message = action.payload; }) }, }); export const { reset } = appSlice.actions; export default appSlice.reducer;
我透過將 toast 訊息函數從「建立應用程式」中的 useEffect 掛鉤移到「應用程式」頁面來修復此問題。剛剛在廁所裡想出來的哈哈。我無法對此進行更多闡述,因為我還沒有完全理解它。我們每天都在學習
更新了「建立應用程式」按鈕中的 useEffect 掛鉤:
更新的應用程式頁面: