React Transfer Componenti Geliştirmek

·JavaScript·
#React#Typescript#Transfer#Component#Frontend

Yakı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.

React Transfer Componenti Geliştirmek

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.