feat: COntact sidebar

This commit is contained in:
iamsivin
2024-11-05 21:34:05 +05:30
parent 759ce1229d
commit fa8a7db612
8 changed files with 307 additions and 75 deletions
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, useSlots } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -52,6 +52,7 @@ const emit = defineEmits([
]);
const { t } = useI18n();
const slots = useSlots();
const selectedContactName = computed(() => {
return props.selectedContact?.name;
@@ -72,6 +73,10 @@ const breadcrumbItems = computed(() => {
return items;
});
const hasSidebar = computed(() => {
return slots.sidebar && props.isDetailView;
});
const handleBreadcrumbClick = () => {
emit('goToContactsList');
};
@@ -82,66 +87,89 @@ const updateCurrentPage = page => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[900px] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span
v-if="!isDetailView"
class="text-xl font-medium text-n-slate-12"
>
{{ headerTitle }}
</span>
<Breadcrumb
v-else
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div class="flex items-center gap-2">
<div v-if="!isDetailView" class="flex items-center gap-2">
<Input
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="['h-8 ltr:!pl-8 rtl:!pr-8']"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div
class="flex flex-col w-full h-full transition-all duration-300"
:class="hasSidebar && `ltr:2xl:ml-56 rtl:2xl:mr-56`"
>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div
class="w-full mx-auto"
:class="hasSidebar ? `max-w-[650px]` : `max-w-[900px]`"
>
<div class="flex items-center justify-between w-full h-20 gap-2">
<span
v-if="!isDetailView"
class="text-xl font-medium text-n-slate-12"
>
{{ headerTitle }}
</span>
<Breadcrumb
v-else
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div class="flex items-center gap-2">
<div v-if="!isDetailView" class="flex items-center gap-2">
<Input
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="['h-8 ltr:!pl-8 rtl:!pr-8']"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
<Button
v-if="!isDetailView"
icon="i-lucide-list-filter"
size="sm"
color="slate"
@click="emit('filter')"
/>
<Button
v-if="!isDetailView"
icon="i-lucide-ellipsis-vertical"
size="sm"
color="slate"
@click="emit('more')"
/>
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
</div>
<Button
v-if="!isDetailView"
icon="i-lucide-list-filter"
size="sm"
color="slate"
@click="emit('filter')"
/>
<Button
v-if="!isDetailView"
icon="i-lucide-ellipsis-vertical"
size="sm"
color="slate"
@click="emit('more')"
/>
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[900px] mx-auto py-4">
<slot name="default" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
</footer>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<div
class="w-full py-4 mx-auto"
:class="hasSidebar ? `max-w-[650px]` : `max-w-[900px]`"
>
<slot name="default" />
</div>
</main>
<footer
v-if="showPaginationFooter"
class="sticky bottom-0 z-10 px-4 pb-4"
>
<PaginationFooter
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
</footer>
</div>
<div
v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-[200px] w-full max-w-[440px] border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
</section>
</template>
@@ -0,0 +1,121 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/Button/Button.vue';
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const { formatMessage } = useMessageFormatter();
const state = reactive({
message: '',
});
const currentUser = useMapGetter('getCurrentUser');
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
const uiFlags = useMapGetter('contactNotes/getUIFlags');
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
const isCreatingNote = computed(() => uiFlags.value.isCreating);
const notes = computed(() => notesByContact.value(route.params.contactId));
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note.user.name;
};
const onAdd = content => {
if (!content) return;
const { contactId } = route.params;
store.dispatch('contactNotes/create', { content, contactId });
state.message = '';
};
const onDelete = noteId => {
if (!noteId) return;
const { contactId } = route.params;
store.dispatch('contactNotes/delete', { noteId, contactId });
};
</script>
<template>
<div class="flex flex-col gap-6 py-6">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
>
<template #actions>
<div class="flex items-center gap-3">
<Button
variant="link"
color="blue"
size="sm"
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
class="hover:no-underline"
:is-loading="isCreatingNote"
:disabled="!state.message || isCreatingNote"
@click="onAdd(state.message)"
/>
</div>
</template>
</Editor>
<div
v-if="isFetchingNotes"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else>
<div
v-for="note in notes"
:key="note.id"
class="flex flex-col gap-2 px-6 py-2 border-b border-n-strong group/note"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 py-2.5">
<Thumbnail
:author="note.user || { name: note.user.name }"
:name="note.user.name"
:src="note.user.thumbnail"
/>
<span
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
>
<span class="text-sm font-medium text-n-slate-11">
{{ getWrittenBy(note) }}
</span>
{{ $t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="text-sm font-medium text-n-slate-11">
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
<Button
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="onDelete(note.id)"
/>
</div>
<p
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
/>
</div>
</div>
</div>
</template>
@@ -72,7 +72,7 @@ const openConfirmDeleteContactDialog = () => {
</script>
<template>
<div class="flex flex-col items-start gap-8">
<div class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<EditableAvatar
:src="selectedContact.thumbnail"
@@ -83,7 +83,13 @@ const openConfirmDeleteContactDialog = () => {
{{ selectedContact.name }}
</h3>
<span class="text-sm text-n-slate-11">
{{ createdAt }} {{ lastActivityAt }}
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
<ContactLabels :contact-id="selectedContact.id" />
@@ -94,12 +100,16 @@ const openConfirmDeleteContactDialog = () => {
@update="handleFormUpdate"
/>
<div
class="flex items-end justify-end w-full gap-4 border-t h-14 border-n-strong"
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<Button
color="slate"
:label="t('CONTACTS_LAYOUT.DETAILS.MERGE_CONTACT')"
/>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, ref, watch, useSlots } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
@@ -45,6 +45,8 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']);
const slots = useSlots();
const isFocused = ref(false);
const characterCount = computed(() => props.modelValue.length);
@@ -119,12 +121,16 @@ watch(
@blur="handleBlur"
/>
<div
v-if="showCharacterCount"
v-if="showCharacterCount || slots.actions"
class="flex items-center justify-end h-4 ltr:right-3 rtl:left-3"
>
<span class="text-xs tabular-nums text-n-slate-10">
<span
v-if="showCharacterCount && !slots.actions"
class="text-xs tabular-nums text-n-slate-10"
>
{{ characterCount }} / {{ maxLength }}
</span>
<slot v-else name="actions" />
</div>
</div>
<p
@@ -144,7 +150,7 @@ watch(
@apply gap-2 !important;
.ProseMirror-menubar {
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !important;
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
.ProseMirror-menuitem {
@apply h-5 !important;
@@ -163,7 +169,7 @@ watch(
@apply m-0 !important;
&::before {
@apply text-n-slate-11 dark:text-n-slate-11 !important;
@apply text-n-slate-10 dark:text-n-slate-10 !important;
}
}
}
@@ -49,7 +49,7 @@ const STYLE_CONFIG = {
faded:
'bg-n-brand/10 text-n-slate-12 hover:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
link: 'text-n-brand hover:underline outline-transparent',
link: 'text-n-blue-text hover:underline outline-transparent',
},
ruby: {
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
@@ -451,7 +451,9 @@
},
"DETAILS": {
"TAG_BUTTON": "tag",
"MERGE_CONTACT": "Merge contact",
"CREATED_AT": "Created {date}",
"LAST_ACTIVITY": "Last active {date}",
"DELETE_CONTACT_DESCRIPTION": "Permanently delete this contact. This action is irreversible",
"DELETE_CONTACT": "Delete contact",
"DELETE_DIALOG": {
"TITLE": "Confirm Deletion",
@@ -462,6 +464,20 @@
"ERROR_MESSAGE": "Could not delete contact. Please try again later."
}
}
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "Attributes",
"HISTORY": "History",
"NOTES": "Notes",
"MERGE": "Merge"
},
"NOTES": {
"PLACEHOLDER": "Add a note",
"WROTE": "wrote",
"YOU": "You",
"SAVE": "Save note"
}
}
}
}
@@ -1,12 +1,14 @@
<script setup>
import { onMounted, computed } from 'vue';
import { onMounted, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import ContactsLayout from 'dashboard/components-next/Contacts/ContactsLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDetails.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
const store = useStore();
const route = useRoute();
const router = useRouter();
@@ -14,10 +16,32 @@ const router = useRouter();
const contact = useMapGetter('contacts/getContact');
const uiFlags = useMapGetter('contacts/getUIFlags');
const activeTab = ref('notes');
const isFetchingItem = computed(() => uiFlags.value.isFetchingItem);
const selectedContact = computed(() => contact.value(route.params.contactId));
const { t } = useI18n();
const CONTACT_TABS_OPTIONS = [
{ key: 'ATTRIBUTES', value: 'attributes' },
{ key: 'HISTORY', value: 'history' },
{ key: 'NOTES', value: 'notes' },
{ key: 'MERGE', value: 'merge' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONTACTS_LAYOUT.SIDEBAR.TABS.${tab.key}`),
value: tab.value,
}));
});
const activeTabIndex = computed(() => {
return CONTACT_TABS_OPTIONS.findIndex(v => v.value === activeTab.value);
});
const goToContactsList = () => {
router.push({
name: 'contacts_dashboard_index',
@@ -30,8 +54,18 @@ const fetchActiveContact = async () => {
}
};
const handleTabChange = tab => {
activeTab.value = tab.value;
};
const fetchContactNotes = () => {
const { contactId } = route.params;
if (contactId) store.dispatch('contactNotes/get', { contactId });
};
onMounted(() => {
fetchActiveContact();
fetchContactNotes();
});
</script>
@@ -57,6 +91,17 @@ onMounted(() => {
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
/>
<template #sidebar>
<div class="px-6">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
class="w-full [&>button]:w-full bg-n-alpha-black2"
@tab-changed="handleTabChange"
/>
</div>
<ContactNotes v-if="activeTab === 'notes'" />
</template>
</ContactsLayout>
</div>
</template>
@@ -1,5 +1,6 @@
import types from '../mutation-types';
import ContactNotesAPI from '../../api/contactNotes';
import camelcaseKeys from 'camelcase-keys';
export const state = {
records: {},
@@ -18,6 +19,11 @@ export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
getAllNotesByContactId: _state => contactId => {
const records = _state.records[contactId] || [];
const contactNotes = records.sort((r1, r2) => r2.id - r1.id);
return camelcaseKeys(contactNotes);
},
};
export const actions = {