diff --git a/api/User.js b/api/User.js
index 0725393ba..343f3f4e5 100644
--- a/api/User.js
+++ b/api/User.js
@@ -32,17 +32,15 @@ setTimeout(() => {
AV.Cloud.define('getUserInfo', async (req) => {
const username = req.params.username
- if (!username) {
- throw new AV.Cloud.Error('The username must be provided', { status: 400 })
+ if (typeof username !== 'string') {
+ throw new AV.Cloud.Error('The username must be a string', { status: 400 })
}
-
const user = await new AV.Query(AV.User)
.equalTo('username', username)
.first({ useMasterKey: true })
if (!user) {
- throw new AV.Cloud.Error('Not Found', { status: 404 })
+ return null
}
-
return {
...(await getTinyUserInfo(user)),
tags: user.get('tags'),
diff --git a/lib/common.js b/lib/common.js
index ac8a021e1..914d98bb3 100755
--- a/lib/common.js
+++ b/lib/common.js
@@ -3,6 +3,14 @@ const _ = require('lodash')
const moment = require('moment')
exports.TIME_RANGE_MAP = {
+ today: {
+ get starts() {
+ return moment().startOf('day').toDate()
+ },
+ get ends() {
+ return moment().endOf('day').toDate()
+ },
+ },
thisMonth: {
starts: moment().startOf('month').toDate(),
ends: moment().endOf('month').toDate(),
@@ -176,9 +184,16 @@ exports.depthFirstSearchFind = (array, fn) => {
}
exports.getTinyCategoryInfo = (category) => {
- return {
- objectId: category.id,
- name: category.get('name'),
+ if (typeof category.get === 'function') {
+ return {
+ objectId: category.id,
+ name: category.get('name'),
+ }
+ } else {
+ return {
+ objectId: category.objectId,
+ name: category.name,
+ }
}
}
diff --git a/modules/App.js b/modules/App.js
index e6ecfe20e..ac1446ef6 100644
--- a/modules/App.js
+++ b/modules/App.js
@@ -10,6 +10,7 @@ import i18next from 'i18next'
import './i18n'
import { auth, db } from '../lib/leancloud'
+import { AppContext } from './context'
import { isCustomerService } from './common'
import GlobalNav from './GlobalNav'
import css from './App.css'
@@ -37,6 +38,7 @@ class App extends Component {
constructor(props) {
super(props)
this.state = {
+ loading: true,
currentUser: auth.currentUser,
isCustomerService: false,
organizations: [],
@@ -65,28 +67,17 @@ class App extends Component {
componentDidMount() {
this._notificationSystem = this.refs.notificationSystem
const user = this.state.currentUser
- if (!user) {
- return
+ if (user) {
+ return user
+ .get()
+ .then((user) => this.refreshGlobalInfo(user))
+ .catch((err) => this.addNotification(err))
}
-
- return user
- .get()
- .then((user) => {
- return this.refreshGlobalInfo(user)
- })
- .catch((err) => {
- this.refreshGlobalInfo()
- this.addNotification(err)
- })
+ return this.refreshGlobalInfo()
}
fetchTagMetadatas() {
- return db
- .class('TagMetadata')
- .find()
- .then((tagMetadatas) => {
- return tagMetadatas
- })
+ return db.class('TagMetadata').find()
}
refreshTagMetadatas() {
@@ -98,9 +89,10 @@ class App extends Component {
})
}
- refreshGlobalInfo(currentUser) {
+ async refreshGlobalInfo(currentUser) {
if (!currentUser) {
this.setState({
+ loading: false,
currentUser: null,
isCustomerService: false,
organizations: [],
@@ -110,23 +102,26 @@ class App extends Component {
return
}
- return Promise.all([
- isCustomerService(currentUser),
- db.class('Organization').include('memberRole').find(),
- this.fetchTagMetadatas(),
- ]).then(([isCustomerService, organizations, tagMetadatas]) => {
+ this.setState({ loading: true })
+ try {
+ const [isCS, organizations, tagMetadatas] = await Promise.all([
+ isCustomerService(currentUser),
+ db.class('Organization').include('memberRole').find(),
+ this.fetchTagMetadatas(),
+ ])
this.setState({
currentUser,
- isCustomerService,
organizations,
tagMetadatas,
+ isCustomerService: isCS,
})
Raven.setUserContext({
username: currentUser.get('username'),
id: currentUser.id,
})
- return
- })
+ } finally {
+ this.setState({ loading: false })
+ }
}
onLogin(user) {
@@ -189,52 +184,58 @@ class App extends Component {
}
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ <>
+ {!this.state.loading && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
-
+ >
)
}
}
@@ -281,9 +282,7 @@ class ServerNotification extends Component {
}
updateLiveQuery() {
- if (this.messageLiveQuery) {
- this.messageLiveQuery.unsubscribe()
- }
+ this.messageLiveQuery?.unsubscribe()
if (!this.props.currentUser) {
return
}
@@ -323,7 +322,7 @@ class ServerNotification extends Component {
}
componentWillUnmount() {
- return this.messageLiveQuery.unsubscribe()
+ this.messageLiveQuery?.unsubscribe()
}
notify({ title, body }) {
diff --git a/modules/CustomerService/Tickets.js b/modules/CustomerService/Tickets.js
new file mode 100644
index 000000000..829273b8c
--- /dev/null
+++ b/modules/CustomerService/Tickets.js
@@ -0,0 +1,708 @@
+import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import PropTypes from 'prop-types'
+import _ from 'lodash'
+import { Link, useHistory, useLocation } from 'react-router-dom'
+import {
+ Button,
+ ButtonGroup,
+ ButtonToolbar,
+ Checkbox,
+ DropdownButton,
+ Form,
+ FormControl,
+ FormGroup,
+ MenuItem,
+ Pager,
+} from 'react-bootstrap'
+import qs from 'query-string'
+import moment from 'moment'
+
+import { auth, cloud, db } from '../../lib/leancloud'
+import css from '../CustomerServiceTickets.css'
+
+import { getTinyCategoryInfo } from '../common'
+import {
+ TICKET_STATUS,
+ TICKET_STATUS_MSG,
+ TIME_RANGE_MAP,
+ getUserDisplayName,
+ ticketOpenedStatuses,
+ ticketClosedStatuses,
+ ticketStatus,
+} from '../../lib/common'
+import TicketStatusLabel from '../TicketStatusLabel'
+import { UserLabel } from '../UserLabel'
+import { AppContext } from '../context'
+import { useCustomerServices } from './useCustomerServices'
+import { CategoryManager, useCategories, getCategoryName } from '../category'
+import { useTitle } from '../utils/hooks'
+import { BlodSearchString } from '../components/BlodSearchString'
+import { DelayInputForm } from '../components/DelayInputForm'
+
+const PAGE_SIZE = 10
+
+function useTicketFilters() {
+ const history = useHistory()
+ const { search, pathname } = useLocation()
+ const filters = useMemo(() => qs.parse(search), [search])
+ const mergePath = useCallback(
+ (newFilters) => {
+ return pathname + '?' + qs.stringify({ ...filters, ...newFilters })
+ },
+ [pathname, filters]
+ )
+ const updatePath = useCallback(
+ (newFilters) => {
+ history.push(mergePath(newFilters))
+ },
+ [history, mergePath]
+ )
+ return { filters, mergePath, updatePath }
+}
+
+/**
+ * @param {object} props
+ * @param {Array} props.tickets
+ * @param {CategoryManager} props.categories
+ * @param {Set} [props.checkedTicketIds]
+ * @param {Function} [props.onCheckTicket]
+ */
+function TicketList({ tickets, categories, checkedTicketIds, onCheckTicket }) {
+ const { t } = useTranslation()
+ const { filters, mergePath } = useTicketFilters()
+
+ if (tickets.length === 0) {
+ return {t('notFound')}
+ }
+ return tickets.map((ticket) => {
+ const createdAt = moment(ticket.createdAt).fromNow()
+ const updatedAt = moment(ticket.updatedAt).fromNow()
+
+ const contributors = _.uniqBy(ticket.joinedCustomerServices || [], 'objectId')
+
+ return (
+
+
onCheckTicket?.(ticket)}
+ />
+
+
+
+
+ {ticket.title}
+
+ {categories.getNodes(ticket.category.objectId).map((c) => (
+
+ {getCategoryName(c)}
+
+ ))}
+ {filters.isOpen === 'false' && (
+
+ {ticket.evaluation &&
+ (ticket.evaluation.star === 1 ? (
+ {t('satisfied')}
+ ) : (
+
+ {t('unsatisfied')}
+
+ ))}
+
+ )}
+
+
+ {ticket.replyCount && (
+
+
+ {ticket.replyCount}
+
+ )}
+
+
+
+
+
+ #{ticket.nid}
+
+
+
+
+
+
+
+ {' '}
+ {t('createdAt')} {createdAt}
+ {createdAt !== updatedAt && (
+
+ {' '}
+ {t('updatedAt')} {updatedAt}
+
+ )}
+
+
+
+
+
+
+ {contributors.map((user) => (
+
+
+
+ ))}
+
+
+
+ {filters.searchString && (
+ <>
+
+ {ticket.replies?.map((r) => (
+
+ ))}
+ >
+ )}
+
+
+ )
+ })
+}
+TicketList.propTypes = {
+ tickets: PropTypes.array.isRequired,
+ categories: PropTypes.instanceOf(CategoryManager).isRequired,
+ checkedTicketIds: PropTypes.instanceOf(Set),
+ onCheckTicket: PropTypes.func,
+ displayEvaluation: PropTypes.bool,
+}
+
+function getIndentString(depth) {
+ return depth === 0 ? '' : ' '.repeat(depth) + '└ '
+}
+
+/**
+ *
+ * @param {object} props
+ * @param {object} props.customerService
+ * @param {CategoryManager} props.categories
+ */
+function TicketMenu({ customerServices, categories }) {
+ const { t } = useTranslation()
+ const { filters, updatePath } = useTicketFilters()
+ const { tagMetadatas, addNotification } = useContext(AppContext)
+ const [authorFilterValidationState, setAuthorFilterValidationState] = useState(null)
+ const [authorUsername, setAuthorUsername] = useState('')
+
+ const {
+ searchString,
+ isOpen,
+ status,
+ authorId,
+ assignee,
+ categoryId,
+ tagKey,
+ tagValue,
+ timeRange,
+ isOnlyUnlike,
+ } = filters
+ const assigneeId = assignee === 'me' ? auth.currentUser?.id : assignee
+
+ let statusTitle
+ if (status) {
+ statusTitle = t(TICKET_STATUS_MSG[status])
+ } else if (isOpen === 'true') {
+ statusTitle = t('incompleted')
+ } else if (isOpen === 'false') {
+ statusTitle = t('completed')
+ } else {
+ statusTitle = t('all')
+ }
+
+ let assigneeTitle
+ if (assignee) {
+ const customerService = customerServices.find((cs) => cs.objectId === assigneeId)
+ if (customerService) {
+ assigneeTitle = getUserDisplayName(customerService)
+ } else {
+ assigneeTitle = `assignee ${t('invalid')}`
+ }
+ } else {
+ assigneeTitle = t('all')
+ }
+
+ let categoryTitle
+ if (categoryId) {
+ const category = categories.get(categoryId)
+ if (category) {
+ categoryTitle = getCategoryName(category)
+ } else {
+ categoryTitle = `categoryId ${t('invalid')}`
+ }
+ } else {
+ categoryTitle = t('all')
+ }
+
+ const assignedToMe = assigneeId === auth.currentUser?.id
+
+ const handleChangeAuthorUsername = (e) => {
+ const username = e.target.value
+ setAuthorFilterValidationState(null)
+ setAuthorUsername(username)
+ }
+
+ const [debouncedUsername, setDebouncedUsername] = useState('')
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedUsername(authorUsername.trim())
+ }, 500)
+ return () => clearTimeout(timer)
+ }, [authorUsername])
+
+ useEffect(() => {
+ if (!debouncedUsername) {
+ if (authorId) {
+ updatePath({ authorId: undefined })
+ }
+ return
+ }
+ cloud
+ .run('getUserInfo', { username: debouncedUsername })
+ .then((user) => {
+ if (!user) {
+ setAuthorFilterValidationState('error')
+ } else {
+ setAuthorFilterValidationState('success')
+ updatePath({ authorId: user.objectId })
+ }
+ return
+ })
+ .catch(addNotification)
+ }, [debouncedUsername, authorId, updatePath])
+
+ return (
+
+ )
+}
+TicketMenu.propTypes = {
+ customerServices: PropTypes.array.isRequired,
+ categories: PropTypes.instanceOf(CategoryManager).isRequired,
+}
+
+/**
+ * @param {object} props
+ * @param {CategoryManager} props.categories
+ */
+function BatchOperationMenu({ categories, onChangeCategory, onBatchOperate }) {
+ const { t } = useTranslation()
+ const { filters } = useTicketFilters()
+
+ return (
+
+
+
+ {categories.map((c, depth) => (
+
+ ))}
+
+ {filters.isOpen === 'true' && (
+
+
+
+ )}
+
+
+ )
+}
+BatchOperationMenu.propTypes = {
+ categories: PropTypes.instanceOf(CategoryManager).isRequired,
+ onChangeCategory: PropTypes.func.isRequired,
+ onBatchOperate: PropTypes.func.isRequired,
+}
+
+/**
+ * @param {object} filter
+ * @param {Array} [filter.statuses]
+ * @param {string} [filter.assigneeId]
+ * @param {string} [filter.authorId]
+ */
+export function useTickets() {
+ const [tickets, setTickets] = useState([])
+ const { tagMetadatas } = useContext(AppContext)
+ const { filters } = useTicketFilters()
+
+ const findTickets = useCallback(async () => {
+ const {
+ timeRange,
+ isOpen,
+ status,
+ assignee,
+ authorId,
+ categoryId,
+ tagKey,
+ tagValue,
+ isOnlyUnlike,
+ page = '0',
+ searchString,
+ } = filters
+
+ let query = db.class('Ticket')
+
+ if (timeRange && timeRange in TIME_RANGE_MAP) {
+ const { starts, ends } = TIME_RANGE_MAP[timeRange]
+ query = query.where('createdAt', '>=', starts).where('createdAt', '<', ends)
+ }
+
+ if (isOpen === 'true') {
+ query = query.where('status', 'in', ticketOpenedStatuses()).orderBy('status')
+ } else if (isOpen === 'false') {
+ query = query.where('status', 'in', ticketClosedStatuses())
+ } else if (status) {
+ query = query.where('status', '==', parseInt(status))
+ }
+
+ if (assignee) {
+ const assigneeId = assignee === 'me' ? auth.currentUser?.id : assignee
+ query = query.where('assignee', '==', db.class('_User').object(assigneeId))
+ }
+
+ if (authorId) {
+ query = query.where('author', '==', db.class('_User').object(authorId))
+ }
+
+ if (categoryId) {
+ query = query.where('category.objectId', '==', categoryId)
+ }
+
+ if (tagKey) {
+ const tagMetadata = tagMetadatas.find((m) => m.data.key === tagKey)
+ if (tagMetadata) {
+ const columnName = tagMetadata.data.isPrivate ? 'privateTags' : 'tags'
+ if (tagValue) {
+ query = query.where(columnName, '==', { key: tagKey, value: tagValue })
+ } else {
+ query = query.where(columnName + '.key', '==', tagKey)
+ }
+ }
+ }
+
+ if (isOnlyUnlike === 'true') {
+ query = query.where('evaluation.star', '==', 0)
+ }
+
+ const trimedSearchString = searchString?.trim()
+ if (trimedSearchString) {
+ const { data: tickets } = await db
+ .search('Ticket')
+ .queryString(`title:${searchString} OR content:${searchString}`)
+ .orderBy('latestReply.updatedAt', 'desc')
+ .limit(1000)
+ .find()
+ const searchMatchedTicketIds = tickets.map((t) => t.id)
+ if (searchMatchedTicketIds.length === 0) {
+ setTickets([])
+ return
+ }
+ query = query.where('objectId', 'in', searchMatchedTicketIds)
+ }
+
+ const ticketObjects = await query
+ .include('author', 'assignee')
+ .limit(PAGE_SIZE)
+ .skip(parseInt(page) * PAGE_SIZE)
+ .orderBy('latestReply.updatedAt', 'desc')
+ .orderBy('updatedAt', 'desc')
+ .find()
+ setTickets(ticketObjects.map((t) => t.toJSON()))
+ }, [filters])
+
+ useEffect(() => {
+ findTickets()
+ }, [filters])
+
+ return { tickets, reload: findTickets }
+}
+
+function TicketPager({ noMore }) {
+ const { t } = useTranslation()
+ const { filters, updatePath } = useTicketFilters()
+ const { page = '0' } = filters
+ const isFirstPage = page === '0'
+
+ if (isFirstPage && noMore) {
+ return null
+ }
+ return (
+
+ updatePath({ page: parseInt(page) - 1 })}
+ >
+ ← {t('previousPage')}
+
+ updatePath({ page: parseInt(page) + 1 })}>
+ {t('nextPage')} →
+
+
+ )
+}
+TicketPager.propTypes = {
+ noMore: PropTypes.bool,
+}
+
+export default function CustomerServiceTickets() {
+ const { t } = useTranslation()
+ useTitle(`${t('customerServiceTickets')} - LeanTicket`)
+ const { addNotification } = useContext(AppContext)
+ const [checkedTickets, setCheckedTickets] = useState(new Set())
+ const customerServices = useCustomerServices()
+ const categories = useCategories()
+ const { tickets, reload } = useTickets()
+
+ const handleCheckAll = (e) => {
+ if (e.target.checked) {
+ setCheckedTickets(new Set(tickets.map((t) => t.objectId)))
+ } else {
+ setCheckedTickets(new Set())
+ }
+ }
+
+ const handleCheckTicket = useCallback(({ objectId }) => {
+ setCheckedTickets((currentCheckedTickets) => {
+ const nextCheckedTickets = new Set(currentCheckedTickets)
+ if (checkedTickets.has(objectId)) {
+ nextCheckedTickets.delete(objectId)
+ } else {
+ nextCheckedTickets.add(objectId)
+ }
+ return nextCheckedTickets
+ })
+ }, [])
+
+ const handleChangeCategory = (categoryId) => {
+ const category = getTinyCategoryInfo(categories.get(categoryId))
+ const p = db.pipeline()
+ tickets
+ .filter((t) => checkedTickets.has(t.objectId))
+ .forEach((t) => p.update('Ticket', t.objectId, { category }))
+ p.commit()
+ .then(() => {
+ setCheckedTickets(new Set())
+ reload()
+ return
+ })
+ .catch(addNotification)
+ }
+
+ const handleBatchOperation = (operation) => {
+ if (operation === 'close') {
+ const ticketIds = tickets
+ .filter((t) => checkedTickets.has(t.objectId) && ticketStatus.isOpened(t.status))
+ .map((t) => t.objectId)
+ cloud
+ .run('operateTicket', { ticketId: ticketIds, action: 'close' })
+ .then(() => {
+ setCheckedTickets(new Set())
+ reload()
+ return
+ })
+ .catch(addNotification)
+ }
+ }
+
+ return (
+
+
+
+ {checkedTickets.size ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/modules/CustomerService.js b/modules/CustomerService/index.js
similarity index 71%
rename from modules/CustomerService.js
rename to modules/CustomerService/index.js
index 5602779ac..f9a00c982 100644
--- a/modules/CustomerService.js
+++ b/modules/CustomerService/index.js
@@ -1,9 +1,9 @@
import React from 'react'
import { Route, Switch, useRouteMatch } from 'react-router-dom'
-import CSTickets from './CustomerServiceTickets'
-import CSStats from './CustomerServiceStats'
-import CSStatsUser from './CustomerServiceStats/User'
+import CSTickets from './Tickets'
+import CSStats from '../CustomerServiceStats'
+import CSStatsUser from '../CustomerServiceStats/User'
export default function CustomerService(props) {
const { path } = useRouteMatch()
@@ -11,7 +11,7 @@ export default function CustomerService(props) {
return (
-
+
diff --git a/modules/CustomerService/useCustomerServices.js b/modules/CustomerService/useCustomerServices.js
new file mode 100644
index 000000000..a74f93676
--- /dev/null
+++ b/modules/CustomerService/useCustomerServices.js
@@ -0,0 +1,20 @@
+import { useEffect, useState } from 'react'
+import { auth } from '../../lib/leancloud'
+
+let getRoleTask
+export async function getCustomerServices() {
+ if (!getRoleTask) {
+ getRoleTask = auth.queryRole().where('name', '==', 'customerService').first()
+ }
+ const role = await getRoleTask
+ const users = await role.queryUser().orderBy('username').find()
+ return users.map((u) => u.toJSON())
+}
+
+export function useCustomerServices() {
+ const [customerServices, setCustomerServices] = useState([])
+ useEffect(() => {
+ getCustomerServices().then(setCustomerServices).catch(console.error)
+ }, [])
+ return customerServices
+}
diff --git a/modules/CustomerServiceTickets.js b/modules/CustomerServiceTickets.js
deleted file mode 100755
index 13725900d..000000000
--- a/modules/CustomerServiceTickets.js
+++ /dev/null
@@ -1,783 +0,0 @@
-import React, { Component } from 'react'
-import { withTranslation } from 'react-i18next'
-import PropTypes from 'prop-types'
-import _ from 'lodash'
-import { Link, withRouter } from 'react-router-dom'
-import {
- Button,
- ButtonGroup,
- ButtonToolbar,
- Checkbox,
- DropdownButton,
- Form,
- FormControl,
- FormGroup,
- MenuItem,
- Pager,
-} from 'react-bootstrap'
-import qs from 'query-string'
-import moment from 'moment'
-import { auth, cloud, db } from '../lib/leancloud'
-import css from './CustomerServiceTickets.css'
-
-import {
- depthFirstSearchFind,
- depthFirstSearchMap,
- getCustomerServices,
- getCategoriesTree,
- getNodeIndentString,
- getNodePath,
- getTinyCategoryInfo,
- getCategoryName,
-} from './common'
-import {
- TICKET_STATUS,
- TICKET_STATUS_MSG,
- TIME_RANGE_MAP,
- getUserDisplayName,
- ticketOpenedStatuses,
- ticketClosedStatuses,
- isTicketOpen,
-} from '../lib/common'
-import TicketStatusLabel from './TicketStatusLabel'
-import { UserLabel } from './UserLabel'
-import { DocumentTitle } from './utils/DocumentTitle'
-
-let authorSearchTimeoutId
-class CustomerServiceTickets extends Component {
- constructor(props) {
- super(props)
- this.state = {
- tickets: [],
- customerServices: [],
- categoriesTree: [],
- checkedTickets: new Set(),
- }
- }
-
- getFilters() {
- return qs.parse(this.props.location.search)
- }
-
- componentDidMount() {
- const filters = this.getFilters()
- const { authorId } = filters
- Promise.all([
- getCustomerServices(),
- getCategoriesTree(false),
- authorId && db.class('_User').object(authorId).get(),
- ])
- .then(([customerServices, categoriesTree, author]) => {
- this.setState({
- customerServices,
- categoriesTree,
- authorUsername: author && author.get('username'),
- })
- return this.findTickets(filters)
- })
- .catch(this.context.addNotification)
- }
-
- componentDidUpdate(prevProps) {
- if (prevProps.location.search !== this.props.location.search) {
- this.findTickets(this.getFilters()).catch(this.context.addNotification)
- }
- }
-
- findTickets(filters) {
- if (_.keys(filters).length === 0) {
- this.updateFilter({
- assigneeId: auth.currentUser.id,
- isOpen: 'true',
- })
- return Promise.resolve()
- }
-
- const {
- assigneeId,
- isOpen,
- status,
- categoryId,
- authorId,
- tagKey,
- tagValue,
- isOnlyUnlike,
- searchString,
- page = '0',
- size = '10',
- timeRange,
- } = filters
- let query = db.class('Ticket')
-
- let statuses = []
- if (isOpen === 'true') {
- statuses = ticketOpenedStatuses()
- query = query.orderBy('status')
- } else if (isOpen === 'false') {
- statuses = ticketClosedStatuses()
- } else if (status) {
- statuses = [parseInt(status)]
- }
-
- if (timeRange) {
- query = query
- .where('createdAt', '>=', TIME_RANGE_MAP[timeRange].starts)
- .where('createdAt', '<', TIME_RANGE_MAP[timeRange].ends)
- }
-
- if (statuses.length !== 0) {
- query = query.where('status', 'in', statuses)
- }
-
- if (assigneeId) {
- query = query.where('assignee', '==', db.class('_User').object(assigneeId))
- }
-
- if (authorId) {
- query = query.where('author', '==', db.class('_User').object(authorId))
- }
-
- if (categoryId) {
- query = query.where('category.objectId', '==', categoryId)
- }
-
- if (tagKey) {
- const tagMetadata = _.find(this.context.tagMetadatas, (m) => m.get('key') == tagKey)
- if (tagMetadata) {
- const columnName = tagMetadata.get('isPrivate') ? 'privateTags' : 'tags'
- if (tagValue) {
- query = query.where(columnName, '==', { key: tagKey, value: tagValue })
- } else {
- query = query.where(columnName + '.key', '==', tagKey)
- }
- }
- }
-
- if (JSON.parse(isOnlyUnlike || false)) {
- query = query.where('evaluation.star', '==', 0)
- }
-
- return Promise.resolve()
- .then(() => {
- if (searchString && searchString.trim().length > 0) {
- return Promise.all([
- db
- .search('Ticket')
- .queryString(`title:${searchString} OR content:${searchString}`)
- .orderBy('latestReply.updatedAt', 'desc')
- .limit(1000)
- .find()
- .then(({ data: tickets }) => {
- return tickets.map((t) => t.id)
- }),
- db
- .search('Reply')
- .queryString(`content:${searchString}`)
- .orderBy('latestReply.updatedAt', 'desc')
- .limit(1000)
- .find()
- .then((result) => result.data),
- ])
- }
- return [[], []]
- })
- .then(([searchMatchedTicketIds, searchMatchedReplaies]) => {
- if (searchMatchedTicketIds.length + searchMatchedReplaies.length > 0) {
- const ticketIds = _.union(
- searchMatchedTicketIds,
- searchMatchedReplaies.map((r) => r.get('ticket').id)
- )
- query = query.where('objectId', 'in', ticketIds)
- }
- return query
- .include('author')
- .include('assignee')
- .limit(parseInt(size))
- .skip(parseInt(page) * parseInt(size))
- .orderBy('latestReply.updatedAt', 'desc')
- .orderBy('updatedAt', 'desc')
- .find()
- .then((tickets) => {
- tickets.forEach((t) => {
- t.replies = _.filter(searchMatchedReplaies, (r) => r.get('ticket').id == t.id)
- })
- this.setState({ tickets })
- return
- })
- })
- }
-
- updateFilter(filter) {
- if (!filter.page && !filter.size) {
- filter.page = '0'
- filter.size = '10'
- }
- this.props.history.push(this.getQueryUrl(filter))
- }
-
- getQueryUrl(filter) {
- const filters = _.assign({}, this.getFilters(), filter)
- return this.props.location.pathname + '?' + qs.stringify(filters)
- }
-
- handleAuthorChange(e) {
- const username = e.target.value
- this.setState({
- authorFilterValidationState: null,
- authorUsername: username,
- })
-
- if (authorSearchTimeoutId) {
- clearTimeout(authorSearchTimeoutId)
- }
- authorSearchTimeoutId = setTimeout(() => {
- if (username.trim() === '') {
- this.setState({ authorFilterValidationState: null })
- const filters = _.assign({}, this.getFilters(), { authorId: null })
- return this.updateFilter(filters)
- }
-
- cloud
- .run('getUserInfo', { username })
- .then((user) => {
- authorSearchTimeoutId = null
- if (!user) {
- this.setState({ authorFilterValidationState: 'error' })
- return
- } else {
- this.setState({ authorFilterValidationState: 'success' })
- const filters = _.assign({}, this.getFilters(), { authorId: user.objectId })
- return this.updateFilter(filters)
- }
- })
- .catch(this.context.addNotification)
- }, 500)
- }
-
- handleUnlikeChange(e) {
- this.updateFilter({ isOnlyUnlike: e.target.checked })
- }
-
- handleClickCheckbox(e) {
- const checkedTickets = this.state.checkedTickets
- if (e.target.checked) {
- checkedTickets.add(e.target.value)
- } else {
- checkedTickets.delete(e.target.value)
- }
- this.setState({ checkedTickets })
- }
-
- handleClickCheckAll(e) {
- if (e.target.checked) {
- this.setState({ checkedTickets: new Set(this.state.tickets.map((t) => t.id)) })
- } else {
- this.setState({ checkedTickets: new Set() })
- }
- }
-
- handleChangeCategory(categoryId) {
- const tickets = _.filter(this.state.tickets, (t) => this.state.checkedTickets.has(t.id))
- const category = getTinyCategoryInfo(
- depthFirstSearchFind(this.state.categoriesTree, (c) => c.id == categoryId)
- )
- const p = db.pipeline()
- tickets.forEach((t) => p.update(t, { category }))
- p.commit()
- .then(() => {
- this.setState({ checkedTickets: new Set() })
- this.updateFilter({})
- return
- })
- .catch(this.context.addNotification)
- }
-
- async handleBatchOperation(operation) {
- if (operation === 'close') {
- const ticketIds = this.state.tickets
- .filter((t) => this.state.checkedTickets.has(t.id) && isTicketOpen(t))
- .map((t) => t.id)
- if (ticketIds.length === 0) {
- return
- }
- try {
- await cloud.run('operateTicket', { ticketId: ticketIds, action: 'close' })
- this.setState({ checkedTickets: new Set() })
- await this.findTickets(this.getFilters())
- } catch (error) {
- this.context.addNotification(error)
- }
- }
- }
-
- render() {
- const { t } = this.props
- const filters = this.getFilters()
- const tickets = this.state.tickets
- const ticketTrs = tickets.map((ticket) => {
- const contributors = _.uniqBy(ticket.data.joinedCustomerServices || [], 'objectId')
- const category = depthFirstSearchFind(
- this.state.categoriesTree,
- (c) => c.id == ticket.data.category.objectId
- )
-
- return (
-
-
-
-
-
-
- {ticket.get('title')}
-
- {getNodePath(category).map((c) => {
- return (
-
- {getCategoryName(c)}
-
- )
- })}
- {filters.isOpen === 'true' || (
-
- {ticket.get('evaluation') &&
- ((ticket.get('evaluation').star === 1 && (
- {t('satisfied')}
- )) || (
-
- {t('unsatisfied')}
-
- ))}
-
- )}
-
-
- {ticket.get('replyCount') && (
-
-
- {ticket.get('replyCount')}
-
- )}
-
-
-
-
-
- #{ticket.get('nid')}
-
-
-
-
-
-
-
- {' '}
- {t('createdAt')} {moment(ticket.get('createdAt')).fromNow()}
- {moment(ticket.get('createdAt')).fromNow() ===
- moment(ticket.get('updatedAt')).fromNow() || (
-
- {' '}
- {t('updatedAt')} {moment(ticket.get('updatedAt')).fromNow()}
-
- )}
-
-
-
-
-
-
- {contributors.map((user) => (
-
-
-
- ))}
-
-
-
-
- {ticket.replies.map((r) => (
-
- ))}
-
-
- )
- })
-
- const statusMenuItems = _.keys(TICKET_STATUS).map((key) => {
- const value = TICKET_STATUS[key]
- return (
-
- )
- })
- const assigneeMenuItems = this.state.customerServices.map((user) => (
-
- ))
- const categoryMenuItems = depthFirstSearchMap(this.state.categoriesTree, (c) => (
-
- ))
-
- let statusTitle
- if (filters.status) {
- statusTitle = t(TICKET_STATUS_MSG[filters.status])
- } else if (filters.isOpen === 'true') {
- statusTitle = t('incompleted')
- } else if (filters.isOpen === 'false') {
- statusTitle = t('completed')
- } else {
- statusTitle = t('all')
- }
-
- let assigneeTitle
- if (filters.assigneeId) {
- const assignee = this.state.customerServices.find((user) => user.id === filters.assigneeId)
- if (assignee) {
- assigneeTitle = getUserDisplayName(assignee)
- } else {
- assigneeTitle = `assigneeId ${t('invalid')}`
- }
- } else {
- assigneeTitle = t('all')
- }
-
- let categoryTitle
- if (filters.categoryId) {
- const category = depthFirstSearchFind(
- this.state.categoriesTree,
- (c) => c.id === filters.categoryId
- )
- if (category) {
- categoryTitle = getCategoryName(category)
- } else {
- categoryTitle = `categoryId ${t('invalid')}`
- }
- } else {
- categoryTitle = t('all')
- }
-
- const assignedToMe = auth.currentUser?.id === filters.assigneeId
- const ticketAdminFilters = (
-
- )
-
- const ticketCheckedOperations = (
-
-
-
- {categoryMenuItems}
-
- {filters.isOpen === 'true' && (
-
-
-
- )}
-
-
- )
-
- if (ticketTrs.length === 0) {
- ticketTrs.push(
-
- {t('notFound')}
-
- )
- }
-
- let pager
- const isFirstPage = filters.page === '0'
- const isLastPage = parseInt(filters.size) !== this.state.tickets.length
- if (!(isFirstPage && isLastPage)) {
- pager = (
-
- this.updateFilter({ page: parseInt(filters.page) - 1 + '' })}
- >
- ← {t('previousPage')}
-
- this.updateFilter({ page: parseInt(filters.page) + 1 + '' })}
- >
- {t('nextPage')} →
-
-
- )
- }
-
- return (
-
-
-
-
- {this.state.checkedTickets.size ? ticketCheckedOperations : ticketAdminFilters}
-
-
- {ticketTrs}
- {pager}
-
- )
- }
-}
-
-CustomerServiceTickets.propTypes = {
- history: PropTypes.object.isRequired,
- location: PropTypes.object.isRequired,
- t: PropTypes.func,
-}
-
-CustomerServiceTickets.contextTypes = {
- addNotification: PropTypes.func.isRequired,
- tagMetadatas: PropTypes.array,
-}
-
-class DelayInputForm extends Component {
- constructor(props) {
- super(props)
- this.state = {
- value: props.value || '',
- timeoutId: null,
- }
- }
-
- handleChange(e) {
- const value = e.target.value
- if (this.state.timeoutId) {
- clearTimeout(this.state.timeoutId)
- }
- const timeoutId = setTimeout(() => {
- this.props.onChange(this.state.value)
- }, this.props.delay || 1000)
- this.setState({ value, timeoutId })
- }
-
- render() {
- return (
-
- )
- }
-}
-
-DelayInputForm.propTypes = {
- onChange: PropTypes.func.isRequired,
- value: PropTypes.string,
- delay: PropTypes.number,
- placeholder: PropTypes.string,
-}
-
-const BlodSearchString = ({ content, searchString }) => {
- if (!searchString || !content.includes(searchString)) {
- return
- }
-
- const aroundLength = 40
- const index = content.indexOf(searchString)
-
- let before = content.slice(0, index)
- if (before.length > aroundLength) {
- before = '...' + before.slice(aroundLength)
- }
- let after = content.slice(index + searchString.length)
- if (after.length > aroundLength) {
- after = after.slice(0, aroundLength) + '...'
- }
- return (
-
- {before}
- {searchString}
- {after}
-
- )
-}
-
-BlodSearchString.propTypes = {
- content: PropTypes.string.isRequired,
- searchString: PropTypes.string,
-}
-
-export default withTranslation()(withRouter(CustomerServiceTickets))
diff --git a/modules/Error.js b/modules/Error.js
index 3ea1d8bcd..fdc5c88b1 100644
--- a/modules/Error.js
+++ b/modules/Error.js
@@ -1,4 +1,4 @@
-/*global SUPPORT_EMAIL*/
+/* global SUPPORT_EMAIL */
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router'
@@ -13,7 +13,7 @@ export default function Error() {
{t('errorPage')}
{t('noErrorMessage')}
- {SUPPORT_EMAIL && (
+ {typeof SUPPORT_EMAIL === 'string' && (
{t('contactUs')} {SUPPORT_EMAIL}
@@ -39,7 +39,7 @@ export default function Error() {
{t('somethingWrong')}
{message}
- {SUPPORT_EMAIL && (
+ {typeof SUPPORT_EMAIL === 'string' && (
{t('contactUs')} {SUPPORT_EMAIL}
diff --git a/modules/GlobalNav.js b/modules/GlobalNav.js
index 8b0700cd7..0e884ce18 100644
--- a/modules/GlobalNav.js
+++ b/modules/GlobalNav.js
@@ -1,157 +1,165 @@
-import React, { Component } from 'react'
-import { withTranslation } from 'react-i18next'
-import { Link, withRouter } from 'react-router-dom'
+import React, { useContext } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Link, useHistory } from 'react-router-dom'
import i18next from 'i18next'
import PropTypes from 'prop-types'
-class GlobalNav extends Component {
- handleNewTicketClick() {
- this.props.history.push('/tickets/new')
- }
+import { getUserDisplayName } from '../lib/common'
+import { getConfig } from './config'
+import { AppContext } from './context'
- handleLanguageSwitch(lang) {
- i18next.changeLanguage(lang)
- window.localStorage.setItem('locale', lang)
- }
+function LanguageSelector({ onChange }) {
+ /* eslint-disable i18n/no-chinese-character */
+ return (
+
+
+ EN/中
+
+
+
+ )
+ /* eslint-enable i18n/no-chinese-character */
+}
+LanguageSelector.propTypes = {
+ onChange: PropTypes.func.isRequired,
+}
- render() {
- const { t } = this.props
- /* eslint-disable i18n/no-chinese-character */
- const langSelector = (
-
-
- EN/中
-
-
+function UserDropdown({ user, onLogout }) {
+ const { t } = useTranslation()
+
+ if (!user) {
+ return (
+
+ {t('login')}
)
- /* eslint-enable i18n/no-chinese-character */
- let user
- if (this.props.currentUser) {
- user = (
-
-
- {this.props.currentUser.get('name')}
-
-
+ }
+ return (
+
+
+ {getUserDisplayName(user)}
+
+
+ -
+ {t('settings')}
- )
- } else {
- user = (
-
- {t('login')}
+ {t('logout')}
- )
- }
+
+
+ )
+}
+UserDropdown.propTypes = {
+ user: PropTypes.object,
+ onLogout: PropTypes.func.isRequired,
+}
- return (
-