Skip to content

Commit

Permalink
Adds ability to view and create notes from the customer details page (#…
Browse files Browse the repository at this point in the history
…755)

* Add notes basic

wip

adds ability to create note

wip

* Adds better note deletion handling

* Revert unnecessary change

* Addresses comments
  • Loading branch information
rhonsby authored Apr 20, 2021
1 parent a89ff64 commit 6cfc04e
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 4 deletions.
9 changes: 7 additions & 2 deletions assets/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
BrowserSession,
Conversation,
Customer,
CustomerNote,
Tag,
User,
WidgetSettings,
Expand Down Expand Up @@ -1087,16 +1088,20 @@ export const enableAccountUser = async (
.then((res) => res.body.data);
};

export type CustomerNotesListResponse = {
data: Array<CustomerNote>;
};

export const fetchCustomerNotes = async (
customerId: string,
token = getAccessToken()
) => {
): Promise<CustomerNote[]> => {
if (!token) {
throw new Error('Invalid token!');
}

return request
.get(`/api/notes`)
.get('/api/notes')
.query({customer_id: customerId})
.set('Authorization', token)
.then((res) => res.body.data);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {Box} from 'theme-ui';
import {Tabs} from '../common';
import CustomerDetailsCard from './CustomerDetailsCard';
import CustomerDetailsConversations from './CustomerDetailsConversations';
import CustomerDetailsNotes from './CustomerDetailsNotes';

const {TabPane} = Tabs;

Expand All @@ -31,7 +32,7 @@ const CustomerDetailsMainSection = ({customerId, history}: Props) => {
/>
</TabPane>
<TabPane tab={TAB_KEYS.Notes} key={TAB_KEYS.Notes}>
Notes
<CustomerDetailsNotes customerId={customerId} />
</TabPane>
</Tabs>
</Box>
Expand Down
70 changes: 70 additions & 0 deletions assets/src/components/customers/CustomerDetailsNewNoteInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, {useState} from 'react';
import * as API from '../../api';
import {Box} from 'theme-ui';
import logger from '../../logger';
import {CustomerNote} from '../../types';
import {TextArea, Text, Button} from '../common';
import {formatServerError} from '../../utils';

const CustomerDetailNewNoteInput = ({
customerId,
onCreateNote,
}: {
customerId: string;
onCreateNote: (note: CustomerNote) => void;
}) => {
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [note, setNote] = useState('');

const handleNoteChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNote(e.target.value);
};

const handleSaveNote = async () => {
if (isSaving || note.length < 1) {
return;
}

setIsSaving(true);

try {
const newNote = await API.createCustomerNote(customerId, note);
onCreateNote(newNote);
setNote('');
} catch (error) {
logger.error('Error creating customer note:', error);
const errorMessage = formatServerError(error);
setErrorMessage(errorMessage);
}

setIsSaving(false);
};

return (
<Box mb={2}>
<TextArea
style={{background: 'transparent'}}
placeholder="Add a note"
autoSize={{minRows: 2}}
disabled={isSaving}
value={note}
onChange={handleNoteChange}
/>
{errorMessage && (
<Box mt={3}>
<Text type="danger" strong>
{errorMessage}
</Text>
</Box>
)}
<Box mt={3}>
<Button onClick={handleSaveNote} disabled={isSaving || note.length < 1}>
Add Note
</Button>
</Box>
</Box>
);
};

export default CustomerDetailNewNoteInput;
150 changes: 150 additions & 0 deletions assets/src/components/customers/CustomerDetailsNotes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import React from 'react';
import dayjs from 'dayjs';
import {Box, Flex} from 'theme-ui';

import {colors, Divider, Empty, Popconfirm, Text} from '../common';
import {CustomerNote} from '../../types';
import {formatRelativeTime} from '../../utils';
import * as API from '../../api';
import CustomerDetailsNewNoteInput from './CustomerDetailsNewNoteInput';
import logger from '../../logger';
import Spinner from '../Spinner';

type Props = {customerId: string};
type State = {
customerNotes: CustomerNote[];
isLoading: boolean;
};

class CustomerDetailsNotes extends React.Component<Props, State> {
state: State = {
customerNotes: [],
isLoading: true,
};

componentDidMount() {
this.fetchCustomerNotes();
}

fetchCustomerNotes = async () => {
this.setState({isLoading: true});

try {
const customerNotes = await API.fetchCustomerNotes(this.props.customerId);
this.setState({customerNotes, isLoading: false});
} catch (error) {
logger.error('Failed to fetch cutsomer notes', error);
}
};

handleCreateNote = () => {
this.fetchCustomerNotes();
};

handleDeleteNote = async (note: CustomerNote) => {
try {
await API.deleteCustomerNote(note.id);
await this.fetchCustomerNotes();
} catch (error) {
logger.error('Failed to delete customer note', error);
}
};

render() {
const {customerId} = this.props;
const {isLoading, customerNotes} = this.state;

if (isLoading) {
return (
<Flex
p={4}
sx={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
height: '100%',
}}
>
<Spinner size={40} />
</Flex>
);
}

return (
<Box p={3}>
<CustomerDetailsNewNoteInput
customerId={customerId}
onCreateNote={this.handleCreateNote}
/>

<Divider dashed />

{customerNotes.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<Box>
{customerNotes
.sort((a: CustomerNote, b: CustomerNote) => {
return +new Date(b.created_at) - +new Date(a.created_at);
})
.map((note) => (
<CustomerDetailNote
key={note.id}
note={note}
onDeleteNote={this.handleDeleteNote}
/>
))}
</Box>
)}
</Box>
);
}
}

