본문 바로가기
프로그래밍/front end 프론트 엔드

Vue/Firebase 이용해서 홈페이지 만들기 (10) 게시글 페이징 처리하기 (total count 관리, watch)

by 어느덧중반 2020. 10. 25.
반응형



오늘의 목표

게시판의 페이징 처리가 되도록 게시글의 total count를 관리하고 페이징 개수별로 처리되도록 해보자.

 


#1. total count 관리를 위한 functions 만들기 (increment / decrement)

#2. 페이징 처리를 위한 v-data-table 옵션 셋팅

#3. head, last 이용해서 첫번째, 마지막 게시물 정보 가져오기

#4. startAfter, endBefore 이용해서 전/후 페이지 이동하기

 


#1. total count 관리를 위한 functions 만들기 (increment / decrement)

 - 페이징 처리를 하기 위해선 게시글의 총 개수 (total count)가 별도로 관리돼야 한다.
   이를 위해 게시글이 생성되거나 삭제될 때 총 개수를 제어하는 functions를 아래와 같이 만들고 배포하자.

const fdb = admin.firestore()

...

// 게시글이 생성될 때마다 이곳을 거치게 됨
exports.incrementBoardCount = functions.firestore.document('boards/{bid}').onCreate(async (snap, context) => {
  try {
    await fdb.collection('meta').doc('boards').update('count', admin.firestore.FieldValue.increment(1))
  } catch (e) {
    await fdb.collection('meta').doc('boards').set({ count: 1 })
  }
})

// 게시글이 생성될 때마다 이곳을 거치게 됨
exports.decrementBoardCount = functions.firestore.document('boards/{bid}').onDelete(async (snap, context) => {
  await fdb.collection('meta').doc('boards').update('count', admin.firestore.FieldValue.increment(-1))
})

게시글의 총 개수가 meta collection의 boards document에서 관리된다.

 

 

#2. 페이징 처리를 위한 v-data-table 옵션 셋팅

 - 아래의 2가지 정도만 있으면 된다. 실질적으론 server-items-length만 있으면 됨

paging을 위해선 위 2가지 옵션을 추가해주면 된다.

 - serverItemsLength 변수를 만들어주자. 해당 정보는 게시글의 총 개수를 가지고 있다. (this.serverItemsLength = doc.data().count)

 - options : 처음에는 비어져 있다. table이 처음 불려질 때 값들이 알아서 채워지는데 이 값들의 변경을 담당하는 것이 watch이다. 
   아래처럼 쓰여지는데, options의 handler에서 감지를 해준다.

 - 우리는 게시판의 변경을 감시해야 하므로 handler에 this.subscribe()를 넣어서 감시해주자.

    watch: {
      options: {
        handler (n, o) {
          감시할 것에 대해 적어주자.
        },
        deep: true
      },
    },
<template>
  <v-card>
    <v-card-title>board</v-card-title>
    <v-data-table
      :headers="headers"
      :items="items"
      :options.sync="options"
      :server-items-length="serverItemsLength"
      :items-per-page="5"
    >
      <template v-slot:item.id="{ item }">
        <v-btn icon @click="openDialog(item)"><v-icon>mdi-pencil</v-icon></v-btn>
        <v-btn icon @click="remove(item)"><v-icon>mdi-delete</v-icon></v-btn>
      </template>
    </v-data-table>
    <v-card-actions>
      <v-btn @click="openDialog(null)"><v-icon left>mdi-pencil</v-icon></v-btn>
    </v-card-actions>
    <v-dialog max-width="500" v-model="dialog">
      <v-card>
        <v-form>
          <v-card-text>
            <v-text-field v-model="form.title"></v-text-field>
            <v-text-field v-model="form.content"></v-text-field>
          </v-card-text>
          <v-card-actions>
            <v-spacer/>
            <v-btn @click="update" v-if="selectedItem">save</v-btn>
            <v-btn @click="add" v-else>save</v-btn>
          </v-card-actions>
        </v-form>
      </v-card>
    </v-dialog>
  </v-card>
</template>
<script>
export default {
  data () {
    return {
      // 게시판 headers 정의해주기
      headers: [
        { value: 'title', text: '제목' },
        { value: 'content', text: '내용' },
        { value: 'id', text: 'id' }
      ],
      items: [],
      form: {
        title: '',
        content: ''
      },
      dialog: false,
      selectedItem: null,
      unsubscribe: null, // 실시간 stream을 받기 위한 변수 (listen for realtime update)
      unsubscribeCount: null,
      serverItemsLength: 0,
      options: {}
    }
  },
  watch: {
    options: {
      handler (n, o) { // n: new, o: old
        this.subscribe()
      },
      deep: true
    }
  },
  destroyed () {
    if (this.unsubscribe) this.unsubscribe() // 다른 페이지 넘어갈 때 구독 해지
    if (this.unsubscribeCount) this.unsubscribeCount()
  },
  methods: {
    subscribe () {
      this.unsubscribeCount = this.$firebase.firestore().collection('meta').doc('boards').onSnapshot((doc) => {
        if (!doc.exists) return
        this.serverItemsLength = doc.data().count
      })
      this.unsubscribe = this.$firebase.firestore().collection('boards').limit(this.options.itemsPerPage).onSnapshot((sn) => {
        if (sn.empty) {
          this.items = []
          return
        }
        this.items = sn.docs.map(v => {
          const item = v.data()
          return {
            id: v.id,
            title: item.title,
            content: item.content
          }
        })
      })
    },
    openDialog (item) {
      this.selectedItem = item
      this.dialog = true
      if (!item) { // 신규 글쓰기인 경우
        this.form.title = ''
        this.form.content = ''
      } else { // 수정 버튼을 클릭한 경우
        this.form.title = item.title
        this.form.content = item.content
      }
    },
    add () {
      this.$firebase.firestore().collection('boards').add(this.form)
      this.dialog = false
    },
    update () {
      this.$firebase.firestore().collection('boards').doc(this.selectedItem.id).update(this.form)
      this.dialog = false
    },
    remove (item) {
      this.$firebase.firestore().collection('boards').doc(item.id).delete()
      this.dialog = false
    }
  }
}
</script>



