add support api requests

This commit is contained in:
nflnkr 2023-03-31 18:01:43 +03:00 committed by krokodilka
parent 7d2102471f
commit 601b954339
4 changed files with 182 additions and 173 deletions

@ -2,32 +2,28 @@ import { Box, Typography, FormControl, InputBase, useMediaQuery, useTheme } from
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import CustomButton from "@components/CustomButton";
import { apiRequestHandler } from "@utils/api/apiRequestHandler";
import { ApiError } from "@utils/api/types";
import { useSnackbar } from "notistack";
import { createTicket } from "@root/api/tickets";
import { enqueueSnackbar } from "notistack";
export default function CreateTicket() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const { enqueueSnackbar } = useSnackbar();
const navigate = useNavigate();
const [ticketName, setTicketName] = useState<string>("");
const [ticketBody, setTicketBody] = useState<string>("");
const [ticketNameField, setTicketNameField] = useState<string>("");
const [ticketBodyField, setTicketBodyField] = useState<string>("");
async function handleCreateTicket() {
const result = await apiRequestHandler.createTicket({
Title: ticketName,
Message: ticketBody,
});
if (result instanceof ApiError) {
enqueueSnackbar(`Error: ${result.message}`);
} else if (result instanceof Error) {
console.log(result);
enqueueSnackbar(`Unknown error`);
} else {
if (!ticketBodyField || !ticketNameField) return;
createTicket({
Title: ticketNameField,
Message: ticketBodyField,
}).then(result => {
navigate(`/support/${result.Ticket}`);
}
}).catch(error => {
enqueueSnackbar(error.message);
});
}
return (
@ -63,7 +59,7 @@ export default function CreateTicket() {
</Typography>
<FormControl sx={{ width: "100%" }}>
<InputBase
value={ticketName}
value={ticketNameField}
fullWidth
placeholder="Заголовок обращения"
id="ticket-header"
@ -84,7 +80,7 @@ export default function CreateTicket() {
px: "19px",
},
}}
onChange={(e) => setTicketName(e.target.value)}
onChange={(e) => setTicketNameField(e.target.value)}
/>
</FormControl>
<FormControl sx={{ width: "100%" }}>
@ -98,7 +94,7 @@ export default function CreateTicket() {
}}
>
<InputBase
value={ticketBody}
value={ticketBodyField}
fullWidth
placeholder="Текст обращения"
id="ticket-body"
@ -121,7 +117,7 @@ export default function CreateTicket() {
height: "300px",
},
}}
onChange={(e) => setTicketBody(e.target.value)}
onChange={(e) => setTicketBodyField(e.target.value)}
/>
</Box>
</FormControl>
@ -129,6 +125,7 @@ export default function CreateTicket() {
<Box sx={{ alignSelf: upMd ? "end" : "start" }}>
<CustomButton
onClick={handleCreateTicket}
disabled={!ticketBodyField || !ticketNameField}
variant="contained"
sx={{
backgroundColor: theme.palette.brightPurple.main,

@ -6,12 +6,75 @@ import ComplexNavText from "@components/ComplexNavText";
import SupportChat from "./SupportChat";
import CreateTicket from "./CreateTicket";
import TicketList from "./TicketList";
import { useEffect } from "react";
import { getTickets, subscribeToAllTickets } from "@root/api/tickets";
import { GetTicketsRequest, Ticket } from "@root/model/ticket";
import { updateTickets, setTicketCount, clearTickets, useTicketStore, setFetchState } from "@root/stores/tickets";
import { enqueueSnackbar } from "notistack";
import { authStore } from "@root/stores/makeRequest";
export default function Support() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const ticketId = useParams().ticketId;
const currentPage = useTicketStore(state => state.currentPage);
const ticketsPerPage = useTicketStore(state => state.ticketsPerPage);
const token = authStore(state => state.token);
useEffect(function fetchTickets() {
const getTicketsBody: GetTicketsRequest = {
amt: ticketsPerPage,
page: currentPage,
status: "open",
};
const controller = new AbortController();
setFetchState("fetching");
getTickets({
body: getTicketsBody,
signal: controller.signal,
}).then(result => {
console.log("GetTicketsResponse", result);
if (result.data) {
updateTickets(result.data);
setTicketCount(result.count);
setFetchState("idle");
} else setFetchState("all fetched");
}).catch(error => {
console.log("Error fetching tickets", error);
enqueueSnackbar(error.message);
});
return () => controller.abort();
}, [currentPage, ticketsPerPage]);
useEffect(function subscribeToTickets() {
if (!token) return;
const unsubscribe = subscribeToAllTickets({
accessToken: token,
onMessage(event) {
try {
const newTicket = JSON.parse(event.data) as Ticket;
console.log("SSE: parsed newTicket:", newTicket);
if ((newTicket as any).count) return; // Костыль пока бэк не починят
updateTickets([newTicket]);
} catch (error) {
console.log("SSE: couldn't parse:", event.data);
console.log("Error parsing ticket SSE", error);
}
},
onError(event) {
console.log("SSE Error:", event);
}
});
return () => {
unsubscribe();
clearTickets();
};
}, [token]);
return (
<SectionWrapper

@ -5,31 +5,79 @@ import { useParams } from "react-router-dom";
import CustomButton from "@components/CustomButton";
import SendIcon from "@components/icons/SendIcon";
import Message from "./Message";
import { apiRequestHandler } from "@utils/api/apiRequestHandler";
import { ApiError, TicketMessage } from "@utils/api/types";
import { throttle } from "@utils/decorators";
import { useSnackbar } from "notistack";
import { enqueueSnackbar } from "notistack";
import { useTicketStore } from "@root/stores/tickets";
import { addOrUpdateMessages, clearMessages, setMessages, useMessageStore } from "@root/stores/messages";
import { getTicketMessages, sendTicketMessage, subscribeToTicketMessages } from "@root/api/tickets";
import { GetMessagesRequest, TicketMessage } from "@root/model/ticket";
import { authStore } from "@root/stores/makeRequest";
export default function SupportChat() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const [messageText, setMessageText] = useState<string>("");
const [messages, setMessages] = useState<TicketMessage[]>([]);
const [messageField, setMessageField] = useState<string>("");
const tickets = useTicketStore(state => state.tickets);
const messages = useMessageStore(state => state.messages);
const token = authStore(state => state.token);
const ticketId = useParams().ticketId;
const { enqueueSnackbar } = useSnackbar();
const ticket = tickets.find(ticket => ticket.id === ticketId);
const chatBoxRef = useRef<HTMLDivElement>();
const [isPreventAutoscroll, setIsPreventAutoscroll] = useState<boolean>(false);
function scrollToBottom() {
if (!chatBoxRef.current) return;
useEffect(function fetchTicketMessages() {
if (!ticketId) return;
chatBoxRef.current.scroll({
left: 0,
top: chatBoxRef.current.scrollHeight,
behavior: "smooth",
const getTicketsBody: GetMessagesRequest = {
amt: 100, // TODO use pagination
page: 0,
ticket: ticketId,
};
const controller = new AbortController();
getTicketMessages({
body: getTicketsBody,
signal: controller.signal,
}).then(result => {
console.log("GetMessagesResponse", result);
setMessages(result);
}).catch(error => {
console.log("Error fetching tickets", error);
enqueueSnackbar(error.message);
});
}
return () => {
controller.abort();
clearMessages();
};
}, [ticketId]);
useEffect(function subscribeToMessages() {
if (!ticketId || !token) return;
const unsubscribe = subscribeToTicketMessages({
ticketId: ticketId,
accessToken: token,
onMessage(event) {
try {
const newMessage = JSON.parse(event.data) as TicketMessage;
console.log("SSE: parsed newMessage:", newMessage);
addOrUpdateMessages([newMessage]);
} catch (error) {
console.log("SSE: couldn't parse:", event.data);
console.log("Error parsing message SSE", error);
}
},
onError(event) {
console.log("SSE Error:", event);
},
});
return () => {
unsubscribe();
};
}, [ticketId, token]);
useEffect(function refreshChatScrollTop() {
if (!chatBoxRef.current) return;
@ -46,36 +94,6 @@ export default function SupportChat() {
};
}, []);
// TODO При подписке на SSE сервер уже отправляет все сообщения тикета
useEffect(function getMessages() {
if (!ticketId) return;
const abortController = new AbortController();
apiRequestHandler
.getMessages(
{
amt: 100,
page: 0,
srch: "",
ticket: ticketId,
},
abortController.signal
)
.then((result) => {
if (result instanceof ApiError) {
enqueueSnackbar(`Api error: ${result.message}`);
} else if (result instanceof Error) {
enqueueSnackbar(`Error: ${result.message}`);
} else {
setMessages(result);
}
});
return () => {
abortController.abort();
};
}, [enqueueSnackbar, ticketId]);
useEffect(function scrollOnMessage() {
if (!chatBoxRef.current) return;
@ -83,47 +101,26 @@ export default function SupportChat() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages]);
useEffect(function subscribeToMessages() {
if (!ticketId) return;
const unsubscribe = apiRequestHandler.subscribeToTicket({
ticketId,
onMessage(event) {
// console.log("SSE received:", event.data);
try {
const newMessage = JSON.parse(event.data) as TicketMessage;
setMessages((prev) =>
prev.findIndex((message) => message.id === newMessage.id) === -1 ? [...prev.slice(), newMessage] : prev
);
} catch (error) {
console.log("SSE is not JSON", error);
}
},
onError(event) {
console.log("SSE Error:", event);
},
});
return () => {
unsubscribe();
};
}, [ticketId]);
async function handleSendMessage() {
if (!ticketId) return;
if (!ticket || !messageField) return;
const result = await apiRequestHandler.sendTicketMessage({
ticket: ticketId,
message: messageText,
sendTicketMessage({
ticket: ticket.id,
message: messageField,
lang: "ru",
files: [],
});
if (result instanceof ApiError) {
enqueueSnackbar(`Api error: ${result.message}`);
} else if (result instanceof Error) {
enqueueSnackbar(`Error: ${result.message}`);
} else {
setMessageText("");
}
setMessageField("");
}
function scrollToBottom() {
if (!chatBoxRef.current) return;
chatBoxRef.current.scroll({
left: 0,
top: chatBoxRef.current.scrollHeight,
behavior: "smooth",
});
}
return (
@ -216,19 +213,19 @@ export default function SupportChat() {
height: "100%",
}}
>
{messages.map((message) => (
{ticket && messages.map((message) => (
<Message
key={message.id}
text={message.message}
time={new Date(message.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
isSelf={true}
isSelf={ticket.user === message.user_id}
/>
))}
</Box>
</Box>
<FormControl>
<InputBase
value={messageText}
value={messageField}
fullWidth
placeholder="Текст обращения"
id="message"
@ -248,7 +245,7 @@ export default function SupportChat() {
maxHeight: "calc(19px * 5)",
},
}}
onChange={(e) => setMessageText(e.target.value)}
onChange={(e) => setMessageField(e.target.value)}
endAdornment={
!upMd && (
<InputAdornment position="end">
@ -274,6 +271,7 @@ export default function SupportChat() {
<Box sx={{ alignSelf: "end" }}>
<CustomButton
onClick={handleSendMessage}
disabled={!messageField}
variant="contained"
sx={{
backgroundColor: theme.palette.brightPurple.main,

@ -1,73 +1,18 @@
import { CircularProgress, List, ListItem, Box, useTheme, Pagination } from "@mui/material";
import { useEffect, useState } from "react";
import TicketCard from "./TicketCard";
import { useSnackbar } from "notistack";
import { apiRequestHandler } from "@utils/api/apiRequestHandler";
import { ApiError, Ticket } from "@utils/api/types";
import { setCurrentPage, useTicketStore } from "@root/stores/tickets";
import { Ticket } from "@root/model/ticket";
const TICKETS_PER_PAGE = 10;
export default function TicketList() {
const theme = useTheme();
const { enqueueSnackbar } = useSnackbar();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [ticketCount, setTicketCount] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState<number>(0);
const [isLoading, setIsLoading] = useState<boolean>(false);
const tickets = useTicketStore(state => state.tickets);
const fetchState = useTicketStore(state => state.fetchState);
const ticketCount = useTicketStore(state => state.ticketCount);
const currentPage = useTicketStore(state => state.currentPage);
const ticketsPerPage = useTicketStore(state => state.ticketsPerPage);
useEffect(function fetchTickets() {
setIsLoading(true);
const abortController = new AbortController();
apiRequestHandler
.getTickets({ amt: TICKETS_PER_PAGE, page: currentPage, srch: "", status: "open" }, abortController.signal)
.then((result) => {
if (result instanceof ApiError) {
enqueueSnackbar(`Error: ${result.message}`);
} else if (result instanceof Error) {
console.log(result);
} else {
setTickets(result.data);
setTicketCount(result.count);
setIsLoading(false);
}
});
return () => {
abortController.abort();
};
}, [currentPage, enqueueSnackbar]);
useEffect(function subscribeToTickets() {
const unsubscribe = apiRequestHandler.subscribeToAllTickets({
onMessage(event) {
console.log("SSE received:", event.data);
try {
const newTicket = JSON.parse(event.data) as Ticket;
const existingTicketIndex = tickets.findIndex((ticket) => ticket.id === newTicket.id);
if (existingTicketIndex !== -1) {
setTickets((prevTickets) => {
const newTickets = prevTickets.slice();
newTickets.splice(existingTicketIndex, 1, newTicket);
return newTickets;
});
return;
}
setTickets((prevTickets) => [newTicket, ...prevTickets.slice(0, TICKETS_PER_PAGE - 1)]);
} catch (error) {
console.log("SSE is not JSON", error);
}
},
onError(event) {
console.log("SSE Error:", event);
},
});
return () => {
unsubscribe();
};
}, [tickets]);
const sortedTickets = tickets.sort(sortTicketsByUpdateTime).slice(currentPage * ticketsPerPage, (currentPage + 1) * ticketsPerPage);
return (
<Box
@ -83,12 +28,12 @@ export default function TicketList() {
display: "flex",
flexDirection: "column",
gap: "40px",
opacity: isLoading ? 0.4 : 1,
opacity: fetchState === "fetching" ? 0.4 : 1,
transitionProperty: "opacity",
transitionDuration: "200ms",
}}
>
{tickets.map((ticket) => (
{sortedTickets.map((ticket) => (
<ListItem key={ticket.id} disablePadding>
<TicketCard
name={ticket.title}
@ -98,7 +43,7 @@ export default function TicketList() {
/>
</ListItem>
))}
{isLoading && (
{fetchState === "fetching" && (
<Box
sx={{
position: "absolute",
@ -114,14 +59,20 @@ export default function TicketList() {
</Box>
)}
</List>
{ticketCount !== null && ticketCount > TICKETS_PER_PAGE && (
{ticketCount > ticketsPerPage &&
<Pagination
count={Math.ceil(ticketCount / TICKETS_PER_PAGE)}
count={Math.ceil(ticketCount / ticketsPerPage)}
page={currentPage + 1}
onChange={(e, value) => setCurrentPage(value - 1)}
sx={{ alignSelf: "center" }}
/>
)}
}
</Box>
);
}
function sortTicketsByUpdateTime(ticket1: Ticket, ticket2: Ticket) {
const date1 = new Date(ticket1.updated_at).getTime();
const date2 = new Date(ticket2.updated_at).getTime();
return date2 - date1;
}