Merge branch 'develop' into snyk-fix-531a03562aa6be95858cdfbccbde51f1

This commit is contained in:
Pranav
2024-12-02 18:24:20 -08:00
committed by GitHub
29 changed files with 449 additions and 80 deletions
+8 -8
View File
@@ -452,7 +452,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.22.0)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -471,7 +471,7 @@ GEM
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
mini_portile2 (2.8.8)
minitest (5.25.1)
mock_redis (0.36.0)
ruby2_keywords
@@ -502,14 +502,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.16.7)
nokogiri (1.16.8)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.7-arm64-darwin)
nokogiri (1.16.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-darwin)
nokogiri (1.16.8-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux)
nokogiri (1.16.8-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -599,9 +599,9 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (~> 1.14)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
@@ -214,6 +214,10 @@ const resetValidation = () => {
v$.value.$reset();
};
const resetForm = () => {
Object.assign(state, defaultState);
};
watch(() => props.contactData, prepareStateBasedOnProps, {
immediate: true,
deep: true,
@@ -224,6 +228,7 @@ defineExpose({
state,
resetValidation,
isFormInvalid,
resetForm,
});
</script>
@@ -261,7 +266,7 @@ defineExpose({
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView ? '[&:not(.error)]:!border-transparent' : ''
!isDetailsView ? '[&:not(.error,.focus)]:!border-transparent' : ''
}`"
class="w-full"
@input="
@@ -27,11 +27,16 @@ const handleDialogConfirm = async () => {
emit('create', contact.value);
};
const onSuccess = () => {
contactsFormRef.value?.resetForm();
dialogRef.value.close();
};
const closeDialog = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef, contactsFormRef });
defineExpose({ dialogRef, contactsFormRef, onSuccess });
</script>
<template>
@@ -54,9 +54,9 @@ const emit = defineEmits([
</script>
<template>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<header class="sticky top-0 z-10">
<div
class="flex items-center justify-between w-full h-20 gap-2 mx-auto max-w-[960px]"
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[960px]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
@@ -7,6 +7,10 @@ import { useAlert, useTrack } from 'dashboard/composables';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import contactFilterItems from 'dashboard/routes/dashboard/contacts/contactFilterItems';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import countries from 'shared/constants/countries';
import {
@@ -23,38 +27,14 @@ import DeleteSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm
import ContactsFilter from 'dashboard/components-next/filter/ContactsFilter.vue';
const props = defineProps({
showSearch: {
type: Boolean,
default: true,
},
searchValue: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
segmentsId: {
type: [String, Number],
default: 0,
},
activeSegment: {
type: Object,
default: null,
},
hasAppliedFilters: {
type: Boolean,
default: false,
},
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
headerTitle: { type: String, default: '' },
segmentsId: { type: [String, Number], default: 0 },
activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -99,8 +79,26 @@ const openDeleteSegmentDialog = () =>
deleteSegmentDialogRef.value?.dialogRef.open();
const onCreate = async contact => {
await store.dispatch('contacts/create', contact);
createNewContactDialogRef.value?.dialogRef.close();
try {
await store.dispatch('contacts/create', contact);
createNewContactDialogRef.value?.onSuccess();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SUCCESS_MESSAGE')
);
} catch (error) {
const i18nPrefix = 'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION';
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(t(`${i18nPrefix}.EMAIL_ADDRESS_DUPLICATE`));
} else if (error.data.includes('phone_number')) {
useAlert(t(`${i18nPrefix}.PHONE_NUMBER_DUPLICATE`));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(t(`${i18nPrefix}.ERROR_MESSAGE`));
}
}
};
const onImport = async file => {
@@ -135,11 +133,19 @@ const onCreateSegment = async payload => {
...payload,
query: segmentsQuery.value,
};
await store.dispatch('customViews/create', payloadData);
const response = await store.dispatch('customViews/create', payloadData);
createSegmentDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.SUCCESS_MESSAGE')
);
const segmentId = response?.data?.id;
if (!segmentId) return;
// Navigate to the created segment
router.push({
name: 'contacts_dashboard_segments_index',
params: { segmentId },
query: { page: 1 },
});
} catch {
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR_MESSAGE')
@@ -176,7 +182,6 @@ const closeAdvanceFiltersModal = () => {
};
const clearFilters = async () => {
await store.dispatch('contacts/clearContactFilters');
emit('clearFilters');
};
@@ -0,0 +1,29 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
const emit = defineEmits(['clearFilters']);
const { t } = useI18n();
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
</script>
<template>
<ActiveFilterPreview
:applied-filters="appliedFilters"
:max-visible-filters="2"
:more-filters-label="
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.MORE_FILTERS', {
count: appliedFilters.length - 2,
})
"
:clear-button-label="
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
class="max-w-[960px] px-6"
@clear-filters="emit('clearFilters')"
/>
</template>
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
@@ -90,8 +91,12 @@ const updateCurrentPage = page => {
@apply-filter="emit('applyFilter', $event)"
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[960px]">
<ContactsActiveFiltersPreview
v-if="hasAppliedFilters && isNotSegmentView"
@clear-filters="emit('clearFilters')"
/>
<slot name="default" />
</div>
</main>
@@ -58,7 +58,7 @@ const toggleExpanded = id => {
</script>
<template>
<div class="flex flex-col gap-4 p-6">
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
<ContactsCard
v-for="contact in contacts"
:id="contact.id"
@@ -76,6 +76,7 @@ const togglePortalSwitcher = () => {
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
@click="togglePortalSwitcher"
@@ -182,6 +182,7 @@ onMounted(() => {
<OnClickOutside @trigger="openAgentsList = false">
<Button
variant="ghost"
color="slate"
class="!px-0 font-normal hover:!bg-transparent"
text-variant="info"
@click="openAgentsList = !openAgentsList"
@@ -214,6 +215,7 @@ onMounted(() => {
"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
color="slate"
class="!px-2 font-normal hover:!bg-transparent"
@click="openCategoryList = !openCategoryList"
>
@@ -244,6 +246,7 @@ onMounted(() => {
"
icon="i-lucide-plus"
variant="ghost"
color="slate"
:disabled="isNewArticle"
class="!px-2 font-normal hover:!bg-transparent hover:!text-n-slate-11"
@click="openProperties = !openProperties"
@@ -66,6 +66,7 @@ onMounted(() => {
icon="i-lucide-x"
size="sm"
variant="ghost"
color="slate"
class="hover:text-n-slate-11"
@click="emit('close')"
/>
@@ -108,6 +108,7 @@ const redirectToPortalHomePage = () => {
<Button
icon="i-lucide-arrow-up-right"
variant="ghost"
color="slate"
icon-lib="lucide"
size="sm"
class="!w-6 !h-6 hover:bg-n-slate-2 text-n-slate-11 !p-0.5 rounded-md"
@@ -133,6 +134,7 @@ const redirectToPortalHomePage = () => {
:key="index"
:label="portal.name"
variant="ghost"
color="slate"
trailing-icon
:icon="isPortalActive(portal) ? 'i-lucide-check' : ''"
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
@@ -114,8 +114,13 @@ const SIZES = ['default', 'sm', 'lg'];
<!-- Ghost & Link Variants -->
<Variant title="Ghost & Link Variants">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Ghost Button" variant="ghost" />
<Button label="Ghost with Icon" variant="ghost" icon="i-lucide-plus" />
<Button label="Ghost Button" variant="ghost" color="slate" />
<Button
label="Ghost with Icon"
variant="ghost"
color="slate"
icon="i-lucide-plus"
/>
<Button label="Link Button" variant="link" />
<Button label="Link with Icon" variant="link" icon="i-lucide-plus" />
</div>
@@ -88,6 +88,7 @@ const STYLE_CONFIG = {
faded:
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
ghost: 'text-n-blue-text hover:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:underline outline-transparent',
},
ruby: {
@@ -95,6 +96,7 @@ const STYLE_CONFIG = {
faded:
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
ghost: 'text-n-ruby-11 hover:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:underline outline-transparent',
},
amber: {
@@ -103,6 +105,7 @@ const STYLE_CONFIG = {
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:underline outline-transparent',
ghost: 'text-n-amber-9 hover:bg-n-alpha-2 outline-transparent',
},
slate: {
solid:
@@ -111,6 +114,7 @@ const STYLE_CONFIG = {
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
},
teal: {
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent',
@@ -118,6 +122,7 @@ const STYLE_CONFIG = {
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:underline outline-transparent',
ghost: 'text-n-teal-9 hover:bg-n-alpha-2 outline-transparent',
},
},
sizes: {
@@ -151,7 +156,7 @@ const STYLE_CONFIG = {
const variantClasses = computed(() => {
const variantMap = {
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
ghost: `${STYLE_CONFIG.colors[computedColor.value].ghost}`,
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-4`,
outline: STYLE_CONFIG.colors[computedColor.value].outline,
faded: STYLE_CONFIG.colors[computedColor.value].faded,
@@ -0,0 +1,66 @@
<script setup>
import { ref, computed } from 'vue';
import { sampleActiveFilters } from './fixtures/filterTypes';
import ActiveFilterPreview from './ActiveFilterPreview.vue';
const appliedFilters = ref([...sampleActiveFilters]);
const maxVisibleFilters = ref(2);
const moreFiltersLabel = computed(
() => `${appliedFilters.value.length - maxVisibleFilters.value} more filters`
);
</script>
<template>
<Story
title="Components/Filters/ActiveFilterPreview"
:layout="{ type: 'grid', width: '960px' }"
>
<Variant title="Default (2 visible filters)">
<ActiveFilterPreview
:applied-filters="appliedFilters.slice(0, 2)"
:max-visible-filters="2"
clear-button-label="Clear Filters"
more-filters-label=""
@clear-filters="() => {}"
/>
</Variant>
<Variant title="Multiple Filters (with more indicator)">
<ActiveFilterPreview
:applied-filters="appliedFilters"
:max-visible-filters="maxVisibleFilters"
clear-button-label="Clear Filters"
:more-filters-label="moreFiltersLabel"
@clear-filters="() => {}"
/>
</Variant>
<Variant title="Show All Filters">
<ActiveFilterPreview
:applied-filters="appliedFilters"
:max-visible-filters="10"
clear-button-label="Clear Filters"
@clear-filters="() => {}"
/>
</Variant>
<Variant title="Single Filter">
<ActiveFilterPreview
:applied-filters="[appliedFilters[0]]"
:max-visible-filters="maxVisibleFilters"
clear-button-label="Clear Filters"
@clear-filters="() => {}"
/>
</Variant>
<Variant title="With Object Values">
<ActiveFilterPreview
:applied-filters="[appliedFilters[6]]"
:max-visible-filters="maxVisibleFilters"
clear-button-label="Clear Filters"
@clear-filters="() => {}"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,117 @@
<script setup>
import { replaceUnderscoreWithSpace } from './helper/filterHelper.js';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
appliedFilters: {
type: Array,
default: () => [],
},
maxVisibleFilters: {
type: Number,
default: 2,
},
clearButtonLabel: {
type: String,
default: '',
},
moreFiltersLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['clearFilters']);
const shouldCapitalizeFirstLetter = key => {
const lowercaseKeys = ['email'];
return !lowercaseKeys.includes(key);
};
const formatOperatorLabel = operator => {
const operators = {
equal_to: 'is',
not_equal_to: 'is not',
contains: 'contains',
does_not_contain: 'does not contain',
is_present: 'is present',
is_not_present: 'is not present',
is_greater_than: 'is greater than',
is_less_than: 'is less than',
days_before: 'days before',
};
return operators[operator] || replaceUnderscoreWithSpace(operator);
};
const formatFilterValue = value => {
if (!value) return '';
if (typeof value === 'object' && value.name) {
return value.name;
}
return value;
};
</script>
<template>
<div class="flex flex-wrap items-center w-full gap-2 mx-auto">
<template
v-for="(filter, index) in appliedFilters"
:key="filter.attributeKey"
>
<div
v-if="index < maxVisibleFilters"
class="inline-flex items-center gap-2 h-7"
>
<div
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg max-w-72 border-n-weak"
>
<span
class="lowercase whitespace-nowrap first-letter:capitalize text-n-slate-12"
>
{{ replaceUnderscoreWithSpace(filter.attributeKey) }}
</span>
<span class="px-1 text-xs text-n-slate-10 whitespace-nowrap">
{{ formatOperatorLabel(filter.filterOperator) }}
</span>
<span
v-if="filter.values"
class="lowercase truncate text-n-slate-12"
:class="{
'first-letter:capitalize': shouldCapitalizeFirstLetter(
filter.attributeKey
),
}"
>
{{ formatFilterValue(filter.values) }}
</span>
</div>
<template
v-if="
index < maxVisibleFilters - 1 && index < appliedFilters.length - 1
"
>
<span
class="content-center h-full px-1 text-xs font-medium uppercase rounded-lg text-n-slate-10"
>
{{ filter.queryOperator }}
</span>
</template>
</div>
</template>
<div
v-if="appliedFilters.length > maxVisibleFilters"
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 h-7"
>
{{ moreFiltersLabel }}
</div>
<div class="w-px h-3 rounded-lg bg-n-strong" />
<Button
:label="clearButtonLabel"
size="xs"
class="!px-1"
variant="ghost"
@click="emit('clearFilters')"
/>
</div>
</template>
@@ -556,3 +556,48 @@ export const filterTypes = [
attributeModel: 'customAttributes',
},
];
export const sampleActiveFilters = [
{
attributeKey: 'name',
filterOperator: 'contains',
values: 'John',
queryOperator: 'and',
},
{
attributeKey: 'email',
filterOperator: 'does_not_contain',
values: 'test@chatwoot.com',
queryOperator: 'or',
},
{
attributeKey: 'phone_number',
filterOperator: 'is_present',
values: '+928383822',
queryOperator: 'and',
},
{
attributeKey: 'created_at',
filterOperator: 'is_greater_than',
values: '2024-01-01',
queryOperator: 'and',
},
{
attributeKey: 'last_activity',
filterOperator: 'days_before',
values: '30',
queryOperator: 'and',
},
{
attributeKey: 'date_of_birth',
filterOperator: 'is_not_present',
values: '',
queryOperator: 'and',
},
{
attributeKey: 'country',
filterOperator: 'not_equal_to',
values: { id: 1, name: 'India' },
queryOperator: 'and',
},
];
@@ -39,3 +39,11 @@ export const buildAttributesFilterTypes = (attributes, getOperatorTypes) => {
attributeModel: 'customAttributes',
}));
};
/**
* Replaces underscores with spaces in a string
* @param {string} text - The input string
* @returns {string} The string with underscores replaced by spaces
*/
export const replaceUnderscoreWithSpace = text =>
text?.replaceAll('_', ' ') ?? '';
@@ -1,6 +1,7 @@
import {
getCustomAttributeInputType,
buildAttributesFilterTypes,
replaceUnderscoreWithSpace,
} from './filterHelper';
describe('filterHelper', () => {
@@ -123,4 +124,14 @@ describe('filterHelper', () => {
expect(result).toEqual([]);
});
});
describe('replaceUnderscoreWithSpace', () => {
it('replaces underscores with spaces', () => {
expect(replaceUnderscoreWithSpace('test_key')).toBe('test key');
});
it('returns empty string if input is null', () => {
expect(replaceUnderscoreWithSpace(null)).toBe('');
});
});
});
@@ -75,6 +75,7 @@ const pageInfo = computed(() => {
icon="i-lucide-chevrons-left"
variant="ghost"
size="sm"
color="slate"
class="!w-8 !h-6"
:disabled="isFirstPage"
@click="changePage(1)"
@@ -82,6 +83,7 @@ const pageInfo = computed(() => {
<Button
icon="i-lucide-chevron-left"
variant="ghost"
color="slate"
size="sm"
class="!w-8 !h-6"
:disabled="isFirstPage"
@@ -96,6 +98,7 @@ const pageInfo = computed(() => {
<Button
icon="i-lucide-chevron-right"
variant="ghost"
color="slate"
size="sm"
class="!w-8 !h-6"
:disabled="isLastPage"
@@ -104,6 +107,7 @@ const pageInfo = computed(() => {
<Button
icon="i-lucide-chevrons-right"
variant="ghost"
color="slate"
size="sm"
class="!w-8 !h-6"
:disabled="isLastPage"
@@ -91,13 +91,18 @@ const activeCountry = computed(() =>
const inputBorderClass = computed(() => {
const errorClass =
'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
const focusClass =
'has-[:focus]:border-n-brand dark:has-[:focus]:border-n-brand';
if (!props.showBorder) {
return hasError.value ? errorClass : 'border-transparent';
if (hasError.value) return errorClass;
return `border-transparent ${focusClass}`;
}
if (hasError.value) {
return errorClass;
}
return 'has-[:focus]:border-n-brand dark:has-[:focus]:border-n-brand border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak';
return `${focusClass} border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak`;
});
const phoneNumberError = computed(() => {
@@ -401,7 +401,11 @@
"ADD_CONTACT": "Add contact",
"EXPORT_CONTACT": "Export contacts",
"IMPORT_CONTACT": "Import contacts",
"SAVE_CONTACT": "Save contact"
"SAVE_CONTACT": "Save contact",
"EMAIL_ADDRESS_DUPLICATE": "This email address is in use for another contact.",
"PHONE_NUMBER_DUPLICATE": "This phone number is in use for another contact.",
"SUCCESS_MESSAGE": "Contact saved successfully",
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
},
"IMPORT_CONTACT": {
"TITLE": "Import contacts",
@@ -491,10 +495,9 @@
"LABEL": "Segment name",
"INPUT_PLACEHOLDER": "Enter the name of the segment"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard filters",
"ADDITIONAL_FILTERS": "Additional filters",
"CUSTOM_ATTRIBUTES": "Custom attributes"
"ACTIVE_FILTERS": {
"MORE_FILTERS": "+ {count} more filters",
"CLEAR_FILTERS": "Clear filters"
}
},
"CARD": {
@@ -118,6 +118,7 @@ const getCommonFetchParams = (page = 1) => ({
});
const fetchContacts = async (page = 1) => {
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/get', getCommonFetchParams(page));
updatePageParam(page);
};
@@ -70,6 +70,7 @@ export const actions = {
data: response.data,
filterType: FILTER_KEYS[obj.filter_type],
});
return response;
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
@@ -0,0 +1,11 @@
# housekeeping
# remove contact inboxes that does not have any conversations
# and are older than 3 months
class Internal::ProcessStaleRedisKeysJob < ApplicationJob
queue_as :low
def perform(account)
Internal::RemoveStaleRedisKeysService.new(account_id: account.id).perform
end
end
@@ -0,0 +1,14 @@
# housekeeping
# ensure stale ONLINE PRESENCE KEYS for contacts are removed periodically
# should result in 50% redis mem size reduction
class Internal::RemoveStaleRedisKeysJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Account.find_each do |account|
Rails.logger.info "Enqueuing ProcessStaleRedisKeysJob for account #{account.id}"
Internal::ProcessStaleRedisKeysJob.perform_later(account)
end
end
end
@@ -0,0 +1,10 @@
class Internal::RemoveStaleRedisKeysService
pattr_initialize [:account_id!]
def perform(account_id)
range_start = (Time.zone.now - PRESENCE_DURATION).to_i
# exclusive minimum score is specified by prefixing (
# we are clearing old records because this could clogg up the sorted set
::Redis::Alfred.zremrangebyscore(presence_key(account_id, 'Contact'), '-inf', "(#{range_start}")
end
end
+7
View File
@@ -26,3 +26,10 @@ remove_stale_contact_inboxes_job.rb:
cron: '30 22 * * *'
class: 'Internal::RemoveStaleContactInboxesJob'
queue: scheduled_jobs
# executed daily at 2230 UTC
# which is our lowest traffic time
remove_stale_redis_keys_job.rb:
cron: '30 22 * * *'
class: 'Internal::RemoveStaleRedisKeysJob'
queue: scheduled_jobs
+20 -20
View File
@@ -194,8 +194,8 @@ importers:
specifier: ^5.1.0
version: 5.1.0(vue@3.5.12(typescript@5.6.2))
vue-i18n:
specifier: 9.14.0
version: 9.14.0(vue@3.5.12(typescript@5.6.2))
specifier: 9.14.2
version: 9.14.2(vue@3.5.12(typescript@5.6.2))
vue-letter:
specifier: ^0.2.0
version: 0.2.0
@@ -905,16 +905,16 @@ packages:
'@internationalized/number@3.5.4':
resolution: {integrity: sha512-h9huwWjNqYyE2FXZZewWqmCdkw1HeFds5q4Siuoms3hUQC5iPJK3aBmkFZoDSLN4UD0Bl8G22L/NdHpeOr+/7A==}
'@intlify/core-base@9.14.0':
resolution: {integrity: sha512-zJn0imh9HIsZZUtt9v8T16PeVstPv6bP2YzlrYJwoF8F30gs4brZBwW2KK6EI5WYKFi3NeqX6+UU4gniz5TkGg==}
'@intlify/core-base@9.14.2':
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
engines: {node: '>= 16'}
'@intlify/message-compiler@9.14.0':
resolution: {integrity: sha512-sXNsoMI0YsipSXW8SR75drmVK56tnJHoYbPXUv2Cf9lz6FzvwsosFm6JtC1oQZI/kU+n7qx0qRrEWkeYFTgETA==}
'@intlify/message-compiler@9.14.2':
resolution: {integrity: sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ==}
engines: {node: '>= 16'}
'@intlify/shared@9.14.0':
resolution: {integrity: sha512-r+N8KRQL7LgN1TMTs1A2svfuAU0J94Wu9wWdJVJqYsoMMLIeJxrPjazihfHpmJqfgZq0ah3Y9Q4pgWV2O90Fyg==}
'@intlify/shared@9.14.2':
resolution: {integrity: sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw==}
engines: {node: '>= 16'}
'@isaacs/cliui@8.0.2':
@@ -4934,8 +4934,8 @@ packages:
peerDependencies:
eslint: '>=6.0.0'
vue-i18n@9.14.0:
resolution: {integrity: sha512-LxmpRuCt2rI8gqU+kxeflRZMQn4D5+4M3oP3PWZdowW/ePJraHqhF7p4CuaME52mUxdw3Mmy2yAUKgfZYgCRjA==}
vue-i18n@9.14.2:
resolution: {integrity: sha512-JK9Pm80OqssGJU2Y6F7DcM8RFHqVG4WkuCqOZTVsXkEzZME7ABejAUqUdA931zEBedc4thBgSUWxeQh4uocJAQ==}
engines: {node: '>= 16'}
peerDependencies:
vue: ^3.0.0
@@ -5770,17 +5770,17 @@ snapshots:
dependencies:
'@swc/helpers': 0.5.15
'@intlify/core-base@9.14.0':
'@intlify/core-base@9.14.2':
dependencies:
'@intlify/message-compiler': 9.14.0
'@intlify/shared': 9.14.0
'@intlify/message-compiler': 9.14.2
'@intlify/shared': 9.14.2
'@intlify/message-compiler@9.14.0':
'@intlify/message-compiler@9.14.2':
dependencies:
'@intlify/shared': 9.14.0
'@intlify/shared': 9.14.2
source-map-js: 1.2.1
'@intlify/shared@9.14.0': {}
'@intlify/shared@9.14.2': {}
'@isaacs/cliui@8.0.2':
dependencies:
@@ -10406,11 +10406,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-i18n@9.14.0(vue@3.5.12(typescript@5.6.2)):
vue-i18n@9.14.2(vue@3.5.12(typescript@5.6.2)):
dependencies:
'@intlify/core-base': 9.14.0
'@intlify/shared': 9.14.0
'@vue/devtools-api': 6.6.3
'@intlify/core-base': 9.14.2
'@intlify/shared': 9.14.2
'@vue/devtools-api': 6.6.4
vue: 3.5.12(typescript@5.6.2)
vue-letter@0.2.0: