어드민 컴포넌트 개편

어드민 컴포넌트 개편
Photo by Brooke Balentine / Unsplash

안녕하세요 셀렉트팀 이진혁입니다.

셀렉트어드민은 YAML 입력으로 다양한 화면을 만들어주는 제품으로 다양한 팀, 회사의 어드민과 내부 운영툴, 파트너센터, 대시보드 역할을 하고 있습니다.

초기에는 SQL 쿼리 실행 결과, API 실행 결과를 시각화하는 방향으로 고안되어 모든 스펙에 type: http type: query 가 필요한 방식을 이어왔습니다.

그러나 다양한 화면구성, 정교한 어드민 구현을 하다보면 많은 중첩(nested), 상위(parent), 중간 데이터가 생겨나며 위의 개념이 불편하거나 충돌을 일으키는 경우가 있습니다.

따라서 UI 컴포넌트 추가 개편에 앞서 방향성을 고민했습니다.

  • Data-UI를 표시하는 안정적인 방식
  • 대부분 사용자(server, backend engineer)의 사용성을 고려
  • 프론트(react, state, vuejs) 개념을 최소화
  • 화면, 모달, 탭등 초기화 과정을 투명

새로운 스펙

UI Blocks

그 결과 존재하는 블록 타입은 이와 같이 시각적인 단위로 정리하였습니다.

  • type: table
  • type: info
  • type: tabs
  • type: form
  • type: search

Functional UI

또한 공통적인 기능-action button, updateOptions등을 정리했습니다.

  • buttons, leftButtons, rightButtons
  • clickFn
  • rowClickFn

또한 어드민에 자주쓰이는 모달(popup modal)을 정리했습니다.

  • type: modal
  • $modal.show
  • $modal.hide

Data API

그리고 데이터 가져오기(fetch)와 중간 저장소(store)를 추가했습니다.

기존의 requestFn, responseFn, responseErrorFn의 어려움을 보완합니다.
기존의 blocks.{name}을 통한 접근시 오류의 가능성을 보완합니다.

  • fetchFn
  • state.{ANY_DATA}
  • state.{method}

Route API

기존에는 params에 대해 자동으로 query string(parameter)를 만들어주었고 편리함도 있지만 과도한 URL 생성, 새로고침의 오류가 있었습니다. 이 부분을 명시적으로 직접 처리하도록 개선했습니다.

  • $router.push (원하는 URL로 저장)
  • $route.query (원하는 URL 부분에서 초기 상태 불러오기)

Input API

기존에는 param.key에 따라 자동으로 block 조회, 수정시 값을 넘겨주었다면 지금은 모든 param은 state에 들어갑니다. 여러 테이블에서도 하나의 값을 사용가능하고, 상세모달에서도 복잡함 없이 활용 가능합니다

기존의 defaultValue, defaultValueFromQuery, valueFromRow, datalistFromQuery등을 보완합니다.

  • params.formName = state.formName = $route.query.formName

Options

기존에는 fn 코드 부분에 어떤 파라메터가 함께 넘어오는지 파악하기 어려웠습니다. 매번 문서를 확인하거나 값 마다 console.log를 해야하는 불편함이 있었습니다.

  • params fetchFn은 해당 파라메터에 모든걸 전달
  • opt 모든 코드 함수(clickFn...)는 해당 파라메터에 모든걸 전달

예제

CRUD(Create-Read-Update-Delete)에 대하여 스펙을 본문에 하나씩 나열합니다.

실제 셀렉트어드민 사용자들의 요구사항, 사용 패턴을 고려하여 재설계 했습니다.

조회

  • 모든 데이터를 표시하면서 불필요한 필드를 hidden하거나
  • 지정한 컬럼만 원하는 순서로 표시합니다.
- type: table
  fetchFn: |
    return [
      { name: "Name" }
    ]
  headers:
    name:

테이블 조회하기 - 헤더 지정 (columnOptions처럼 replace)

- type: table
  fetchFn: |
    return [
      { name: "Name" }
    ]
  autoHeaders: true

테이블 조회하기 - 헤더 자동으로 만들기 (columns 처럼 merge)

조회 조건

  • 검색조건과 데이터결과 부분을 분리했습니다.
  • 1개의 검색조건으로 여러개 데이터를 새로고침하는 경우
  • AND, OR등 조건에 따른 submit 버튼이 여러개이거나 분기 처리하는 경우
- type: search
  params:
    - key: id
    - key: name
  buttons:
    - label: Search
      type: submit
      clickFn: |
        const {state} = opt
        await state.reload(opt)