const CustomerDetailNote = ({
note,
onDeleteNote,
}: {
note: CustomerNote;
onDeleteNote: (note: CustomerNote) => void;
}) => {
const {created_at: createdAt} = note;
let authorIdentifier;

if (note.author) {
const {display_name: displayName, full_name: fullName, email} = note.author;
const authorName = displayName || fullName;
authorIdentifier = !!authorName ? `${authorName} · ${email}` : email;
}

return (
<Popconfirm
title="Delete this note?"
okText="Delete"
cancelText="Cancel"
placement="left"
onConfirm={() => onDeleteNote(note)}
>
<Box
py={2}
px={3}
mb={2}
sx={{
bg: colors.note,
borderRadius: 2,
cursor: 'pointer',
}}
>
<Box mb={3} sx={{whiteSpace: 'break-spaces'}}>
{note.body}
</Box>
<Flex sx={{justifyContent: 'space-between'}}>
<Text type="secondary">{authorIdentifier}</Text>
<Text type="secondary">{formatRelativeTime(dayjs(createdAt))}</Text>
</Flex>
</Box>
</Popconfirm>
);
};

export default CustomerDetailsNotes;
1 change: 1 addition & 0 deletions assets/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export type CustomerNote = {
author_id: number;
created_at: string;
updated_at: string;
author?: User;
};

export type Tag = {
Expand Down
1 change: 1 addition & 0 deletions lib/chat_api/notes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ defmodule ChatApi.Notes do
|> where(^filter_where(filters))
|> order_by(desc: :inserted_at)
|> Repo.all()
|> Repo.preload(author: :profile)
end

@spec get_note!(binary()) :: Note.t()
Expand Down
22 changes: 21 additions & 1 deletion lib/chat_api_web/views/note_view.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
defmodule ChatApiWeb.NoteView do
use ChatApiWeb, :view
alias ChatApiWeb.NoteView

alias ChatApiWeb.{
NoteView,
UserView
}

alias ChatApi.Users.User

def render("index.json", %{notes: notes}) do
%{data: render_many(notes, NoteView, "note.json")}
Expand All @@ -20,5 +26,19 @@ defmodule ChatApiWeb.NoteView do
created_at: note.inserted_at,
updated_at: note.updated_at
}
|> maybe_render_author(note)
end

defp maybe_render_author(json, %{author: author}) do
case author do
nil ->
Map.merge(json, %{author: nil})

%User{} = author ->
Map.merge(json, %{author: render_one(author, UserView, "user.json")})

_ ->
json
end
end
end

0 comments on commit 6cfc04e

Please sign in to comment.