From aa6572a859fd86e6fdcd2fd0fbb7fe3c8a69c073 Mon Sep 17 00:00:00 2001 From: Amine Hassou Date: Fri, 26 May 2023 23:32:40 +0100 Subject: [PATCH 1/7] Renamed ticket_show_handler.jsx class to match the filename --- .../javascripts/components/tickets/ticket_show_handler.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/components/tickets/ticket_show_handler.jsx b/app/assets/javascripts/components/tickets/ticket_show_handler.jsx index a63f813194..315f0a6279 100644 --- a/app/assets/javascripts/components/tickets/ticket_show_handler.jsx +++ b/app/assets/javascripts/components/tickets/ticket_show_handler.jsx @@ -14,9 +14,10 @@ import { fetchTickets, notifyOfMessage, readAllMessages, - selectTicket } from '../../actions/tickets_actions'; + selectTicket +} from '../../actions/tickets_actions'; -export class TicketShow extends React.Component { +export class TicketShowHandler extends React.Component { componentDidMount() { const id = this.props.router.params.id; const ticket = this.props.ticketsById[id]; @@ -70,4 +71,4 @@ const mapDispatchToProps = { }; const connector = connect(mapStateToProps, mapDispatchToProps); -export default withRouter(connector(TicketShow)); +export default withRouter(connector(TicketShowHandler)); From 25295564d55da4981038426150c0a1975acf0bd5 Mon Sep 17 00:00:00 2001 From: Amine Hassou Date: Sat, 27 May 2023 23:01:47 +0100 Subject: [PATCH 2/7] Converted TicketShowHandler to functional component + added reux hooks changes --- .../components/tickets/new_reply_form.jsx | 8 +- .../components/tickets/sidebar.jsx | 14 ++- .../components/tickets/ticket_show.jsx | 10 ++- .../tickets/ticket_show_handler.jsx | 88 ++++++++----------- 4 files changed, 59 insertions(+), 61 deletions(-) diff --git a/app/assets/javascripts/components/tickets/new_reply_form.jsx b/app/assets/javascripts/components/tickets/new_reply_form.jsx index 4771963191..29d0218622 100644 --- a/app/assets/javascripts/components/tickets/new_reply_form.jsx +++ b/app/assets/javascripts/components/tickets/new_reply_form.jsx @@ -4,6 +4,10 @@ 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'; const isBlank = (string) => { if (/\S/.test(string)) { @@ -87,8 +91,8 @@ export class NewReplyForm extends React.Component { body = { ...body, details }; } - this.props.createReply(body, status, bccToSalesforce) - .then(() => this.props.fetchTicket(ticket.id)) + this.props.dispatch(createReply(body, status, bccToSalesforce)) + .then(() => this.props.dispatch(fetchTicket(ticket.id))) .then(() => this.setState({ cc: '', content: '', sending: false })); } 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..b5ca2ba66b 100644 --- a/app/assets/javascripts/components/tickets/ticket_show.jsx +++ b/app/assets/javascripts/components/tickets/ticket_show.jsx @@ -3,15 +3,18 @@ import { Link } from 'react-router-dom'; import Reply from './reply'; import Sidebar from './sidebar'; import NewReplyForm from './new_reply_form'; +import { useDispatch, useSelector } from 'react-redux'; export const TicketShow = ({ createReply, deleteTicket, - currentUser, fetchTicket, notifyOfMessage, ticket, }) => { + const dispatch = useDispatch(); + const currentUser = useSelector(state => state.currentUserFromHtml); + const createdAt = ticket.messages[0].created_at; const replies = ticket.messages.map(message => ); @@ -19,9 +22,9 @@ 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(); + + 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(TicketShowHandler)); + +export default (TicketShowHandler); From cfadfc44acb30f2988e99b9872639f9706ae0a8f Mon Sep 17 00:00:00 2001 From: Amine Hassou Date: Sun, 28 May 2023 00:48:47 +0100 Subject: [PATCH 3/7] Converted NewReplyFrom into a functional component + added redux hooks --- .../components/tickets/new_reply_form.jsx | 322 +++++++++--------- .../components/tickets/ticket_show.jsx | 8 +- .../tickets/new_reply_form.spec.jsx | 34 +- 3 files changed, 185 insertions(+), 179 deletions(-) diff --git a/app/assets/javascripts/components/tickets/new_reply_form.jsx b/app/assets/javascripts/components/tickets/new_reply_form.jsx index 29d0218622..abff3bcffa 100644 --- a/app/assets/javascripts/components/tickets/new_reply_form.jsx +++ b/app/assets/javascripts/components/tickets/new_reply_form.jsx @@ -1,4 +1,4 @@ -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'; @@ -8,6 +8,7 @@ import { createReply, fetchTicket, } from '../../actions/tickets_actions'; +import { useDispatch } from 'react-redux'; const isBlank = (string) => { if (/\S/.test(string)) { @@ -16,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, @@ -86,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.dispatch(createReply(body, status, bccToSalesforce)) - .then(() => this.props.dispatch(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/ticket_show.jsx b/app/assets/javascripts/components/tickets/ticket_show.jsx index b5ca2ba66b..4aaaf3c3c3 100644 --- a/app/assets/javascripts/components/tickets/ticket_show.jsx +++ b/app/assets/javascripts/components/tickets/ticket_show.jsx @@ -3,16 +3,13 @@ import { Link } from 'react-router-dom'; import Reply from './reply'; import Sidebar from './sidebar'; import NewReplyForm from './new_reply_form'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; export const TicketShow = ({ - createReply, deleteTicket, - fetchTicket, notifyOfMessage, ticket, }) => { - const dispatch = useDispatch(); const currentUser = useSelector(state => state.currentUserFromHtml); const createdAt = ticket.messages[0].created_at; @@ -29,10 +26,7 @@ export const TicketShow = ({ {replies} { describe('NewReplyForm', () => { @@ -27,18 +33,28 @@ describe('Tickets', () => { }; 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 + fetchTicket: fetchTicketFn, + dispatch: mockDispatchFn + }; + const store = mockStore({ validations: { validations: { key: 2 } } }); + const MockProvider = (mockProps) => { + return ( + + + + ); }; - const form = shallow(); + const form = mount().find('.tickets-reply'); afterEach(() => { - form.instance().setState({ + form.setState({ cc: '', content: '', plainText: '', @@ -81,7 +97,7 @@ describe('Tickets', () => { 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: () => {} }); + await form.find('#reply-resolve').simulate('click', { preventDefault: () => { } }); expect(createReplyFn).not.toHaveBeenCalled(); expect(fetchTicketFn).not.toHaveBeenCalled(); }); @@ -113,7 +129,7 @@ describe('Tickets', () => { content: content, plainText: content }); - await form.find('#reply-resolve').simulate('click', { preventDefault: () => {} }); + await form.find('#reply-resolve').simulate('click', { preventDefault: () => { } }); const body = { content, @@ -132,7 +148,7 @@ describe('Tickets', () => { content: content, plainText: content }); - await form.find('#reply').simulate('click', { preventDefault: () => {} }); + await form.find('#reply').simulate('click', { preventDefault: () => { } }); const body = { content, @@ -151,7 +167,7 @@ describe('Tickets', () => { content: content, plainText: content }); - await form.find('#create-note').simulate('click', { preventDefault: () => {} }); + await form.find('#create-note').simulate('click', { preventDefault: () => { } }); const body = { content, From 8a641127b554f35781daf5adc39c0c8a95b9a145 Mon Sep 17 00:00:00 2001 From: Amine Hassou Date: Sun, 28 May 2023 04:13:38 +0100 Subject: [PATCH 4/7] Modified new reply form tests. Cleaned up code --- .../components/tickets/new_reply_form.jsx | 20 +-- .../tickets/new_reply_form.spec.jsx | 134 +++--------------- 2 files changed, 29 insertions(+), 125 deletions(-) diff --git a/app/assets/javascripts/components/tickets/new_reply_form.jsx b/app/assets/javascripts/components/tickets/new_reply_form.jsx index abff3bcffa..6a9bc83f55 100644 --- a/app/assets/javascripts/components/tickets/new_reply_form.jsx +++ b/app/assets/javascripts/components/tickets/new_reply_form.jsx @@ -124,7 +124,7 @@ const NewReplyForm = ({ ticket, currentUser }) => { alt="Show BCC" title="Show BCC" className="button border plus" - onClick={onCCClick.bind(this)} + onClick={onCCClick} > + @@ -136,7 +136,7 @@ const NewReplyForm = ({ ticket, currentUser }) => { className="ml1 top2" id="bcc" name="bcc" - onChange={toggleBcc.bind(this)} + onChange={toggleBcc} type="checkbox" /> @@ -149,7 +149,7 @@ const NewReplyForm = ({ ticket, currentUser }) => { { id="content" editable label="Enter your reply" - onChange={onTextAreaChange.bind(this)} + onChange={onTextAreaChange} value={replyDetails.content} value_key="content" wysiwyg={true} @@ -172,27 +172,27 @@ const NewReplyForm = ({ ticket, currentUser }) => {