#3. head, last 이용해서 첫번째, 마지막 게시물 정보 가져오기

 - 사용을 위해 import 하자 (import { head, last } from 'lodash')

 - subscribe() 에서 sn.docs를 this.docs에 저장해보자. (docs = [] 로 data에 추가로 선언할 것)

 - head, last를 이용해서 배열의 첫번째/마지막째 값을 가져다 쓸 수 있다. (다음 #4에서 사용할 일이 있을 것이다.)

 

#4. startAfter, endBefore 이용해서 전/후 페이지 이동하기

 - arrow 라는 변수를 통해 전/후페이지 이동에 대한 파악을 하자. (n.page - o.page 했을 때 -1이면 전페이지, 1이면 후페이지 등)

  watch: {
    options: {
      handler (n, o) { // n: new, o: old
        const arrow = n.page - o.page // arrow를 통해 전페이지 이동인지 후페이지 이동인지 파악
        this.subscribe(arrow)
      },
      deep: true
    }
  },

 - subscribe()에 order, sort, limit, ref, query값 추가 (전/후페이지 이동에 따라 query값을 변동시켜주며 그때그때 값을 불러옴)
  ※ endBefore일 때는 limitToLast, startAfter일 때는 limit으로 사용해줄 것

    subscribe (arrow) {
      this.unsubscribeCount = this.$firebase.firestore().collection('meta').doc('boards').onSnapshot((doc) => {
        if (!doc.exists) return
        this.serverItemsLength = doc.data().count
      })
      const order = head(this.options.sortBy)
      const sort = head(this.options.sortDesc) ? 'desc' : 'asc'
      const limit = this.options.itemsPerPage
      const ref = this.$firebase.firestore().collection('boards').orderBy(order, sort)
      let query
      switch (arrow) {
        case -1: query = ref.endBefore(head(this.docs)).limit(limit) // 전페이지 이동
          break
        case 1: query = ref.startAfter(last(this.docs)).limit(limit) // 후페이지 이동
          break
        default: query = ref.limit(limit)
          break
      }

      this.unsubscribe = query.onSnapshot((sn) => {
        if (sn.empty) {
          this.items = []
          return
        }
        this.items = sn.docs.map(v => {
          const item = v.data()
          return {
            id: v.id,
            title: item.title,
            content: item.content,
            createdAt: item.createdAt.toDate()
          }
        })
      })
    },

이제 항목별로 정렬, 페이징 모두 가능하다.

전체 소스

<template>
  <v-card>
    <v-card-title>board</v-card-title>
    <v-data-table
      :headers="headers"
      :items="items"
      :options.sync="options"
      :server-items-length="serverItemsLength"
      :items-per-page="5"
      must-sort
    >
      <template v-slot:item.id="{ item }">
        <v-btn icon @click="openDialog(item)"><v-icon>mdi-pencil</v-icon></v-btn>
        <v-btn icon @click="remove(item)"><v-icon>mdi-delete</v-icon></v-btn>
      </template>
      <template v-slot:item.createdAt="{ item }">
        {{item.createdAt.toLocaleString()}}
      </template>
    </v-data-table>
    <v-card-actions>
      <v-btn @click="openDialog(null)"><v-icon left>mdi-pencil</v-icon></v-btn>
    </v-card-actions>
    <v-dialog max-width="500" v-model="dialog">
      <v-card>
        <v-form>
          <v-card-text>
            <v-text-field v-model="form.title"></v-text-field>
            <v-text-field v-model="form.content"></v-text-field>
          </v-card-text>
          <v-card-actions>
            <v-spacer/>
            <v-btn @click="update" v-if="selectedItem">save</v-btn>
            <v-btn @click="add" v-else>save</v-btn>
          </v-card-actions>
        </v-form>
      </v-card>
    </v-dialog>
  </v-card>
</template>
<script>
import { head, last } from 'lodash'

export default {
  data () {
    return {
      // 게시판 headers 정의해주기
      headers: [
        { value: 'createdAt', text: '작성일' },
        { value: 'title', text: '제목' },
        { value: 'content', text: '내용' },
        { value: 'id', text: '', sortable: false } // sort 불가능 옵션 추가
      ],
      items: [],
      form: {
        title: '',
        content: ''
      },
      dialog: false,
      selectedItem: null,
      unsubscribe: null, // 실시간 stream을 받기 위한 변수 (listen for realtime update)
      unsubscribeCount: null,
      serverItemsLength: 0,
      options: { // 작성일 기준 내림차순 정렬 옵션
        sortBy: ['createdAt'],
        sortDesc: [true]
      },
      docs: []
    }
  },
  watch: {
    options: {
      handler (n, o) { // n: new, o: old
        const arrow = n.page - o.page // arrow를 통해 전페이지 이동인지 후페이지 이동인지 파악
        this.subscribe(arrow)
      },
      deep: true
    }
  },
  destroyed () {
    if (this.unsubscribe) this.unsubscribe() // 다른 페이지 넘어갈 때 구독 해지
    if (this.unsubscribeCount) this.unsubscribeCount()
  },
  methods: {
    subscribe (arrow) {
      this.unsubscribeCount = this.$firebase.firestore().collection('meta').doc('boards').onSnapshot((doc) => {
        if (!doc.exists) return
        this.serverItemsLength = doc.data().count
      })
      const order = head(this.options.sortBy)
      const sort = head(this.options.sortDesc) ? 'desc' : 'asc'
      const limit = this.options.itemsPerPage
      const ref = this.$firebase.firestore().collection('boards').orderBy(order, sort)
      let query
      switch (arrow) {
        case -1: query = ref.endBefore(head(this.docs)).limitToLast(limit) // 전페이지 이동
          break
        case 1: query = ref.startAfter(last(this.docs)).limit(limit) // 후페이지 이동
          break
        default: query = ref.limit(limit)
          break
      }

      this.unsubscribe = query.onSnapshot((sn) => {
        if (sn.empty) {
          this.items = []
          return
        }
        this.docs = sn.docs
        this.items = sn.docs.map(v => {
          const item = v.data()
          return {
            id: v.id,
            title: item.title,
            content: item.content,
            createdAt: item.createdAt.toDate()
          }
        })
      })
    },
    openDialog (item) {
      this.selectedItem = item
      this.dialog = true
      if (!item) { // 신규 글쓰기인 경우
        this.form.title = ''
        this.form.content = ''
      } else { // 수정 버튼을 클릭한 경우
        this.form.title = item.title
        this.form.content = item.content
      }
    },
    add () {
      const item = {}
      // item.content = this.form.content; item.title = this.form.title; 과 같은 의미
      Object.assign(item, this.form)
      item.createdAt = new Date()
      this.$firebase.firestore().collection('boards').add(item)
      this.dialog = false
    },
    update () {
      this.$firebase.firestore().collection('boards').doc(this.selectedItem.id).update(this.form)
      this.dialog = false
    },
    remove (item) {
      this.$firebase.firestore().collection('boards').doc(item.id).delete()
      this.dialog = false
    }
  }
}
</script>

 

※ 해당 내용은 아래의 사이트에서 강의를 보며 실습한 내용입니다.

 

memi.dev

개발에 대한 모든 것을 다룹니다

memi.dev

 



반응형

댓글