Virtualization of contact selector & improved decoupling of contacts on PUT
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+155
-164
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user