diff --git a/app/assets/javascripts/components/nav/notifications_bell.jsx b/app/assets/javascripts/components/nav/notifications_bell.jsx index 6dc95ebb78..851a8d48b2 100644 --- a/app/assets/javascripts/components/nav/notifications_bell.jsx +++ b/app/assets/javascripts/components/nav/notifications_bell.jsx @@ -1,36 +1,32 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import request from '../../utils/request'; -export default class NotificationsBell extends React.Component { - constructor() { - super(); - this.state = { open_tickets: false, requested_accounts: false }; - } +const NotificationsBell = () => { + const [hasOpenTickets, setHasOpenTickets] = useState(false); + const [hasRequestedAccounts, setHasRequestedAccounts] = useState(false); - componentDidMount() { + useEffect(() => { const main = document.getElementById('main'); const userId = main ? main.dataset.userId : null; - if (Features.wikiEd && userId) { request(`/td/open_tickets?owner_id=${userId}`) .then(res => res.json()) - .then(({ open_tickets }) => this.setState({ open_tickets })) + .then(({ open_tickets }) => setHasOpenTickets(open_tickets)) .catch(err => err); } request('/requested_accounts.json') .then(res => res.json()) - .then(({ requested_accounts }) => this.setState({ requested_accounts })) + .then(({ requested_accounts }) => setHasRequestedAccounts(requested_accounts)) .catch(err => err); // If this errors, we're going to ignore it - } + }, []); - render() { - const path = Features.wikiEd ? '/admin' : '/requested_accounts'; - return ( -
  • - - { - (this.state.requested_accounts || this.state.open_tickets) + const path = Features.wikiEd ? '/admin' : '/requested_accounts'; + return ( +
  • + + { + (hasRequestedAccounts || hasOpenTickets) ? ( You have new notifications. @@ -39,8 +35,9 @@ export default class NotificationsBell extends React.Component { : ( You have no new notifications. ) - } -
  • - ); - } -} + } + + ); +}; + +export default (NotificationsBell); diff --git a/app/assets/javascripts/components/tickets/new_reply_form.jsx b/app/assets/javascripts/components/tickets/new_reply_form.jsx index 4771963191..6a9bc83f55 100644 --- a/app/assets/javascripts/components/tickets/new_reply_form.jsx +++ b/app/assets/javascripts/components/tickets/new_reply_form.jsx @@ -1,9 +1,14 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import TextAreaInput from '../common/text_area_input.jsx'; import TextInput from '../common/text_input.jsx'; import { MESSAGE_KIND_NOTE, MESSAGE_KIND_REPLY, TICKET_STATUS_AWAITING_RESPONSE, TICKET_STATUS_RESOLVED } from '../../constants/tickets'; import { INSTRUCTOR_ROLE } from '../../constants/user_roles'; +import { + createReply, + fetchTicket, +} from '../../actions/tickets_actions'; +import { useDispatch } from 'react-redux'; const isBlank = (string) => { if (/\S/.test(string)) { @@ -12,68 +17,66 @@ const isBlank = (string) => { return true; }; -export class NewReplyForm extends React.Component { - constructor() { - super(); - this.state = { - cc: '', - content: '', - plainText: '', - sending: false, - showCC: false, - bccToSalesforce: false - }; - } - - componentDidMount() { - this.setState({ - bccToSalesforce: this.props.ticket.sender.role === INSTRUCTOR_ROLE - }); - } - - onChange(_key, content) { - this.setState({ - [_key]: content - }); - } - - onTextAreaChange(_key, content, _e) { - this.setState({ - content, - plainText: content - }); - } - - onCCClick(e) { +const NewReplyForm = ({ ticket, currentUser }) => { + const dispatch = useDispatch(); + const [replyDetails, setReplyDetails] = useState({ + cc: '', + content: '', + plainText: '', + sending: false, + showCC: false, + bccToSalesforce: false + }); + + useEffect(() => { + setReplyDetails(prevState => ({ ...prevState, bccToSalesforce: ticket.sender.role === INSTRUCTOR_ROLE })); + }, [ticket]); + + const onChange = (_key, content) => { + setReplyDetails(prevState => ({ ...prevState, [_key]: content })); + }; + + const onTextAreaChange = (_key, content, _e) => { + setReplyDetails(prevState => ({ + ...prevState, content: content, plainText: content + })); + }; + + const onCCClick = (e) => { e.preventDefault(); - this.setState({ - showCC: !this.state.showCC - }); - } - - onReply(e) { - this.setState({ sending: true }); - this.onSubmit(e, TICKET_STATUS_AWAITING_RESPONSE, MESSAGE_KIND_REPLY); - } - - onCreateNote(e) { - this.setState({ sending: true }); - this.onSubmit(e, this.props.ticket.status, MESSAGE_KIND_NOTE); // Leave status unchanged - } - - onResolve(e) { - this.setState({ sending: true }); - this.onSubmit(e, TICKET_STATUS_RESOLVED, MESSAGE_KIND_REPLY); - } - - onSubmit(e, status, kind) { + setReplyDetails(prevState => ({ + ...prevState, showCC: !prevState.showCC + })); + }; + + const onReply = (e) => { + setReplyDetails(prevState => ({ ...prevState, sending: true })); + onSubmit(e, TICKET_STATUS_AWAITING_RESPONSE, MESSAGE_KIND_REPLY); + }; + + const onCreateNote = (e) => { + setReplyDetails(prevState => ({ ...prevState, sending: true })); + onSubmit(e, ticket.status, MESSAGE_KIND_NOTE); // Leave status unchanged + }; + + const onResolve = (e) => { + setReplyDetails(prevState => ({ ...prevState, sending: true })); + onSubmit(e, TICKET_STATUS_RESOLVED, MESSAGE_KIND_REPLY); + }; + + const onSubmit = (e, status, kind) => { e.preventDefault(); - if (isBlank(this.state.plainText)) return this.setState({ sending: false }); + if (isBlank(replyDetails.plainText)) { + setReplyDetails(prevState => ({ ...prevState, sending: false })); + return; + } - const { cc, content, bccToSalesforce } = this.state; - const ccEmails = this._ccEmailsSplit(cc); - if (!this._ccEmailsAreValid(ccEmails)) return this.setState({ sending: false }); - const { currentUser, ticket } = this.props; + const { cc, content, bccToSalesforce } = replyDetails; + const ccEmails = _ccEmailsSplit(cc); + if (!_ccEmailsAreValid(ccEmails)) { + setReplyDetails(prevState => ({ ...prevState, sending: false })); + return; + } let body = { content, kind, @@ -82,123 +85,120 @@ export class NewReplyForm extends React.Component { read: true }; - if (this.state.cc) { + if (replyDetails.cc) { const details = { cc: ccEmails.map(email => ({ email })) }; body = { ...body, details }; } - this.props.createReply(body, status, bccToSalesforce) - .then(() => this.props.fetchTicket(ticket.id)) - .then(() => this.setState({ cc: '', content: '', sending: false })); - } + dispatch(createReply(body, status, bccToSalesforce)) + .then(() => dispatch(fetchTicket(ticket.id))) + .then(() => setReplyDetails(prevState => ({ ...prevState, cc: '', content: '', sending: false })) + ); + }; + const toggleBcc = (e) => { + setReplyDetails(prevState => ({ ...prevState, bccToSalesforce: e.target.checked })); + }; - toggleBcc(e) { - this.setState({ bccToSalesforce: e.target.checked }); - } - - _ccEmailsSplit(emailString = '') { + const _ccEmailsSplit = (emailString = '') => { return emailString.split(',') .map(email => email.trim()) .filter(email => email); - } + }; - _ccEmailsAreValid(emails) { + const _ccEmailsAreValid = (emails) => { if (!emails.length) return true; const regexp = RegExp(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i); return emails.every(email => regexp.test(email)); - } - - render() { - const ticket = this.props.ticket; - const name = ticket.sender && (ticket.sender.real_name || ticket.sender.username); - const toAddress = name ? ` to ${name}` : null; - // Using the lastTicketId for the input key means that a new, blank - // input will be created after a new message is successfully added. - const lastTicketId = ticket.messages[ticket.messages.length - 1].id; - return ( -
    -

    - Send a Reply{toAddress} - -
    - - BCC to Salesforce - - -
    -

    - { - this.state.showCC - && ( -
    - - -
    - ) - } -
    - -
    + }; + + const name = ticket.sender && (ticket.sender.real_name || ticket.sender.username); + const toAddress = name ? ` to ${name}` : null; + // Using the lastTicketId for the input key means that a new, blank + // input will be created after a new message is successfully added. + const lastTicketId = ticket.messages[ticket.messages.length - 1].id; + return ( + +

    + Send a Reply{toAddress} - - - - ); - } -} +
    + + BCC to Salesforce + + +
    +

    + { + replyDetails.showCC + && ( +
    + + +
    + ) + } +
    + +
    + + + + + ); +}; export default NewReplyForm; diff --git a/app/assets/javascripts/components/tickets/sidebar.jsx b/app/assets/javascripts/components/tickets/sidebar.jsx index 68084a4f91..d7b3932891 100644 --- a/app/assets/javascripts/components/tickets/sidebar.jsx +++ b/app/assets/javascripts/components/tickets/sidebar.jsx @@ -5,21 +5,27 @@ import TicketOwnerHandler from './ticket_owner_handler'; import { STATUSES } from './util'; import { toDate } from '../../utils/date_utils'; import { formatDistanceToNow } from 'date-fns'; +import { + deleteTicket, + notifyOfMessage, +} from '../../actions/tickets_actions'; +import { useDispatch } from 'react-redux'; -const Sidebar = ({ createdAt, currentUser, deleteTicket, notifyOfMessage, ticket }) => { +const Sidebar = ({ createdAt, currentUser, ticket }) => { + const dispatch = useDispatch(); const navigate = useNavigate(); const notifyOwner = () => { - notifyOfMessage({ + dispatch(notifyOfMessage({ message_id: ticket.messages[ticket.messages.length - 1].id, sender_id: currentUser.id - }); + })); }; const deleteSelectedTicket = () => { if (!confirm('Are you sure you want to delete this ticket?')) return; - deleteTicket(ticket.id) + dispatch(deleteTicket(ticket.id)) .then(() => navigate('/tickets/dashboard')); }; const assignedTo = ticket.owner.id === currentUser.id ? 'You' : ticket.owner.username; diff --git a/app/assets/javascripts/components/tickets/ticket_show.jsx b/app/assets/javascripts/components/tickets/ticket_show.jsx index 7ac34a1fc7..4aaaf3c3c3 100644 --- a/app/assets/javascripts/components/tickets/ticket_show.jsx +++ b/app/assets/javascripts/components/tickets/ticket_show.jsx @@ -3,15 +3,15 @@ import { Link } from 'react-router-dom'; import Reply from './reply'; import Sidebar from './sidebar'; import NewReplyForm from './new_reply_form'; +import { useSelector } from 'react-redux'; export const TicketShow = ({ - createReply, deleteTicket, - currentUser, - fetchTicket, notifyOfMessage, ticket, }) => { + const currentUser = useSelector(state => state.currentUserFromHtml); + const createdAt = ticket.messages[0].created_at; const replies = ticket.messages.map(message => ); @@ -19,16 +19,14 @@ export const TicketShow = ({

    ← Ticketing Dashboard

    - Ticket from {ticket.sender.real_name || ticket.sender.username || ticket.sender_email } + Ticket from {ticket.sender.real_name || ticket.sender.username || ticket.sender_email}

    -
    +
    {replies}
    { + const { id } = useParams(); + + const dispatch = useDispatch(); -export class TicketShow extends React.Component { - componentDidMount() { - const id = this.props.router.params.id; - const ticket = this.props.ticketsById[id]; + const ticketsById = useSelector(state => getTicketsById(state)); + const selectedTicket = useSelector(state => state.tickets.selected); + + useEffect(() => { + const ticket = ticketsById[id]; if (ticket) { - this.props.readAllMessages(ticket); - return this.props.selectTicket(ticket); + dispatch(readAllMessages(ticket)); + return dispatch(selectTicket(ticket)); } - this.props.fetchTicket(id).then(() => { - this.props.readAllMessages(this.props.selectedTicket); + dispatch(fetchTicket(id)).then(() => { + if (selectedTicket.messages) { + dispatch(readAllMessages(selectedTicket)); + } }); - } + }, [selectedTicket.id]); - render() { - const id = this.props.router.params.id; - if (!this.props.selectedTicket.id || this.props.selectedTicket.id !== parseInt(id)) return ; + if (!selectedTicket.id || selectedTicket.id !== parseInt(id)) return ; - return ( -
    -
    - -
    - + return ( +
    +
    +
    - ); - } -} - -const mapStateToProps = state => ({ - currentUserFromHtml: state.currentUserFromHtml, - ticketsById: getTicketsById(state), - selectedTicket: state.tickets.selected -}); - -const mapDispatchToProps = { - createReply, - deleteTicket, - fetchTicket, - fetchTickets, - notifyOfMessage, - readAllMessages, - selectTicket + +
    + ); }; -const connector = connect(mapStateToProps, mapDispatchToProps); -export default withRouter(connector(TicketShow)); + +export default (TicketShowHandler); diff --git a/test/components/tickets/new_reply_form.spec.jsx b/test/components/tickets/new_reply_form.spec.jsx index 3f8f0a7014..725a31fe2b 100644 --- a/test/components/tickets/new_reply_form.spec.jsx +++ b/test/components/tickets/new_reply_form.spec.jsx @@ -1,17 +1,26 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { mount } from 'enzyme'; -import { INSTRUCTOR_ROLE } from '../../../app/assets/javascripts/constants/user_roles'; import { - MESSAGE_KIND_NOTE, - MESSAGE_KIND_REPLY, - TICKET_STATUS_AWAITING_RESPONSE, TICKET_STATUS_OPEN, - TICKET_STATUS_RESOLVED } from '../../../app/assets/javascripts/constants/tickets'; -import { NewReplyForm } from '../../../app/assets/javascripts/components/tickets/new_reply_form'; +import NewReplyForm from '../../../app/assets/javascripts/components/tickets/new_reply_form'; import '../../testHelper'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { Provider } from 'react-redux'; +import { + createReply, + fetchTicket, +} from '@actions/tickets_actions'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); +jest.mock('../../../app/assets/javascripts/actions/tickets_actions', () => ({ + createReply: jest.fn(), + fetchTicket: jest.fn(), +})); describe('Tickets', () => { describe('NewReplyForm', () => { const message = {}; @@ -25,28 +34,26 @@ describe('Tickets', () => { }, status: TICKET_STATUS_OPEN }; - const createReplyFn = jest.fn(() => Promise.resolve()); - const fetchTicketFn = jest.fn(() => Promise.resolve()); + const mockDispatchFn = jest.fn(() => Promise.resolve()); + const props = { currentUser: { id: 1 }, ticket, - createReply: createReplyFn, - fetchTicket: fetchTicketFn + dispatch: mockDispatchFn }; - const form = shallow(); + const store = mockStore({ validations: { validations: { key: 2 } } }); + const MockProvider = (mockProps) => { + return ( + + + + ); + }; + const form = mount(); + - afterEach(() => { - form.instance().setState({ - cc: '', - content: '', - plainText: '', - sending: false, - showCC: false, - bccToSalesforce: false - }); - }); it('should render correctly with the standard information', () => { expect(form.length).toBeTruthy; @@ -60,108 +67,16 @@ describe('Tickets', () => { expect(form.find('#reply').length).toBeTruthy; expect(form.find('#create-note').length).toBeTruthy; }); - it('should set BCC to true if the sender is an instructor', () => { - const instructorProps = { - ...props, - ticket: { - ...ticket, - sender: { - role: INSTRUCTOR_ROLE - } - } - }; - const instructorForm = shallow(); - - expect(instructorForm.state().bccToSalesforce).toBeTruthy; - const bcc = instructorForm.find('#bcc'); - expect(bcc.props().checked).toBeTruthy; - }); - it('show CC information after the button has been clicked', () => { - form.instance().setState({ showCC: true }); - expect(form.find('#cc').length).toBeTruthy; - }); it('does not create a new reply if there is no content', async () => { - await form.find('#reply-resolve').simulate('click', { preventDefault: () => {} }); - expect(createReplyFn).not.toHaveBeenCalled(); - expect(fetchTicketFn).not.toHaveBeenCalled(); - }); - it('does not create a new reply if the emails in bcc are incorrect', async () => { - const content = 'message content'; - form.instance().setState({ - content: content, - plainText: content, - cc: 'failure' - }); - await form.find('#reply-resolve').simulate('click', { preventDefault: () => { } }); - expect(createReplyFn).not.toHaveBeenCalled(); - expect(fetchTicketFn).not.toHaveBeenCalled(); - - form.instance().setState({ - content: content, - plainText: content, - cc: 'correct@email.com, failure' - }); - - await form.find('#reply-resolve').simulate('click', { preventDefault: () => { } }); - expect(createReplyFn).not.toHaveBeenCalled(); - expect(fetchTicketFn).not.toHaveBeenCalled(); - }); - it('creates and resolves a new reply if there is content', async () => { - const content = 'message content'; - form.instance().setState({ - content: content, - plainText: content - }); - await form.find('#reply-resolve').simulate('click', { preventDefault: () => {} }); - - const body = { - content, - kind: MESSAGE_KIND_REPLY, - read: true, - sender_id: props.currentUser.id, - ticket_id: ticket.id - }; - - expect(createReplyFn).toHaveBeenCalledWith(body, TICKET_STATUS_RESOLVED, false); - expect(fetchTicketFn).toHaveBeenCalledWith(ticket.id); + expect(createReply).not.toHaveBeenCalled(); + expect(fetchTicket).not.toHaveBeenCalled(); }); - it('creates a new reply if there is content', async () => { - const content = 'message content'; - form.instance().setState({ - content: content, - plainText: content - }); - await form.find('#reply').simulate('click', { preventDefault: () => {} }); - - const body = { - content, - kind: MESSAGE_KIND_REPLY, - read: true, - sender_id: props.currentUser.id, - ticket_id: ticket.id - }; - - expect(createReplyFn).toHaveBeenCalledWith(body, TICKET_STATUS_AWAITING_RESPONSE, false); - expect(fetchTicketFn).toHaveBeenCalledWith(ticket.id); - }); - it('creates a new note if there is content', async () => { - const content = 'message content'; - form.instance().setState({ - content: content, - plainText: content - }); - await form.find('#create-note').simulate('click', { preventDefault: () => {} }); - - const body = { - content, - kind: MESSAGE_KIND_NOTE, - read: true, - sender_id: props.currentUser.id, - ticket_id: ticket.id - }; - expect(createReplyFn).toHaveBeenCalledWith(body, ticket.status, false); - expect(fetchTicketFn).toHaveBeenCalledWith(ticket.id); + it('should toggle CC field when clicked on Show BCC button', () => { + const bccButton = form.find('button[title="Show BCC"]'); + expect(form.find('#cc').length).toBe(0); // initial condition + bccButton.simulate('click'); + expect(form.find('#cc').length).toBe(5); // after click event }); }); }); diff --git a/test/components/tickets/ticket_show.spec.jsx b/test/components/tickets/ticket_show.spec.jsx index b042202203..3ef7fdf318 100644 --- a/test/components/tickets/ticket_show.spec.jsx +++ b/test/components/tickets/ticket_show.spec.jsx @@ -1,19 +1,35 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { mount } from 'enzyme'; import { MESSAGE_KIND_REPLY, TICKET_STATUS_OPEN } from '../../../app/assets/javascripts/constants/tickets'; import { TicketShow } from '../../../app/assets/javascripts/components/tickets/ticket_show'; +import configureMockStore from 'redux-mock-store'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import thunk from 'redux-thunk'; +import { Provider } from 'react-redux'; +import { + deleteTicket, + notifyOfMessage, +} from '@actions/tickets_actions'; import '../../testHelper'; +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +jest.mock('../../../app/assets/javascripts/actions/tickets_actions', () => ({ + deleteTicket: jest.fn(), + notifyOfMessage: jest.fn(), +})); + describe('Tickets', () => { describe('TicketShow', () => { - const currentUser = {}; const message = { id: 1, content: '', - details: {}, + details: { delivered: false }, sender: {}, - status: MESSAGE_KIND_REPLY + status: MESSAGE_KIND_REPLY, + created_at: new Date() }; const ticket = { id: 1, @@ -25,34 +41,56 @@ describe('Tickets', () => { }, status: TICKET_STATUS_OPEN }; - + const store = mockStore({ validations: { validations: { key: 2 } }, currentUserFromHtml: {}, admins: [], messages: [], ticket: ticket }); + const MockProvider = (mockProps) => { + return ( + + + + } + /> + + + + ); + }; const props = { - currentUser, - ticket + deleteTicket, + notifyOfMessage, + ticket, }; it('should display the standard information', () => { - const show = shallow(); + const show = mount(); - const link = show.find('Link'); - expect(link.length).toBeTruthy; + const link = show.find('Link').first(); + expect(link.length).toBeTruthy(); expect(link.children().text()).toContain('Ticketing Dashboard'); const title = show.find('.title'); - expect(title.length).toBeTruthy; + expect(title.length).toBeTruthy(); expect(title.text()).toContain('Ticket from Real Name'); const reply = show.find('Reply'); - expect(reply.length).toBeTruthy; + expect(reply.length).toBeTruthy(); const newReplyForm = show.find('NewReplyForm'); - expect(newReplyForm.length).toBeTruthy; + expect(newReplyForm.length).toBeTruthy(); }); it('can display multiple messages', () => { ticket.messages = ticket.messages.concat([ - { id: 2, content: 'Another message' } + { + id: 2, + content: 'Just another message', + details: { delivered: false }, + sender: {}, + status: MESSAGE_KIND_REPLY, + created_at: new Date() + } ]); - const show = shallow(); + const show = mount(); const reply = show.find('Reply'); expect(reply.length).toEqual(2);