Skip to content

Commit

Permalink
Set up UX for using canned responses (#919)
Browse files Browse the repository at this point in the history
* Set up UX for using canned responses/saved replies

* Minor improvements

* Remove unused
  • Loading branch information
reichert621 authored Jul 23, 2021
1 parent d36b976 commit 9ed1d08
Show file tree
Hide file tree
Showing 9 changed files with 562 additions and 22 deletions.
6 changes: 5 additions & 1 deletion assets/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
57 changes: 57 additions & 0 deletions assets/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Tag,
User,
WidgetSettings,
CannedResponse,
} from './types';

// TODO: handle this on the server instead
Expand Down Expand Up @@ -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<CannedResponse>,
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<CannedResponse>,
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!');
Expand Down
9 changes: 9 additions & 0 deletions assets/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -497,6 +498,9 @@ const Dashboard = (props: RouteComponentProps) => {
<Menu.Item key="chat-widget">
<Link to="/settings/chat-widget">Chat widget</Link>
</Menu.Item>
<Menu.Item key="saved-replies">
<Link to="/settings/saved-replies">Saved replies</Link>
</Menu.Item>
{shouldDisplayBilling && (
<Menu.Item key="billing">
<Link to="/settings/billing">Billing</Link>
Expand Down Expand Up @@ -545,10 +549,15 @@ const Dashboard = (props: RouteComponentProps) => {
/>
<Redirect from="/account*" to="/settings*" />
<Redirect from="/billing" to="/settings/billing" />
<Redirect from="/saved-replies" to="/settings/saved-replies" />

<Route path="/settings/account" component={AccountOverview} />
<Route path="/settings/team" component={TeamOverview} />
<Route path="/settings/profile" component={UserProfile} />
<Route
path="/settings/saved-replies"
component={CannedResponsesOverview}
/>
<Route path="/settings/chat-widget" component={ChatWidgetSettings} />
{shouldDisplayBilling && (
<Route path="/settings/billing" component={BillingOverview} />
Expand Down
229 changes: 229 additions & 0 deletions assets/src/components/canned-responses/CannedResponsesOverview.tsx
Original file line number Diff line number Diff line change
@@ -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<CannedResponse>;
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 <Text code>/{value}</Text>;
},
},
{
title: 'Content',
dataIndex: 'content',
key: 'content',
render: (value: string) => {
return value;
},
},
{
title: '',
dataIndex: 'action',
key: 'action',
render: (value: string, record: CannedResponse) => {
return (
<Flex mx={-1} sx={{justifyContent: 'flex-end'}}>
{/*
TODO: implement me!
<Box mx={1}>
<Button>Edit</Button>
</Box>
*/}

<Box mx={1}>
<Popconfirm
title="Are you sure you want to delete this API key?"
okText="Yes"
cancelText="No"
placement="topLeft"
onConfirm={() => onDeleteCannedResponse(record.id)}
>
<Button danger>Delete</Button>
</Popconfirm>
</Box>
</Flex>
);
},
},
];

return <Table loading={loading} dataSource={data} columns={columns} />;
};

type Props = {};
type State = {
filterQuery: string;
filteredCannedResponses: Array<CannedResponse>;
isNewCannedResponseModalVisible: boolean;
loading: boolean;
cannedResponses: Array<CannedResponse>;
};

const filterCannedResponsesByQuery = (
cannedResponses: Array<CannedResponse>,
query?: string
): Array<CannedResponse> => {
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<Props, State> {
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 (
<Container>
<Flex sx={{justifyContent: 'space-between', alignItems: 'center'}}>
<Title level={3}>Saved replies</Title>

<NewCannedResponseModalButton
onSuccess={this.handleRefreshCannedResponses}
>
New
</NewCannedResponseModalButton>
</Flex>

<Box mb={4}>
<Paragraph>
Use saved replies to respond more quickly to common questions.
</Paragraph>
</Box>

<Box mb={3}>
<Input.Search
placeholder="Search saved replies..."
allowClear
onSearch={this.handleSearchCannedResponses}
style={{width: 400}}
/>
</Box>

<Box my={4}>
<CannedResponsesTable
loading={loading}
cannedResponses={filteredCannedResponses}
onDeleteCannedResponse={this.handleDeleteCannedResponse}
/>
</Box>
</Container>
);
}
}

export default CannedResponsesOverview;
Loading

0 comments on commit 9ed1d08

Please sign in to comment.