diff --git a/api/customerService/api.js b/api/customerService/api.js new file mode 100644 index 000000000..f7d877d03 --- /dev/null +++ b/api/customerService/api.js @@ -0,0 +1,31 @@ +const AV = require('leancloud-storage') +const { Router } = require('express') + +const { requireAuth, catchError, customerServiceOnly } = require('../middleware') + +const router = Router().use(requireAuth, customerServiceOnly) + +router.get( + '/', + catchError(async (req, res) => { + const role = await new AV.Query(AV.Role).equalTo('name', 'customerService').first() + if (!role) { + res.throw(500, 'Missing customer services role') + } + const customerServices = await role.getUsers().query().find({ useMasterKey: true }) + res.json( + customerServices.map((user) => { + return { + id: user.id, + nid: user.get('nid'), + email: user.get('email') || '', + username: user.get('username'), + name: user.get('name') || '', + category_ids: user.get('categories')?.map((c) => c.objectId) || [], + } + }) + ) + }) +) + +module.exports = router diff --git a/api/index.js b/api/index.js index d1d3ef9be..e83940a6b 100644 --- a/api/index.js +++ b/api/index.js @@ -41,6 +41,7 @@ apiRouter.use('/ticket-fields', require('./TicketField')) apiRouter.use('/tickets', require('./ticket/api')) apiRouter.use('/users', require('./user/api')) apiRouter.use('/categories', require('./category/api')) +apiRouter.use('/customer-services', require('./customerService/api')) apiRouter.get('/debug/search', parseSearchingQ, (req, res) => { res.json({ q: req.q, query: req.query }) }) diff --git a/index.js b/index.js index c2406f688..3a6268a0e 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,24 @@ import React from 'react' import { render } from 'react-dom' import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from 'react-query' + import App from './modules/App' import './config.webapp' +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, +}) + render( - + + + , document.getElementById('app') ) diff --git a/modules/About.js b/modules/About.js index 8179e557e..09a91183d 100644 --- a/modules/About.js +++ b/modules/About.js @@ -1,10 +1,11 @@ import React from 'react' import { useTranslation } from 'react-i18next' -import { useTitle } from 'react-use' + +import { useTitle } from './utils/hooks' export default function About() { const { t } = useTranslation() - useTitle(`${t('about')} - LeanTicket`) + useTitle(t('about')) return (
diff --git a/modules/App.js b/modules/App.js index b078d87c0..8709b2eda 100644 --- a/modules/App.js +++ b/modules/App.js @@ -190,6 +190,7 @@ class App extends Component { {!this.state.loading && ( { - localStorage.removeItem(`ticket:${this.props.ticket.id}:evaluation`) - return - }) - .catch(this.context.addNotification) - } - - render() { - const { - t, - ticket: { evaluation }, - } = this.props - if (evaluation) { - return ( - - {t('feedback')} - - } - /> - } - /> - - - - - - ) - } - - if (!this.props.isCustomerService) { - return ( - - {t('satisfiedOrNot')} -
- - } - /> - } - /> - - - - - -
-
- ) - } - - return null - } -} - -Evaluation.propTypes = { - ticket: PropTypes.object.isRequired, - isCustomerService: PropTypes.bool, - saveEvaluation: PropTypes.func.isRequired, - t: PropTypes.func, -} - -Evaluation.contextTypes = { - addNotification: PropTypes.func.isRequired, -} - -export default withTranslation()(Evaluation) diff --git a/modules/NotFound.js b/modules/NotFound.js index 8f9957487..200383785 100644 --- a/modules/NotFound.js +++ b/modules/NotFound.js @@ -1,11 +1,12 @@ /*global SUPPORT_EMAIL*/ import React from 'react' import { useTranslation } from 'react-i18next' -import { useTitle } from 'react-use' + +import { useTitle } from './utils/hooks' export default function NotFound() { const { t } = useTranslation() - useTitle('404 - LeanTicket') + useTitle('404') return (
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')}{' '} + + +
+ {imageFiles.map(({ id, name, url }) => ( + + {name} + + ))} + + {otherFiles.length > 0 && ( + + {otherFiles.map(({ id, name, url }) => ( +
+ { + 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')}
- {' '} - {' '} +
@@ -40,7 +52,7 @@ export function TicketOperation({ ticket, isCustomerService, onOperate }) { {t('ticketOperation')}
-
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 && ( - <> - {' '} - {' '} - - )} - -
-
-
- ) - } + + +
+ {isCustomerService && ( + <> + {' '} + {' '} + + )} + +
+
+ + ) } - 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')} + + } + > + + + )} +
+ ) +} +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 ( - - {f.name} - - ) - }) - } - - if (otherFiles.length > 0) { - const fileLinks = otherFiles.map((f) => { - return ( - - - ) - }) - 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",