Skip to content

Commit

Permalink
feat: add messaging page and validate csv upload headers
Browse files Browse the repository at this point in the history
  • Loading branch information
ckthiessen committed Jun 7, 2024
1 parent 6e5a9f2 commit fff1f17
Show file tree
Hide file tree
Showing 8 changed files with 199 additions and 34 deletions.
9 changes: 1 addition & 8 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,4 @@ jobs:
run: npm run lint

- name: Build
run: npm run build

- name: Deploy to GH Pages
uses: peaceiris/actions-gh-pages@v3
if: ${{ github.ref == 'refs/heads/main' }}
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: build
run: npm run build
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import PickupPage from "./pages/PickupPage";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import DonorsPage from "./pages/DonorsPage";
import MessagePage from "./pages/MessagingPage";

const router = createBrowserRouter([
{
Expand All @@ -31,6 +32,10 @@ const router = createBrowserRouter([
path: "donors",
element: <AuthRoute><DonorsPage /></AuthRoute>,
},
{
path: "messaging",
element: <AuthRoute><MessagePage /></AuthRoute>,
},
{
path: "*",
element: <Login />,
Expand Down
59 changes: 59 additions & 0 deletions src/components/common/ConfirmMessageButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from "react";
import IconButton from "@mui/material/IconButton";

Check warning on line 2 in src/components/common/ConfirmMessageButton.tsx

View workflow job for this annotation

GitHub Actions / Continuous Integration

'IconButton' is defined but never used

Check warning on line 2 in src/components/common/ConfirmMessageButton.tsx

View workflow job for this annotation

GitHub Actions / Continuous Integration

'IconButton' is defined but never used
import SendIcon from "@mui/icons-material/Send";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogActions from "@mui/material/DialogActions";
import Button from "@mui/material/Button";
import { useTheme } from "@mui/material";
import { Recipient, recipientDescriptions } from "../messaging/Messaging";

interface ConfirmMessageButtonProps {
recipients: Recipient;
message: string;
setMessage(string): void;
setOpenSnackbar(boolean): void;
setSnackbarMessage(string): void;
}

export const ConfirmMessageButton = ({ recipients, message, setMessage, setOpenSnackbar, setSnackbarMessage }: ConfirmMessageButtonProps) => {
const [open, setOpen] = React.useState(false);
const theme = useTheme();

const handleCloseDialog = () => {
setOpen(false);
};

const sendMessage = async (message: string, recipients: Recipient) => {
try {
console.log(message);
console.log(recipients);
setSnackbarMessage("Message successfully sent");
setMessage("");
} catch {
setSnackbarMessage("Failed to send message");
}
setOpenSnackbar(true);
handleCloseDialog();
};

return (
<>
<Dialog open={open} onClose={handleCloseDialog} maxWidth='sm'>
<DialogTitle> Are you sure you want to send message to {recipientDescriptions[recipients]}? </DialogTitle>
<DialogActions sx={{ mr: 1 }}>
<Button sx={{ backgroundColor: theme.palette.error.main }} onClick={handleCloseDialog}>Do not Send</Button>
<Button sx={{ backgroundColor: theme.palette.success.main }} onClick={() => sendMessage(message, recipients)}>Send</Button>
</DialogActions>
</Dialog>
<Button
endIcon={<SendIcon />}
sx={{
backgroundColor: theme.palette.success.main,
my: 1
}}
onClick={() => setOpen(true)}
>Send</Button>
</>
);
};
7 changes: 7 additions & 0 deletions src/components/dashboard/listItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import DashboardIcon from "@mui/icons-material/Dashboard";
import PeopleIcon from "@mui/icons-material/People";
import MessageIcon from "@mui/icons-material/Message";
import AirlineStopsIcon from "@mui/icons-material/AirlineStops";
import ShoppingBagIcon from "@mui/icons-material/ShoppingBag";
import { NavigateFunction } from "react-router-dom";
Expand Down Expand Up @@ -35,6 +36,12 @@ export const listItems = ({ navigate }: { navigate: NavigateFunction }) => {
</ListItemIcon>
<ListItemText primary="Donors" />
</ListItemButton>
<ListItemButton onClick={() => { navigate("/messaging"); }}>
<ListItemIcon>
<MessageIcon />
</ListItemIcon>
<ListItemText primary="Messaging" />
</ListItemButton>
</React.Fragment>
);
};
90 changes: 90 additions & 0 deletions src/components/messaging/Messaging.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React, { useState } from "react";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import DeleteIcon from "@mui/icons-material/Delete";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select";
import { useSnackbar } from "../../hooks/useSnackbar";
import { ConfirmMessageButton } from "../common/ConfirmMessageButton";
import { getAuth } from "firebase/auth";
import useGetAdmins from "../../hooks/useGetAdmins";

export enum Recipient {
All = "All",
PickedUp = "PickedUp",
NotPickedUp = "NotPickedUp"
}

export const recipientDescriptions = {
"All": "All Donors",
"PickedUp": "Donors who have picked up",
"NotPickedUp": "Donors who have not picked up"
};

export default function Messaging() {
const theme = useTheme();
const auth = getAuth();
const { data: admins, isLoading: loadingAdmins } = useGetAdmins();

const { setOpenSnackbar, setSnackbarMessage, snackbar } = useSnackbar();

const [message, setMessage] = useState("");
const [recipients, setRecipients] = useState(Recipient.All);

return (
<>
<Box
sx={{
flexGrow: 1,
height: "100%",
marginBottom: "3%"
}}
>
<Box sx={{ mx: 2 }}>
<Typography fontSize={30} sx={{ borderBottom: "solid", borderWidth: 2, borderColor: "primary.main", mb: 2, width: "100%" }}>Messaging</Typography>
{
!loadingAdmins && admins?.map((admin) => admin.email).indexOf(auth.currentUser?.email ?? "") !== -1 ? (
<>
<Stack my={2} direction="row" alignItems="center">
<Typography variant="h6">Select Recipients:</Typography>
<Select
value={recipients}
onChange={(e) => setRecipients(e.target.value as Recipient)}
sx={{ my: 1, mx: 2 }}
>
<MenuItem value={Recipient.All}>{recipientDescriptions.All}</MenuItem>
<MenuItem value={Recipient.PickedUp}>{recipientDescriptions.PickedUp}</MenuItem>
<MenuItem value={Recipient.NotPickedUp}>{recipientDescriptions.NotPickedUp}</MenuItem>
</Select>
</Stack>
<TextField
variant="outlined"
multiline
fullWidth
placeholder="Enter Message"
minRows={3}
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<ConfirmMessageButton message={message} setMessage={setMessage} recipients={recipients} setOpenSnackbar={setOpenSnackbar} setSnackbarMessage={setSnackbarMessage} /><Button
endIcon={<DeleteIcon />}
sx={{
backgroundColor: theme.palette.error.main,
my: 1,
mx: 1
}}
onClick={() => setMessage("")}
>Clear</Button>
</>
) : <Typography my={1} variant="h6">Only site admins can use this feature</Typography>
}
{snackbar}
</Box>
</Box>
</>
);
}
6 changes: 4 additions & 2 deletions src/hooks/useGetOrders.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { db } from "../config";
import { collection, getDocs, query } from "firebase/firestore";
import { collection, getDocs, limit, query } from "firebase/firestore";
import { useQuery } from "@tanstack/react-query";
import { ORDERS_COLLECTION } from "../constants";
import { Order } from "../types/Order";

const ORDER_LIMIT = 3000;

const useGetOrders = () => {
return useQuery({
queryKey: [ORDERS_COLLECTION],
queryFn: async () => {
const clientsRef = collection(db, ORDERS_COLLECTION);
const snapshot = await getDocs(query(clientsRef));
const snapshot = await getDocs(query(clientsRef, limit(ORDER_LIMIT)));
const orderObjects = snapshot.docs.map((doc) => {
const data = doc.data();
return {
Expand Down
11 changes: 11 additions & 0 deletions src/pages/MessagingPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from "react";
import { Dashboard } from "../components/dashboard/Dashboard";
import Messaging from "../components/messaging/Messaging";

export default function MessagingPage() {
return (
<Dashboard>
<Messaging />
</Dashboard>
);
}
46 changes: 22 additions & 24 deletions src/utils/csvUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,28 @@ export const getCSVDataAndUpload = async (csv: string, orders: Order[], mutation
return "Please submit non-empty file.";
}

const records = parse(csv,
{
skip_empty_lines: true,
columns: true,
bom: true,
skip_records_with_empty_values: true
}
);
try {
const headers = csv.split("\n")[0];
assertCsvHeaders(headers);

const newOrders = records.map(csvToOrder);
const records = parse(csv,
{
skip_empty_lines: true,
columns: true,
bom: true,
skip_records_with_empty_values: true
}
);

const orderIds = new Set(orders.map((order) => order.id));
const filteredOrders = newOrders.filter((order) => !orderIds.has(order.id));
const newOrders = records.map(csvToOrder);

const orderIds = new Set(orders.map((order) => order.id));
const filteredOrders = newOrders.filter((order) => !orderIds.has(order.id));

try {
await mutationHook.mutateAsync(filteredOrders);
return "Successfully uploaded CSV rows";
} catch {
return "Could not upload CSV rows";
} catch (error) {
return error.message;
}

};
Expand Down Expand Up @@ -65,25 +68,20 @@ const csvToOrder = (data: any): Order => {
};
};

const stripReturn = (word: string) => {
return word.replace("\r", "");
};

const assertCsvHeaders = (line: string): boolean => {
const assertCsvHeaders = (line: string): void => {
const expectedHeaders = [
"First Name", "Last Name", "Cell Phone",
"Home Phone", "E-mail", "Customer Comments",
"Boxes for AFAC", "Boxes for Customer", "Total"
];

const headers = line.split(",");
console.log(headers);

if (!headers) { return false; }
if (headers.length != expectedHeaders.length) { return false; }
if (!headers) { throw new Error("Could not find CSV headers"); }
if (headers.length != expectedHeaders.length) { throw new Error(`Incorrect number of headers. Found ${headers.length}. Expected ${expectedHeaders.length}`); }

for (let i = 0; i < expectedHeaders.length; i++) {
if (!headers[i].startsWith(expectedHeaders[i])) { return false; }
if (!headers[i].trim().startsWith(expectedHeaders[i])) { throw new Error(`Invalid header: "${headers[i]}"`); }
}

return true;
};

0 comments on commit fff1f17

Please sign in to comment.