- type: search
  params:
    - key: id
    - key: name
  buttons:
    - label: Search
      type: submit
      clickFn: |
        const {state, $router} = opt
        await state.reload(opt)

        $router.push({
          query: {
            id: state.id,
            name: state.name,
          }
        })

새로고침시 검색조건을 유지해야할 필요있는 경우

폼 입력

  • 검증 로직을 개별말고 submit 이전에 추가
  • 기존의 input filter 형태가 아닌 폼 형태를 기본값으로
- type: form
  params: 
    - key: comment
      format: textarea
      rows: 5
      vertical: true
  buttons:
    - type: submit
      label: Submit
      clickFn: |
        alert('Saved')

업데이트

  • updateOptions등 편리한 기능이 있지만 1개 값 수정만 가능했던 한계
state:
  formName: wow
blocks:
- type: table
  fetchFn: |
    return [
      { id: 1000, name: 'wow' }
    ]
  autoHeader: true
  headers:
    name:
      iconEnd: pencil
  rowClickFn: |
    const {key, row, $modal, state} = opt
    if (key == 'name') {
      state.formName = row[key]
      $modal.show('editName')
    }
- type: modal
  name: editName
  blocks:
  - type: form
    params:
      - key: formName
        vertical: true
    buttons:
      - label: Update

상세 조회 (중첩 모달)

  • 기존 viewModal, modals는 부모-자식 값 교환 문제가 있음
  • modal name 단위로 수동으로 표시, 여러개 모달 띄울때 문제 제거
  • valueFromRow가 아닌 명시적인 브라우저 주소로 해결
- type: table
  fetchFn: |
    return [
      { org_id: 1000 }
    ]

  autoHeader: true
  width: 100%
  rowClickFn: |
    const {block, row, key, $modal, $router} = opt
    
    $router.push({
      query: {
        oid: row.org_id,
      }
    }).catch()

    $modal.show('org_id')
    
- type: modal
  name: org_id
  header: Teams of organization
  routeQuery: oid 
  width: 800
  defaultHeight: 50vh
  blocks:
    - type: table
      fetchFn: |
        const r = await query('DEV_RO', `
          SELECT * 
          FROM Team
          WHERE org_id = '${ params.$route?.query?.oid || 0 }'
          LIMIT 1000
        `)

        return r
      autoHeader: true

URL 화면 상태 처리

  • 셀렉트 기본제공이 아닌 커스텀하게 처리하는 경우
  • 조회를 분기하는 경우
- type: table
  fetchFn: |
    const {block, $route} = params
    
    state.id = $route.query.id
    state.name = $route.query.name

    state.reload = async () => {
      if (state.id) {
        const r = await query('DEV_RO', `
          SELECT id, name, domain, plan 
          FROM Team 
          WHERE id = ${ +state.id || 0}
          LIMIT 100
        `)
        return params.$result(r)
      }
      else if (state.name) {
        const r = await query('DEV_RO', `
          SELECT id, name, domain, plan 
          FROM Team 
          WHERE name LIKE '%${ state.name }%'
          LIMIT 100
        `)
        return params.$result(r)
      }
      else {
        const r = await query('DEV_RO', `
          SELECT id, name, domain, plan 
          FROM Team 
          LIMIT 100
        `)
        return params.$result(r)
      }
    }

    
    return state.reload()
  
  autoHeader: true

체크하여 일괄 처리

  • 정렬, 선택을 직접 처리 가능
  • 기존 actions(forEach, valueFromSelectedRows) 부분을 state로 해결
  • 기타 테이블 로직을 그대로 공개
