chore: Update the metric card component to support generic cases (#9030)
Rename the CSAT metric card to a generic name, updated the implementation to use composition API and removed all the custom CSS in the component to conform with TailwindCSS styles --------- Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
@@ -37,15 +37,6 @@ import format from 'date-fns/format';
|
|||||||
import { formatTime } from '@chatwoot/utils';
|
import { formatTime } from '@chatwoot/utils';
|
||||||
import reportMixin from 'dashboard/mixins/reportMixin';
|
import reportMixin from 'dashboard/mixins/reportMixin';
|
||||||
import ChartStats from './components/ChartElements/ChartStats.vue';
|
import ChartStats from './components/ChartElements/ChartStats.vue';
|
||||||
const REPORTS_KEYS = {
|
|
||||||
CONVERSATIONS: 'conversations_count',
|
|
||||||
INCOMING_MESSAGES: 'incoming_messages_count',
|
|
||||||
OUTGOING_MESSAGES: 'outgoing_messages_count',
|
|
||||||
FIRST_RESPONSE_TIME: 'avg_first_response_time',
|
|
||||||
RESOLUTION_TIME: 'avg_resolution_time',
|
|
||||||
RESOLUTION_COUNT: 'resolutions_count',
|
|
||||||
REPLY_TIME: 'reply_time',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { ChartStats },
|
components: { ChartStats },
|
||||||
@@ -55,18 +46,22 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
|
reportKeys: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
CONVERSATIONS: 'conversations_count',
|
||||||
|
INCOMING_MESSAGES: 'incoming_messages_count',
|
||||||
|
OUTGOING_MESSAGES: 'outgoing_messages_count',
|
||||||
|
FIRST_RESPONSE_TIME: 'avg_first_response_time',
|
||||||
|
RESOLUTION_TIME: 'avg_resolution_time',
|
||||||
|
RESOLUTION_COUNT: 'resolutions_count',
|
||||||
|
REPLY_TIME: 'reply_time',
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
metrics() {
|
metrics() {
|
||||||
const reportKeys = [
|
const reportKeys = Object.keys(this.reportKeys);
|
||||||
'CONVERSATIONS',
|
|
||||||
'FIRST_RESPONSE_TIME',
|
|
||||||
'REPLY_TIME',
|
|
||||||
'RESOLUTION_TIME',
|
|
||||||
'RESOLUTION_COUNT',
|
|
||||||
'INCOMING_MESSAGES',
|
|
||||||
'OUTGOING_MESSAGES',
|
|
||||||
];
|
|
||||||
const infoText = {
|
const infoText = {
|
||||||
FIRST_RESPONSE_TIME: this.$t(
|
FIRST_RESPONSE_TIME: this.$t(
|
||||||
`REPORT.METRICS.FIRST_RESPONSE_TIME.INFO_TEXT`
|
`REPORT.METRICS.FIRST_RESPONSE_TIME.INFO_TEXT`
|
||||||
@@ -75,11 +70,11 @@ export default {
|
|||||||
};
|
};
|
||||||
return reportKeys.map(key => ({
|
return reportKeys.map(key => ({
|
||||||
NAME: this.$t(`REPORT.METRICS.${key}.NAME`),
|
NAME: this.$t(`REPORT.METRICS.${key}.NAME`),
|
||||||
KEY: REPORTS_KEYS[key],
|
KEY: this.reportKeys[key],
|
||||||
DESC: this.$t(`REPORT.METRICS.${key}.DESC`),
|
DESC: this.$t(`REPORT.METRICS.${key}.DESC`),
|
||||||
INFO_TEXT: infoText[key],
|
INFO_TEXT: infoText[key],
|
||||||
TOOLTIP_TEXT: `REPORT.METRICS.${key}.TOOLTIP_TEXT`,
|
TOOLTIP_TEXT: `REPORT.METRICS.${key}.TOOLTIP_TEXT`,
|
||||||
trend: this.calculateTrend(REPORTS_KEYS[key]),
|
trend: this.calculateTrend(this.reportKeys[key]),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="md:w-[16%] sm:w-[50%] sm:max-w-[50%] md:max-w-[16%] csat--metric-card"
|
|
||||||
:class="{
|
|
||||||
disabled: disabled,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<h3 class="heading">
|
|
||||||
<span>{{ label }}</span>
|
|
||||||
<fluent-icon
|
|
||||||
v-tooltip="infoText"
|
|
||||||
size="14"
|
|
||||||
icon="info"
|
|
||||||
class="csat--icon"
|
|
||||||
/>
|
|
||||||
</h3>
|
|
||||||
<h4 class="metric">
|
|
||||||
{{ value }}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
infoText: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.csat--metric-card {
|
|
||||||
margin: 0;
|
|
||||||
padding: var(--space-normal);
|
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
// grayscale everything
|
|
||||||
filter: grayscale(100%);
|
|
||||||
opacity: 0.3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
font-size: var(--font-size-small);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
margin: 0;
|
|
||||||
@apply text-slate-800 dark:text-slate-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric {
|
|
||||||
font-size: var(--font-size-bigger);
|
|
||||||
font-weight: var(--font-weight-feather);
|
|
||||||
margin-bottom: 0;
|
|
||||||
margin-top: var(--space-smaller);
|
|
||||||
@apply text-slate-700 dark:text-slate-100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.csat--icon {
|
|
||||||
@apply text-slate-500 dark:text-slate-200 my-0 mx-0.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700"
|
class="flex-col lg:flex-row flex flex-wrap mx-0 bg-white dark:bg-slate-800 rounded-[4px] p-4 mb-5 border border-solid border-slate-75 dark:border-slate-700"
|
||||||
>
|
>
|
||||||
<csat-metric-card
|
<csat-metric-card
|
||||||
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
|
||||||
@@ -18,16 +18,19 @@
|
|||||||
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
|
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
|
||||||
:value="formatToPercent(responseRate)"
|
:value="formatToPercent(responseRate)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
|
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
|
||||||
class="w-[50%] max-w-[50%] flex-[50%] report-card rtl:[direction:initial]"
|
class="w-full md:w-1/2 md:max-w-[50%] flex-1 rtl:[direction:initial] p-4"
|
||||||
>
|
>
|
||||||
<h3 class="heading text-slate-800 dark:text-slate-100">
|
<h3
|
||||||
|
class="flex items-center text-xs md:text-sm font-medium m-0 text-slate-800 dark:text-slate-100"
|
||||||
|
>
|
||||||
<div class="flex justify-end flex-row-reverse">
|
<div class="flex justify-end flex-row-reverse">
|
||||||
<div
|
<div
|
||||||
v-for="(rating, key, index) in ratingPercentage"
|
v-for="(rating, key, index) in ratingPercentage"
|
||||||
:key="rating + key + index"
|
:key="rating + key + index"
|
||||||
class="pl-4"
|
class="pr-4 rtl:pl-4"
|
||||||
>
|
>
|
||||||
<span class="my-0 mx-0.5">{{ ratingToEmoji(key) }}</span>
|
<span class="my-0 mx-0.5">{{ ratingToEmoji(key) }}</span>
|
||||||
<span>{{ formatToPercent(rating) }}</span>
|
<span>{{ formatToPercent(rating) }}</span>
|
||||||
@@ -42,7 +45,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import CsatMetricCard from './CsatMetricCard.vue';
|
import CsatMetricCard from './ReportMetricCard.vue';
|
||||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import VTooltip from 'v-tooltip';
|
import VTooltip from 'v-tooltip';
|
||||||
import CsatMetricCard from './CsatMetricCard';
|
import ReportMetricCard from './ReportMetricCard';
|
||||||
|
|
||||||
Vue.use(VTooltip, { defaultHtml: false });
|
Vue.use(VTooltip, { defaultHtml: false });
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Components/CSAT/Metrics Card',
|
title: 'Components/CSAT/Metrics Card',
|
||||||
component: CsatMetricCard,
|
component: ReportMetricCard,
|
||||||
argTypes: {
|
argTypes: {
|
||||||
label: {
|
label: {
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
@@ -31,12 +31,12 @@ export default {
|
|||||||
|
|
||||||
const Template = (_, { argTypes }) => ({
|
const Template = (_, { argTypes }) => ({
|
||||||
props: Object.keys(argTypes),
|
props: Object.keys(argTypes),
|
||||||
components: { CsatMetricCard },
|
components: { ReportMetricCard },
|
||||||
template: '<csat-metric-card v-bind="$props" />',
|
template: '<report-metric-card v-bind="$props" />',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CsatMetricCardTemplate = Template.bind({});
|
export const ReportMetricCardTemplate = Template.bind({});
|
||||||
CsatMetricCardTemplate.args = {
|
ReportMetricCardTemplate.args = {
|
||||||
infoText: 'No. of responses / No. of survey messages sent * 100',
|
infoText: 'No. of responses / No. of survey messages sent * 100',
|
||||||
label: 'Satisfaction Score',
|
label: 'Satisfaction Score',
|
||||||
value: '98.5',
|
value: '98.5',
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="reportMetricContainer"
|
||||||
|
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%] m-0 p-4"
|
||||||
|
:class="{
|
||||||
|
'grayscale pointer-events-none opacity-30': disabled,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="flex items-center text-sm font-medium m-0 text-slate-800 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
<span ref="reportMetricLabel">{{ label }}</span>
|
||||||
|
<fluent-icon
|
||||||
|
ref="reportMetricInfo"
|
||||||
|
v-tooltip="infoText"
|
||||||
|
size="14"
|
||||||
|
icon="info"
|
||||||
|
class="text-slate-500 dark:text-slate-200 my-0 mx-1 mt-0.5"
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
|
<h4
|
||||||
|
ref="reportMetricValue"
|
||||||
|
class="text-slate-700 dark:text-slate-100 mb-0 mt-1 font-thin text-3xl"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,46 +1,50 @@
|
|||||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||||
import CsatMetricCard from '../CsatMetricCard.vue';
|
import ReportMetricCard from '../ReportMetricCard.vue';
|
||||||
|
|
||||||
import VTooltip from 'v-tooltip';
|
import VTooltip from 'v-tooltip';
|
||||||
|
|
||||||
const localVue = createLocalVue();
|
const localVue = createLocalVue();
|
||||||
localVue.use(VTooltip);
|
localVue.use(VTooltip);
|
||||||
|
|
||||||
describe('CsatMetricCard.vue', () => {
|
describe('ReportMetricCard.vue', () => {
|
||||||
it('renders props correctly', () => {
|
it('renders props correctly', () => {
|
||||||
const label = 'Total Responses';
|
const label = 'Total Responses';
|
||||||
const value = '100';
|
const value = '100';
|
||||||
const infoText = 'Total number of responses';
|
const infoText = 'Total number of responses';
|
||||||
const wrapper = shallowMount(CsatMetricCard, {
|
const wrapper = shallowMount(ReportMetricCard, {
|
||||||
propsData: { label, value, infoText },
|
propsData: { label, value, infoText },
|
||||||
localVue,
|
localVue,
|
||||||
stubs: ['fluent-icon'],
|
stubs: ['fluent-icon'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(wrapper.find('.heading span').text()).toMatch(label);
|
expect(wrapper.find({ ref: 'reportMetricLabel' }).text()).toMatch(label);
|
||||||
expect(wrapper.find('.metric').text()).toMatch(value);
|
expect(wrapper.find({ ref: 'reportMetricValue' }).text()).toMatch(value);
|
||||||
expect(wrapper.find('.csat--icon').classes()).toContain('has-tooltip');
|
expect(wrapper.find({ ref: 'reportMetricInfo' }).classes()).toContain(
|
||||||
|
'has-tooltip'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds disabled class when disabled prop is true', () => {
|
it('adds disabled class when disabled prop is true', () => {
|
||||||
const wrapper = shallowMount(CsatMetricCard, {
|
const wrapper = shallowMount(ReportMetricCard, {
|
||||||
propsData: { label: '', value: '', infoText: '', disabled: true },
|
propsData: { label: '', value: '', infoText: '', disabled: true },
|
||||||
localVue,
|
localVue,
|
||||||
stubs: ['fluent-icon'],
|
stubs: ['fluent-icon'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(wrapper.find('.csat--metric-card').classes()).toContain('disabled');
|
expect(wrapper.classes().join(' ')).toContain(
|
||||||
|
'grayscale pointer-events-none opacity-30'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not add disabled class when disabled prop is false', () => {
|
it('does not add disabled class when disabled prop is false', () => {
|
||||||
const wrapper = shallowMount(CsatMetricCard, {
|
const wrapper = shallowMount(ReportMetricCard, {
|
||||||
propsData: { label: '', value: '', infoText: '', disabled: false },
|
propsData: { label: '', value: '', infoText: '', disabled: false },
|
||||||
localVue,
|
localVue,
|
||||||
stubs: ['fluent-icon'],
|
stubs: ['fluent-icon'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(wrapper.find('.csat--metric-card').classes()).not.toContain(
|
expect(
|
||||||
'disabled'
|
wrapper.find({ ref: 'reportMetricContainer' }).classes().join(' ')
|
||||||
);
|
).not.toContain('grayscale pointer-events-none opacity-30');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user