8
4

超軽量Reactive UIフレームワークVanJSをつかったkintoneカスタマイズ

Last updated at Posted at 2023-10-18

この記事が対象としている人

  • kintoneのJSカスタマイズについての基本的な知識がある人
  • ReactやVueなどのリアクティブなUIライブラリについて基本的な知識がある人
  • Vanilla JSで軽いJSカスタマイズを書くこともあるけど、そういうときにReactとかは厳しいし、かといってJQuery使うのも嫌だし、なんかいいライブラリないかな…と思っている人

VanJS

下記の記事で、VanJSというライブラリについて知りました(著者に感謝)。

VanJSは超軽量で外部ライブラリに依存しない、unopinionatedなリアクティブなUIフレームワークで、Vanilla JSとDOMをベースにしています、とのことです(公式ページより)。

公式ページのHello Worldを読むと、JSXを使わずにdivpといったDOM要素を返す関数ですっきりコンポーネントを書けることが分かります。

// 再利用可能なコンポーネントは単純なVanilla Javascriptの関数として書けます。
// Reactの規約に沿って最初の文字を大文字にしています。
const Hello = () => div(
  p("👋Hello"),
  ul(
    li("🗺️World"),
    li(a({href: "https://vanjs.org/"}, "🍦VanJS")),
  ),
)

van.add(document.body, Hello())
// document.body.appendChild(Hello())と書くこともできます。

下記のコードは、Reactの入門記事でよくありそうなシンプルなカウンターのサンプルです。

const Counter = () => {
  const counter = van.state(0)
  return span(
    "❤️ ", counter, " ",
    button({onclick: () => ++counter.val}, "👍"),
    button({onclick: () => --counter.val}, "👎"),
  )
}

van.add(document.body, Counter())

「へー、宣言的なUIをVanilla JSでこんなにシンプルに書けるんだ。kintoneカスタマイズでも使えるかも」と思った(自分のような)方は、続きを読んでみていただければと思います。

kintoneカスタマイズのサンプル

宣言的に書くうれしさのあるサンプルを考えたら、けっこう長いものになってしまった…

  • カスタマイズのベースはアプリテンプレートの「日報アプリ」
  • 一覧画面に開始日/終了日の入力フィールドを表示
  • 日付が入力した開始日〜終了日のレコードを取得して表示
  • 日付入力やレコードの表示にはkintone UI Componentを使用
  • ページネーションつき

というカスタマイズを、一覧のカスタマイズビューに実装します。

image1.gif

カスタマイズの準備

カスタマイズのコード

// list-query.js

const { span, div, a } = van.tags
const LIMIT = 10

kintone.events.on('app.record.index.show', (event) => {
  if (event.viewName !== 'DateRange') {
    return event
  }

  const fromDate = van.state('')
  const toDate = van.state('')

  const fromDatePicker = new Kuc.DatePicker({
    label: '開始日',
    className: 'horizontal',
    onchange: (e) => (fromDate.val = e.detail.value),
  })
  const toDatePicker = new Kuc.DatePicker({
    label: '終了日',
    className: 'horizontal',
    onchange: (e) => (toDate.val = e.detail.value),
  })

  van.add(
    kintone.app.getHeaderMenuSpaceElement(),
    div({ style: `display: flex; gap: 16px; margin-left: 16px` }, fromDatePicker, toDatePicker)
  )

  const currentRecords = van.state([])
  const totalCount = van.state(0)
  const offset = van.state(0)
  const isLoading = van.state(false)

  van.derive(async () => {
    try {
      isLoading.val = true

      const conditions = [fromDate.val && `日付 >= "${fromDate.val}"`, toDate.val && `日付 <= "${toDate.val}"`].filter(
        Boolean
      )

      if (conditions.length === 0) {
        currentRecords.val = []
        offset.val = 0
        totalCount.val = 0
        return
      }

      const query = `${conditions.join(' and ')} limit ${LIMIT} offset ${offset.val}`
      const result = await new KintoneRestAPIClient().record.getRecords({
        app: kintone.app.getId(),
        query,
        totalCount: true,
      })
      currentRecords.val = result.records
      totalCount.val = result.totalCount
    } finally {
      isLoading.val = false
    }
  })

  const tableData = van.derive(() =>
    currentRecords.val.length > 0
      ? currentRecords.val.map((record) => recordToData(record))
      : [
          {
            日付: 'データなし',
            ドロップダウン: '',
            文字列__複数行_: '',
            文字列__複数行__0: '',
          },
        ]
  )

  const columns = [
    { title: '日付', field: '日付' },
    { title: '部署', field: 'ドロップダウン' },
    { title: '業務内容', field: '文字列__複数行_' },
    { title: '所感、学び', field: '文字列__複数行__0' },
  ]

  const Table = () =>
    new Kuc.ReadOnlyTable({
      actionButton: false,
      columns,
      data: tableData.val,
      style: { marginLeft: '16px' },
      rowsPerPage: LIMIT,
    })

  const Pagenation = () => {
    return totalCount.val > 0
      ? div(
          { style: `display: flex; gap: 8px;` },
          offset.val > 0 ? a({ onclick: () => (offset.val -= LIMIT) }, '<') : '<',
          offset.val < totalCount.val - LIMIT ? a({ onclick: () => (offset.val += LIMIT) }, '>') : '>',
          span(`${offset.val} - ${Math.min(totalCount.val, offset.val + LIMIT)}${totalCount.val}件中)`)
        )
      : ''
  }

  van.add(document.getElementById('root'), div({ style: 'margin-left: 16px;' }, Pagenation, Table))

  const spinner = new Kuc.Spinner()

  van.derive(() => {
    if (isLoading.val) spinner.open()
    else spinner.close()
  })

  return event
})

