feat: Add notes for Contacts (#3273)

Fixes #2275
This commit is contained in:
Pranav Raj S 2021-10-25 18:35:58 +05:30 committed by GitHub
parent e5e73a08fe
commit 8e6ce3a813
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 416 additions and 278 deletions

View file

@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
before_action :note, except: [:index, :create] before_action :note, except: [:index, :create]
def index def index
@notes = @contact.notes.includes(:user) @notes = @contact.notes.latest.includes(:user)
end end
def create def create

View file

@ -2,7 +2,27 @@ import ApiClient from './ApiClient';
class ContactNotes extends ApiClient { class ContactNotes extends ApiClient {
constructor() { constructor() {
super('contact_notes', { accountScoped: true }); super('notes', { accountScoped: true });
this.contactId = null;
}
get url() {
return `${this.baseUrl()}/contacts/${this.contactId}/notes`;
}
get(contactId) {
this.contactId = contactId;
return super.get();
}
create(contactId, content) {
this.contactId = contactId;
return super.create({ content });
}
delete(contactId, id) {
this.contactId = contactId;
return super.delete(id);
} }
} }

View file

@ -9,7 +9,7 @@
.card { .card {
margin-bottom: var(--space-small); margin-bottom: var(--space-small);
padding: var(--space-small); padding: var(--space-normal);
} }
.button-wrapper .button.link.grey-btn { .button-wrapper .button.link.grey-btn {

View file

@ -55,6 +55,10 @@
justify-content: space-between; justify-content: space-between;
} }
.w-100 { .w-full {
width: 100%; width: 100%;
} }
.h-full {
height: 100%;
}

View file

@ -1,3 +1,44 @@
.margin-right-small { .margin-right-small {
margin-right: var(--space-small); margin-right: var(--space-small);
} }
.fs-small {
font-size: var(--font-size-small);
}
.fs-default {
font-size: var(--font-size-default);
}
.fw-medium {
font-weight: var(--font-weight-medium);
}
.p-normal {
padding: var(--space-normal);
}
.overflow-scroll {
overflow: scroll;
}
.overflow-auto {
overflow: auto;
}
.overflow-hidden {
overflow: hidden;
}
.border-right {
border-right: 1px solid var(--color-border);
}
.border-left {
border-left: 1px solid var(--color-border);
}
.bg-white {
background-color: var(--white);
}

View file

@ -14,7 +14,6 @@
@import 'helper-classes'; @import 'helper-classes';
@import 'formulate'; @import 'formulate';
@import 'date-picker'; @import 'date-picker';
@import 'utility-helpers';
@import 'foundation-sites/scss/foundation'; @import 'foundation-sites/scss/foundation';
@import '~bourbon/core/bourbon'; @import '~bourbon/core/bourbon';
@ -50,3 +49,4 @@
@import 'plugins/multiselect'; @import 'plugins/multiselect';
@import 'plugins/dropdown'; @import 'plugins/dropdown';
@import '~shared/assets/stylesheets/ionicons'; @import '~shared/assets/stylesheets/ionicons';
@import 'utility-helpers';

View file

@ -21,10 +21,6 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
.margin-right-small {
margin-right: var(--space-small);
}
.display-flex { .display-flex {
display: flex; display: flex;
} }

View file

@ -1,6 +1,6 @@
<template> <template>
<span class="back-button ion-ios-arrow-left" @click.capture="goBack"> <span class="back-button ion-ios-arrow-left" @click.capture="goBack">
{{ $t('GENERAL_SETTINGS.BACK') }} {{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</span> </span>
</template> </template>
<script> <script>
@ -12,6 +12,10 @@ export default {
type: [String, Object], type: [String, Object],
default: '', default: '',
}, },
buttonLabel: {
type: String,
default: '',
},
}, },
methods: { methods: {
goBack() { goBack() {

View file

@ -64,6 +64,7 @@ export default {
placeholder: { type: String, default: '' }, placeholder: { type: String, default: '' },
isPrivate: { type: Boolean, default: false }, isPrivate: { type: Boolean, default: false },
isFormatMode: { type: Boolean, default: false }, isFormatMode: { type: Boolean, default: false },
enableSuggestions: { type: Boolean, default: true },
}, },
data() { data() {
return { return {
@ -78,6 +79,10 @@ export default {
}, },
computed: { computed: {
plugins() { plugins() {
if (!this.enableSuggestions) {
return [];
}
return [ return [
suggestionsPlugin({ suggestionsPlugin({
matcher: triggerCharacters('@'), matcher: triggerCharacters('@'),

View file

@ -7,6 +7,7 @@
"COMPANY": "Company", "COMPANY": "Company",
"LOCATION": "Location", "LOCATION": "Location",
"CONVERSATION_TITLE": "Conversation Details", "CONVERSATION_TITLE": "Conversation Details",
"VIEW_PROFILE": "View Profile",
"BROWSER": "Browser", "BROWSER": "Browser",
"OS": "Operating System", "OS": "Operating System",
"INITIATED_FROM": "Initiated from", "INITIATED_FROM": "Initiated from",
@ -188,6 +189,10 @@
"VIEW_DETAILS": "View details" "VIEW_DETAILS": "View details"
} }
}, },
"CONTACT_PROFILE": {
"BACK_BUTTON": "Contacts",
"LOADING": "Loading contact profile..."
},
"REMINDER": { "REMINDER": {
"ADD_BUTTON": { "ADD_BUTTON": {
"BUTTON": "Add", "BUTTON": "Add",
@ -199,16 +204,21 @@
} }
}, },
"NOTES": { "NOTES": {
"FETCHING_NOTES": "Fetching notes...",
"NOT_AVAILABLE": "There are no notes created for this contact",
"HEADER": { "HEADER": {
"TITLE": "Notes" "TITLE": "Notes"
}, },
"LIST": {
"LABEL": "added a note"
},
"ADD": { "ADD": {
"BUTTON": "Add", "BUTTON": "Add",
"PLACEHOLDER": "Add a note", "PLACEHOLDER": "Add a note",
"TITLE": "Shift + Enter to create a note" "TITLE": "Shift + Enter to create a note"
}, },
"FOOTER": { "CONTENT_HEADER": {
"BUTTON": "View all notes" "DELETE": "Delete note"
} }
}, },
"EVENTS": { "EVENTS": {

View file

@ -103,7 +103,8 @@
}, },
"APP_GLOBAL": { "APP_GLOBAL": {
"TRIAL_MESSAGE": "days trial remaining.", "TRIAL_MESSAGE": "days trial remaining.",
"TRAIL_BUTTON": "Buy Now" "TRAIL_BUTTON": "Buy Now",
"DELETED_USER": "Deleted User"
}, },
"COMPONENTS": { "COMPONENTS": {
"CODE": { "CODE": {

View file

@ -3,7 +3,7 @@ import { frontendURL } from '../../helper/URLHelper';
const contacts = accountId => ({ const contacts = accountId => ({
routes: [ routes: [
'contacts_dashboard', 'contacts_dashboard',
'contacts_dashboard_manage', 'contact_profile_dashboard',
'contacts_labels_dashboard', 'contacts_labels_dashboard',
], ],
menuItems: { menuItems: {

View file

@ -1,74 +0,0 @@
<template>
<div class="wrap">
<div class="left">
<contact-panel v-if="!uiFlags.isFetchingItem" :contact="contact" />
</div>
<div class="center"></div>
<div class="right">
<contact-notes :contact-id="Number(contactId)" />
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import ContactPanel from './ContactPanel';
import ContactNotes from 'dashboard/modules/notes/NotesOnContactPage';
export default {
components: {
ContactPanel,
ContactNotes,
},
props: {
contactId: {
type: [String, Number],
required: true,
},
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
showEmptySearchResult() {
const hasEmptyResults = !!this.searchQuery && this.records.length === 0;
return hasEmptyResults;
},
contact() {
return this.$store.getters['contacts/getContact'](this.contactId);
},
},
mounted() {
this.fetchContactDetails();
},
methods: {
fetchContactDetails() {
const { contactId: id } = this;
this.$store.dispatch('contacts/show', { id });
},
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/mixins';
.wrap {
@include three-column-grid(27.2rem);
min-height: 0;
background: var(--color-background-light);
border-top: 1px solid var(--color-border);
}
.left {
overflow: auto;
}
.center {
border-right: 1px solid var(--color-border);
border-left: 1px solid var(--color-border);
}
.right {
padding: var(--space-normal);
}
</style>

View file

@ -1,8 +1,14 @@
<template> <template>
<note-list :notes="notes" @add="onAdd" @delete="onDelete" /> <note-list
:is-fetching="uiFlags.isFetching"
:notes="notes"
@add="onAdd"
@delete="onDelete"
/>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
import NoteList from './components/NoteList'; import NoteList from './components/NoteList';
export default { export default {
@ -16,6 +22,7 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters({ uiFlags: 'contactNotes/getUIFlags' }),
notes() { notes() {
return this.$store.getters['contactNotes/getAllNotesByContact']( return this.$store.getters['contactNotes/getAllNotesByContact'](
this.contactId this.contactId

View file

@ -1,45 +1,64 @@
<template> <template>
<div class="card"> <div class="card">
<textarea <woot-message-editor
v-model="inputText" v-model="noteContent"
:placeholder="$t('NOTES.ADD.PLACEHOLDER')"
class="input--note" class="input--note"
@keydown.enter.shift.exact="onAdd" :placeholder="$t('NOTES.ADD.PLACEHOLDER')"
:enable-suggestions="false"
/> />
<div class="footer"> <div class="footer">
<woot-button <woot-button
size="tiny"
color-scheme="warning" color-scheme="warning"
:title="$t('NOTES.ADD.TITLE')" :title="$t('NOTES.ADD.TITLE')"
:is-disabled="buttonDisabled" :is-disabled="buttonDisabled"
@click="onAdd" @click="onAdd"
> >
{{ $t('NOTES.ADD.BUTTON') }} {{ $t('NOTES.ADD.BUTTON') }} ()
</woot-button> </woot-button>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import { hasPressedCommandAndEnter } from 'shared/helpers/KeyboardHelpers';
export default { export default {
components: {
WootMessageEditor,
},
data() { data() {
return { return {
inputText: '', noteContent: '',
}; };
}, },
computed: { computed: {
buttonDisabled() { buttonDisabled() {
return this.inputText === ''; return this.noteContent === '';
}, },
}, },
mounted() {
document.addEventListener('keydown', this.onMetaEnter);
},
beforeDestroy() {
document.removeEventListener('keydown', this.onMetaEnter);
},
methods: { methods: {
onAdd() { onMetaEnter(e) {
if (this.inputText !== '') { if (hasPressedCommandAndEnter(e)) {
this.$emit('add', this.inputText); e.preventDefault();
this.onAdd();
} }
this.inputText = ''; },
onAdd() {
if (this.noteContent !== '') {
this.$emit('add', this.noteContent);
}
this.noteContent = '';
}, },
}, },
}; };
@ -47,12 +66,14 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.input--note { .input--note {
font-size: var(--font-size-mini); &::v-deep .ProseMirror-menubar {
border-color: transparent; padding: 0;
margin-bottom: var(--space-small); margin-top: var(--space-minus-small);
padding: 0; }
resize: none;
min-height: var(--space-larger); &::v-deep .ProseMirror-woot-style {
max-height: 36rem;
}
} }
.footer { .footer {

View file

@ -1,26 +1,22 @@
<template> <template>
<div class="card note-wrap"> <div class="card note-wrap">
<p class="note__content"> <div class="header">
{{ note }}
</p>
<div class="footer">
<div class="meta"> <div class="meta">
<div :title="userName"> <thumbnail
<thumbnail :src="thumbnail" :username="userName" size="16px" /> :title="noteAuthorName"
</div> :src="noteAuthor.thumbnail"
:username="noteAuthorName"
size="20px"
/>
<div class="date-wrap"> <div class="date-wrap">
<span>{{ readableTime }}</span> <span class="fw-medium"> {{ noteAuthorName }} </span>
<span> {{ $t('NOTES.LIST.LABEL') }} </span>
<span class="fw-medium"> {{ readableTime }} </span>
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<woot-button <woot-button
variant="smooth" v-tooltip="$t('NOTES.CONTENT_HEADER.DELETE')"
size="tiny"
icon="ion-compose"
color-scheme="secondary"
@click="onEdit"
/>
<woot-button
variant="smooth" variant="smooth"
size="tiny" size="tiny"
icon="ion-trash-b" icon="ion-trash-b"
@ -29,19 +25,21 @@
/> />
</div> </div>
</div> </div>
<p class="note__content" v-html="formatMessage(note || '')" />
</div> </div>
</template> </template>
<script> <script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail'; import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import timeMixin from 'dashboard/mixins/time'; import timeMixin from 'dashboard/mixins/time';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
export default { export default {
components: { components: {
Thumbnail, Thumbnail,
}, },
mixins: [timeMixin], mixins: [timeMixin, messageFormatterMixin],
props: { props: {
id: { id: {
@ -52,30 +50,29 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
userName: { user: {
type: String, type: Object,
default: '', default: () => {},
}, },
timeStamp: { createdAt: {
type: Number, type: Number,
default: 0, default: 0,
}, },
thumbnail: {
type: String,
default: '',
},
}, },
computed: { computed: {
readableTime() { readableTime() {
return this.dynamicTime(this.timeStamp); return this.dynamicTime(this.createdAt);
},
noteAuthor() {
return this.user || {};
},
noteAuthorName() {
return this.noteAuthor.name || this.$t('APP_GLOBAL.DELETED_USER');
}, },
}, },
methods: { methods: {
onEdit() {
this.$emit('edit', this.id);
},
onDelete() { onDelete() {
this.$emit('delete', this.id); this.$emit('delete', this.id);
}, },
@ -85,24 +82,23 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.note__content { .note__content {
font-size: var(--font-size-mini); margin-top: var(--space-normal);
margin-bottom: var(--space-smaller);
} }
.footer { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-end; align-items: flex-end;
font-size: var(--font-size-mini);
.meta { .meta {
display: flex; display: flex;
padding: var(--space-smaller) 0; align-items: center;
.date-wrap { .date-wrap {
margin-left: var(--space-smaller); margin-left: var(--space-smaller);
padding: var(--space-micro); padding: var(--space-micro);
color: var(--color-body); color: var(--color-body);
font-size: var(--font-size-micro);
} }
} }
.actions { .actions {

View file

@ -1,39 +1,37 @@
<template> <template>
<div> <div>
<div class="notelist-wrap"> <add-note @add="onAddNote" />
<h3 class="block-title"> <contact-note
{{ $t('NOTES.HEADER.TITLE') }} v-for="note in notes"
</h3> :id="note.id"
<add-note @add="onAddNote" /> :key="note.id"
<contact-note :note="note.content"
v-for="note in notes" :user="note.user"
:id="note.id" :created-at="note.created_at"
:key="note.id" @edit="onEditNote"
:note="note.content" @delete="onDeleteNote"
:user-name="note.user.name" />
:time-stamp="note.created_at"
:thumbnail="note.user.thumbnail" <div v-if="isFetching" class="text-center p-normal fs-default">
@edit="onEditNote" <spinner size="" />
@delete="onDeleteNote" <span>{{ $t('NOTES.FETCHING_NOTES') }}</span>
/> </div>
<div class="button-wrap"> <div v-else-if="!notes.length" class="text-center p-normal fs-default">
<woot-button variant="link" @click="onclick"> <span>{{ $t('NOTES.NOT_AVAILABLE') }}</span>
{{ $t('NOTES.FOOTER.BUTTON') }}
<i class="ion-arrow-right-c" />
</woot-button>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import ContactNote from './ContactNote';
import AddNote from './AddNote'; import AddNote from './AddNote';
import ContactNote from './ContactNote';
import Spinner from 'shared/components/Spinner';
export default { export default {
components: { components: {
ContactNote,
AddNote, AddNote,
ContactNote,
Spinner,
}, },
props: { props: {
@ -41,12 +39,13 @@ export default {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
isFetching: {
type: Boolean,
default: false,
},
}, },
methods: { methods: {
onclick() {
this.$emit('show');
},
onAddNote(value) { onAddNote(value) {
this.$emit('add', value); this.$emit('add', value);
}, },
@ -59,9 +58,3 @@ export default {
}, },
}; };
</script> </script>
<style lang="scss" scoped>
.button-wrap {
margin-top: var(--space-one);
}
</style>

View file

@ -1,9 +1,17 @@
<template> <template>
<div class="medium-3 bg-white contact--panel"> <div
<span class="close-button" @click="onClose"> class="small-12 medium-3 bg-white contact--panel"
:class="{ 'border-left': showAvatar }"
>
<span v-if="showAvatar" class="close-button" @click="onClose">
<i class="ion-android-close close-icon" /> <i class="ion-android-close close-icon" />
</span> </span>
<contact-info show-new-message :contact="contact" @panel-close="onClose" /> <contact-info
:show-avatar="showAvatar"
show-new-message
:contact="contact"
@panel-close="onClose"
/>
<accordion-item <accordion-item
:title="$t('CONTACT_PANEL.SIDEBAR_SECTIONS.CUSTOM_ATTRIBUTES')" :title="$t('CONTACT_PANEL.SIDEBAR_SECTIONS.CUSTOM_ATTRIBUTES')"
:is-open="isContactSidebarItemOpen('is_ct_custom_attr_open')" :is-open="isContactSidebarItemOpen('is_ct_custom_attr_open')"
@ -61,6 +69,10 @@ export default {
type: Function, type: Function,
default: () => {}, default: () => {},
}, },
showAvatar: {
type: Boolean,
default: true,
},
}, },
computed: { computed: {
hasContactAttributes() { hasContactAttributes() {
@ -85,7 +97,7 @@ export default {
overflow-y: auto; overflow-y: auto;
overflow: auto; overflow: auto;
position: relative; position: relative;
border-left: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
} }
.close-button { .close-button {

View file

@ -28,6 +28,7 @@
<script> <script>
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import { VeTable } from 'vue-easytable'; import { VeTable } from 'vue-easytable';
import flag from 'country-code-emoji';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
@ -94,10 +95,10 @@ export default {
...item, ...item,
phone_number: item.phone_number || '---', phone_number: item.phone_number || '---',
company: additional.company_name || '---', company: additional.company_name || '---',
location: additional.location || '---',
profiles: additional.social_profiles || {}, profiles: additional.social_profiles || {},
city: additional.city || '---', city: additional.city || '---',
country: additional.country || '---', country: additional.country,
countryCode: additional.country_code,
conversationsCount: item.conversations_count || '---', conversationsCount: item.conversations_count || '---',
last_activity_at: lastActivityAt last_activity_at: lastActivityAt
? this.dynamicTime(lastActivityAt) ? this.dynamicTime(lastActivityAt)
@ -128,12 +129,17 @@ export default {
status={row.availability_status} status={row.availability_status}
/> />
<div class="user-block"> <div class="user-block">
<h6 class="sub-block-title user-name text-truncate"> <h6 class="sub-block-title text-truncate">
{row.name} <router-link
to={`/app/accounts/${this.$route.params.accountId}/contacts/${row.id}`}
class="user-name"
>
{row.name}
</router-link>
</h6> </h6>
<span class="button clear small link"> <button class="button clear small link view-details--button">
{this.$t('CONTACTS_PAGE.LIST.VIEW_DETAILS')} {this.$t('CONTACTS_PAGE.LIST.VIEW_DETAILS')}
</span> </button>
</div> </div>
</div> </div>
</woot-button> </woot-button>
@ -186,6 +192,16 @@ export default {
key: 'country', key: 'country',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.COUNTRY'), title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.COUNTRY'),
align: 'left', align: 'left',
renderBodyCell: ({ row }) => {
if (row.country) {
return (
<div class="text-truncate">
{`${flag(row.countryCode)} ${row.country}`}
</div>
);
}
return '---';
},
}, },
{ {
field: 'profiles', field: 'profiles',
@ -281,10 +297,15 @@ export default {
.user-name { .user-name {
font-size: var(--font-size-small); font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
margin: 0; margin: 0;
text-transform: capitalize; text-transform: capitalize;
} }
.view-details--button {
color: var(--color-body);
}
.user-email { .user-email {
margin: 0; margin: 0;
} }

View file

@ -1,81 +1,136 @@
<template> <template>
<div class="contact-manage-view"> <div class="view-box columns bg-white">
<contacts-header <settings-header
:search-query="searchQuery" button-route="new"
:on-search-submit="onSearchSubmit" :header-title="contact.name"
:on-input-search="onInputSearch" show-back-button
:on-toggle-create="onToggleCreate" :back-button-label="$t('CONTACT_PROFILE.BACK_BUTTON')"
/> :back-url="backUrl"
<manage-layout :contact-id="contactId" /> :show-new-button="false"
>
<thumbnail
v-if="contact.thumbnail"
:src="contact.thumbnail"
:username="contact.name"
size="32px"
class="margin-right-small"
/>
</settings-header>
<create-contact :show="showCreateModal" @cancel="onToggleCreate" /> <div
v-if="uiFlags.isFetchingItem"
class="text-center p-normal fs-default h-full"
>
<spinner size="" />
<span>{{ $t('CONTACT_PROFILE.LOADING') }}</span>
</div>
<div
v-else-if="contact.id"
class="overflow-hidden column contact--dashboard-content"
>
<div class="row h-full">
<contact-info-panel :show-avatar="false" :contact="contact" />
<div class="small-12 medium-9 h-full">
<woot-tabs :index="selectedTabIndex" @change="onClickTabChange">
<woot-tabs-item
v-for="tab in tabs"
:key="tab.key"
:name="tab.name"
:show-badge="false"
/>
</woot-tabs>
<div class="tab-content overflow-auto">
<contact-notes
v-if="selectedTabIndex === 0"
:contact-id="Number(contactId)"
/>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ContactsHeader from '../components/Header'; import ContactInfoPanel from '../components/ContactInfoPanel.vue';
import ManageLayout from 'dashboard/modules/contact/components/ManageLayout'; import ContactNotes from 'dashboard/modules/notes/NotesOnContactPage';
import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact'; import SettingsHeader from '../../settings/SettingsHeader.vue';
import Spinner from 'shared/components/Spinner';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
export default { export default {
components: { components: {
ContactsHeader, ContactInfoPanel,
CreateContact, ContactNotes,
ManageLayout, SettingsHeader,
Spinner,
Thumbnail,
}, },
props: { props: {
contactId: { contactId: {
type: [String, Number], type: [String, Number],
default: 0, required: true,
}, },
}, },
data() { data() {
return { return {
searchQuery: '', selectedTabIndex: 0,
showCreateModal: false,
}; };
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
uiFlags: 'contacts/getUIFlags', uiFlags: 'contacts/getUIFlags',
}), }),
tabs() {
return [
{
key: 0,
name: this.$t('NOTES.HEADER.TITLE'),
},
];
},
showEmptySearchResult() { showEmptySearchResult() {
const hasEmptyResults = !!this.searchQuery && this.records.length === 0; const hasEmptyResults = !!this.searchQuery && this.records.length === 0;
return hasEmptyResults; return hasEmptyResults;
}, },
contact() {
return this.$store.getters['contacts/getContact'](this.contactId);
},
backUrl() {
return `/app/accounts/${this.$route.params.accountId}/contacts`;
},
},
mounted() {
this.fetchContactDetails();
}, },
mounted() {},
methods: { methods: {
onInputSearch(event) { onClickTabChange(index) {
const newQuery = event.target.value; this.selectedTabIndex = index;
const refetchAllContacts = !!this.searchQuery && newQuery === '';
if (refetchAllContacts) {
this.$store.dispatch('contacts/get', { page: 1 });
}
this.searchQuery = newQuery;
}, },
onSearchSubmit() { fetchContactDetails() {
this.selectedContactId = ''; const { contactId: id } = this;
if (this.searchQuery) { this.$store.dispatch('contacts/show', { id });
this.$store.dispatch('contacts/search', {
search: this.searchQuery,
page: 1,
});
}
},
onToggleCreate() {
this.showCreateModal = !this.showCreateModal;
}, },
}, },
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.contact-manage-view { @import '~dashboard/assets/scss/mixins';
display: flex;
flex-direction: column; .left {
width: 100%; border-right: 1px solid var(--color-border);
flex: 1 1 0; overflow: auto;
}
.right {
padding: var(--space-normal);
}
.tab-content {
background: var(--color-background-light);
height: calc(100% - 40px);
padding: var(--space-normal);
} }
</style> </style>

View file

@ -21,7 +21,7 @@ export const routes = [
}, },
{ {
path: frontendURL('accounts/:accountId/contacts/:contactId'), path: frontendURL('accounts/:accountId/contacts/:contactId'),
name: 'contacts_dashboard_manage', name: 'contact_profile_dashboard',
roles: ['administrator', 'agent'], roles: ['administrator', 'agent'],
component: ContactManageView, component: ContactManageView,
props: route => { props: route => {

View file

@ -222,9 +222,8 @@ export default {
return this.additionalAttributes.initiated_at; return this.additionalAttributes.initiated_at;
}, },
browserName() { browserName() {
return `${this.browser.browser_name || ''} ${ return `${this.browser.browser_name || ''} ${this.browser
this.browser.browser_version || '' .browser_version || ''}`;
}`;
}, },
contactAdditionalAttributes() { contactAdditionalAttributes() {
return this.contact.additional_attributes || {}; return this.contact.additional_attributes || {};
@ -248,8 +247,10 @@ export default {
return `${cityAndCountry} ${countryFlag}`; return `${cityAndCountry} ${countryFlag}`;
}, },
platformName() { platformName() {
const { platform_name: platformName, platform_version: platformVersion } = const {
this.browser; platform_name: platformName,
platform_version: platformVersion,
} = this.browser;
return `${platformName || ''} ${platformVersion || ''}`; return `${platformName || ''} ${platformVersion || ''}`;
}, },
channelType() { channelType() {
@ -403,7 +404,7 @@ export default {
::v-deep { ::v-deep {
.contact--profile { .contact--profile {
padding-bottom: var(--space-slab); padding-bottom: var(--space-slab);
border-bottom: 1px solid var(--color-border-light); border-bottom: 1px solid var(--color-border);
} }
.conversation--actions .multiselect-wrap--small { .conversation--actions .multiselect-wrap--small {
.multiselect { .multiselect {

View file

@ -2,6 +2,7 @@
<div class="contact--profile"> <div class="contact--profile">
<div class="contact--info"> <div class="contact--info">
<thumbnail <thumbnail
v-if="showAvatar"
:src="contact.thumbnail" :src="contact.thumbnail"
size="56px" size="56px"
:username="contact.name" :username="contact.name"
@ -9,8 +10,16 @@
/> />
<div class="contact--details"> <div class="contact--details">
<h3 class="sub-block-title contact--name"> <h3 v-if="showAvatar" class="sub-block-title contact--name">
{{ contact.name }} <a
:href="contactProfileLink"
class="fs-default"
target="_blank"
rel="noopener nofollow noreferrer"
>
{{ contact.name }}
<i class="ion-android-open open-link--icon" />
</a>
</h3> </h3>
<p v-if="additionalAttributes.description" class="contact--bio"> <p v-if="additionalAttributes.description" class="contact--bio">
{{ additionalAttributes.description }} {{ additionalAttributes.description }}
@ -25,7 +34,6 @@
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')" :title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
show-copy show-copy
/> />
<contact-info-row <contact-info-row
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''" :href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
:value="contact.phone_number" :value="contact.phone_number"
@ -161,6 +169,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showAvatar: {
type: Boolean,
default: true,
},
}, },
data() { data() {
return { return {
@ -172,6 +184,9 @@ export default {
}, },
computed: { computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }), ...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
additionalAttributes() { additionalAttributes() {
return this.contact.additional_attributes || {}; return this.contact.additional_attributes || {};
}, },
@ -266,6 +281,16 @@ export default {
.contact--name { .contact--name {
text-transform: capitalize; text-transform: capitalize;
white-space: normal; white-space: normal;
a {
color: var(--color-body);
}
.open-link--icon {
color: var(--color-body);
font-size: var(--font-size-small);
margin-left: var(--space-smaller);
}
} }
.contact--metadata { .contact--metadata {

View file

@ -2,8 +2,13 @@
<div class="settings-header"> <div class="settings-header">
<h1 class="page-title"> <h1 class="page-title">
<woot-sidemenu-icon></woot-sidemenu-icon> <woot-sidemenu-icon></woot-sidemenu-icon>
<back-button v-if="showBackButton" :back-url="backUrl"></back-button> <back-button
<i :class="iconClass"></i> v-if="showBackButton"
:button-label="backButtonLabel"
:back-url="backUrl"
/>
<i v-if="icon" :class="iconClass"></i>
<slot></slot>
<span>{{ headerTitle }}</span> <span>{{ headerTitle }}</span>
</h1> </h1>
<router-link <router-link
@ -51,6 +56,10 @@ export default {
type: [String, Object], type: [String, Object],
default: '', default: '',
}, },
backButtonLabel: {
type: String,
default: '',
},
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({

View file

@ -1,4 +1,4 @@
import * as types from '../mutation-types'; import types from '../mutation-types';
import Vue from 'vue'; import Vue from 'vue';
import ContactNotesAPI from '../../api/contactNotes'; import ContactNotesAPI from '../../api/contactNotes';
@ -13,7 +13,8 @@ export const state = {
export const getters = { export const getters = {
getAllNotesByContact: _state => contactId => { getAllNotesByContact: _state => contactId => {
return _state.records[contactId] || []; const records = _state.records[contactId] || [];
return records.sort((r1, r2) => r2.id - r1.id);
}, },
getUIFlags(_state) { getUIFlags(_state) {
return _state.uiFlags; return _state.uiFlags;
@ -22,76 +23,58 @@ export const getters = {
export const actions = { export const actions = {
async get({ commit }, { contactId }) { async get({ commit }, { contactId }) {
commit(types.default.SET_CONTACT_NOTES_UI_FLAG, { commit(types.SET_CONTACT_NOTES_UI_FLAG, { isFetching: true });
isFetching: true,
});
try { try {
const { data } = await ContactNotesAPI.get(contactId); const { data } = await ContactNotesAPI.get(contactId);
commit(types.default.SET_CONTACT_NOTES, { contactId, data }); commit(types.SET_CONTACT_NOTES, { contactId, data });
} catch (error) { } catch (error) {
throw new Error(error); throw new Error(error);
} finally { } finally {
commit(types.default.SET_CONTACT_NOTES_UI_FLAG, { commit(types.SET_CONTACT_NOTES_UI_FLAG, { isFetching: false });
isFetching: false,
});
} }
}, },
async create({ commit }, { contactId, content }) { async create({ commit }, { contactId, content }) {
commit(types.default.SET_CONTACT_NOTES_UI_FLAG, { commit(types.SET_CONTACT_NOTES_UI_FLAG, { isCreating: true });
isCreating: true,
});
try { try {
const { data } = await ContactNotesAPI.create({ const { data } = await ContactNotesAPI.create(contactId, content);
content, commit(types.ADD_CONTACT_NOTE, { contactId, data });
contactId,
});
commit(types.default.ADD_CONTACT_NOTE, {
contactId,
data,
});
} catch (error) { } catch (error) {
throw new Error(error); throw new Error(error);
} finally { } finally {
commit(types.default.SET_CONTACT_NOTES_UI_FLAG, { commit(types.SET_CONTACT_NOTES_UI_FLAG, { isCreating: false });
isCreating: false,
});
} }
}, },
async delete({ commit }, { noteId, contactId }) { async delete({ commit }, { noteId, contactId }) {
commit(types.default.SET_CONTACT_NOTES_UI_FLAG, { commit(types.SET_CONTACT_NOTES_UI_FLAG, { isDeleting: true });
isDeleting: true,
});
try { try {
await ContactNotesAPI.delete(contactId, noteId); await ContactNotesAPI.delete(contactId, noteId);
commit(types.default.DELETE_CONTACT_NOTE, { contactId, noteId }); commit(types.DELETE_CONTACT_NOTE, { contactId, noteId });
} catch (error) { } catch (error) {
throw new Error(error); throw new Error(error);
} finally { } finally {
commit(types.default.SET_CONTACT_NOTES_UI_FLAG, { commit(types.SET_CONTACT_NOTES_UI_FLAG, { isDeleting: false });
isDeleting: false,
});
} }
}, },
}; };
export const mutations = { export const mutations = {
[types.default.SET_CONTACT_NOTES_UI_FLAG](_state, data) { [types.SET_CONTACT_NOTES_UI_FLAG](_state, data) {
_state.uiFlags = { _state.uiFlags = {
..._state.uiFlags, ..._state.uiFlags,
...data, ...data,
}; };
}, },
[types.default.SET_CONTACT_NOTES]($state, { data, contactId }) { [types.SET_CONTACT_NOTES]($state, { data, contactId }) {
Vue.set($state.records, contactId, data); Vue.set($state.records, contactId, data);
}, },
[types.default.ADD_CONTACT_NOTE]($state, { data, contactId }) { [types.ADD_CONTACT_NOTE]($state, { data, contactId }) {
const contactNotes = $state.records[contactId] || []; const contactNotes = $state.records[contactId] || [];
$state.records[contactId] = [...contactNotes, data]; $state.records[contactId] = [...contactNotes, data];
}, },
[types.default.DELETE_CONTACT_NOTE]($state, { noteId, contactId }) { [types.DELETE_CONTACT_NOTE]($state, { noteId, contactId }) {
const contactNotes = $state.records[contactId]; const contactNotes = $state.records[contactId];
const withoutDeletedNote = contactNotes.filter(note => note.id !== noteId); const withoutDeletedNote = contactNotes.filter(note => note.id !== noteId);
$state.records[contactId] = [...withoutDeletedNote]; $state.records[contactId] = [...withoutDeletedNote];

View file

@ -10,6 +10,10 @@ export const hasPressedShift = e => {
return e.shiftKey; return e.shiftKey;
}; };
export const hasPressedCommandAndEnter = e => {
return e.metaKey && e.keyCode === 13;
};
export const hasPressedCommandAndForwardSlash = e => { export const hasPressedCommandAndForwardSlash = e => {
return e.metaKey && e.keyCode === 191; return e.metaKey && e.keyCode === 191;
}; };

View file

@ -33,6 +33,8 @@ class Note < ApplicationRecord
belongs_to :contact belongs_to :contact
belongs_to :user belongs_to :user
scope :latest, -> { order(created_at: :desc) }
private private
def ensure_account_id def ensure_account_id

View file

@ -2,8 +2,10 @@ json.id resource.id
json.content resource.content json.content resource.content
json.account_id json.account_id json.account_id json.account_id
json.contact_id json.contact_id json.contact_id json.contact_id
json.user do if resource.user.present?
json.partial! 'api/v1/models/agent.json.jbuilder', resource: resource.user json.user do
json.partial! 'api/v1/models/agent.json.jbuilder', resource: resource.user
end
end end
json.created_at resource.created_at json.created_at resource.created_at.to_i
json.updated_at resource.updated_at json.updated_at resource.updated_at.to_i

View file

@ -4309,9 +4309,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0" lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001214: caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001214:
version "1.0.30001219" version "1.0.30001271"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001219.tgz#5bfa5d0519f41f993618bd318f606a4c4c16156b" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001271.tgz"
integrity sha512-c0yixVG4v9KBc/tQ2rlbB3A/bgBFRvl8h8M4IeUbqCca4gsiCfvtaheUssbnux/Mb66Vjz7x8yYjDgYcNQOhyQ== integrity sha512-BBruZFWmt3HFdVPS8kceTBIguKxu4f99n5JNp06OlPD/luoAMIaIK5ieV5YjnBLH3Nysai9sxj9rpJj4ZisXOA==
capture-exit@^2.0.0: capture-exit@^2.0.0:
version "2.0.0" version "2.0.0"