diff --git a/modules/Tag.js b/modules/Tag.js
deleted file mode 100644
index b54ae42d2..000000000
--- a/modules/Tag.js
+++ /dev/null
@@ -1,73 +0,0 @@
-/* global ENABLE_LEANCLOUD_INTEGRATION */
-import React, { useEffect, useState } from 'react'
-import { Badge, Button, Form } from 'react-bootstrap'
-import { useTranslation } from 'react-i18next'
-import PropTypes from 'prop-types'
-import LC, { cloud } from '../lib/leancloud'
-
-export default function Tag({ tag, ticket, isCustomerService }) {
- const { t } = useTranslation()
- const [data, setData] = useState()
-
- // TODO: fix race condition
- useEffect(() => {
- ;(async () => {
- if (ENABLE_LEANCLOUD_INTEGRATION && tag.data.key === 'appId') {
- const appId = tag.data.value
- if (!appId) {
- return
- }
- const app = await cloud.run('getLeanCloudApp', {
- appId,
- username: ticket.author.username,
- })
- setData({ key: 'application', value: app.app_name })
- if (isCustomerService) {
- const url = await cloud.run('getLeanCloudAppUrl', {
- appId,
- region: app.region,
- })
- if (url) {
- setData((prevData) => ({ ...prevData, url }))
- }
- }
- }
- })()
- }, [tag, ticket, isCustomerService])
-
- if (!data) {
- return (
-
-
- {tag.data.key}: {tag.data.value}
-
-
- )
- }
- return (
-
- {t(data.key)}
-
- {data.url ? (
-
- ) : (
-
- )}
-
-
- )
-}
-
-Tag.propTypes = {
- tag: PropTypes.instanceOf(LC.LCObject).isRequired,
- ticket: PropTypes.object.isRequired,
- isCustomerService: PropTypes.bool,
-}
-
-Tag.contextTypes = {
- addNotification: PropTypes.func.isRequired,
-}
diff --git a/modules/TagForm.js b/modules/TagForm.js
deleted file mode 100644
index 2e5b1a427..000000000
--- a/modules/TagForm.js
+++ /dev/null
@@ -1,112 +0,0 @@
-import React from 'react'
-import { Badge, Button, Form } from 'react-bootstrap'
-import { withTranslation } from 'react-i18next'
-import PropTypes from 'prop-types'
-import validUrl from 'valid-url'
-import * as Icon from 'react-bootstrap-icons'
-
-class TagForm extends React.Component {
- constructor(props) {
- super(props)
- this.state = {
- isUpdate: false,
- value: props.tag ? props.tag.value : '',
- }
- }
-
- handleChange(e) {
- const tagMetadata = this.props.tagMetadata
- if (tagMetadata.get('type') == 'select') {
- return this.props
- .changeTagValue(tagMetadata.get('key'), e.target.value, tagMetadata.get('isPrivate'))
- .then(() => {
- this.setState({ isUpdate: false })
- return
- })
- }
-
- this.setState({ value: e.target.value })
- }
-
- handleCommit() {
- const tagMetadata = this.props.tagMetadata
- return this.props
- .changeTagValue(tagMetadata.get('key'), this.state.value, tagMetadata.get('isPrivate'))
- .then(() => {
- this.setState({ isUpdate: false })
- return
- })
- }
-
- render() {
- const { t, tagMetadata, tag, isCustomerService } = this.props
- const isPrivate = tagMetadata.get('isPrivate')
- if (isPrivate && !isCustomerService) {
- return
- }
-
- // 如果标签不存在,说明标签还没设置过。对于非客服来说则什么都不显示
- if (!tag && !isCustomerService) {
- return
- }
-
- return (
-
-
- {tagMetadata.get('key')} {isPrivate && Private}
-
- {this.state.isUpdate ? (
- tagMetadata.get('type') == 'select' ? (
-
-
- {tagMetadata.get('values').map((v) => {
- return
- })}
-
- ) : (
-
-
-
-
-
- )
- ) : (
-
- {tag ? (
- validUrl.isUri(tag.value) ? (
-
- {tag.value}
-
- ) : (
- {tag.value}
- )
- ) : (
- `<${t('unconfigured')}>`
- )}
- {isCustomerService && (
-
- )}
-
- )}
-
- )
- }
-}
-
-TagForm.propTypes = {
- tagMetadata: PropTypes.object.isRequired,
- tag: PropTypes.object,
- changeTagValue: PropTypes.func,
- isCustomerService: PropTypes.bool,
- t: PropTypes.func,
-}
-
-export default withTranslation()(TagForm)
diff --git a/modules/Ticket/Category/index.css b/modules/Ticket/Category/index.css
new file mode 100644
index 000000000..63de3ac60
--- /dev/null
+++ b/modules/Ticket/Category/index.css
@@ -0,0 +1,16 @@
+.category {
+ display: inline-block;
+ color: #afafaf;
+ border: 1px solid #d6d6d6;
+ font-size: 13px;
+ padding: 3px 5px;
+ border-radius: 4px;
+ line-height: 1;
+}
+
+.category.block {
+ color: #777;
+ border-color: #777;
+ font-size: inherit;
+ padding: 4px 8px;
+}
diff --git a/modules/Ticket/Category/index.js b/modules/Ticket/Category/index.js
new file mode 100644
index 000000000..87ddd21d1
--- /dev/null
+++ b/modules/Ticket/Category/index.js
@@ -0,0 +1,126 @@
+import React, { useMemo } from 'react'
+import { Form } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { useQuery } from 'react-query'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+
+import css from './index.css'
+import { fetch } from '../../../lib/leancloud'
+
+function AsyncCategory({ block, categoryId }) {
+ const { t } = useTranslation()
+ const { data: categories, isLoading } = useQuery('categories', () => fetch('/api/1/categories'))
+ const categoryPath = useMemo(() => {
+ if (!categories) {
+ return []
+ }
+ const path = [categories.find((c) => c.id === categoryId)]
+ while (path[0]?.parent_id) {
+ path.unshift(categories.find((c) => c.id === path[0].parent_id))
+ }
+ return path.map((c) => c.name)
+ }, [categories, categoryId])
+ return (
+
+ {isLoading ? `${t('loading')}...` : categoryPath.join(' / ')}
+
+ )
+}
+AsyncCategory.propTypes = {
+ categoryId: PropTypes.string.isRequired,
+ block: PropTypes.bool,
+}
+
+function CategoryByPath({ block, categoryPath }) {
+ const fullName = useMemo(() => categoryPath.map((c) => c.name).join(' / '), [categoryPath])
+ return
{fullName}
+}
+CategoryByPath.propTypes = {
+ categoryPath: PropTypes.array.isRequired,
+ block: PropTypes.bool,
+}
+
+// eslint-disable-next-line react/prop-types
+export function Category({ categoryId, categoryPath, block }) {
+ if (categoryPath) {
+ return
+ }
+ return
+}
+
+/**
+ * @param {any[]} categoryTree
+ * @param {(any, any) => number} compareFn
+ */
+function sortCategoryTree(categoryTree, compareFn) {
+ categoryTree.forEach((category) => {
+ if (category.children) {
+ sortCategoryTree(category.children, compareFn)
+ }
+ })
+ return categoryTree.sort(compareFn)
+}
+
+/**
+ * @param {any[]} categories
+ */
+function makeCategoryTree(categories) {
+ const categoryById = categories.reduce((map, category) => {
+ map[category.id] = { ...category }
+ return map
+ }, {})
+ const parents = []
+ Object.values(categoryById).forEach((category) => {
+ if (category.parent_id) {
+ const parent = categoryById[category.parent_id]
+ if (!parent.children) {
+ parent.children = []
+ }
+ parent.children.push(category)
+ } else {
+ parents.push(category)
+ }
+ })
+ return sortCategoryTree(parents, (a, b) => a.position - b.position)
+}
+
+function mapCategoryTree(categoryTree, callback, depth = 0, results = []) {
+ categoryTree.forEach((category) => {
+ results.push(callback(category, depth))
+ if (category.children) {
+ mapCategoryTree(category.children, callback, depth + 1, results)
+ }
+ })
+ return results
+}
+
+export function CategorySelect({ categories, value, onChange, children, ...props }) {
+ const { t } = useTranslation()
+ const categoryNames = useMemo(() => {
+ return mapCategoryTree(makeCategoryTree(categories), (category, depth) => {
+ const indent = depth ? ' '.repeat(depth) + '└ ' : ''
+ return {
+ ...category,
+ name: indent + category.name + (category.active ? '' : t('disabled')),
+ }
+ })
+ }, [t, categories])
+
+ return (
+
onChange(e.target.value)}>
+ {children}
+ {categoryNames.map(({ id, name }) => (
+
+ ))}
+
+ )
+}
+CategorySelect.propTypes = {
+ categories: PropTypes.array.isRequired,
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ children: PropTypes.node,
+}
diff --git a/modules/Ticket/Evaluation.js b/modules/Ticket/Evaluation.js
new file mode 100644
index 000000000..cc3c5bd33
--- /dev/null
+++ b/modules/Ticket/Evaluation.js
@@ -0,0 +1,100 @@
+import React, { useCallback, useContext, useState } from 'react'
+import { Alert, Button, Form } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import PropTypes from 'prop-types'
+import * as Icon from 'react-bootstrap-icons'
+
+import { AppContext } from '../context'
+import { fetch } from '../../lib/leancloud'
+import { useMutation, useQueryClient } from 'react-query'
+
+export function Evaluation({ ticket, isCustomerService }) {
+ const { t } = useTranslation()
+ const { addNotification } = useContext(AppContext)
+ const storageKey = `ticket:${ticket.id}:evaluation`
+ const [star, setStar] = useState(ticket.evaluation?.star ?? 1)
+ const [content, setContent] = useState(localStorage.getItem(storageKey) ?? '')
+
+ const setEvaluationContent = useCallback(
+ (content) => {
+ setContent(content)
+ if (content) {
+ localStorage.setItem(storageKey, content)
+ } else {
+ localStorage.removeItem(storageKey)
+ }
+ },
+ [storageKey]
+ )
+
+ const queryClient = useQueryClient()
+ const { mutate, isLoading } = useMutation({
+ mutationFn: (evaluation) =>
+ fetch(`/api/1/tickets/${ticket.id}`, {
+ method: 'PATCH',
+ body: { evaluation },
+ }),
+ onSuccess: (evaluation) => {
+ queryClient.setQueryData(['ticket', ticket.id], (current) => ({
+ ...current,
+ evaluation,
+ }))
+ localStorage.removeItem(storageKey)
+ },
+ onError: (error) => addNotification(error),
+ })
+
+ if (!ticket.evaluation && isCustomerService) {
+ return null
+ }
+ return (
+
+ {ticket.evaluation ? t('feedback') : t('satisfiedOrNot')}
+
+ }
+ type="radio"
+ inline
+ disabled={!!ticket.evaluation || isLoading}
+ checked={star === 1}
+ value={1}
+ onChange={() => setStar(1)}
+ />
+ }
+ type="radio"
+ inline
+ disabled={!!ticket.evaluation || isLoading}
+ checked={star === 0}
+ value={0}
+ onChange={() => setStar(0)}
+ />
+
+
+ setEvaluationContent(e.target.value)}
+ />
+
+ {!ticket.evaluation && (
+
+ )}
+
+ )
+}
+
+Evaluation.propTypes = {
+ ticket: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ evaluation: PropTypes.object,
+ }).isRequired,
+ isCustomerService: PropTypes.bool,
+}
diff --git a/modules/Ticket/LeanCloudApp.js b/modules/Ticket/LeanCloudApp.js
new file mode 100644
index 000000000..938e367f9
--- /dev/null
+++ b/modules/Ticket/LeanCloudApp.js
@@ -0,0 +1,71 @@
+import React, { useContext, useEffect, useState } from 'react'
+import { Button, Form } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import PropTypes from 'prop-types'
+
+import { cloud, db } from '../../lib/leancloud'
+import { AppContext } from '../context'
+
+export function LeanCloudApp({ ticketId, authorUserame, isCustomerService }) {
+ const { t } = useTranslation()
+ const { addNotification } = useContext(AppContext)
+ const [appId, setAppId] = useState('')
+ const [appName, setAppName] = useState('')
+ const [appURL, setAppURL] = useState('')
+
+ useEffect(() => {
+ db.class('Tag')
+ .where('ticket', '==', db.class('Ticket').object(ticketId))
+ .where('key', '==', 'appId')
+ .first()
+ .then((tag) => {
+ if (tag) {
+ setAppId(tag.data.value)
+ }
+ return
+ })
+ .catch(addNotification)
+ }, [ticketId, addNotification])
+
+ useEffect(() => {
+ ;(async () => {
+ if (!window.ENABLE_LEANCLOUD_INTEGRATION || !appId) {
+ return
+ }
+ const app = await cloud.run('getLeanCloudApp', {
+ appId,
+ username: authorUserame,
+ })
+ setAppName(app.app_name)
+ if (isCustomerService) {
+ const url = await cloud.run('getLeanCloudAppUrl', {
+ appId,
+ region: app.region,
+ })
+ if (url) {
+ setAppURL(url)
+ }
+ }
+ })()
+ }, [appId, authorUserame, isCustomerService])
+
+ if (!appId) {
+ return null
+ }
+ return (
+
+ {t('application')}
+
+
+
+
+ )
+}
+
+LeanCloudApp.propTypes = {
+ ticketId: PropTypes.string.isRequired,
+ authorUserame: PropTypes.string.isRequired,
+ isCustomerService: PropTypes.bool,
+}
diff --git a/modules/Ticket/OpsLog.js b/modules/Ticket/OpsLog.js
new file mode 100644
index 000000000..7d313406a
--- /dev/null
+++ b/modules/Ticket/OpsLog.js
@@ -0,0 +1,229 @@
+import React from 'react'
+import * as Icon from 'react-bootstrap-icons'
+import { useTranslation } from 'react-i18next'
+import { useQuery } from 'react-query'
+import PropTypes from 'prop-types'
+
+import { fetch } from '../../lib/leancloud'
+import { UserLabel } from '../UserLabel'
+import { Time } from './Time'
+import { Category } from './Category'
+
+export function AsyncUserLabel({ userId }) {
+ const { t } = useTranslation()
+ const { data } = useQuery({
+ queryKey: ['user', userId],
+ queryFn: () => fetch(`/api/1/users/${userId}`),
+ enabled: userId && userId !== 'system',
+ })
+ if (userId === undefined || userId === 'system') {
+ return t('system')
+ }
+ return data ?
: 'Loading...'
+}
+AsyncUserLabel.propTypes = {
+ userId: PropTypes.string.isRequired,
+}
+
+function SelectAssignee({ id, assignee_id, created_at }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+
+
+ {t('system')} {t('assignedTicketTo')}
(
+
)
+
+
+ )
+}
+SelectAssignee.propTypes = {
+ id: PropTypes.string.isRequired,
+ assignee_id: PropTypes.string.isRequired,
+ created_at: PropTypes.string.isRequired,
+}
+
+function ChangeCategory({ id, operator_id, category_id, created_at }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+
+
+
{t('changedTicketCategoryTo')}{' '}
+
(
)
+
+
+ )
+}
+ChangeCategory.propTypes = {
+ id: PropTypes.string.isRequired,
+ operator_id: PropTypes.string.isRequired,
+ category_id: PropTypes.string.isRequired,
+ created_at: PropTypes.string.isRequired,
+}
+
+function ChangeAssignee({ id, operator_id, assignee_id, created_at }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+
+
+
{t('changedTicketAssigneeTo')}{' '}
+
(
)
+
+
+ )
+}
+ChangeAssignee.propTypes = {
+ id: PropTypes.string.isRequired,
+ operator_id: PropTypes.string.isRequired,
+ assignee_id: PropTypes.string.isRequired,
+ created_at: PropTypes.string.isRequired,
+}
+
+function ReplyWithNoContent({ id, operator_id, created_at }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+
+
+
{t('thoughtNoNeedToReply')} (
+
)
+
+
+ )
+}
+ReplyWithNoContent.propTypes = {
+ id: PropTypes.string.isRequired,
+ operator_id: PropTypes.string.isRequired,
+ created_at: PropTypes.string.isRequired,
+}
+
+function ReplySoon({ id, operator_id, created_at }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+
+
+
{t('thoughtNeedTime')} (
+
)
+
+
+ )
+}
+ReplySoon.propTypes = {
+ id: PropTypes.string.isRequired,
+ operator_id: PropTypes.string.isRequired,
+ created_at: PropTypes.string.isRequired,
+}
+
+function Resolve({ id, operator_id, created_at }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+
+
+
{t('thoughtResolved')} (
+
)
+
+
+ )
+}
+Resolve.propTypes = {
+ id: PropTypes.string.isRequired,
+ operator_id: PropTypes.string.isRequired,
+ created_at: PropTypes.string.isRequired,
+}
+
+function Close({ id, operator_id, created_at }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+
+
+
{t('closedTicket')} (
+
)
+
+
+ )
+}
+Close.propTypes = {
+ id: PropTypes.string.isRequired,
+ operator_id: PropTypes.string.isRequired,
+ created_at: PropTypes.string.isRequired,
+}
+
+function Reopen({ id, operator_id, created_at }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+
+
+
+
+
{t('reopenedTicket')} (
+
)
+
+
+ )
+}
+Reopen.propTypes = {
+ id: PropTypes.string.isRequired,
+ operator_id: PropTypes.string.isRequired,
+ created_at: PropTypes.string.isRequired,
+}
+
+export function OpsLog({ data }) {
+ switch (data.action) {
+ case 'selectAssignee':
+ return
+ case 'changeCategory':
+ return
+ case 'changeAssignee':
+ return
+ case 'replyWithNoContent':
+ return
+ case 'replySoon':
+ return
+ case 'resolve':
+ return
+ case 'reject':
+ case 'close':
+ return
+ case 'reopen':
+ return
+ }
+}
+OpsLog.propTypes = {
+ data: PropTypes.object.isRequired,
+}
diff --git a/modules/Ticket/ReplyCard.js b/modules/Ticket/ReplyCard.js
new file mode 100644
index 000000000..ee4adba0e
--- /dev/null
+++ b/modules/Ticket/ReplyCard.js
@@ -0,0 +1,75 @@
+import React, { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Card } from 'react-bootstrap'
+import * as Icon from 'react-bootstrap-icons'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import xss from 'xss'
+import _ from 'lodash'
+
+import css from './index.css'
+import { UserLabel } from '../UserLabel'
+import { Time } from './Time'
+
+// get a copy of default whiteList
+const whiteList = xss.getDefaultWhiteList()
+
+// allow class attribute for span and code tag
+whiteList.span.push('class')
+whiteList.code.push('class')
+
+// specified you custom whiteList
+const myxss = new xss.FilterXSS({
+ whiteList,
+ css: false,
+})
+
+const IMAGE_FILE_MIMES = ['image/png', 'image/jpeg', 'image/gif']
+
+export function ReplyCard({ data }) {
+ const { t } = useTranslation()
+ const [imageFiles, otherFiles] = useMemo(() => {
+ return _.partition(data.files, (file) => IMAGE_FILE_MIMES.includes(file.mime))
+ }, [data.files])
+
+ return (
+
+
+ {t('submittedAt')}{' '}
+
+ {data.is_customer_service && {t('staff')}}
+
+
+
+ {imageFiles.map(({ id, name, url }) => (
+
+
+
+ ))}
+
+ {otherFiles.length > 0 && (
+
+ {otherFiles.map(({ id, name, url }) => (
+
+ ))}
+
+ )}
+
+ )
+}
+ReplyCard.propTypes = {
+ data: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ author: PropTypes.object.isRequired,
+ is_customer_service: PropTypes.bool,
+ content_HTML: PropTypes.string.isRequired,
+ files: PropTypes.array.isRequired,
+ }),
+}
diff --git a/modules/Ticket/TagForm.js b/modules/Ticket/TagForm.js
new file mode 100644
index 000000000..1322a8b3c
--- /dev/null
+++ b/modules/Ticket/TagForm.js
@@ -0,0 +1,104 @@
+import React, { useState } from 'react'
+import { Badge, Button, Form, InputGroup } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import PropTypes from 'prop-types'
+import { isUri } from 'valid-url'
+import * as Icon from 'react-bootstrap-icons'
+
+export function TagForm({ tagMetadata, tag, isCustomerService, onChange, disabled }) {
+ const { t } = useTranslation()
+ const [value, setValue] = useState(tag?.value || '')
+ const [editing, setEditing] = useState(false)
+ const [committing, setCommitting] = useState(false)
+
+ if (tagMetadata.isPrivate && !isCustomerService) {
+ return null
+ }
+
+ // 如果标签不存在,说明标签还没设置过。对于非客服来说则什么都不显示
+ if (!tag && !isCustomerService) {
+ return null
+ }
+
+ const handleCommit = async (value) => {
+ setCommitting(true)
+ try {
+ await onChange(tagMetadata.key, value, tagMetadata.isPrivate)
+ setEditing(false)
+ } finally {
+ setCommitting(false)
+ }
+ }
+
+ return (
+
+
+ {tagMetadata.key} {tagMetadata.isPrivate && Private}
+
+ {editing ? (
+ tagMetadata.type == 'select' ? (
+ handleCommit(e.target.value)}
+ onBlur={() => setEditing(false)}
+ >
+
+ {tagMetadata.values.map((v) => (
+
+ ))}
+
+ ) : (
+
+
+ setValue(e.target.value)}
+ />
+
+
+
+
+
+
+ )
+ ) : (
+
+ {tag ? (
+ isUri(tag.value) ? (
+
+ {tag.value}
+
+ ) : (
+
{tag.value}
+ )
+ ) : (
+ `<${t('unconfigured')}>`
+ )}
+ {isCustomerService && (
+
+ )}
+
+ )}
+
+ )
+}
+TagForm.propTypes = {
+ tagMetadata: PropTypes.object.isRequired,
+ tag: PropTypes.object,
+ onChange: PropTypes.func.isRequired,
+ isCustomerService: PropTypes.bool,
+ disabled: PropTypes.bool,
+}
diff --git a/modules/Ticket/TicketMetadata.js b/modules/Ticket/TicketMetadata.js
index d9319eaa2..ef35489d3 100644
--- a/modules/Ticket/TicketMetadata.js
+++ b/modules/Ticket/TicketMetadata.js
@@ -1,174 +1,228 @@
-import React, { Component } from 'react'
+import React, { useCallback, useContext, useMemo, useState } from 'react'
import { Button, Form } from 'react-bootstrap'
-import { withTranslation } from 'react-i18next'
-import PropTypes from 'prop-types'
-import _ from 'lodash'
import * as Icon from 'react-bootstrap-icons'
+import { useTranslation } from 'react-i18next'
+import { useMutation, useQuery, useQueryClient } from 'react-query'
+import PropTypes from 'prop-types'
-import { getCustomerServices, getCategoryPathName } from '../common'
+import { fetch } from '../../lib/leancloud'
import { UserLabel } from '../UserLabel'
-import TagForm from '../TagForm'
-import css from './index.css'
-import csCss from '../CustomerServiceTickets.css'
-import { depthFirstSearchFind } from '../../lib/common'
-import CategoriesSelect from '../CategoriesSelect'
+import { AppContext } from '../context'
import { getConfig } from '../config'
import { MountCustomElement } from '../custom/element'
+import css from './index.css'
+import { Category, CategorySelect } from './Category'
+import { TagForm } from './TagForm'
+
+function updateTicket(id, data) {
+ return fetch(`/api/1/tickets/${id}`, {
+ method: 'PATCH',
+ body: data,
+ })
+}
-class TicketMetadata extends Component {
- constructor(props) {
- super(props)
- this.state = {
- isUpdateAssignee: false,
- isUpdateCategory: false,
- assignees: [],
- }
- }
-
- componentDidMount() {
- this.fetchDatas()
- }
-
- fetchDatas() {
- getCustomerServices()
- .then((assignees) => {
- this.setState({ assignees })
- return
- })
- .catch(this.context.addNotification)
- }
-
- handleAssigneeChange(e) {
- const customerService = _.find(this.state.assignees, { id: e.target.value })
- this.props
- .updateTicketAssignee(customerService)
- .then(() => {
- this.setState({ isUpdateAssignee: false })
- return
- })
- .then(this.context.addNotification)
- .catch(this.context.addNotification)
- }
-
- handleCategoryChange(e) {
- this.props
- .updateTicketCategory(
- depthFirstSearchFind(this.props.categoriesTree, (c) => c.id == e.target.value)
- )
- .then(() => {
- this.setState({ isUpdateCategory: false })
- return
- })
- .then(this.context.addNotification)
- .catch(this.context.addNotification)
- }
-
- handleTagChange(key, value, isPrivate) {
- return this.props.saveTag(key, value, isPrivate)
- }
-
- render() {
- const { t, ticket, isCustomerService } = this.props
-
- return (
- <>
-
- {t('assignee')}
- {this.state.isUpdateAssignee ? (
-
- {this.state.assignees.map((cs) => (
-
- ))}
-
- ) : (
-
-
- {isCustomerService && (
-
- )}
-
+function AssigneeSection({ ticket, isCustomerService }) {
+ const { t } = useTranslation()
+ const { addNotification } = useContext(AppContext)
+ const [editingAssignee, setEditingAssignee] = useState(false)
+ const { data: customerServices, isLoading } = useQuery({
+ queryKey: 'customerServices',
+ queryFn: () => fetch('/api/1/customer-services'),
+ enabled: editingAssignee,
+ })
+ const queryClient = useQueryClient()
+
+ const { mutate: updateAssignee, isLoading: updating } = useMutation({
+ mutationFn: (assignee_id) => updateTicket(ticket.id, { assignee_id }),
+ onSuccess: () => {
+ queryClient.invalidateQueries(['ticket', ticket.id])
+ setEditingAssignee(false)
+ },
+ onError: (error) => addNotification(error),
+ })
+
+ return (
+
+ {t('assignee')}
+ {editingAssignee ? (
+ updateAssignee(e.target.value)}
+ onBlur={() => setEditingAssignee(false)}
+ >
+ {isLoading && }
+ {customerServices?.map((cs) => (
+
+ ))}
+
+ ) : (
+
+
+ {isCustomerService && (
+
)}
-
-
-
- {t('category')}
- {this.state.isUpdateCategory ? (
-
- ) : (
-
-
- {getCategoryPathName({ id: ticket.category_id }, this.props.categoriesTree)}
-
- {isCustomerService && (
-
- )}
-
+
+ )}
+
+ )
+}
+AssigneeSection.propTypes = {
+ ticket: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ assignee: PropTypes.object.isRequired,
+ }),
+ isCustomerService: PropTypes.bool,
+}
+
+function CategorySection({ ticket, isCustomerService }) {
+ const { t } = useTranslation()
+ const { addNotification } = useContext(AppContext)
+ const [editingCategory, setEditingCategory] = useState(false)
+ const { data: categories, isLoading } = useQuery({
+ queryKey: ['categories', { active: true }],
+ queryFn: () => fetch('/api/1/categories?active=true'),
+ })
+ const queryClient = useQueryClient()
+
+ const { mutate: updateCategory, isLoading: updating } = useMutation({
+ mutationFn: (category_id) => updateTicket(ticket.id, { category_id }),
+ onSuccess: () => {
+ queryClient.invalidateQueries(['ticket', ticket.id])
+ setEditingCategory(false)
+ },
+ onError: (error) => addNotification(error),
+ })
+
+ return (
+
+ {t('category')}
+ {editingCategory ? (
+ setEditingCategory(false)}
+ disabled={isLoading || updating}
+ />
+ ) : (
+
+
+ {isCustomerService && (
+
)}
-
-
- {isCustomerService && ticket.metadata && (
-
- {t('details')}
- {Object.entries(ticket.metadata)
- .filter(([, v]) => v && (typeof v === 'string' || typeof v === 'number'))
- .map(([key, value]) => {
- const comments = getConfig('ticket.metadata.customMetadata.comments', {})
- return (
-
- {comments[key] || key}:
- {value}
-
- )
- })}
-
- )}
-
-
-
- {this.context.tagMetadatas.map((tagMetadata) => {
- const tags = ticket[tagMetadata.get('isPrivate') ? 'private_tags' : 'tags']
- const tag = _.find(tags, (t) => t.key == tagMetadata.get('key'))
- return (
-
- )
- })}
- >
- )
- }
+
+ )}
+
+ )
+}
+CategorySection.propTypes = {
+ ticket: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ category_id: PropTypes.string.isRequired,
+ category_path: PropTypes.array.isRequired,
+ }),
+ isCustomerService: PropTypes.bool,
}
-TicketMetadata.propTypes = {
- isCustomerService: PropTypes.bool.isRequired,
- ticket: PropTypes.object.isRequired,
- categoriesTree: PropTypes.array.isRequired,
- updateTicketAssignee: PropTypes.func.isRequired,
- updateTicketCategory: PropTypes.func.isRequired,
- saveTag: PropTypes.func.isRequired,
- t: PropTypes.func.isRequired,
+function CustomMetadata({ metadata }) {
+ const { t } = useTranslation()
+ const comments = getConfig('ticket.metadata.customMetadata.comments', {})
+ const filteredMetadata = useMemo(() => {
+ return Object.entries(metadata).reduce((arr, [key, value]) => {
+ if (typeof value === 'string' || typeof value === 'number') {
+ arr.push([key, value])
+ }
+ return arr
+ }, [])
+ }, [metadata])
+
+ return (
+ filteredMetadata.length > 0 && (
+
+ {t('details')}
+ {filteredMetadata.map(([key, value]) => (
+
+ {comments[key] || key}:
+ {value}
+
+ ))}
+
+ )
+ )
+}
+CustomMetadata.propTypes = {
+ metadata: PropTypes.object.isRequired,
}
-TicketMetadata.contextTypes = {
- tagMetadatas: PropTypes.array,
+function TagSection({ ticket, isCustomerService }) {
+ const { addNotification, tagMetadatas } = useContext(AppContext)
+ const queryClient = useQueryClient()
+ const { mutateAsync, isLoading } = useMutation((data) => updateTicket(ticket.id, data), {
+ onSuccess: () => queryClient.invalidateQueries(['ticket', ticket.id]),
+ onError: (error) => addNotification(error),
+ })
+
+ const handleSaveTag = useCallback(
+ (key, value, isPrivate) => {
+ const tags = [...ticket[isPrivate ? 'private_tags' : 'tags']]
+ const index = tags.findIndex((tag) => tag.key === key)
+ if (index === -1) {
+ if (!value) {
+ return
+ }
+ tags.push({ key, value })
+ } else {
+ if (value) {
+ tags[index] = { ...tags[index], value }
+ } else {
+ tags.splice(index, 1)
+ }
+ }
+ return mutateAsync({ [isPrivate ? 'private_tags' : 'tags']: tags })
+ },
+ [ticket, mutateAsync]
+ )
+
+ return tagMetadatas.map((tagMetadata) => {
+ const tags = tagMetadata.data.isPrivate ? ticket.private_tags : ticket.tags
+ const tag = tags?.find((tag) => tag.key === tagMetadata.data.key)
+ return (
+
+ )
+ })
}
-export default withTranslation()(TicketMetadata)
+export function TicketMetadata({ ticket, isCustomerService }) {
+ return (
+ <>
+
+
+
+
+ {isCustomerService && }
+
+
+
+
+ >
+ )
+}
+TicketMetadata.propTypes = {
+ ticket: PropTypes.object.isRequired,
+ isCustomerService: PropTypes.bool,
+}
diff --git a/modules/Ticket/TicketOperation.js b/modules/Ticket/TicketOperation.js
index 73dd6048c..ce20d763e 100644
--- a/modules/Ticket/TicketOperation.js
+++ b/modules/Ticket/TicketOperation.js
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, { useState } from 'react'
import { Alert, Button, Form } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
@@ -7,16 +7,26 @@ import { TICKET_STATUS, ticketStatus } from '../../lib/common'
export function TicketOperation({ ticket, isCustomerService, onOperate }) {
const { t } = useTranslation()
+ const [operating, setOperating] = useState(false)
+
+ const operate = async (action) => {
+ setOperating(true)
+ try {
+ await onOperate(action)
+ } finally {
+ setOperating(false)
+ }
+ }
if (ticketStatus.isOpened(ticket.status)) {
return (
{t('ticketOperation')}
-
@@ -28,8 +38,10 @@ export function TicketOperation({ ticket, isCustomerService, onOperate }) {
{t('confirmResolved')}
- onOperate('resolve')}>{t('resolutionConfirmed')}{' '}
- onOperate('reopen')}>
+ operate('resolve')}>
+ {t('resolutionConfirmed')}
+ {' '}
+ operate('reopen')}>
{t('unresolved')}
@@ -40,7 +52,7 @@ export function TicketOperation({ ticket, isCustomerService, onOperate }) {
{t('ticketOperation')}
- onOperate('reopen')}>
+ operate('reopen')}>
{t('reopen')}
diff --git a/modules/Ticket/TicketReply.js b/modules/Ticket/TicketReply.js
index b1eeaeebc..151d84cec 100644
--- a/modules/Ticket/TicketReply.js
+++ b/modules/Ticket/TicketReply.js
@@ -1,139 +1,124 @@
-import React, { Component } from 'react'
+import React, { useCallback, useContext, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
import { Button, Form } from 'react-bootstrap'
-import { withTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
import * as Icon from 'react-bootstrap-icons'
import TextareaWithPreview from '../components/TextareaWithPreview'
import css from './index.css'
+import { useMutation } from 'react-query'
+import { fetch } from '../../lib/leancloud'
+import { uploadFiles } from '../common'
+import { AppContext } from '../context'
-class TicketReply extends Component {
- constructor(props) {
- super(props)
- this.state = {
- reply: localStorage.getItem(`ticket:${this.props.ticket.id}:reply`) || '',
- files: [],
- isCommitting: false,
- }
- this.fileInput = React.createRef()
- }
-
- handleReplyOnChange(value) {
- localStorage.setItem(`ticket:${this.props.ticket.id}:reply`, value)
- this.setState({ reply: value })
- }
+export function TicketReply({ ticket, isCustomerService, onCommitted, onOperate }) {
+ const { t } = useTranslation()
+ const { addNotification } = useContext(AppContext)
+ const storageKey = `ticket:${ticket.id}:reply`
+ const [content, setContent] = useState(localStorage.getItem(storageKey) ?? '')
+ const $fileInput = useRef()
+ const [operating, setOperating] = useState(false)
- handleReplyOnKeyDown(e) {
- if (e.keyCode == 13 && e.metaKey) {
- this.handleReplyCommit(e)
- }
- }
+ const setReplyContent = useCallback(
+ (content) => {
+ setContent(content)
+ if (content) {
+ localStorage.setItem(storageKey, content)
+ } else {
+ localStorage.removeItem(storageKey)
+ }
+ },
+ [storageKey]
+ )
- handleReplyCommit(e) {
- e.preventDefault()
- this.setState({ isCommitting: true })
- return this.props
- .commitReply(this.state.reply, this.fileInput.files)
- .then(() => {
- localStorage.removeItem(`ticket:${this.props.ticket.id}:reply`)
- this.setState({ reply: '' })
- this.fileInput.value = ''
- return
+ const { mutate: commit, isLoading: committing } = useMutation({
+ mutationFn: async ({ content, files }) => {
+ let file_ids = undefined
+ if (files?.length) {
+ file_ids = (await uploadFiles(files)).map((file) => file.id)
+ }
+ await fetch(`/api/1/tickets/${ticket.id}/replies`, {
+ method: 'POST',
+ body: { content, file_ids },
})
- .catch(this.context.addNotification)
- .finally(() => this.setState({ isCommitting: false }))
- }
+ },
+ onSuccess: (reply) => {
+ setReplyContent('')
+ $fileInput.current.value = ''
+ onCommitted?.(reply)
+ },
+ onError: (error) => addNotification(error),
+ })
- handleReplySoon(e) {
- e.preventDefault()
- this.setState({ isCommitting: true })
- return this.props
- .commitReplySoon()
- .catch(this.context.addNotification)
- .finally(() => this.setState({ isCommitting: false }))
- }
-
- handleReplyNoContent(e) {
- e.preventDefault()
- this.setState({ isCommitting: true })
- return this.props
- .operateTicket('replyWithNoContent')
- .catch(this.context.addNotification)
- .finally(() => this.setState({ isCommitting: false }))
+ const operate = async (action) => {
+ setOperating(true)
+ try {
+ await onOperate(action)
+ } finally {
+ setOperating(false)
+ }
}
- render() {
- const { t, isCustomerService } = this.props
- return (
-
-
-
+ return (
+
+ {
+ if (e.metaKey && e.keyCode == 13) {
+ commit({ content, files: $fileInput.current.files })
+ }
+ }}
+ />
+
-
- (this.fileInput = ref)} />
- {t('multipleAttachments')}
-
+
+
+ {t('multipleAttachments')}
+
-
-
-
- {isCustomerService && (
- <>
-
- {t('noNeedToReply')}
- {' '}
-
- {t('replyLater')}
- {' '}
- >
- )}
-
- {t('submit')}
-
-
-
-
- )
- }
+
+
+
+ {isCustomerService && (
+ <>
+ operate('replyWithNoContent')}
+ >
+ {t('noNeedToReply')}
+ {' '}
+ operate('replySoon')}>
+ {t('replyLater')}
+ {' '}
+ >
+ )}
+ commit({ content, files: $fileInput.current.files })}
+ >
+ {t('submit')}
+
+
+
+
+ )
}
-
TicketReply.propTypes = {
ticket: PropTypes.object.isRequired,
- commitReply: PropTypes.func.isRequired,
- commitReplySoon: PropTypes.func.isRequired,
- operateTicket: PropTypes.func.isRequired,
isCustomerService: PropTypes.bool,
- t: PropTypes.func.isRequired,
+ onCommitted: PropTypes.func,
+ onOperate: PropTypes.func,
}
-
-TicketReply.contextTypes = {
- addNotification: PropTypes.func.isRequired,
-}
-
-export default withTranslation()(TicketReply)
diff --git a/modules/Ticket/Time.js b/modules/Ticket/Time.js
new file mode 100644
index 000000000..3361d6a3a
--- /dev/null
+++ b/modules/Ticket/Time.js
@@ -0,0 +1,26 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import moment from 'moment'
+
+export function Time({ value, href }) {
+ if (!moment.isMoment(value)) {
+ value = moment(value)
+ }
+ if (moment().diff(value) > 86400000) {
+ return (
+
+ {value.calendar()}
+
+ )
+ } else {
+ return (
+
+ {value.fromNow()}
+
+ )
+ }
+}
+Time.propTypes = {
+ value: PropTypes.any.isRequired,
+ href: PropTypes.string,
+}
diff --git a/modules/Ticket/index.css b/modules/Ticket/index.css
index ec1e6aa8d..37dacaa85 100644
--- a/modules/Ticket/index.css
+++ b/modules/Ticket/index.css
@@ -61,16 +61,6 @@
margin-left: 0;
}
-.categoryBlock {
- font-size: inherit;
- display: inline-block;
- vertical-align: inherit;
- margin-left: 0;
- padding: 4px 8px;
- color: #777;
- border-color: #777;
-}
-
.submit {
min-width: 74px;
}
diff --git a/modules/Ticket/index.js b/modules/Ticket/index.js
index 48a938df0..e9c077f18 100644
--- a/modules/Ticket/index.js
+++ b/modules/Ticket/index.js
@@ -1,718 +1,376 @@
-import React, { Component } from 'react'
-import { Button, Card, Col, OverlayTrigger, Row, Tooltip } from 'react-bootstrap'
-import { useTranslation, withTranslation } from 'react-i18next'
-import { withRouter } from 'react-router-dom'
-import moment from 'moment'
-import _ from 'lodash'
-import xss from 'xss'
+import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'
+import { useHistory, useRouteMatch } from 'react-router'
+import { useTranslation } from 'react-i18next'
+import { useMutation, useQuery, useQueryClient } from 'react-query'
+import { Button, Col, OverlayTrigger, Row, Tooltip } from 'react-bootstrap'
import PropTypes from 'prop-types'
import * as Icon from 'react-bootstrap-icons'
+import moment from 'moment'
+import _ from 'lodash'
+
import css from './index.css'
-import { cloud, db, fetch } from '../../lib/leancloud'
-import { uploadFiles, getCategoryPathName, getCategoriesTree } from '../common'
import csCss from '../CustomerServiceTickets.css'
-import { ticketStatus } from '../../lib/common'
-import Evaluation from '../Evaluation'
-import TicketMetadata from './TicketMetadata'
-import TicketReply from './TicketReply'
-import { TicketStatusLabel } from '../components/TicketStatusLabel'
-import Tag from '../Tag'
+import { db, fetch } from '../../lib/leancloud'
+import { useTitle } from '../utils/hooks'
import { WeekendWarning } from '../components/WeekendWarning'
+import { AppContext } from '../context'
+import { TicketStatusLabel } from '../components/TicketStatusLabel'
import { UserLabel } from '../UserLabel'
-import { DocumentTitle } from '../utils/DocumentTitle'
+import { ReplyCard } from './ReplyCard'
+import { OpsLog } from './OpsLog'
+import { TicketMetadata } from './TicketMetadata'
import { TicketOperation } from './TicketOperation'
+import { ticketStatus } from '../../lib/common'
+import { TicketReply } from './TicketReply'
+import { Evaluation } from './Evaluation'
+import { LeanCloudApp } from './LeanCloudApp'
+
+function updateTicket(id, data) {
+ return fetch(`/api/1/tickets/${id}`, {
+ method: 'PATCH',
+ body: data,
+ })
+}
-// get a copy of default whiteList
-const whiteList = xss.getDefaultWhiteList()
-
-// allow class attribute for span and code tag
-whiteList.span.push('class')
-whiteList.code.push('class')
-
-// specified you custom whiteList
-const myxss = new xss.FilterXSS({
- whiteList,
- css: false,
-})
-
-function UserOrSystem({ operator }) {
- const { t } = useTranslation(0)
- if (!operator || operator.id === 'system') {
- return t('system')
- }
- return
+function fetchReplies(ticketId, cursor) {
+ return fetch(`/api/1/tickets/${ticketId}/replies`, {
+ query: {
+ q: cursor ? `created_at:>${cursor}` : undefined,
+ },
+ })
}
-UserOrSystem.propTypes = {
- operator: PropTypes.object,
+
+function fetchOpsLogs(ticketId, cursor) {
+ return fetch(`/api/1/tickets/${ticketId}/ops-logs`, {
+ query: {
+ q: cursor ? `created_at:>${cursor}` : undefined,
+ },
+ })
}
-class Ticket extends Component {
- constructor(props) {
- super(props)
- this.state = {
- categoriesTree: [],
- ticket: null,
- replies: [],
- opsLogs: [],
- watch: null,
- tags: [],
+function useTicket(nid) {
+ const { data: tickets, isLoading: loadingTickets, error: ticketsError } = useQuery(
+ ['tickets', { nid }],
+ () => fetch('/api/1/tickets', { query: { nid } })
+ )
+ const ticketId = tickets?.[0].id
+
+ const {
+ data: ticket,
+ isLoading: loadingTicket,
+ error: ticketError,
+ refetch: refetchTicket,
+ } = useQuery({
+ queryKey: ['ticket', ticketId],
+ queryFn: () => fetch(`/api/1/tickets/${ticketId}`),
+ enabled: !!ticketId,
+ })
+
+ const noTicketError = useMemo(() => {
+ if (!loadingTickets && ticketId) {
+ return new Error(`Ticket ${nid} not exists`)
}
+ }, [nid, loadingTickets, ticketId])
+
+ return {
+ ticket,
+ refetchTicket,
+ isLoading: loadingTickets || loadingTicket,
+ error: ticketsError || ticketError || noTicketError,
}
+}
- async componentDidMount() {
- const { nid } = this.props.match.params
- const tickets = await fetch(`/api/1/tickets`, { query: { q: 'nid:' + nid } })
- if (tickets.length === 0) {
- this.props.history.replace({
- pathname: '/error',
- state: { code: 'Unauthorized' },
- })
+/**
+ * @param {string} [ticketId]
+ */
+function useReplies(ticketId) {
+ const { addNotification } = useContext(AppContext)
+ const [replies, setReplies] = useState([])
+
+ const $ticketId = useRef(ticketId)
+ useEffect(() => {
+ $ticketId.current = ticketId
+ }, [ticketId])
+
+ const $cursor = useRef()
+ useEffect(() => {
+ $cursor.current = _.last(replies)?.created_at
+ }, [replies])
+
+ const $isLoading = useRef(false)
+ const $loadMoreReplies = useRef(async () => {
+ if (!$ticketId.current || $isLoading.current) {
return
}
- const ticket = await this.fetchTicket(tickets[0].id)
- const [tags, categoriesTree, replies, opsLogs] = await Promise.all([
- db.class('Tag').where('ticket', '==', db.class('Ticket').object(ticket.id)).find(),
- getCategoriesTree(false),
- this.fetchReplies(ticket.id),
- this.fetchOpsLogs(ticket.id),
- ])
- this.setState({ ticket, watch: ticket.subscribed, tags, categoriesTree, replies, opsLogs })
- this.subscribeReply(ticket.id)
- this.subscribeOpsLog(ticket.id)
- this.subscribeTicket(ticket.id)
- }
-
- componentWillUnmount() {
- if (this.replyLiveQuery) {
- Promise.all([
- this.ticketLiveQuery.unsubscribe(),
- this.replyLiveQuery.unsubscribe(),
- this.opsLogLiveQuery.unsubscribe(),
- ]).catch(this.context.addNotification)
+ $isLoading.current = true
+ try {
+ const newReplies = await fetchReplies($ticketId.current, $cursor.current)
+ setReplies((current) => current.concat(newReplies))
+ } catch (error) {
+ addNotification(error)
+ } finally {
+ $isLoading.current = false
}
- }
-
- getTicketQuery(nid) {
- const query = db
- .class('Ticket')
- .where('nid', '==', nid)
- .include('author')
- .include('organization')
- .include('assignee')
- .include('files')
- .limit(1)
- query
- .subscribe()
- .then((liveQuery) => {
- this.ticketLiveQuery = liveQuery
- return this.ticketLiveQuery.on('update', (ticket) => {
- if (ticket.updatedAt.getTime() != this.state.ticket.updatedAt.getTime()) {
- return Promise.all([
- ticket.get({ include: ['author', 'organization', 'assignee', 'files'] }),
- cloud.run('getPrivateTags', { ticketId: ticket.id }),
- ])
- .then(([ticket, privateTags]) => {
- if (privateTags) {
- ticket.data.privateTags = privateTags.privateTags
- }
- this.setState({ ticket })
- cloud.run('exploreTicket', { ticketId: ticket.id })
- return
- })
- .catch(this.context.addNotification)
- }
- })
- })
- .catch(this.context.addNotification)
- return query
- }
-
- async fetchTicket(ticketId) {
- return fetch(`/api/1/tickets/${ticketId}`)
- }
+ }, [])
- async subscribeTicket(ticketId) {
- const query = db.class('Ticket').where('objectId', '==', ticketId)
- const subscription = await query.subscribe()
- this.ticketLiveQuery = subscription
- subscription.on('update', async () => {
- const ticket = await this.fetchTicket(ticketId)
- this.setState({ ticket })
- })
- }
-
- async fetchReplies(ticketId, after) {
- const replies = await fetch(`/api/1/tickets/${ticketId}/replies`, {
- query: {
- q: after ? `created_at:>${after}` : undefined,
- },
- })
- return replies
- }
-
- async subscribeReply(ticketId) {
- const query = db.class('Reply').where('ticket', '==', db.class('Ticket').object(ticketId))
- const subscription = await query.subscribe()
- this.replyLiveQuery = subscription
- subscription.on('create', async () => {
- const replies = await this.fetchReplies(ticketId, _.last(this.state.replies)?.created_at)
- this.setState((state) => ({
- replies: _.uniqBy(state.replies.concat(replies), 'id'),
- }))
- })
- }
-
- async fetchOpsLogs(ticketId, after) {
- const opsLogs = await fetch(`/api/1/tickets/${ticketId}/ops-logs`, {
- query: { after },
- })
- const userIds = new Set()
- const categoryIds = new Set()
- opsLogs.forEach((opsLog) => {
- if (opsLog.assignee_id) {
- userIds.add(opsLog.assignee_id)
- }
- if (opsLog.operator_id && opsLog.operator_id !== 'system') {
- userIds.add(opsLog.operator_id)
- }
- if (opsLog.category_id) {
- categoryIds.add(opsLog.category_id)
- }
- })
- const [users, categories] = await Promise.all([
- userIds.size
- ? fetch(`/api/1/users`, { query: { q: `id:${Array.from(userIds).join(',')}` } })
- : [],
- categoryIds.size
- ? fetch(`/api/1/categories`, { query: { q: `id:${Array.from(categoryIds).join(',')}` } })
- : [],
- ])
- opsLogs.forEach((opsLog) => {
- opsLog.type = 'opsLog'
- if (opsLog.assignee_id) {
- opsLog.assignee = users.find((user) => user.id === opsLog.assignee_id)
- }
- if (opsLog.operator_id && opsLog.operator_id !== 'system') {
- opsLog.operator = users.find((user) => user.id === opsLog.operator_id)
- }
- if (opsLog.category_id) {
- opsLog.category = categories.find((category) => category.id === opsLog.category_id)
- }
- })
- return opsLogs
- }
-
- async subscribeOpsLog(ticketId) {
- const query = db.class('OpsLog').where('ticket', '==', db.class('Ticket').object(ticketId))
- const subscription = await query.subscribe()
- this.opsLogLiveQuery = subscription
- subscription.on('create', async () => {
- const opsLogs = await this.fetchOpsLogs(ticketId, _.last(this.state.opsLogs)?.created_at)
- this.setState((state) => ({
- opsLogs: _.uniqBy(state.opsLogs.concat(opsLogs), 'id'),
- }))
- })
- }
-
- getReplyQuery(ticket) {
- const replyQuery = db
- .class('Reply')
- .where('ticket', '==', ticket)
- .include('author')
- .include('files')
- .orderBy('createdAt')
- .limit(500)
- replyQuery
- .subscribe()
- .then((liveQuery) => {
- this.replyLiveQuery = liveQuery
- return this.replyLiveQuery.on('create', (reply) => {
- return this.appendReply(reply).catch(this.context.addNotification)
- })
- })
- .catch(this.context.addNotification)
- return replyQuery
- }
-
- getOpsLogQuery(ticket) {
- const opsLogQuery = db.class('OpsLog').where('ticket', '==', ticket).orderBy('createdAt')
- opsLogQuery
- .subscribe()
- .then((liveQuery) => {
- this.opsLogLiveQuery = liveQuery
- return this.opsLogLiveQuery.on('create', (opsLog) => {
- return opsLog
- .get()
- .then((opsLog) => {
- const opsLogs = this.state.opsLogs
- opsLogs.push(opsLog)
- this.setState({ opsLogs })
- return
- })
- .catch(this.context.addNotification)
- })
- })
- .catch(this.context.addNotification)
- return opsLogQuery
- }
+ useEffect(() => {
+ if (ticketId) {
+ setReplies([])
+ $loadMoreReplies.current()
+ }
+ }, [ticketId])
- async appendReply(reply) {
- reply = await reply.get({ include: ['author', 'files'] })
- this.setState(({ replies }) => {
- return {
- replies: _.uniqBy([...replies, reply], (r) => r.id),
- }
- })
- }
+ return { replies, loadMoreReplies: $loadMoreReplies.current }
+}
- async commitReply(content, files) {
- content = content.trim()
- if (content === '' && files.length === 0) {
+/**
+ * @param {string} [ticketId]
+ */
+function useOpsLogs(ticketId) {
+ const { addNotification } = useContext(AppContext)
+ const [opsLogs, setOpsLogs] = useState([])
+
+ const $ticketId = useRef(ticketId)
+ useEffect(() => {
+ $ticketId.current = ticketId
+ }, [ticketId])
+
+ const $cursor = useRef()
+ useEffect(() => {
+ $cursor.current = _.last(opsLogs)?.created_at
+ }, [opsLogs])
+
+ const $isLoading = useRef(false)
+ const $loadMoreOpsLogs = useRef(async () => {
+ if (!$ticketId.current || $isLoading.current) {
return
}
+ $isLoading.current = true
try {
- const uploadedFiles = await uploadFiles(files)
- await fetch(`/api/1/tickets/${this.state.ticket.id}/replies`, {
- method: 'POST',
- body: {
- content,
- file_ids: uploadedFiles.map((file) => file.id),
- },
- })
+ const newOpsLogs = await fetchOpsLogs($ticketId.current, $cursor.current)
+ setOpsLogs((current) => current.concat(newOpsLogs))
} catch (error) {
- this.context.addNotification(error)
+ addNotification(error)
+ } finally {
+ $isLoading.current = false
}
- }
-
- commitReplySoon() {
- return this.operateTicket('replySoon')
- }
+ }, [])
- async operateTicket(action) {
- const ticket = this.state.ticket
- try {
- await fetch(`/api/1/tickets/${ticket.id}/operate`, {
- method: 'POST',
- body: { action },
- })
- } catch (error) {
- this.context.addNotification(error)
+ useEffect(() => {
+ if (ticketId) {
+ setOpsLogs([])
+ $loadMoreOpsLogs.current()
}
- }
+ }, [ticketId])
- updateTicketCategory(category) {
- const ticket = this.state.ticket
- return fetch(`/api/1/tickets/${ticket.id}`, {
- method: 'PATCH',
- body: { category_id: category.id },
- })
- }
+ return { opsLogs, loadMoreOpsLogs: $loadMoreOpsLogs.current }
+}
- updateTicketAssignee(assignee) {
- const ticket = this.state.ticket
- return fetch(`/api/1/tickets/${ticket.id}`, {
- method: 'PATCH',
- body: { assignee_id: assignee.id },
- })
- }
+/**
+ * @param {string} [ticketId]
+ */
+function useTimeline(ticketId) {
+ const { replies, loadMoreReplies } = useReplies(ticketId)
+ const { opsLogs, loadMoreOpsLogs } = useOpsLogs(ticketId)
+ const timeline = useMemo(() => {
+ return [
+ ...replies.map((reply) => ({ ...reply, type: 'reply' })),
+ ...opsLogs.map((opsLog) => ({ ...opsLog, type: 'opsLog' })),
+ ].sort((a, b) => (a.created_at > b.created_at ? 1 : -1))
+ }, [replies, opsLogs])
+ return { timeline, loadMoreReplies, loadMoreOpsLogs }
+}
- async saveTag(key, value, isPrivate) {
- const ticket = this.state.ticket
- let tags = ticket[isPrivate ? 'private_tags' : 'tags']
- if (!tags) {
- tags = []
- }
- const tag = _.find(tags, { key })
- if (!tag) {
- if (value == '') {
- return
- }
- tags.push({ key, value })
- } else {
- if (value == '') {
- tags = _.reject(tags, { key })
- } else {
- tag.value = value
- }
- }
- try {
- await fetch(`/api/1/tickets/${ticket.id}`, {
- method: 'PATCH',
- body: {
- [isPrivate ? 'private_tags' : 'tags']: tags,
- },
- })
- } catch (error) {
- this.context.addNotification(error)
+function TicketInfo({ ticket, isCustomerService }) {
+ const { t } = useTranslation()
+ const { addNotification } = useContext(AppContext)
+ const createdAt = useMemo(() => moment(ticket.created_at), [ticket.created_at])
+ const updatedAt = useMemo(() => moment(ticket.updated_at), [ticket.updated_at])
+ const queryClient = useQueryClient()
+
+ const { mutate: updateSubscribed, isLoading: updatingSubscribed } = useMutation(
+ (subscribed) => updateTicket(ticket.id, { subscribed }),
+ {
+ onSuccess: () => queryClient.invalidateQueries(['ticket', ticket.id]),
+ onError: (error) => addNotification(error),
}
- }
+ )
+
+ return (
+
+ #{ticket.nid}
+
+
+ {t('createdAt')}{' '}
+ {createdAt.fromNow()}
+ {createdAt.fromNow() !== updatedAt.fromNow() && (
+ <>
+ {`, ${t('updatedAt')} `}
+ {updatedAt.fromNow()}
+ >
+ )}
+
+ {isCustomerService && (
+
+ {ticket.subscribed ? t('clickToUnsubscribe') : t('clickToSubscribe')}
+
+ }
+ >
+ updateSubscribed(!ticket.subscribed)}
+ >
+ {ticket.subscribed ? : }
+
+
+ )}
+
+ )
+}
+TicketInfo.propTypes = {
+ ticket: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ nid: PropTypes.number.isRequired,
+ status: PropTypes.number.isRequired,
+ author: PropTypes.object.isRequired,
+ subscribed: PropTypes.bool.isRequired,
+ created_at: PropTypes.string.isRequired,
+ updated_at: PropTypes.string.isRequired,
+ }),
+ isCustomerService: PropTypes.bool,
+}
- saveEvaluation(evaluation) {
- const ticket = this.state.ticket
- return fetch(`/api/1/tickets/${ticket.id}`, {
- method: 'PATCH',
- body: { evaluation },
- })
+function Timeline({ data }) {
+ switch (data.type) {
+ case 'opsLog':
+ return
+ case 'reply':
+ return
}
+}
+Timeline.propTypes = {
+ data: PropTypes.object.isRequired,
+}
- async handleAddWatch() {
- try {
- await fetch(`/api/1/tickets/${this.state.ticket.id}`, {
- method: 'PATCH',
- body: { subscribed: true },
- })
- this.setState({ watch: true })
- } catch (error) {
- this.context.addNotification(error)
+export default function Ticket() {
+ const {
+ params: { nid },
+ } = useRouteMatch()
+ const { t } = useTranslation()
+ const { addNotification, currentUser, isCustomerService } = useContext(AppContext)
+ const history = useHistory()
+ const { ticket, isLoading: loadingTicket, refetchTicket, error } = useTicket(nid)
+ const { timeline, loadMoreReplies, loadMoreOpsLogs } = useTimeline(ticket?.id)
+ useTitle(ticket?.title)
+ const $onTicketUpdate = useRef()
+
+ $onTicketUpdate.current = (obj, updatedKeys) => {
+ if (obj.updatedAt.toISOString() !== ticket?.updated_at) {
+ refetchTicket()
}
- }
-
- async handleRemoveWatch() {
- try {
- await fetch(`/api/1/tickets/${this.state.ticket.id}`, {
- method: 'PATCH',
- body: { subscribed: false },
- })
- this.setState({ watch: false })
- } catch (error) {
- this.context.addNotification(error)
+ const keySet = new Set(updatedKeys)
+ if (keySet.has('latestReply')) {
+ loadMoreReplies()
}
- }
-
- contentView(content) {
- return
- }
-
- getTime(avObj) {
- if (moment().diff(avObj.created_at) > 86400000) {
- return (
-
- {moment(avObj.created_at).calendar()}
-
- )
- } else {
- return (
-
- {moment(avObj.created_at).fromNow()}
-
- )
+ if (keySet.has('assignee') || keySet.has('category') || keySet.has('status')) {
+ loadMoreOpsLogs()
}
}
- ticketTimeline(avObj) {
- const { t } = this.props
- if (avObj.type === 'opsLog') {
- switch (avObj.action) {
- case 'selectAssignee':
- return (
-
-
-
-
-
-
-
- {t('system')} {t('assignedTicketTo')} (
- {this.getTime(avObj)})
-
-
- )
- case 'changeCategory':
- return (
-
-
-
-
-
-
-
- {t('changedTicketCategoryTo')}{' '}
-
- {getCategoryPathName(avObj.category, this.state.categoriesTree)}
- {' '}
- ({this.getTime(avObj)})
-
-
- )
- case 'changeAssignee':
- return (
-
-
-
-
-
-
-
- {t('changedTicketAssigneeTo')}{' '}
- ({this.getTime(avObj)})
-
-
- )
- case 'replyWithNoContent':
- return (
-
-
-
-
-
-
-
- {t('thoughtNoNeedToReply')} (
- {this.getTime(avObj)})
-
-
- )
- case 'replySoon':
- return (
-
-
-
-
-
-
-
- {t('thoughtNeedTime')} ({this.getTime(avObj)})
-
-
- )
- case 'resolve':
- return (
-
-
-
-
-
-
-
- {t('thoughtResolved')} ({this.getTime(avObj)})
-
-
- )
- case 'close':
- case 'reject': // 向前兼容
- return (
-
-
-
-
-
-
-
- {t('closedTicket')} ({this.getTime(avObj)})
-
-
- )
- case 'reopen':
- return (
-
-
-
-
-
-
-
- {t('reopenedTicket')} ({this.getTime(avObj)})
-
-
- )
- }
- } else {
- let panelFooter = null
- let imgBody =
- const files = avObj.files
- if (files && files.length !== 0) {
- const imgFiles = []
- const otherFiles = []
- files.forEach((f) => {
- if (['image/png', 'image/jpeg', 'image/gif'].indexOf(f.mime) != -1) {
- imgFiles.push(f)
- } else {
- otherFiles.push(f)
- }
- })
-
- if (imgFiles.length > 0) {
- imgBody = imgFiles.map((f) => {
- return (
-
-
-
- )
- })
- }
-
- if (otherFiles.length > 0) {
- const fileLinks = otherFiles.map((f) => {
- return (
-
-
- {f.name}
- {' '}
-
- )
- })
- panelFooter = {fileLinks}
- }
- }
- const userLabel = avObj.is_customer_service ? (
-
-
- {t('staff')}
-
- ) : (
-
- )
- return (
-
-
- {userLabel} {t('submittedAt')} {this.getTime(avObj)}
-
-
- {this.contentView(avObj.content_HTML)}
- {imgBody}
-
- {panelFooter}
-
- )
+ useEffect(() => {
+ if (!ticket?.id) {
+ return
}
- }
-
- render() {
- const { t } = this.props
- const ticket = this.state.ticket
- if (ticket === null) {
- return {t('loading')}……
+ const query = db.class('Ticket').where('objectId', '==', ticket.id)
+ let subscription = null
+ query
+ .subscribe()
+ .then((subs) => {
+ subscription = subs
+ subs.on('update', (...args) => $onTicketUpdate.current(...args))
+ return
+ })
+ .catch(console.error)
+ return () => {
+ subscription?.unsubscribe()
}
+ }, [ticket?.id])
- // 如果是客服自己提交工单,则当前客服在该工单中认为是用户,
- // 这是为了方便工单作为内部工作协调使用。
- const isCustomerService =
- this.props.isCustomerService && ticket.author_id !== this.props.currentUser.id
- const timeline = _.chain(this.state.replies)
- .concat(this.state.opsLogs)
- .sortBy((data) => data.created_at)
- .map(this.ticketTimeline.bind(this))
- .value()
-
- return (
- <>
-
-
-
- {!isCustomerService && }
- {ticket.title}
-
-
#{ticket.nid}
-
{' '}
-
- {t('createdAt')}{' '}
-
- {moment(ticket.created_at).fromNow()}
-
- {moment(ticket.created_at).fromNow() === moment(ticket.updated_at).fromNow() || (
-
- , {t('updatedAt')}{' '}
-
- {moment(ticket.updated_at).fromNow()}
-
-
- )}
- {' '}
- {this.props.isCustomerService ? (
- this.state.watch ? (
-
{t('clickToUnsubscribe')}}
- >
-
-
-
-
- ) : (
-
{t('clickToSubscribe')}}
- >
-
-
-
-
- )
- ) : (
-
- )}
-
-
-
-
-
-
-
-
- {this.ticketTimeline(ticket)}
-
{timeline}
-
-
-
-
- {ticketStatus.isOpened(ticket.status) ? (
-
- ) : (
-
- )}
-
-
-
-
- {this.state.tags.map((tag) => (
-
- ))}
+ const { mutateAsync: operateTicket } = useMutation({
+ mutationFn: (action) =>
+ fetch(`/api/1/tickets/${ticket.id}/operate`, {
+ method: 'POST',
+ body: { action },
+ }),
+ onSuccess: () => refetchTicket(),
+ onError: (error) => addNotification(error),
+ })
-
-
-
-
-
- >
- )
- }
-}
+ const isCsInThisTicket = isCustomerService && ticket?.author_id !== currentUser.id
-Ticket.propTypes = {
- history: PropTypes.object.isRequired,
- match: PropTypes.object.isRequired,
- currentUser: PropTypes.object,
- isCustomerService: PropTypes.bool,
- t: PropTypes.func.isRequired,
-}
+ if (loadingTicket) {
+ return {t('loading') + '...'}
+ }
+ if (error) {
+ history.replace({
+ pathname: '/error',
+ state: { code: 'Unauthorized' },
+ })
+ return null
+ }
+ return (
+ <>
+
+ {!isCsInThisTicket && }
+
{ticket.title}
+
+
+
+
+
+
+
+
+ {timeline.map((data) => (
+
+ ))}
+
-Ticket.contextTypes = {
- addNotification: PropTypes.func.isRequired,
+
+
+ {ticketStatus.isOpened(ticket.status) ? (
+ loadMoreReplies()}
+ onOperate={operateTicket}
+ />
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
}
-
-export default withTranslation()(withRouter(Ticket))
diff --git a/modules/context/index.js b/modules/context/index.js
index 7317e488e..43fd8a13a 100644
--- a/modules/context/index.js
+++ b/modules/context/index.js
@@ -2,6 +2,7 @@ import React from 'react'
import _ from 'lodash'
export const AppContext = React.createContext({
+ currentUser: null,
isCustomerService: false,
tagMetadatas: [],
addNotification: _.noop,
diff --git a/modules/utils/DocumentTitle.js b/modules/utils/DocumentTitle.js
index 883bcbfd5..902e68f5c 100644
--- a/modules/utils/DocumentTitle.js
+++ b/modules/utils/DocumentTitle.js
@@ -1,4 +1,4 @@
-import { useTitle } from 'react-use'
+import { useTitle } from './hooks'
export function DocumentTitle({ title }) {
useTitle(title)
diff --git a/modules/utils/hooks.js b/modules/utils/hooks.js
index dfeef6940..814969376 100644
--- a/modules/utils/hooks.js
+++ b/modules/utils/hooks.js
@@ -1,7 +1,17 @@
import { useMemo, useCallback } from 'react'
import { useHistory, useLocation } from 'react-router-dom'
+import * as ReactUse from 'react-use'
import _ from 'lodash'
+/**
+ * @param {string} [title]
+ */
+export function useTitle(title) {
+ ReactUse.useTitle(title === undefined ? 'LeanTicket' : `${title} - LeanTicket`, {
+ restoreOnUnmount: true,
+ })
+}
+
export const useApplyChanges = () => {
const history = useHistory()
const location = useLocation()
diff --git a/package-lock.json b/package-lock.json
index d3cd6a748..d45f89433 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2681,6 +2681,11 @@
"tweetnacl": "^0.14.3"
}
},
+ "big-integer": {
+ "version": "1.6.48",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.48.tgz",
+ "integrity": "sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w=="
+ },
"big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@@ -2910,6 +2915,43 @@
}
}
},
+ "broadcast-channel": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.5.3.tgz",
+ "integrity": "sha512-OLOXfwReZa2AAAh9yOUyiALB3YxBe0QpThwwuyRHLgpl8bSznSDmV6Mz7LeBJg1VZsMcDcNMy7B53w12qHrIhQ==",
+ "requires": {
+ "@babel/runtime": "^7.7.2",
+ "detect-node": "^2.0.4",
+ "js-sha3": "0.8.0",
+ "microseconds": "0.2.0",
+ "nano-time": "1.0.0",
+ "rimraf": "3.0.2",
+ "unload": "2.2.0"
+ },
+ "dependencies": {
+ "@babel/runtime": {
+ "version": "7.14.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.0.tgz",
+ "integrity": "sha512-JELkvo/DlpNdJ7dlyw/eY7E0suy5i5GQH+Vlxaq1nsNJ+H7f4Vtv3jMeCEgRhZZQFXTjldYfQgv2qmM6M1v5wA==",
+ "requires": {
+ "regenerator-runtime": "^0.13.4"
+ }
+ },
+ "regenerator-runtime": {
+ "version": "0.13.7",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
+ "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
+ },
+ "rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ }
+ }
+ },
"browserslist": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.1.tgz",
@@ -4753,8 +4795,7 @@
"detect-node": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.0.4.tgz",
- "integrity": "sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==",
- "dev": true
+ "integrity": "sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw=="
},
"digest-header": {
"version": "0.0.1",
@@ -7727,6 +7768,11 @@
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
"integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ=="
},
+ "js-sha3": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
+ "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="
+ },
"js-tokens": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
@@ -8184,6 +8230,30 @@
"object-visit": "^1.0.0"
}
},
+ "match-sorter": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.0.tgz",
+ "integrity": "sha512-efYOf/wUpNb8FgNY+cOD2EIJI1S5I7YPKsw0LBp7wqPh5pmMS6i/wr3ZWwfwrAw1NvqTA2KUReVRWDX84lUcOQ==",
+ "requires": {
+ "@babel/runtime": "^7.12.5",
+ "remove-accents": "0.4.2"
+ },
+ "dependencies": {
+ "@babel/runtime": {
+ "version": "7.14.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.0.tgz",
+ "integrity": "sha512-JELkvo/DlpNdJ7dlyw/eY7E0suy5i5GQH+Vlxaq1nsNJ+H7f4Vtv3jMeCEgRhZZQFXTjldYfQgv2qmM6M1v5wA==",
+ "requires": {
+ "regenerator-runtime": "^0.13.4"
+ }
+ },
+ "regenerator-runtime": {
+ "version": "0.13.7",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
+ "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
+ }
+ }
+ },
"md5": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz",
@@ -8283,6 +8353,11 @@
}
}
},
+ "microseconds": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
+ "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA=="
+ },
"migrate": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/migrate/-/migrate-1.6.2.tgz",
@@ -8561,6 +8636,14 @@
}
}
},
+ "nano-time": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
+ "integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=",
+ "requires": {
+ "big-integer": "^1.6.16"
+ }
+ },
"nanoid": {
"version": "3.1.22",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz",
@@ -12810,6 +12893,31 @@
}
}
},
+ "react-query": {
+ "version": "3.16.0",
+ "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.16.0.tgz",
+ "integrity": "sha512-YOvI8mO9WG+r4XsyJinjlDMiV5IewUWUcTv2J7z6bIP3KOFvgT6k6HM8vQouz4hPnme7Ktq9j5e7LarUqgJXFQ==",
+ "requires": {
+ "@babel/runtime": "^7.5.5",
+ "broadcast-channel": "^3.4.1",
+ "match-sorter": "^6.0.2"
+ },
+ "dependencies": {
+ "@babel/runtime": {
+ "version": "7.14.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.0.tgz",
+ "integrity": "sha512-JELkvo/DlpNdJ7dlyw/eY7E0suy5i5GQH+Vlxaq1nsNJ+H7f4Vtv3jMeCEgRhZZQFXTjldYfQgv2qmM6M1v5wA==",
+ "requires": {
+ "regenerator-runtime": "^0.13.4"
+ }
+ },
+ "regenerator-runtime": {
+ "version": "0.13.7",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
+ "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
+ }
+ }
+ },
"react-router": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz",
@@ -13399,6 +13507,11 @@
"autolinker": "~0.28.0"
}
},
+ "remove-accents": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
+ "integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U="
+ },
"remove-trailing-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@@ -15538,6 +15651,30 @@
"crypto-random-string": "^1.0.0"
}
},
+ "unload": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
+ "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
+ "requires": {
+ "@babel/runtime": "^7.6.2",
+ "detect-node": "^2.0.4"
+ },
+ "dependencies": {
+ "@babel/runtime": {
+ "version": "7.14.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.0.tgz",
+ "integrity": "sha512-JELkvo/DlpNdJ7dlyw/eY7E0suy5i5GQH+Vlxaq1nsNJ+H7f4Vtv3jMeCEgRhZZQFXTjldYfQgv2qmM6M1v5wA==",
+ "requires": {
+ "regenerator-runtime": "^0.13.4"
+ }
+ },
+ "regenerator-runtime": {
+ "version": "0.13.7",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
+ "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
+ }
+ }
+ },
"unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
diff --git a/package.json b/package.json
index 4c3d6e17b..aa7e381e5 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,7 @@
"react-dom": "^16.14.0",
"react-i18next": "^11.8.7",
"react-notification-system": "^0.2.14",
+ "react-query": "^3.16.0",
"react-router-dom": "^5.2.0",
"react-use": "^17.2.4",
"remarkable": "^1.7.2",