React Transfer Componenti Geliştirmek
#React, #Typescript, #Transfer, #Component, #FrontendYakın zamanlarda React tarafına artan ilgimle birlikte şirkette geliştirdiğim Liman Merkezi Yönetim Sistemini modern bir görüntüye ve kolaylaştırılmış kullanıcı deneyimine kavuşturmak istedik.
Bu yenileme işlemini yaparken NextJS frameworkünü ve trend olan Radix UI’ın Tailwind formatına dönüştürülmüş hali olan shadcn/ui componentlerini kullandık. Ancak bu component kütüphanesinin içerisinde transfer componenti bulunmuyordu.
Transfer componenti nedir?
Transfer componenti pasif/aktif olan iki tarafın tek ekranda görüntülenerek sergilenen itemların durumlarının hızlıca değiştirilmesine olanak sağlayan bir component türüdür.
Biz bu componenti kullanıcılara atabilen yetkileri ve halihazırda atanmış yetkileri görüntüleyip hızlıca değişiklik sağlayabileceği bir durumun kullanıcı deneyimini geliştirmek için yaptık.
Yukarıda görebileceğiniz üzere oluşturulmuş bir yetkiye hangi kullanıcının sahip olabileceğini ve hangi kullanıcıların halihazırda sahip olduğunu görüntüleyebiliyor ve bu kullanıcıların sahiplik durumları arasında hızlıca değişiklik gerçekleştirebiliyoruz.
React Transfer componentini nasıl geliştirebilirim?
Bu componentin geliştirilmesini ben Tailwind ve Typescript kullanarak anlatacağım. Ayrıca kullandığım kütüphaneden gelen Checkbox, Button gibi inputları kendi kütüphanenizdekiler ile değiştirmeniz gerekebilir.
Kullanacağımız stateleri belirleyelim
checked
Seçilmiş olduğumuz elemanları tutacağımız state. Tip (T[])
left
Sol kısmımızda (seçilmemiş) olan elemanları tuttuğumuz state. Tip (T[])
right
Sağ kısımda (seçilmiş) olan elemanları tuttuğumuz state. Tip (T[])
Kullanacağımız fonksiyonları oluşturalım
not
a ve b olarak T[] tipinde iki argüman alır, a’nın b’yi içermeyen elemanlarını belirler.
function not(a: T[], b: T[]) {
return a.filter((value) => b.indexOf(value) === -1)
}
intersection
a ve b olarak T[] tipinde iki argüman alır, a’nın b’yi içeren elemanlarını belirler.
function intersection(a: T[], b: T[]) {
return a.filter((value) => b.indexOf(value) !== -1)
}
union
a ve b olarak T[] tipinde iki argüman alır, iki taraftaki elemanları sadece unique olacak şekilde birleştirir.
function union(a: T[], b: T[]) {
return [...a, ...not(b, a)]
}
numberOfChecked
Verdiğimiz taraftaki itemların kaçının seçili olduğunu intersection kullanarak bulur.
const numberOfChecked = (items: T[]) => intersection(checked, items).length
handleToggle
Bu fonksiyon işaretlediğimiz elemanın checked stateine eklenmesini ya da çıkarılması işlemini sağlar.
const handleToggle = (value: T) => () => {
const currentIndex = checked.indexOf(value)
const newChecked = [...checked]
if (currentIndex === -1) {
newChecked.push(value)
} else {
newChecked.splice(currentIndex, 1)
}
setChecked(newChecked)
}
handleToggleAll
Belirttiğimiz taraftaki tüm itemların seçilmesini ya da seçimlerinin kaldırılmasını sağlar.
const handleToggleAll = (items: T[]) => () => {
if (numberOfChecked(items) === items.length) {
setChecked(not(checked, items))
} else {
setChecked(union(checked, items))
}
}
leftChecked / rightChecked
intersection fonksiyonumuzu kullanarak hangi itemların seçili olduğunu bulur.
const leftChecked = intersection(checked, left)
const rightChecked = intersection(checked, right)
handleCheckedLeft / handleCheckedRight
Orta kısımdaki butonlarımızın sağa ya da sola gönderme işlemlerini yaptığımız fonksiyonlar bütünüdür. Bir taraftaki itemlardan seçili olanları öbür kısım ile birleştirir ve diğer taraftan kaldırma işlemlerini yaptıktan sonra checked stateinden kaldırır.
const handleCheckedRight = () => {
setRight(right.concat(leftChecked))
setLeft(not(left, leftChecked))
setChecked(not(checked, leftChecked))
}
const handleCheckedLeft = () => {
setLeft(left.concat(rightChecked))
setRight(not(right, rightChecked))
setChecked(not(checked, rightChecked))
}
View kısımlarımızı oluşturalım
Bu component iki adet aynı kutudan ve bir flexbox içerisinde bulunan alt alta iki düğmeden oluşmaktadır. İlk aşamada sol ve sağ kısımlarımız aynı olduğundan tek bir component yazalım.
Checkboxlu kutuların geliştirilmesi
// Itemlar ve başlığa ihtiyacımız mevcut
const customList = (items: T[], title: string) => {
return (
<Card className="flex-1">
<CardHeader>
{/* Başlık propumuzu bu alanda kullanıyoruz */}
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-2">
{/* handleToggleAll fonksiyonumuzu bu alanda kullanarak tümünün seçilebilmesini sağlıyoruz */}
<Checkbox
id={`select_all_${title}`}
onClick={handleToggleAll(items)}
checked={
numberOfChecked(items) === items.length && items.length !== 0
}
disabled={items.length === 0}
/>
<Label
htmlFor={`select_all_${title}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Tümünü seç {/* kaç adet item seçtiğimizi belirtiyoruz */}
{items.length > 0 &&
`(${numberOfChecked(items)}/${items.length} seçili)`}
</Label>
</div>
<Separator className="my-6" />
<ScrollArea className="h-72">
<div className="flex flex-col gap-4">
{/* hiç item yoksa veya kalmadıysa boş state'in gösterimi */}
{items.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3">
<FolderOpen className="h-8 w-8 text-foreground/70" />
<span className="text-sm font-medium leading-none text-foreground/70">
Veri yok
</span>
</div>
)}
{/* itemların checkboxları ve labelları koyularak seçilebilir şekilde listelenmesi */}
{items.map((value) => {
return (
<div className="flex items-center space-x-2" key={value.id}>
{/* işaretlendiğinde valuenun değerinin toggle edildiği durum */}
<Checkbox
id={value.id}
onClick={handleToggle(value)}
checked={checked.indexOf(value) !== -1}
/>
<Label
htmlFor={value.id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{value.name}
</Label>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
);
};
Tüm componentlerin bir araya getirilmesi
return (
<div>
<div className="flex gap-5">
{/* Sol kısmın renderlanması */}
{customList(left, props.leftTitle)}
<div className="buttons flex flex-col items-center justify-center gap-3">
{/* Sağda seçililerin sol tarafa taşınmasını sağlayan buton */}
<Button
variant="outline"
onClick={() => handleCheckedLeft()}
disabled={rightChecked.length === 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{/* Solda seçililerin sağ tarafa taşınmasını sağlayan buton */}
<Button
variant="outline"
onClick={() => handleCheckedRight()}
disabled={leftChecked.length === 0}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* Sağ kısmın renderlanması */}
{customList(right, props.rightTitle)}
</div>
{/* Eğer tanımlandıysa kaydet fonksiyonu ile birlikte kaydet düğmesinin gösterilmesi*/}
{props.onSave && (
<div className="mt-5 flex justify-end">
<Button onClick={() => props.onSave && props.onSave(right)}>
<Save className="mr-2 h-4 w-4" /> Kaydet
</Button>
</div>
)}
</div>
);
Transfer componentinin tamamlanmış hali
interface ITransferListItem {
id: string
name: string
}
interface ITransferListProps<T extends ITransferListItem> {
items: T[]
selected?: T[]
leftTitle: string
rightTitle: string
loading: boolean
onSave?: (items: T[]) => void
renderName?: keyof T
}
const TransferList = <T extends ITransferListItem>(
props: ITransferListProps<T>
) => {
const [checked, setChecked] = useState<T[]>([])
const [left, setLeft] = useState<T[]>([])
const [right, setRight] = useState<T[]>([])
const [leftSearch, setLeftSearch] = useState<string>("")
const [rightSearch, setRightSearch] = useState<string>("")
const handleSelectedProp = () => {
if (!props.selected) return
// Remove selected items from left side
setLeft((prev) =>
prev.filter(
(object1) =>
!props.selected?.some((object2) => object1.id === object2.id)
)
)
setRight(props.selected)
}
useEffect(() => {
setLeft(props.items)
handleSelectedProp()
}, [props.items, props.selected])
const leftChecked = intersection(checked, left)
const rightChecked = intersection(checked, right)
function not(a: T[], b: T[]) {
return a.filter((value) => b.indexOf(value) === -1)
}
function intersection(a: T[], b: T[]) {
return a.filter((value) => b.indexOf(value) !== -1)
}
function union(a: T[], b: T[]) {
return [...a, ...not(b, a)]
}
const numberOfChecked = (items: T[]) => intersection(checked, items).length
const handleToggle = (value: T) => () => {
const currentIndex = checked.indexOf(value)
const newChecked = [...checked]
if (currentIndex === -1) {
newChecked.push(value)
} else {
newChecked.splice(currentIndex, 1)
}
setChecked(newChecked)
}
const handleToggleAll = (items: T[]) => () => {
if (numberOfChecked(items) === items.length) {
setChecked(not(checked, items))
} else {
setChecked(union(checked, items))
}
}
const handleCheckedRight = () => {
setRight(right.concat(leftChecked))
setLeft(not(left, leftChecked))
setChecked(not(checked, leftChecked))
}
const handleCheckedLeft = () => {
setLeft(left.concat(rightChecked))
setRight(not(right, rightChecked))
setChecked(not(checked, rightChecked))
}
const customList = (
items: T[],
title: string,
search: string,
setSearch: (e: string) => void
) => {
return (
<Card className="flex-1">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-2">
<Checkbox
id={`select_all_${title}`}
onClick={handleToggleAll(items)}
checked={
numberOfChecked(items) === items.length && items.length !== 0
}
disabled={items.length === 0}
/>
<Label
htmlFor={`select_all_${title}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Tümünü seç{" "}
{items.length > 0 &&
`(${numberOfChecked(items)}/${items.length} seçili)`}
</Label>
</div>
<Separator className="my-6" />
<div className="search relative">
<Input
placeholder="Arama..."
className="mb-6 h-8"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Search className="absolute right-2 top-2 h-4 w-4 text-foreground/70" />
</div>
<ScrollArea className="h-72">
<div className="flex flex-col gap-4">
{props.loading && (
<>
{[...Array(10)].map((_, index) => (
<div className="flex gap-2" key={index}>
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-full" />
</div>
))}
</>
)}
{!props.loading &&
items.filter((item) => item.name.toLowerCase().includes(search))
.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3">
<FolderOpen className="h-8 w-8 text-foreground/70" />
<span className="text-sm font-medium leading-none text-foreground/70">
Veri yok
</span>
</div>
)}
{items
.filter((item) => item.name.toLowerCase().includes(search))
.map((value) => {
return (
<div className="flex items-center space-x-2" key={value.id}>
<Checkbox
id={value.id}
onClick={handleToggle(value)}
checked={checked.indexOf(value) !== -1}
/>
<Label
htmlFor={value.id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{props.renderName
? value[props.renderName]
: value.name}
</Label>
</div>
)
})}
</div>
</ScrollArea>
</CardContent>
</Card>
)
}
return (
<div>
<div className="flex gap-5">
{customList(left, props.leftTitle, leftSearch, setLeftSearch)}
<div className="buttons flex flex-col items-center justify-center gap-3">
<Button
variant="outline"
onClick={() => handleCheckedLeft()}
disabled={rightChecked.length === 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => handleCheckedRight()}
disabled={leftChecked.length === 0}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{customList(right, props.rightTitle, rightSearch, setRightSearch)}
</div>
{props.onSave && (
<div className="mt-5 flex justify-end">
<Button onClick={() => props.onSave && props.onSave(right)}>
<Save className="mr-2 h-4 w-4" /> Kaydet
</Button>
</div>
)}
</div>
)
}
export default TransferList
Örnek kullanım senaryosu
<TransferList
items={users}
selected={selected}
loading={loading}
leftTitle="Bu role sahip olmayan kullanıcılar"
rightTitle="Bu role sahip kullanıcılar"
onSave={onSave}
/>
Son düşünceler
Bu componenti Material UI kütüphanesinden algoritma ve görünüşünü esinlenerek geliştirdim. Kodların en doğru patternda yazıldığını da düşünmüyorum. Şurası şu şekilde değiştirilirse daha doğru olur diyenler olursa gerekli güncellemeleri yapmak isterim.
Okuduğunuz için teşekkür ederim, umarım faydalı olmuştur.