const recordToData = (record) => {
  return Object.fromEntries(Object.entries(record).map(([code, field]) => [code, field.value]))
}

addStyle(`
    [class^="kuc-date-picker"][class$="__group"] {
      flex-direction: row;
      align-items: center;
    }
    
    [class^="kuc-date-picker"][class$="__group__label"] {
      padding: 0 8px 0 0;
    }
  `)

function addStyle(styleText) {
  const style = document.createElement('style')
  style.textContent = styleText
  document.head.appendChild(style)
}

…長いですね。すみません。要点を軽く説明してみます。


  const fromDate = van.state('')
  const toDate = van.state('')

  const fromDatePicker = new Kuc.DatePicker({
    label: '開始日',
    className: 'horizontal',
    onchange: (e) => (fromDate.val = e.detail.value),
  })
  const toDatePicker = new Kuc.DatePicker({
    label: '終了日',
    className: 'horizontal',
    onchange: (e) => (toDate.val = e.detail.value),
  })

  van.add(
    kintone.app.getHeaderMenuSpaceElement(),
    div({ style: `display: flex; gap: 16px; margin-left: 16px` }, fromDatePicker, toDatePicker)
  )

ここで、開始日と終了日を格納するStateを用意しています。

fromDatePickertoDatePickerはkintone UI ComponentのDatePickerを利用していますが、VanJSで通常のDOM要素のように扱えています。

DatePickerのonchangeで入力値をStateに格納しています。Stateの読み書きはvalプロパティから行うのがVanJSのお作法です。


  const currentRecords = van.state([])
  const totalCount = van.state(0)
  const offset = van.state(0)
  const isLoading = van.state(false)

  van.derive(async () => {
    try {
      isLoading.val = true

      const conditions = [fromDate.val && `日付 >= "${fromDate.val}"`, toDate.val && `日付 <= "${toDate.val}"`].filter(
        Boolean
      )

      if (conditions.length === 0) {
        currentRecords.val = []
        offset.val = 0
        totalCount.val = 0
        return
      }

      const query = `${conditions.join(' and ')} limit ${LIMIT} offset ${offset.val}`
      const result = await new KintoneRestAPIClient().record.getRecords({
        app: kintone.app.getId(),
        query,
        totalCount: true,
      })
      currentRecords.val = result.records
      totalCount.val = result.totalCount
    } finally {
      isLoading.val = false
    }
  })

入力した開始日、終了日からクエリを作成してレコードを取得している部分です。

van.deriveのところは、ReactのuseEffectを連想した方も多いかと思いますが、関数をStateにBindするには、van.deriveに渡す必要があります。

これで、fromDatetoDateが更新されるたびにクエリを作成してレコードを取得しなおし、レコードと件数をStateに格納されます。

あとは、Stateに格納されたレコードの値をkintone UI ComponentのReadOnlyTableを使って表示しています。

van.deriveについて補足

自分はvan.deriveの理解に少し時間が掛かったので、ちょっと補足を。

さきほど記載したカウンターのサンプルで、counterを2倍にしたdoubledCounterという変数を定義するとします。

const {button, div, span} = van.tags

const Counter = () => {
  const counter = van.state(0)
  
  // NG
  // const doubledCounter = counter * 2
  // GOOD
  const doubledCounter = van.derive(() => counter.val * 2)
  
  return div(
    span(
      "❤️ ", counter, " ",
      button({onclick: () => ++counter.val}, "👍"),
      button({onclick: () => --counter.val}, "👎"),
    ),
    span(
      "❤️ x 2 ", doubledCounter
    )
  )
}

van.add(document.body, Counter())

Reactだと、コンポーネント関数は再レンダリングのたびに実行されるので、const doubledCounter = counter * 2と書けるのですが、VanJSではこれはNGです。

以下、公式ドキュメントのvan.derive説明を意訳してみました。「派生(derived)state」という概念に留意してください。

`van.derive`は、派生関数fに基づいた「派生(derived)Stateオブジェクト」を生成します。
 
「派生Stateオブジェクト」の`val`は、常にfの結果と同期しています。つまり、その依存性が変化するたびに、同期的にfが呼び出されて「派生Stateオブジェクト」の`val`が更新されます。

上記のサンプルでは、counter.valが変化するたびに、

  • () => counter.val * 2が実行され、派生StateのdoubledCounterが更新される
  • doubledCounterとbindされているspanが更新される

という動作になります。

まとめ

いまのところ、VanJSの実戦投入例はまだひとつだけなのですが、今後、適用できるユースケースがあれば、積極的に使っていきたいと思っています。

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4