From 9ed1d0866a854f1b3d1a504494be8a7cdde71a93 Mon Sep 17 00:00:00 2001 From: Alex Reichert Date: Thu, 22 Jul 2021 17:58:15 -0700 Subject: [PATCH] Set up UX for using canned responses (#919) * Set up UX for using canned responses/saved replies * Minor improvements * Remove unused --- assets/src/App.css | 6 +- assets/src/api.ts | 57 +++++ assets/src/components/Dashboard.tsx | 9 + .../CannedResponsesOverview.tsx | 229 ++++++++++++++++++ .../NewCannedResponseModal.tsx | 139 +++++++++++ .../conversations/ConversationFooter.tsx | 127 ++++++++-- assets/src/types.ts | 8 + lib/chat_api/canned_responses.ex | 5 - .../controllers/canned_response_controller.ex | 4 +- 9 files changed, 562 insertions(+), 22 deletions(-) create mode 100644 assets/src/components/canned-responses/CannedResponsesOverview.tsx create mode 100644 assets/src/components/canned-responses/NewCannedResponseModal.tsx diff --git a/assets/src/App.css b/assets/src/App.css index 354a9fabc..39186916a 100644 --- a/assets/src/App.css +++ b/assets/src/App.css @@ -21,7 +21,11 @@ body { .TextArea--transparent, .TextArea--transparent:hover, .TextArea--transparent:focus, -.TextArea--transparent:active { +.TextArea--transparent:active, +.TextArea--transparent > textarea, +.TextArea--transparent > textarea:hover, +.TextArea--transparent > textarea:focus, +.TextArea--transparent > textarea:active { background: transparent; border: none; box-shadow: none; diff --git a/assets/src/api.ts b/assets/src/api.ts index 81304ca4f..73954cac2 100644 --- a/assets/src/api.ts +++ b/assets/src/api.ts @@ -15,6 +15,7 @@ import { Tag, User, WidgetSettings, + CannedResponse, } from './types'; // TODO: handle this on the server instead @@ -1680,6 +1681,62 @@ export const getOnboardingStatus = async ( .then((res) => res.body); }; +export const fetchCannedResponses = async (token = getAccessToken()) => { + if (!token) { + throw new Error('Invalid token!'); + } + + return request + .get(`/api/canned_responses`) + .set('Authorization', token) + .then((res) => res.body.data); +}; + +export const createCannedResponse = async ( + params: Partial, + token = getAccessToken() +) => { + if (!token) { + throw new Error('Invalid token!'); + } + + return request + .post(`/api/canned_responses`) + .send({canned_response: params}) + .set('Authorization', token) + .then((res) => res.body.data); +}; + +export const updateCannedResponse = async ( + id: string, + updates: Partial, + token = getAccessToken() +) => { + if (!token) { + throw new Error('Invalid token!'); + } + + return request + .put(`/api/canned_responses/${id}`) + .send({canned_response: updates}) + .set('Authorization', token) + .then((res) => res.body.data); +}; + +export const deleteCannedResponse = async ( + id: string, + token = getAccessToken() +) => { + if (!token) { + throw new Error('Invalid token!'); + } + + return request + .delete(`/api/canned_responses/${id}`) + .set('Authorization', token) + .then((res) => res.body); +}; + export const fetchLambdas = async (token = getAccessToken()) => { if (!token) { throw new Error('Invalid token!'); diff --git a/assets/src/components/Dashboard.tsx b/assets/src/components/Dashboard.tsx index eec55ec75..3afeec86b 100644 --- a/assets/src/components/Dashboard.tsx +++ b/assets/src/components/Dashboard.tsx @@ -86,6 +86,7 @@ import EventSubscriptionsPage from './developers/EventSubscriptionsPage'; import EmailTemplateBuilder from './developers/EmailTemplateBuilder'; import LambdaDetailsPage from './lambdas/LambdaDetailsPage'; import LambdasOverview from './lambdas/LambdasOverview'; +import CannedResponsesOverview from './canned-responses/CannedResponsesOverview'; const { REACT_APP_ADMIN_ACCOUNT_ID = 'eb504736-0f20-4978-98ff-1a82ae60b266', @@ -497,6 +498,9 @@ const Dashboard = (props: RouteComponentProps) => { Chat widget + + Saved replies + {shouldDisplayBilling && ( Billing @@ -545,10 +549,15 @@ const Dashboard = (props: RouteComponentProps) => { /> + + {shouldDisplayBilling && ( diff --git a/assets/src/components/canned-responses/CannedResponsesOverview.tsx b/assets/src/components/canned-responses/CannedResponsesOverview.tsx new file mode 100644 index 000000000..3a03d3055 --- /dev/null +++ b/assets/src/components/canned-responses/CannedResponsesOverview.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import {Box, Flex} from 'theme-ui'; +import { + Button, + Container, + Input, + Paragraph, + Popconfirm, + Table, + Text, + Title, +} from '../common'; +import * as API from '../../api'; +import {CannedResponse} from '../../types'; +import logger from '../../logger'; +import {NewCannedResponseModalButton} from './NewCannedResponseModal'; + +const CannedResponsesTable = ({ + loading, + cannedResponses, + onDeleteCannedResponse, +}: { + loading?: boolean; + cannedResponses: Array; + onDeleteCannedResponse: (id: string) => void; +}) => { + const data = cannedResponses + .map((cannedResponse) => { + return {key: cannedResponse.id, ...cannedResponse}; + }) + .sort((a, b) => { + return +new Date(b.updated_at) - +new Date(a.updated_at); + }); + + const columns = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (value: string) => { + return /{value}; + }, + }, + { + title: 'Content', + dataIndex: 'content', + key: 'content', + render: (value: string) => { + return value; + }, + }, + { + title: '', + dataIndex: 'action', + key: 'action', + render: (value: string, record: CannedResponse) => { + return ( + + {/* + TODO: implement me! + + + + + */} + + + onDeleteCannedResponse(record.id)} + > + + + + + ); + }, + }, + ]; + + return ; +}; + +type Props = {}; +type State = { + filterQuery: string; + filteredCannedResponses: Array; + isNewCannedResponseModalVisible: boolean; + loading: boolean; + cannedResponses: Array; +}; + +const filterCannedResponsesByQuery = ( + cannedResponses: Array, + query?: string +): Array => { + if (!query || !query.length) { + return cannedResponses; + } + + return cannedResponses.filter((cannedResponse) => { + const {id, name, content} = cannedResponse; + + const words = [id, name, content] + .filter((str) => str && String(str).trim().length > 0) + .join(' ') + .replace('_', ' ') + .split(' ') + .map((str) => str.toLowerCase()); + + const queries = query.split(' ').map((str) => str.toLowerCase()); + + return queries.every((q) => { + return words.some((word) => word.indexOf(q) !== -1); + }); + }); +}; + +class CannedResponsesOverview extends React.Component { + state: State = { + filteredCannedResponses: [], + filterQuery: '', + isNewCannedResponseModalVisible: false, + loading: true, + cannedResponses: [], + }; + + async componentDidMount() { + await this.handleRefreshCannedResponses(); + } + + handleSearchCannedResponses = (filterQuery: string) => { + const {cannedResponses = []} = this.state; + + if (!filterQuery?.length) { + this.setState({ + filterQuery: '', + filteredCannedResponses: cannedResponses, + }); + } + + this.setState({ + filterQuery, + filteredCannedResponses: filterCannedResponsesByQuery( + cannedResponses, + filterQuery + ), + }); + }; + + handleRefreshCannedResponses = async () => { + try { + const {filterQuery} = this.state; + const cannedResponses = await API.fetchCannedResponses(); + + this.setState({ + filteredCannedResponses: filterCannedResponsesByQuery( + cannedResponses, + filterQuery + ), + loading: false, + cannedResponses, + }); + } catch (err) { + logger.error('Error loading canned responses!', err); + + this.setState({loading: false}); + } + }; + + handleDeleteCannedResponse = async (id: string) => { + try { + this.setState({loading: true}); + + await API.deleteCannedResponse(id); + await this.handleRefreshCannedResponses(); + } catch (err) { + logger.error('Error deleting canned responses!', err); + + this.setState({loading: false}); + } + }; + + render() { + const {loading, filteredCannedResponses = []} = this.state; + + return ( + + + Saved replies + + + New + + + + + + Use saved replies to respond more quickly to common questions. + + + + + + + + + + + + ); + } +} + +export default CannedResponsesOverview; diff --git a/assets/src/components/canned-responses/NewCannedResponseModal.tsx b/assets/src/components/canned-responses/NewCannedResponseModal.tsx new file mode 100644 index 000000000..8cf316265 --- /dev/null +++ b/assets/src/components/canned-responses/NewCannedResponseModal.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import {Box} from 'theme-ui'; +import {ButtonProps} from 'antd/lib/button'; +import {Button, Input, Modal, Text, TextArea} from '../common'; +import {PlusOutlined} from '../icons'; +import * as API from '../../api'; +import logger from '../../logger'; +import {formatServerError} from '../../utils'; + +const NewCannedResponseModal = ({ + visible, + onSuccess, + onCancel, +}: { + visible: boolean; + onSuccess: (params: any) => void; + onCancel: () => void; +}) => { + const [name, setName] = React.useState(''); + const [content, setContent] = React.useState(''); + const [error, setErrorMessage] = React.useState(null); + const [isSaving, setIsSaving] = React.useState(false); + + const handleChangeName = (e: any) => setName(e.target.value); + const handleChangeContent = (e: any) => setContent(e.target.value); + const resetInputFields = () => { + setName(''); + setContent(''); + setErrorMessage(null); + }; + + const handleCancelCannedResponse = () => { + onCancel(); + resetInputFields(); + }; + + const handleCreateCannedResponse = async () => { + setIsSaving(true); + + return API.createCannedResponse({name: name.replace('/', ''), content}) + .then((result) => { + onSuccess(result); + resetInputFields(); + }) + .catch((err) => { + logger.error('Error creating saved reply:', err); + const errorMessage = formatServerError(err); + setErrorMessage(errorMessage); + }) + .finally(() => setIsSaving(false)); + }; + + return ( + + Cancel + , + , + ]} + > + + + + + + + +