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]
def index
@notes = @contact.notes.includes(:user)
@notes = @contact.notes.latest.includes(:user)
end
def create

View file

@ -2,7 +2,27 @@ import ApiClient from './ApiClient';
class ContactNotes extends ApiClient {
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 {
margin-bottom: var(--space-small);
padding: var(--space-small);
padding: var(--space-normal);
}
.button-wrapper .button.link.grey-btn {

View file

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

View file

@ -1,3 +1,44 @@
.margin-right-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 'formulate';
@import 'date-picker';
@import 'utility-helpers';
@import 'foundation-sites/scss/foundation';
@import '~bourbon/core/bourbon';
@ -50,3 +49,4 @@
@import 'plugins/multiselect';
@import 'plugins/dropdown';
@import '~shared/assets/stylesheets/ionicons';
@import 'utility-helpers';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { frontendURL } from '../../helper/URLHelper';
const contacts = accountId => ({
routes: [
'contacts_dashboard',
'contacts_dashboard_manage',
'contact_profile_dashboard',
'contacts_labels_dashboard',
],
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>
<note-list :notes="notes" @add="onAdd" @delete="onDelete" />
<note-list
:is-fetching="uiFlags.isFetching"
:notes="notes"
@add="onAdd"
@delete="onDelete"
/>
</template>
<script>
import { mapGetters } from 'vuex';
import NoteList from './components/NoteList';
export default {
@ -16,6 +22,7 @@ export default {
},
},
computed: {
...mapGetters({ uiFlags: 'contactNotes/getUIFlags' }),
notes() {
return this.$store.getters['contactNotes/getAllNotesByContact'](
this.contactId

View file

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

View file

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

View file

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

View file

@ -1,9 +1,17 @@
<template>
<div class="medium-3 bg-white contact--panel">
<span class="close-button" @click="onClose">
<div
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" />
</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
:title="$t('CONTACT_PANEL.SIDEBAR_SECTIONS.CUSTOM_ATTRIBUTES')"
:is-open="isContactSidebarItemOpen('is_ct_custom_attr_open')"
@ -61,6 +69,10 @@ export default {
type: Function,
default: () => {},
},
showAvatar: {
type: Boolean,
default: true,
},
},
computed: {
hasContactAttributes() {
@ -85,7 +97,7 @@ export default {
overflow-y: auto;
overflow: auto;
position: relative;
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
}
.close-button {

View file

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

View file

@ -1,81 +1,136 @@
<template>
<div class="contact-manage-view">
<contacts-header
:search-query="searchQuery"
:on-search-submit="onSearchSubmit"
:on-input-search="onInputSearch"
:on-toggle-create="onToggleCreate"
/>
<manage-layout :contact-id="contactId" />
<div class="view-box columns bg-white">
<settings-header
button-route="new"
:header-title="contact.name"
show-back-button
:back-button-label="$t('CONTACT_PROFILE.BACK_BUTTON')"
:back-url="backUrl"
: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>
</template>
<script>
import { mapGetters } from 'vuex';
import ContactsHeader from '../components/Header';
import ManageLayout from 'dashboard/modules/contact/components/ManageLayout';
import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact';
import ContactInfoPanel from '../components/ContactInfoPanel.vue';
import ContactNotes from 'dashboard/modules/notes/NotesOnContactPage';
import SettingsHeader from '../../settings/SettingsHeader.vue';
import Spinner from 'shared/components/Spinner';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
export default {
components: {
ContactsHeader,
CreateContact,
ManageLayout,
ContactInfoPanel,
ContactNotes,
SettingsHeader,
Spinner,
Thumbnail,
},
props: {
contactId: {
type: [String, Number],
default: 0,
required: true,
},
},
data() {
return {
searchQuery: '',
showCreateModal: false,
selectedTabIndex: 0,
};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
tabs() {
return [
{
key: 0,
name: this.$t('NOTES.HEADER.TITLE'),
},
];
},
showEmptySearchResult() {
const hasEmptyResults = !!this.searchQuery && this.records.length === 0;
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: {
onInputSearch(event) {
const newQuery = event.target.value;
const refetchAllContacts = !!this.searchQuery && newQuery === '';
if (refetchAllContacts) {
this.$store.dispatch('contacts/get', { page: 1 });
}
this.searchQuery = newQuery;
onClickTabChange(index) {
this.selectedTabIndex = index;
},
onSearchSubmit() {
this.selectedContactId = '';
if (this.searchQuery) {
this.$store.dispatch('contacts/search', {
search: this.searchQuery,
page: 1,
});
}
},
onToggleCreate() {
this.showCreateModal = !this.showCreateModal;
fetchContactDetails() {
const { contactId: id } = this;
this.$store.dispatch('contacts/show', { id });
},
},
};
</script>
<style lang="scss" scoped>
.contact-manage-view {
display: flex;
flex-direction: column;
width: 100%;
flex: 1 1 0;
@import '~dashboard/assets/scss/mixins';
.left {
border-right: 1px solid var(--color-border);
overflow: auto;
}
.right {
padding: var(--space-normal);
}
.tab-content {
background: var(--color-background-light);
height: calc(100% - 40px);
padding: var(--space-normal);
}
</style>

View file

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

View file

@ -222,9 +222,8 @@ export default {
return this.additionalAttributes.initiated_at;
},
browserName() {
return `${this.browser.browser_name || ''} ${
this.browser.browser_version || ''
}`;
return `${this.browser.browser_name || ''} ${this.browser
.browser_version || ''}`;
},
contactAdditionalAttributes() {
return this.contact.additional_attributes || {};
@ -248,8 +247,10 @@ export default {
return `${cityAndCountry} ${countryFlag}`;
},
platformName() {
const { platform_name: platformName, platform_version: platformVersion } =
this.browser;
const {
platform_name: platformName,
platform_version: platformVersion,
} = this.browser;
return `${platformName || ''} ${platformVersion || ''}`;
},
channelType() {
@ -403,7 +404,7 @@ export default {
::v-deep {
.contact--profile {
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 {
.multiselect {

View file

@ -2,6 +2,7 @@
<div class="contact--profile">
<div class="contact--info">
<thumbnail
v-if="showAvatar"
:src="contact.thumbnail"
size="56px"
:username="contact.name"
@ -9,8 +10,16 @@
/>
<div class="contact--details">
<h3 class="sub-block-title contact--name">
{{ contact.name }}
<h3 v-if="showAvatar" class="sub-block-title 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>
<p v-if="additionalAttributes.description" class="contact--bio">
{{ additionalAttributes.description }}
@ -25,7 +34,6 @@
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
show-copy
/>
<contact-info-row
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
:value="contact.phone_number"
@ -161,6 +169,10 @@ export default {
type: Boolean,
default: false,
},
showAvatar: {
type: Boolean,
default: true,
},
},
data() {
return {
@ -172,6 +184,9 @@ export default {
},
computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
additionalAttributes() {
return this.contact.additional_attributes || {};
},
@ -266,6 +281,16 @@ export default {
.contact--name {
text-transform: capitalize;
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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4309,9 +4309,9 @@ caniuse-api@^3.0.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:
version "1.0.30001219"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001219.tgz#5bfa5d0519f41f993618bd318f606a4c4c16156b"
integrity sha512-c0yixVG4v9KBc/tQ2rlbB3A/bgBFRvl8h8M4IeUbqCca4gsiCfvtaheUssbnux/Mb66Vjz7x8yYjDgYcNQOhyQ==
version "1.0.30001271"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001271.tgz"
integrity sha512-BBruZFWmt3HFdVPS8kceTBIguKxu4f99n5JNp06OlPD/luoAMIaIK5ieV5YjnBLH3Nysai9sxj9rpJj4ZisXOA==
capture-exit@^2.0.0:
version "2.0.0"