Skip to content

Commit

Permalink
Merge pull request #51 from useplunk/dev-driaug-campaign-speed-improv…
Browse files Browse the repository at this point in the history
…ements

Virtualisation of contact selector & improved decoupling of contacts
  • Loading branch information
driaug authored Aug 13, 2024
2 parents 4fcb966 + 4fd6c66 commit 694c9c0
Show file tree
Hide file tree
Showing 5 changed files with 219 additions and 568 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plunk",
"version": "1.0.0",
"version": "1.0.1",
"private": true,
"license": "agpl-3.0",
"workspaces": {
Expand Down
7 changes: 7 additions & 0 deletions packages/api/src/controllers/v1/Campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,13 @@ export class Campaigns {
});

const chunkSize = 500;

for (let i = 0; i < campaign.recipients.length; i += chunkSize) {
const chunk = campaign.recipients.slice(i, i + chunkSize);

await prisma.$executeRaw`DELETE FROM "_CampaignToContact" WHERE "A" = ${campaign.id} AND "B" = ANY(${chunk.map((c) => c.id)})`;
}

for (let i = 0; i < recipients.length; i += chunkSize) {
const chunk = recipients.slice(i, i + chunkSize);

Expand Down
2 changes: 2 additions & 0 deletions packages/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"react-dom": "18.3.1",
"react-hook-form": "^7.52.1",
"react-syntax-highlighter": "^15.5.0",
"react-window": "^1.8.10",
"recharts": "^2.12.7",
"sharp": "^0.33.4",
"sonner": "^1.5.0",
Expand All @@ -62,6 +63,7 @@
"@types/nprogress": "^0.2.3",
"@types/react": "18.3.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-window": "^1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,181 +1,172 @@
import React, {MutableRefObject, useEffect, useState} from 'react';
import {AnimatePresence, motion} from 'framer-motion';
import { AnimatePresence, motion } from "framer-motion";
import React, { type MutableRefObject, useEffect, useState } from "react";
import { FixedSizeList as List } from "react-window";

export interface MultiselectDropdownProps {
onChange: (value: string[]) => void;
values: readonly {
name: string;
value: string;
tag?: string;
}[];
selectedValues?: readonly string[];
disabled?: boolean;
className?: string;
onChange: (value: string[]) => void;
values: readonly {
name: string;
value: string;
tag?: string;
}[];
selectedValues?: readonly string[];
disabled?: boolean;
className?: string;
}

/**
* @param root0
* @param root0.onChange
* @param root0.values
* @param root0.selectedValues
* @param root0.className
* @param root0.disabled
*/
export default function MultiselectDropdown({
onChange,
values,
selectedValues: PropsselectedValues,
className,
disabled = false,
onChange,
values,
selectedValues: PropsselectedValues,
className,
disabled = false,
}: MultiselectDropdownProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedValues, setSelectedValues] = useState<readonly string[]>([]);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [selectedValues, setSelectedValues] = useState<readonly string[]>([]);

const ref = React.createRef<HTMLDivElement>();
const ref = React.createRef<HTMLDivElement>();

useEffect(() => {
if (PropsselectedValues) {
setSelectedValues(PropsselectedValues);
}
}, [PropsselectedValues]);
useEffect(() => {
if (PropsselectedValues) {
setSelectedValues(PropsselectedValues);
}
}, [PropsselectedValues]);

useEffect(() => {
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
useEffect(() => {
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;

const handleClickOutside = (event: any) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (mutableRef.current && !mutableRef.current.contains(event.target) && open) {
setOpen(!open);
}
};
const handleClickOutside = (event: MouseEvent) => {
if (mutableRef.current && !mutableRef.current.contains(event.target as Node) && open) {
setOpen(!open);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref]);
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref, open]);

return (
<>
<div ref={ref} className={className ?? ''}>
<div className="relative mt-1">
<button
type="button"
className={`${
disabled ? 'cursor-default bg-neutral-100' : 'cursor-pointer bg-white'
} relative w-full rounded border border-neutral-300 py-2 pl-3 pr-10 text-left sm:text-sm`}
aria-haspopup="listbox"
aria-expanded="true"
aria-labelledby="listbox-label"
onClick={() => {
if (!disabled) {
setOpen(!open);
}
}}
>
<span className="block truncate">{selectedValues.length} selected</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<motion.svg
initial={{rotate: '90deg'}}
animate={open ? {rotate: '0deg'} : {rotate: '90deg'}}
className="h-5 w-5 text-neutral-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</motion.svg>
</span>
</button>
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
const value = filteredValues[index];
return (
<li
style={style}
key={`multiselect-${index}`}
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
onClick={() => {
const isAlreadySelected = selectedValues.find((selection) => value.value === selection);

<AnimatePresence>
{open && (
<motion.ul
initial={{opacity: 0, height: 0}}
animate={{opacity: 1, height: 'auto'}}
exit={{opacity: 0, height: 0}}
transition={{duration: 0.2, ease: 'easeInOut'}}
className="scrollbar-w-2 scrollbar scrollbar-thumb-rounded-full scrollbar-thumb-neutral-400 scrollbar-track-neutral-100 absolute z-40 mt-1 max-h-72 w-full overflow-y-scroll rounded-md border border-black border-opacity-5 bg-white p-1 pr-1 text-base shadow focus:outline-none sm:text-sm"
tabIndex={-1}
role="listbox"
>
<li className="relative cursor-default select-none px-3 py-2 text-neutral-800">
<input
type="search"
name="search"
autoComplete={'off'}
className="block w-full rounded border-neutral-300 focus:border-black focus:ring-black sm:text-sm"
placeholder={'Search'}
onChange={e => setQuery(e.target.value)}
/>
</li>
const updatedArray = isAlreadySelected
? selectedValues.filter((selection) => selection !== value.value)
: [...selectedValues, value.value];

{values.filter(value => value.name.toLowerCase().includes(query.toLowerCase())).length === 0 ? (
<li className="relative cursor-default select-none py-2 pl-3 pr-9 text-neutral-800">
No results found
</li>
) : (
values
.filter(value => value.name.toLowerCase().includes(query.toLowerCase()))
.map((value, index) => {
return (
<li
key={`multiselect-${index}`}
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
role="option"
onClick={() => {
const isAlreadySelected = selectedValues.find(selection => value.value === selection);
onChange(updatedArray);
setSelectedValues(updatedArray);
}}
>
{value.tag && (
<span className={"mr-3 whitespace-nowrap rounded bg-blue-100 px-3 py-0.5 text-xs text-blue-900"}>{value.tag}</span>
)}
<span className="truncate font-normal">
{value.name.charAt(0).toUpperCase() + value.name.slice(1).toLowerCase()}
</span>
{value.value === selectedValues.find((selection) => value.value === selection) ? (
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-black">
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
) : null}
</li>
);
};

const updatedArray = isAlreadySelected
? selectedValues.filter(selection => selection !== value.value)
: [...selectedValues, value.value];
const filteredValues = values.filter((value) => value.name.toLowerCase().includes(query.toLowerCase()));

onChange(updatedArray);
setSelectedValues(updatedArray);
}}
>
{value.tag && (
<span
className={'mr-3 whitespace-nowrap rounded bg-blue-100 px-3 py-0.5 text-xs text-blue-900'}
>
{value.tag}
</span>
)}
<span className="truncate font-normal">
{value.name.charAt(0).toUpperCase() + value.name.slice(1).toLowerCase()}
</span>
{value.value === selectedValues.find(selection => value.value === selection) ? (
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-black">
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
) : null}
</li>
);
})
)}
</motion.ul>
)}
</AnimatePresence>
</div>
</div>
</>
);
return (
<>
<div ref={ref} className={className ?? ""}>
<div className="relative mt-1">
<button
type="button"
className={`${
disabled ? "cursor-default bg-neutral-100" : "cursor-pointer bg-white"
} relative w-full rounded border border-neutral-300 py-2 pl-3 pr-10 text-left sm:text-sm`}
aria-haspopup="listbox"
aria-expanded="true"
aria-labelledby="listbox-label"
onClick={() => {
if (!disabled) {
setOpen(!open);
}
}}
>
<span className="block truncate">{selectedValues.length} selected</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<motion.svg
initial={{ rotate: "90deg" }}
animate={open ? { rotate: "0deg" } : { rotate: "90deg" }}
className="h-5 w-5 text-neutral-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</motion.svg>
</span>
</button>

<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="scrollbar-w-2 scrollbar scrollbar-thumb-rounded-full scrollbar-thumb-neutral-400 scrollbar-track-neutral-100 absolute z-40 mt-1 max-h-72 w-full list-none space-y-3 overflow-y-scroll rounded-md border border-black border-opacity-5 bg-white p-1 pr-1 text-base shadow focus:outline-none sm:text-sm"
tabIndex={-1}
role="listbox"
>
<li className="relative cursor-default select-none px-3 py-2 text-neutral-800">
<input
type="search"
name="search"
autoComplete={"off"}
className="block w-full rounded border-neutral-300 focus:border-black focus:ring-black sm:text-sm"
placeholder={"Search"}
onChange={(e) => setQuery(e.target.value)}
/>
</li>

{filteredValues.length === 0 ? (
<li className="relative cursor-default select-none py-2 pl-3 pr-9 text-neutral-800">No results found</li>
) : (
<List height={500} itemCount={filteredValues.length} itemSize={40} width={"100%"}>
{Row}
</List>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</>
);
}
Loading

0 comments on commit 694c9c0

Please sign in to comment.