Virtualization of contact selector & improved decoupling of contacts on PUT

This commit is contained in:
Dries Augustyns
2024-08-13 18:59:26 +02:00
parent 4fcb966e73
commit 4fd6c66882
5 changed files with 219 additions and 568 deletions
@@ -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);
+2
View File
@@ -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",
@@ -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",
@@ -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>
</>
);
}