React实战--利用甘特图和看板强化kintone应用

cybozu发表于:2020年12月09日 10:50:34更新于:2022年08月31日 21:11:44

开发者中心包含此范例模板(强化项目管理),请前往开发者中心下载学习。

Index

概述


这是一篇 React 在 kintone 上的实战,我们需要利用看板和甘特图来来强化项目管理 app。另外这次用到了 webpack,想了解基本配置思路的可以看这里


项目地址
https://github.com/kintone-samples/SAMPLE-kintone-ganttchart-kanban-cn


效果图

0015fd03badd16ce511a25c6956d643

需求整理


看板

  • 卡片上需要显示负责人、时间、类型、标题、详细信息

  • 看板上不同的跑道代表不同的状态,需要按照顺序显示

  • 允许卡片在各个跑道自由切换,当移动完成时需要同步更新记录的状态信息

  • 点击卡片能进入详细画面

  • 不建议用流程管理来设置状态,因为它需要设置每个 action 动作


甘特图

  • 甘特图需要能标示今天日期

  • 甘特图要有不同的 viewmode 来控制时间长度信息

  • 能显示父子任务关系图

  • 任务能被自由拖拽,时间被改变后需要同步更新记录的时间信息

  • 任务状态以进度百分比的方式显示

  • 任务标题和时间信息能以文字的形式显示出来

  • 点击任务能进入详细画面


其他

  • 要有切换功能

  • 不同的任务类型需要用不同的颜色显示

  • 和 kintone 的列表功能结合起来,来控制数据大小

  • 详细画面要有甘特图,只显示自己的父子任务

  • lookup 只能查其他 app 的信息,无法利用它来实现父子关系绑定,需要有其他的手段


kintone App 设置

0015fd03c23248e40dde791c21e156e


字段名类型字段代码备注
Type下拉菜单type任务类型
Priority下拉菜单priority优先度
Fix version下拉菜单version版本号,列表中用于任务分类
Status下拉菜单status状态,看板的跑道
Start日期startDate开始时间
End日期endDate结束时间
Assignee选择用户assignee负责人
Summary单行文本框summary标题
Detail多行文本框detail详细信息
Parent Task数值parent父任务 ID
Subtasks关联记录列表subtasks子任务列表

空白元素addSub子任务添加按钮


代码详解

部分代码由于系统的原因无法完全显示,请以github上源文件为准

第三方包选择

甘特图
看板
React UI library
任务类型名转不同颜色


index.tsx

import React from 'react'
import ReactDOM from 'react-dom'
import { AppSwitcher } from './components/App'
import GanttCharts from './components/GanttCharts'
import AddSub from './components/AddSub'

interface KintoneEvent {
  record: kintone.types.SavedFields;
}

/**
 * 一览画面上显示一个switcher开关,来控制图表的显示
 */
kintone.events.on('app.record.index.show', (event: KintoneEvent) => {
  ReactDOM.render(, kintone.app.getHeaderMenuSpaceElement())
  return event
})

/**
 * 详细画面上显示甘特图和子任务按钮,由于考虑到代码的通用性,这里传一个query语句给甘特图,查找父id、id、子id
 */
kintone.events.on('app.record.detail.show', (event: KintoneEvent) => {
  let query = `parent = ${event.record.$id.value} or $id= ${event.record.$id.value}`
  event.record.parent.value && (query += ` or $id = ${event.record.parent.value}`)
  ReactDOM.render(, kintone.app.record.getHeaderMenuSpaceElement())
  ReactDOM.render(, kintone.app.record.getSpaceElement('addSub'))
  return event
})

/**
 * 添加画面如果是addSub打开的,那么url里面会传入pid的参数,读取该参数放入event的父ID中
 */
kintone.events.on('app.record.create.show', (event: KintoneEvent) => {
  const queryString = window.location.search
  const urlParams = new URLSearchParams(queryString)
  const pid = urlParams.get('pid')
  if (pid) {
    event.record.parent.value = pid
  }
  return event
})


