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 ( +
+ + updatePath({ searchString: searchString || undefined })} + /> + {' '} + + + + + + updatePath({ status: eventKey, isOpen: undefined })} + > + {t('all')} + {Object.values(TICKET_STATUS).map((value) => ( + + {t(TICKET_STATUS_MSG[value])} + + ))} + + + + + updatePath({ assignee })} + > + {t('all')} + {customerServices.map((user) => ( + + {getUserDisplayName(user)} + + ))} + + + + updatePath({ categoryId })} + > + {t('all')} + {categories.map((c, depth) => ( + + {getIndentString(depth) + getCategoryName(c)} + + ))} + + + + updatePath({ tagKey, tagValue: undefined })} + > + {t('all')} + {tagMetadatas.map((tagMetadata) => { + const key = tagMetadata.get('key') + return ( + + {key} + + ) + })} + + {tagKey && ( + updatePath({ tagValue })} + > + {t('allTagValues')} + {tagMetadatas + .find((m) => m.data.key === tagKey) + .data.values.map((value) => ( + + {value} + + ))} + + )} + + + updatePath({ timeRange })} + > + {t('allTime')} + + {t('today')} + + + {t('thisMonth')} + + + {t('lastMonth')} + + + {t('monthBeforeLast')} + + + + + {' '} + + + {' '} + {isOpen === 'false' && ( + + updatePath({ isOnlyUnlike: e.target.checked ? 'true' : undefined })} + > + {t('badReviewsOnly')} + + + )} +
+ ) +} +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) => ( + + {getIndentString(depth) + c.name} + + ))} + + {filters.isOpen === 'true' && ( + + {t('close')} + + )} + + + ) +} +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 ( - - {t(TICKET_STATUS_MSG[value])} - - ) - }) - const assigneeMenuItems = this.state.customerServices.map((user) => ( - - {getUserDisplayName(user)} - - )) - const categoryMenuItems = depthFirstSearchMap(this.state.categoriesTree, (c) => ( - - {getNodeIndentString(c) + getCategoryName(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 = ( -
- - this.updateFilter({ searchString: value })} - /> - - {' '} - - - - - - this.updateFilter({ status: eventKey, isOpen: undefined })} - > - {t('all')} - {statusMenuItems} - - - - - this.updateFilter({ assigneeId: eventKey })} - > - {t('all')} - {assigneeMenuItems} - - - - this.updateFilter({ categoryId: eventKey })} - > - {t('all')} - {categoryMenuItems} - - - - - this.updateFilter({ tagKey: eventKey, tagValue: undefined }) - } - > - {t('all')} - {this.context.tagMetadatas.map((tagMetadata) => { - const key = tagMetadata.get('key') - return ( - - {key} - - ) - })} - - {filters.tagKey && ( - this.updateFilter({ tagValue: eventKey })} - > - {t('allTagValues')} - {this.context.tagMetadatas.length > 0 && - _.find(this.context.tagMetadatas, (m) => m.get('key') == filters.tagKey) - .get('values') - .map((value) => ( - - {value} - - ))} - - )} - - - this.updateFilter({ timeRange: eventKey })} - > - {t('allTime')} - - {t('thisMonth')} - - - {t('lastMonth')} - - - {t('monthBeforeLast')} - - - - - - {' '} - - - - - {' '} - {filters.isOpen === 'false' && ( - - - {t('badReviewsOnly')} - - - )} -
- ) - - const ticketCheckedOperations = ( - - - - {categoryMenuItems} - - {filters.isOpen === 'true' && ( - - {t('close')} - - )} - - - ) - - 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 ( +
  • + + +
  • + ) + /* 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 = ( -
  • - - +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 = ( -
  • - - + } + return ( +
  • + +
      +
    • + {t('settings')}
    • - ) - } else { - user = (
    • - {t('login')} + {t('logout')}
    • - ) - } +
    +
  • + ) +} +UserDropdown.propTypes = { + user: PropTypes.object, + onLogout: PropTypes.func.isRequired, +} - return ( - - ) - } + + + ) } - GlobalNav.propTypes = { - currentUser: PropTypes.object, - isCustomerService: PropTypes.bool, - logout: PropTypes.func.isRequired, - t: PropTypes.func, - history: PropTypes.object.isRequired, + user: PropTypes.object, + onLogout: PropTypes.func.isRequired, } - -export default withTranslation()(withRouter(GlobalNav)) diff --git a/modules/Home.js b/modules/Home.js index 386b35ba3..ff2266bb9 100644 --- a/modules/Home.js +++ b/modules/Home.js @@ -12,7 +12,7 @@ export default function Home({ isCustomerService }) { return } if (isCustomerService) { - history.replace('/customerService/tickets') + history.replace('/customerService/tickets?assignee=me&isOpen=true') } else { history.replace('/tickets') } diff --git a/modules/category/index.js b/modules/category/index.js new file mode 100644 index 000000000..4b1f06c56 --- /dev/null +++ b/modules/category/index.js @@ -0,0 +1,129 @@ +import i18next from 'i18next' +import { useEffect, useState } from 'react' + +import { db } from '../../lib/leancloud' + +/** + * @typedef {{ + * name: string; + * createdAt: Date; + * order?: number; + * parent?: Category; + * children?: Array; + * }} Category + */ + +/** + * @param {Category} category + * @returns {string} + */ +export function getCategoryName(category) { + let { name } = category + if (category.deletedAt) { + name += i18next.t('disabled') + } + return name +} + +/** + * Sort categories in place. + * @param {Array} categories + */ +function sortCategories(categories) { + categories.sort((c1, c2) => { + return (c1.order ?? c1.createdAt.getTime()) - (c2.order ?? c2.createdAt.getTime()) + }) + categories.forEach((c) => { + if (c.children) { + sortCategories(c.children) + } + }) +} + +/** + * @param {Array} categories + * @param {Function} callback + * @param {Array} results + * @param {number} [depth] + */ +function mapCategories(categories, callback, results, depth = 0) { + categories.forEach((category) => { + results.push(callback(category, depth)) + if (category.children) { + mapCategories(category.children, callback, results, depth + 1) + } + }) +} + +export class CategoryManager { + /** + * @param {Array} categories + */ + constructor(categories) { + this.categories = categories + this.categoryById = {} + this.categories.forEach((category) => { + this.categoryById[category.objectId] = category + }) + this.categories.forEach((category) => { + if (category.parent) { + category.parent = this.categoryById[category.parent.objectId] + if (!category.parent.children) { + category.parent.children = [] + } + category.parent.children.push(category) + } + }) + this.topLevelCategories = this.categories.filter((category) => !category.parent) + sortCategories(this.topLevelCategories) + } + + /** + * @param {string} id + * @returns {Array} + */ + getNodes(id) { + const nodes = [] + if (id in this.categoryById) { + nodes.push(this.categoryById[id]) + while (nodes[0].parent) { + nodes.unshift(nodes[0].parent) + } + } + return nodes + } + + /** + * @param {string} id + * @returns {Category} + */ + get(id) { + return this.categoryById[id] + } + + /** + * @param {Function} callback + * @returns {Array} + */ + map(callback) { + const results = [] + mapCategories(this.topLevelCategories, callback, results) + return results + } +} + +const EMPTY_CATEGORIES = new CategoryManager([]) + +/** + * @returns {CategoryManager} + */ +export function useCategories() { + const [categories, setCategories] = useState(EMPTY_CATEGORIES) + useEffect(() => { + db.class('Category') + .find() + .then((objects) => setCategories(new CategoryManager(objects.map((o) => o.toJSON())))) + .catch(console.error) + }, []) + return categories +} diff --git a/modules/components/BlodSearchString/index.js b/modules/components/BlodSearchString/index.js new file mode 100644 index 000000000..5c5923179 --- /dev/null +++ b/modules/components/BlodSearchString/index.js @@ -0,0 +1,40 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const AROUND_LENGTH = 40 +const OVERFLOW = '...' + +/** + * @param {object} props + * @param {string} props.content + * @param {string} props.searchString + */ +export function BlodSearchString({ content, searchString }) { + console.log({ content, searchString }) + const index = content.indexOf(searchString) + if (index < 0) { + return null + } + + let before = content.slice(0, index) + if (before.length > AROUND_LENGTH) { + before = OVERFLOW + before.slice(before.length - AROUND_LENGTH) + } + + let after = content.slice(index + searchString.length) + if (after.length > AROUND_LENGTH) { + after = after.slice(0, AROUND_LENGTH) + OVERFLOW + } + + return ( +
    + {before} + {searchString} + {after} +
    + ) +} +BlodSearchString.propTypes = { + content: PropTypes.string.isRequired, + searchString: PropTypes.string.isRequired, +} diff --git a/modules/components/DelayInputForm/index.js b/modules/components/DelayInputForm/index.js new file mode 100644 index 000000000..95158bd49 --- /dev/null +++ b/modules/components/DelayInputForm/index.js @@ -0,0 +1,52 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { FormControl } from 'react-bootstrap' +import PropTypes from 'prop-types' + +/** + * @param {object} props + * @param {string} [props.value] + * @param {Function} [props.onChange] + * @param {number} [props.delay] + * @param {string} [props.placeholder] + */ +export function DelayInputForm({ value = '', onChange, placeholder, delay = 1000 }) { + const [debouncedValue, setDebouncedValue] = useState(value) + + const $onChange = useRef(onChange) + useEffect(() => { + $onChange.current = onChange + }, [onChange]) + const $delay = useRef(delay) + useEffect(() => { + $delay.current = delay + }, [delay]) + + useEffect(() => { + const timer = setTimeout(() => { + $onChange.current?.(debouncedValue) + }, $delay.current) + return () => clearTimeout(timer) + }, [debouncedValue]) + + const handleChange = useCallback( + (e) => { + setDebouncedValue(e.target.value) + }, + [setDebouncedValue] + ) + + return ( + + ) +} +DelayInputForm.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func, + delay: PropTypes.number, + placeholder: PropTypes.string, +} diff --git a/modules/context/index.js b/modules/context/index.js new file mode 100644 index 000000000..7317e488e --- /dev/null +++ b/modules/context/index.js @@ -0,0 +1,8 @@ +import React from 'react' +import _ from 'lodash' + +export const AppContext = React.createContext({ + isCustomerService: false, + tagMetadatas: [], + addNotification: _.noop, +}) diff --git a/modules/i18n/locales.js b/modules/i18n/locales.js index 2cf0a18b6..800e41ce2 100644 --- a/modules/i18n/locales.js +++ b/modules/i18n/locales.js @@ -646,6 +646,10 @@ const messages = { 'All time', '全部时间' ], + 'today': [ + 'Today', + '今日' + ], 'thisMonth': [ 'This month', '本月'