- type: table
  headers:
    checked:
      label: 선택
      width: 50px
      format: checkbox
    id: 
      label: ID
    oid: 
      label: OID

    name:
      # width: 1000px
      width: 200px
    priceText:
      label: Price
      clickFn: |
        const {header, block, _} = opt  
        if (!header.iconEnd) {
          // asc
          block._rows = block.rows
          block.rows = _.sortBy(block.rows, 'price')

          header.iconEnd = 'arrow-down'
        } 
        else if (header.iconEnd == 'arrow-down') {
          // desc
          block.rows = _.sortBy(block.rows, 'price').reverse()

          header.iconEnd = 'arrow-up'
        }
        else {
          block.rows = block._rows
          header.iconEnd = ''
        }
      classFn: |
        const {row} = opt
        if (+row.price > 500000) return 'bg-amber-200/20'
        if (+row.price < 200000) return 'bg-sky-200/20'
        return ''
  
  rowClickFn: |
    const {row, key, block} = opt
    row.checked = !row.checked 

  leftButtons:
    - label: 전체선택
      clickFn: |
        const {block} = opt
        const next = !block.rows[0].checked
        block.rows.forEach(e => {
          e.checked = next
        })
    - label: 삭제
      clickFn: |
        const { $modal, $toast, block, state } = opt

        const checked = block.rows.filter(e => e.checked)
        if (checked.length == 0) {
          return $toast('항목을 선택해주세요.')
        }

        state.selectedRows = checked
        
        $modal.show('confirm-delete')
    - label: 비어있는 버튼
    
  rightButtons:
    - label: 추가
      clickFn: |
        const { $modal, $toast, block, state } = opt
        $modal.show('add')
  

  # 세로폭 줄이기만 가능
  # height: 200px
  
  # 가로폭 줄이기만 가능
  # width: 500px

  # 항상 크게 채우기
  full: true

  fetchFn: |
    const rows = await params.loadOrders()
    
    return rows.map(e => {
      e.checked = false
      e.priceText = filters.number(e.price)
      return e
    })

모달, 탭의 새로고침

  • 조회조건등 변경으로 하위 내용이 새로고침되어야함
  • routeQuery 직접 지정하여 다시 렌더링

삭제시 confirm

  • 기존은 confirm: true, confirmText등 단순한 형태의 컨펌만 가능함
  • 별도 모달띄워서 안전한 확인 내용 표시, 입력받은 후(사유) 삭제 실행 가능함
  • 삭제완료후 결과표시, 되돌리기(undo) 액션도 가능
  • 삭제완료후 조회페이지의 경우 목록페이지로 이동하기

개선 방향

기존에 지원하던 옵션들을 하나씩 적용 예정입니다.

  • columns: format 추가지원 및 사용법
  • 기존 formatFn 사용법
  • html, template 직접 사용법
  • param: type 추가지원 및 사용법

장단점

  • 기존 사용법 대비 높은 커스텀이 가능하지만 간단한 작업에도 js 코드를 입력이 필요해서 번거로움이 높아질 수 있습니다. → 그러나 셀렉트 어드민 당장 못만들어서 결국 별도 페이지를 개발하는하는 상황을 벗어날 수 있습니다.
  • 변수명을 직접 짓고 id, name, state(key)를 별도로 고려해야하는게 번거롭다. → 그러나 화면 복잡도가 커질수록 정확한 변수(key) 이름으로 YAML 스펙을 이해하기 쉽고, 안정적으로 고치기 쉬워집니다.
  • 본인은 다른 언어를 주로 사용하고 있고 자바스크립트 코딩은 원하지 않는다. → 대부분 코드는 복사-붙여넣기 가능하도록 레시피를 제공하고 있습니다. 또한 GPT등을 통해 자동완성 여부를 꾸준히 검토하고 있습니다. LLM 생성에 친화적으로 스펙을 관리하고 있습니다.

그동안 많은 의견을 주셔서 셀렉트 팀에 큰 힘이 되었습니다.

앞으로도 많은 의견 부탁드립니다.

감사합니다.

Read more

[팀블로그] 개편이야기-2

어드민 화면 제작 경험에서 고려한점 셀렉트어드민을 통해 사용자는 관리자 화면을 제공함 * 관리자 화면의 관리자 화면(편집,설정)이 존재하는 상황 * 어드민 목록, 어드민 화면, 어드민 설정 사이의 흐름이 불편함 (고객 혼란, 주소 공유 어려움등 발생) * 해결하기 위해 레이아웃 일치 (선택 메뉴, 어드민 화면, 어드민 설정) 여러가지 디자인 요소가 섞여있어서 정리가

By LEE JINHYUK

[팀블로그] 개편이야기-1

개편이야기-1 [왜 Front로 다시 구상했는지?] 셀렉트 어드민은 한주도 멈추지 않고 약 200주 연속으로 점진적 개선을 이어옴 * 셀렉트 어드민 기존 서비스는 2021년 가을부터 운영중 * 2022년 유료화 이후 많은 개선 * 2023년 어드민 넘어서 대시보드, 파트너센터까지 확장 * 2024년 대기업, 중견기업 요구사항 충족하면서 고도화 셀렉트 어드민은 확장해왔지만 기본 사용법은 그대로 머물러있다고 생각 * 편집 환경의

By LEE JINHYUK