App.tsx

import React, { useEffect } from 'react'
import ReactDOM from 'react-dom'
import { Radio } from 'antd'
import GanttCharts from './GanttCharts'
import 'antd/dist/antd.css'
import Kanban from './Kanban'

// eslint-disable-next-line no-shadow
export enum AppType {
  Gantt = 'Gantt',
  Board = 'Kanban',
}

/**
 *切换按钮,使用antd的radio样式
 */
export const AppSwitcher = () => {
  /**
   *由于渲染区域不同,这里选择强行渲染
   */
  const onAppChange = (app: AppType) => {
    switch (app) {
      case AppType.Gantt:
        ReactDOM.render(
          //kintone.app.getQueryCondition()获取当前列表的查询参数,无limit、order信息,
          kintone.app.getHeaderSpaceElement(),
        )
        break
      default:
        ReactDOM.render(, kintone.app.getHeaderSpaceElement())
    }
  }

  /**
   *模拟componentDidMount,在加载结束后只渲染一次看板画面
   */
  useEffect(() => {
    ReactDOM.render(, kintone.app.getHeaderSpaceElement())
  }, [])

  return (
    <>onAppChange(e.target.value)}
      >{AppType.Gantt}{AppType.Board}</>
  )
}


KintoneAppRepository.tsx

import { KintoneRestAPIClient, KintoneRecordField, KintoneFormFieldProperty } from '@kintone/rest-api-client'
import stc from 'string-to-color'

export type AppRecord = {
  $id: KintoneRecordField.ID
  parent: KintoneRecordField.Number
  summary: KintoneRecordField.SingleLineText
  startDate: KintoneRecordField.Date
  endDate: KintoneRecordField.Date
  type: KintoneRecordField.Dropdown
  status: KintoneRecordField.Dropdown
}

export type AppProperty = {
  type: KintoneFormFieldProperty.Dropdown
  status: KintoneFormFieldProperty.Dropdown
}

const getRecordsByApi = (query?: string) => {
  return new KintoneRestAPIClient().record.getRecords({
    app: kintone.app.getId()!,
    query: `${query ? query : ''} order by $id asc`,
  })
}

const getFieldsByApi = () => {
  return new KintoneRestAPIClient().app.getFormFields({ app: kintone.app.getId()! })
}

export const updateStatus = async (recordID: string, status: string) => {
  await new KintoneRestAPIClient().record.updateRecord({
    app: kintone.app.getId()!,
    id: recordID,
    record: {
      status: {
        value: status,
      },
    },
  })
}
export const updateDate = async (recordID: string, start: string, end: string) => {
  await new KintoneRestAPIClient().record.updateRecord({
    app: kintone.app.getId()!,
    id: recordID,
    record: {
      startDate: {
        value: start,
      },
      endDate: {
        value: end,
      },
    },
  })
}

/***
 * 列表查询,使用Promise.all让api并发执行
 */
export const getRecords = async (
  cb: (records: AppRecord[], status: Map, type: Map) => void,
  query?: string,
) => {
  const [fields, list] = await kintone.Promise.all([getFieldsByApi(), getRecordsByApi(query)])
  //把转换的颜色和进度进行存储,防止多次运算浪费资源
  const type = new Map()
  const status = new Map()
  Object.keys(fields.properties.type.options).forEach((k) => {
    type.set(k, stc(k))
  })
  Object.keys(fields.properties.status.options).forEach((k) => {
    //status按照顺序排列
    status.set(k, fields.properties.status.options[k].index)
  })
  cb(list.records, status, type)
}


Card.tsx

import { KintoneRecordField } from '@kintone/rest-api-client'
import { Card, Avatar, Tag } from 'antd'
import React from 'react'

const { Meta } = Card

export interface KCard extends ReactTrello.DraggableCard {
  labelColor?: string
  assignee: KintoneRecordField.UserSelect
  startDate: string
  endDate: string
  onClick?: () => void
}

/**
 * 负责人部分,需要显示多个负责人
 */
const Avatars = (props: { assignee: KintoneRecordField.UserSelect }) => {
  return (
    <Avatar.Group
      maxCount={2}
      size="large"
      maxStyle={{
        color: '#f56a00',
        backgroundColor: '#fde3cf',
      }}
    >
      {props.assignee.value.map((element) => {
        return (
          <Avatar
            // src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png"
            style={{
              backgroundColor: '#15dad2',
            }}
            key={element.code}
          >
            {element.name}
          </Avatar>
        )
      })}
    </Avatar.Group>
  )
}

/**
 * 看板卡片部分,详见antd api
 */
export const AntdCard = (props: KCard) => {
  return (
    <Card
      extra={<Tag color={props.labelColor}>{props.label}</Tag>}
      style={{ width: 300 }}
      title={props.title}
      onClick={props.onClick}
    >
      <Meta
        avatar={<Avatars assignee={props.assignee} />}
        title={`${props.startDate.substring(5)}~${props.endDate.substring(5)}`}
        description={props.description}
      />
    </Card>
  )
}

Kanban.tsx

import React, { useEffect } from 'react'
import Board from 'react-trello'
import { AppRecord, getRecords, updateStatus } from '../KintoneAppRepository'
import { AntdCard, KCard } from './Card'
import { Spin, Result } from 'antd'
import './app.css'

const Kanban = () => {
  const [data, setData] = React.useState<ReactTrello.BoardData>()
  const [isLoading, setLoading] = React.useState<boolean>(true)

  const display = (records: AppRecord[], status: Map<string, number>, type: Map<string, string>) => {
    // 无数据停止加载
    if (records.length === 0) {
      setLoading(false)
    } else {
      const lanes = new Array(status.size)
      status.forEach((v, k) => {
        lanes[v] = {
          id: k,
          title: k,
          cards: new Array<KCard>(),
        }
      })
      // 转换
      records.forEach((record) =>
        lanes[status.get(record.status.value!)!].cards!.push({
          id: record.$id.value,
          title: record.summary.value,
          label: record.type.value!,
          labelColor: type.get(record.type.value!),
          description: record.detail.value,
          assignee: record.assignee,
          startDate: record.startDate.value!,
          endDate: record.endDate.value!,
        }),
      )
      setData({ lanes })
    }
  }

  // 模拟componentDidMount,在这里进行异步数据加载
  useEffect(() => {
    // 回调处理
    getRecords(display, kintone.app.getQueryCondition() || undefined)
  }, [])

  // 点击卡片后通过组合url的方式打开详细画面
  const onCardClick = (cardId: string) => {
    const url =
      window.location.protocol + '//' + window.location.host + window.location.pathname + 'show#record=' + cardId
    window.location.assign(url)
  }
  // 拖拽结束后更新记录状态
  const handleDragEnd = (cardId: string, _sourceLandId: string, targetLaneId: string) => {
    updateStatus(cardId, targetLaneId)
  }

  // 显示内容,在一开始显示加载画面,没得到任何数据时显示nodata画面,在得到数据后显示看板
  let content
  if (data) {
    content = (
      <Board
        data={data}
        draggable
        onCardClick={onCardClick}
        hideCardDeleteIcon
        handleDragEnd={handleDragEnd}
        style={{ padding: '30px 20px', backgroundColor: '#5F9AF8' }}
        components={{ Card: AntdCard }}
      />
    )
  } else if (isLoading) {
    content = (
      <div className="center">
        <Spin size="large" tip="Loading..." />
      </div>
    )
  } else {
    content = <Result title="No data" />
  }

  return <>{content}</>
}
export default Kanban

GanttViewSwitcher.tsx && GanttCharts.tsx

基本思路和看板模块差不多,这里不再解读,有兴趣的可以去看源码学习