From 25c61aba25e4b0abe5ff59786bd97823c1ba3d7a Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 28 Nov 2024 09:35:54 +0530 Subject: [PATCH] feat(v4): Add new conversation filters component (#10502) Co-authored-by: Pranav Co-authored-by: Pranav --- .../dropdown-menu/base/DropdownBody.vue | 28 +- .../dropdown-menu/base/DropdownItem.vue | 2 +- .../filter/ConditionRow.story.vue | 14 +- .../components-next/filter/ConditionRow.vue | 14 +- .../filter/ConversationFilter.vue | 175 ++++++++++ .../components-next/filter/SaveCustomView.vue | 130 ++++++++ .../filter/inputs/FilterSelect.vue | 2 +- .../filter/inputs/MultiSelect.vue | 2 +- .../filter/inputs/SingleSelect.vue | 2 +- .../components-next/filter/operators.js | 164 ++++++++++ .../components-next/filter/provider.js | 299 ++++++++++++++++++ .../dashboard/components/ChatList.vue | 101 +++--- .../dashboard/components/ChatListHeader.vue | 64 ++-- .../dashboard/composables/useTransformKeys.js | 25 ++ .../dashboard/helper/validations.js | 29 +- .../i18n/locale/en/advancedFilters.json | 18 +- .../dashboard/store/modules/attributes.js | 11 + .../store/modules/conversations/getters.js | 5 + package.json | 1 + pnpm-lock.yaml | 53 +++- 20 files changed, 1039 insertions(+), 100 deletions(-) create mode 100644 app/javascript/dashboard/components-next/filter/ConversationFilter.vue create mode 100644 app/javascript/dashboard/components-next/filter/SaveCustomView.vue create mode 100644 app/javascript/dashboard/components-next/filter/operators.js create mode 100644 app/javascript/dashboard/components-next/filter/provider.js create mode 100644 app/javascript/dashboard/composables/useTransformKeys.js diff --git a/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownBody.vue b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownBody.vue index 7616c16d8..246eba5aa 100644 --- a/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownBody.vue +++ b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownBody.vue @@ -1,7 +1,33 @@ + + - + { {{ t('COMBOBOX.PLACEHOLDER') }} - + { {{ t('COMBOBOX.PLACEHOLDER') }} - +
} + */ +const filterOperatorIcon = { + [FILTER_OPS.EQUAL_TO]: 'i-ph-equals-bold', + [FILTER_OPS.NOT_EQUAL_TO]: 'i-ph-not-equals-bold', + [FILTER_OPS.IS_PRESENT]: 'i-ph-member-of-bold', + [FILTER_OPS.IS_NOT_PRESENT]: 'i-ph-not-member-of-bold', + [FILTER_OPS.CONTAINS]: 'i-ph-superset-of-bold', + [FILTER_OPS.DOES_NOT_CONTAIN]: 'i-ph-not-superset-of-bold', + [FILTER_OPS.IS_GREATER_THAN]: 'i-ph-greater-than-bold', + [FILTER_OPS.IS_LESS_THAN]: 'i-ph-less-than-bold', + [FILTER_OPS.DAYS_BEFORE]: 'i-ph-calendar-minus-bold', + [FILTER_OPS.STARTS_WITH]: 'i-ph-caret-line-right-bold', +}; + +/** + * Vue composable providing access to filter operators and related functionality + * @returns {Object} Collection of operators and utility functions + * @property {import('vue').ComputedRef>} operators - All available operators + * @property {import('vue').ComputedRef} equalityOperators - Equality comparison operators + * @property {import('vue').ComputedRef} presenceOperators - Presence check operators + * @property {import('vue').ComputedRef} containmentOperators - Containment check operators + * @property {import('vue').ComputedRef} comparisonOperators - Numeric comparison operators + * @property {import('vue').ComputedRef} dateOperators - Date-specific operators + * @property {(key: 'list'|'text'|'number'|'link'|'date'|'checkbox') => Operator[]} getOperatorTypes - Get operators for a field type + */ +export function useOperators() { + const { t } = useI18n(); + + /** @type {import('vue').ComputedRef>} */ + const operators = computed(() => { + return Object.values(FILTER_OPS).reduce((acc, value) => { + acc[value] = { + value, + label: t(`FILTER.OPERATOR_LABELS.${value}`), + hasInput: !NO_INPUT_OPTS.includes(value), + inputOverride: OPS_INPUT_OVERRIDE[value] || null, + icon: h('span', { + class: `${filterOperatorIcon[value]} !text-n-blue-text`, + }), + }; + return acc; + }, {}); + }); + + /** @type {import('vue').ComputedRef>} */ + const equalityOperators = computed(() => [ + operators.value[FILTER_OPS.EQUAL_TO], + operators.value[FILTER_OPS.NOT_EQUAL_TO], + ]); + + /** @type {import('vue').ComputedRef>} */ + const presenceOperators = computed(() => [ + operators.value[FILTER_OPS.EQUAL_TO], + operators.value[FILTER_OPS.NOT_EQUAL_TO], + operators.value[FILTER_OPS.IS_PRESENT], + operators.value[FILTER_OPS.IS_NOT_PRESENT], + ]); + + /** @type {import('vue').ComputedRef>} */ + const containmentOperators = computed(() => [ + operators.value[FILTER_OPS.EQUAL_TO], + operators.value[FILTER_OPS.NOT_EQUAL_TO], + operators.value[FILTER_OPS.CONTAINS], + operators.value[FILTER_OPS.DOES_NOT_CONTAIN], + ]); + + /** @type {import('vue').ComputedRef>} */ + const comparisonOperators = computed(() => [ + operators.value[FILTER_OPS.EQUAL_TO], + operators.value[FILTER_OPS.NOT_EQUAL_TO], + operators.value[FILTER_OPS.IS_PRESENT], + operators.value[FILTER_OPS.IS_NOT_PRESENT], + operators.value[FILTER_OPS.IS_GREATER_THAN], + operators.value[FILTER_OPS.IS_LESS_THAN], + ]); + + /** @type {import('vue').ComputedRef>} */ + const dateOperators = computed(() => [ + operators.value[FILTER_OPS.IS_GREATER_THAN], + operators.value[FILTER_OPS.IS_LESS_THAN], + operators.value[FILTER_OPS.DAYS_BEFORE], + ]); + + /** + * Get operator types based on key + * @param {string} key - Type of operator to get + * @returns {Array} + */ + const getOperatorTypes = key => { + switch (key) { + case 'list': + return equalityOperators.value; + case 'text': + return containmentOperators.value; + case 'number': + return equalityOperators.value; + case 'link': + return equalityOperators.value; + case 'date': + return comparisonOperators.value; + case 'checkbox': + return equalityOperators.value; + default: + return equalityOperators.value; + } + }; + + return { + operators, + equalityOperators, + presenceOperators, + containmentOperators, + comparisonOperators, + dateOperators, + getOperatorTypes, + }; +} diff --git a/app/javascript/dashboard/components-next/filter/provider.js b/app/javascript/dashboard/components-next/filter/provider.js new file mode 100644 index 000000000..267732f76 --- /dev/null +++ b/app/javascript/dashboard/components-next/filter/provider.js @@ -0,0 +1,299 @@ +import { computed, h } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useOperators } from './operators'; +import { useMapGetter } from 'dashboard/composables/store.js'; +import { useChannelIcon } from 'next/icon/provider'; +import countries from 'shared/constants/countries.js'; +import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js'; + +/** + * @typedef {Object} FilterOption + * @property {string|number} id + * @property {string} name + * @property {import('vue').VNode} [icon] + */ + +/** + * @typedef {Object} FilterOperator + * @property {string} value + * @property {string} label + * @property {string} icon + * @property {boolean} hasInput + */ + +/** + * @typedef {Object} FilterType + * @property {string} attributeKey - The attribute key + * @property {string} value - This is a proxy for the attribute key used in FilterSelect + * @property {string} attributeName - The attribute name used to display on the UI + * @property {string} label - This is a proxy for the attribute name used in FilterSelect + * @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute + * @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type + * @property {'text'|'number'} dataType + * @property {FilterOperator[]} filterOperators - The operators available for the attribute + * @property {'standard'|'additional'|'customAttributes'} attributeModel + */ + +/** + * @typedef {Object} FilterGroup + * @property {string} name + * @property {FilterType[]} attributes + */ + +/** + * Determines the input type for a custom attribute based on its key + * @param {string} key - The attribute display type key + * @returns {'date'|'plainText'|'searchSelect'|'booleanSelect'} The corresponding input type + */ +const customAttributeInputType = key => { + switch (key) { + case 'date': + return 'date'; + case 'text': + return 'plainText'; + case 'list': + return 'searchSelect'; + case 'checkbox': + return 'booleanSelect'; + default: + return 'plainText'; + } +}; + +/** + * Composable that provides conversation filtering context + * @returns {{ filterTypes: import('vue').ComputedRef, filterGroups: import('vue').ComputedRef }} + */ +export function useConversationFilterContext() { + const { t } = useI18n(); + + const conversationAttributes = useMapGetter( + 'attributes/getConversationAttributes' + ); + + const labels = useMapGetter('labels/getLabels'); + const agents = useMapGetter('agents/getAgents'); + const inboxes = useMapGetter('inboxes/getInboxes'); + const teams = useMapGetter('teams/getTeams'); + const campaigns = useMapGetter('campaigns/getAllCampaigns'); + + const { + equalityOperators, + presenceOperators, + containmentOperators, + dateOperators, + getOperatorTypes, + } = useOperators(); + + /** + * @type {import('vue').ComputedRef} + */ + const customFilterTypes = computed(() => { + return conversationAttributes.value.map(attr => { + return { + attributeKey: attr.attributeKey, + value: attr.attributeKey, + attributeName: attr.attributeDisplayName, + label: attr.attributeDisplayName, + inputType: customAttributeInputType(attr.attributeDisplayType), + filterOperators: getOperatorTypes(attr.attributeDisplayType), + options: + attr.attributeDisplayType === 'list' + ? attr.attributeValues.map(item => ({ id: item, name: item })) + : [], + attributeModel: 'customAttributes', + }; + }); + }); + + /** + * @type {import('vue').ComputedRef} + */ + const filterTypes = computed(() => [ + { + attributeKey: 'status', + value: 'status', + attributeName: t('FILTER.ATTRIBUTES.STATUS'), + label: t('FILTER.ATTRIBUTES.STATUS'), + inputType: 'multiSelect', + options: ['open', 'resolved', 'pending', 'snoozed', 'all'].map(id => { + return { + id, + name: t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${id}.TEXT`), + }; + }), + dataType: 'text', + filterOperators: equalityOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: 'assignee_id', + value: 'assignee_id', + attributeName: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'), + label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'), + inputType: 'searchSelect', + options: agents.value.map(agent => { + return { + id: agent.id, + name: agent.name, + }; + }), + dataType: 'text', + filterOperators: presenceOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: 'inbox_id', + value: 'inbox_id', + attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'), + label: t('FILTER.ATTRIBUTES.INBOX_NAME'), + inputType: 'searchSelect', + options: inboxes.value.map(inbox => { + return { + ...inbox, + icon: useChannelIcon(inbox).value, + }; + }), + dataType: 'text', + filterOperators: presenceOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: 'team_id', + value: 'team_id', + attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'), + label: t('FILTER.ATTRIBUTES.TEAM_NAME'), + inputType: 'searchSelect', + options: teams.value, + dataType: 'number', + filterOperators: presenceOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: 'display_id', + value: 'display_id', + attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'), + label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'), + inputType: 'plainText', + datatype: 'number', + filterOperators: containmentOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: 'campaign_id', + value: 'campaign_id', + attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'), + label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'), + inputType: 'searchSelect', + options: campaigns.value.map(campaign => ({ + id: campaign.id, + name: campaign.title, + })), + datatype: 'number', + filterOperators: presenceOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: 'labels', + value: 'labels', + attributeName: t('FILTER.ATTRIBUTES.LABELS'), + label: t('FILTER.ATTRIBUTES.LABELS'), + inputType: 'multiSelect', + options: labels.value.map(label => { + return { + id: label.title, + name: label.title, + icon: h('span', { + class: `rounded-full`, + style: { + backgroundColor: label.color, + height: '6px', + width: '6px', + }, + }), + }; + }), + dataType: 'text', + filterOperators: presenceOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: 'browser_language', + value: 'browser_language', + attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'), + label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'), + inputType: 'searchSelect', + options: languages, + dataType: 'text', + filterOperators: equalityOperators.value, + attributeModel: 'additional', + }, + { + attributeKey: 'country_code', + value: 'country_code', + attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'), + label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'), + inputType: 'searchSelect', + options: countries, + dataType: 'text', + filterOperators: equalityOperators.value, + attributeModel: 'additional', + }, + { + attributeKey: 'referer', + value: 'referer', + attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'), + label: t('FILTER.ATTRIBUTES.REFERER_LINK'), + inputType: 'plainText', + dataType: 'text', + filterOperators: containmentOperators.value, + attributeModel: 'additional', + }, + { + attributeKey: 'created_at', + value: 'created_at', + attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'), + label: t('FILTER.ATTRIBUTES.CREATED_AT'), + inputType: 'date', + dataType: 'text', + filterOperators: dateOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: 'last_activity_at', + value: 'last_activity_at', + attributeName: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'), + label: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'), + inputType: 'date', + dataType: 'text', + filterOperators: dateOperators.value, + attributeModel: 'standard', + }, + ...customFilterTypes.value, + ]); + + const filterGroups = computed(() => { + return [ + { + name: t(`FILTER.GROUPS.STANDARD_FILTERS`), + attributes: filterTypes.value.filter( + filter => filter.attributeModel === 'standard' + ), + }, + { + name: t(`FILTER.GROUPS.ADDITIONAL_FILTERS`), + attributes: filterTypes.value.filter( + filter => filter.attributeModel === 'additional' + ), + }, + { + name: t(`FILTER.GROUPS.CUSTOM_ATTRIBUTES`), + attributes: filterTypes.value.filter( + filter => filter.attributeModel === 'customAttributes' + ), + }, + ]; + }); + + return { filterTypes, filterGroups }; +} diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index c4c81cbfb..0108e02df 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -22,10 +22,10 @@ import { // https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import ChatListHeader from './ChatListHeader.vue'; -import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue'; +import ConversationFilter from 'next/filter/ConversationFilter.vue'; +import SaveCustomView from 'next/filter/SaveCustomView.vue'; import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ConversationItem from './ConversationItem.vue'; -import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue'; import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue'; import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue'; import IntersectionObserver from './IntersectionObserver.vue'; @@ -37,9 +37,15 @@ import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions'; import { useFilter } from 'shared/composables/useFilter'; import { useTrack } from 'dashboard/composables'; import { useI18n } from 'vue-i18n'; +import { + useCamelCase, + useSnakeCase, +} from 'dashboard/composables/useTransformKeys'; import { useEmitter } from 'dashboard/composables/emitter'; import { useEventListener } from '@vueuse/core'; +import { emitter } from 'shared/helpers/mitt'; + import wootConstants from 'dashboard/constants/globals'; import advancedFilterOptions from './widgets/conversation/advancedFilterItems'; import filterQueryGenerator from '../helper/filterQueryGenerator.js'; @@ -51,12 +57,11 @@ import { isOnMentionsView, isOnUnattendedView, } from '../store/modules/conversations/helpers/actionHelpers'; -import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events'; -import { emitter } from 'shared/helpers/mitt'; import { getUserPermissions, filterItemsByPermission, } from 'dashboard/helper/permissionsHelper.js'; +import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events'; import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js'; import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; @@ -107,7 +112,7 @@ const unAssignedChatsList = useMapGetter('getUnAssignedChats'); const chatListLoading = useMapGetter('getChatListLoadingStatus'); const activeInbox = useMapGetter('getSelectedInbox'); const conversationStats = useMapGetter('conversationStats/getStats'); -const appliedFilters = useMapGetter('getAppliedConversationFilters'); +const appliedFilters = useMapGetter('getAppliedConversationFiltersV2'); const folders = useMapGetter('customViews/getConversationCustomViews'); const agentList = useMapGetter('agents/getAgents'); const teamsList = useMapGetter('teams/getTeams'); @@ -371,6 +376,7 @@ function emitConversationLoaded() { } function fetchFilteredConversations(payload) { + payload = useSnakeCase(payload); let page = currentFiltersPage.value + 1; store .dispatch('fetchFilteredConversations', { @@ -383,6 +389,7 @@ function fetchFilteredConversations(payload) { } function fetchSavedFilteredConversations(payload) { + payload = useSnakeCase(payload); let page = currentFiltersPage.value + 1; store .dispatch('fetchFilteredConversations', { @@ -393,6 +400,7 @@ function fetchSavedFilteredConversations(payload) { } function onApplyFilter(payload) { + payload = useSnakeCase(payload); resetBulkActions(); foldersQuery.value = filterQueryGenerator(payload); store.dispatch('conversationPage/reset'); @@ -406,10 +414,11 @@ function closeAdvanceFiltersModal() { } function onUpdateSavedFilter(payload, folderName) { + const transformedPayload = useSnakeCase(payload); const payloadData = { ...unref(activeFolder), name: unref(folderName), - query: filterQueryGenerator(payload), + query: filterQueryGenerator(transformedPayload), }; store.dispatch('customViews/update', payloadData); closeAdvanceFiltersModal(); @@ -461,17 +470,19 @@ function initializeExistingFilterToModal() { currentUserDetails.value, activeAssigneeTab.value ); + // TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase if (statusFilter) { - appliedFilter.value = [...appliedFilter.value, statusFilter]; + appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)]; } + // TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase const otherFilters = initializeInboxTeamAndLabelFilterToModal( props.conversationInbox, inbox.value, props.teamId, activeTeam.value, props.label - ); + ).map(useCamelCase); appliedFilter.value = [...appliedFilter.value, ...otherFilters]; } @@ -486,27 +497,47 @@ function initializeFolderToFilterModal(newActiveFolder) { const query = unref(newActiveFolder)?.query?.payload; if (!Array.isArray(query)) return; - const newFilters = query.map(filter => ({ - attribute_key: filter.attribute_key, - attribute_model: filter.attribute_model, - filter_operator: filter.filter_operator, - values: Array.isArray(filter.values) - ? generateValuesForEditCustomViews(filter, setParamsForEditFolderModal()) - : [], - query_operator: filter.query_operator, - custom_attribute_type: filter.custom_attribute_type, - })); + const newFilters = query.map(filter => { + const transformed = useCamelCase(filter); + const values = Array.isArray(transformed.values) + ? generateValuesForEditCustomViews( + useSnakeCase(filter), + setParamsForEditFolderModal() + ) + : []; + + return { + attributeKey: transformed.attributeKey, + attributeModel: transformed.attributeModel, + customAttributeType: transformed.customAttributeType, + filterOperator: transformed.filterOperator, + queryOperator: transformed.queryOperator ?? 'and', + values, + }; + }); appliedFilter.value = [...appliedFilter.value, ...newFilters]; } +function initalizeAppliedFiltersToModal() { + appliedFilter.value = [...appliedFilters.value]; +} + function onToggleAdvanceFiltersModal() { + if (showAdvancedFilters.value === true) { + closeAdvanceFiltersModal(); + return; + } + if (!hasAppliedFilters.value && !hasActiveFolders.value) { initializeExistingFilterToModal(); } if (hasActiveFolders.value) { initializeFolderToFilterModal(activeFolder.value); } + if (hasAppliedFilters.value) { + initalizeAppliedFiltersToModal(); + } showAdvancedFilters.value = true; } @@ -751,7 +782,7 @@ watch(conversationFilters, (newVal, oldVal) => {
- - + - + diff --git a/app/javascript/dashboard/components/ChatListHeader.vue b/app/javascript/dashboard/components/ChatListHeader.vue index 86157515e..175b69ef1 100644 --- a/app/javascript/dashboard/components/ChatListHeader.vue +++ b/app/javascript/dashboard/components/ChatListHeader.vue @@ -62,14 +62,18 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
- +
+ +
+
} payload - Object or array to convert + * @returns {Object|Array} Converted payload with camelCase keys + */ +export function useCamelCase(payload) { + const unrefPayload = unref(payload); + return camelcaseKeys(unrefPayload); +} + +/** + * Vue composable that converts object keys to snake_case + * @param {Object|Array|import('vue').Ref} payload - Object or array to convert + * @returns {Object|Array} Converted payload with snake_case keys + */ +export function useSnakeCase(payload) { + const unrefPayload = unref(payload); + return snakecaseKeys(unrefPayload); +} diff --git a/app/javascript/dashboard/helper/validations.js b/app/javascript/dashboard/helper/validations.js index 155108c61..4347f55aa 100644 --- a/app/javascript/dashboard/helper/validations.js +++ b/app/javascript/dashboard/helper/validations.js @@ -6,6 +6,24 @@ export const VALUE_MUST_BE_BETWEEN_1_AND_998 = export const ACTION_PARAMETERS_REQUIRED = 'ACTION_PARAMETERS_REQUIRED'; export const ATLEAST_ONE_CONDITION_REQUIRED = 'ATLEAST_ONE_CONDITION_REQUIRED'; export const ATLEAST_ONE_ACTION_REQUIRED = 'ATLEAST_ONE_ACTION_REQUIRED'; + +const isEmptyValue = value => { + if (!value) { + return true; + } + + if (Array.isArray(value)) { + return !value.length; + } + + // We can safely check the type here as both the null value + // and the array is ruled out earlier. + if (typeof value === 'object') { + return !Object.keys(value).length; + } + + return false; +}; // ------------------------------------------------------------------ // ------------------------ Filter Validation ----------------------- // ------------------------------------------------------------------ @@ -29,12 +47,11 @@ export const validateSingleFilter = filter => { return FILTER_OPERATOR_REQUIRED; } - if ( - filter.filter_operator !== 'is_present' && - filter.filter_operator !== 'is_not_present' && - (!filter.values || - (Array.isArray(filter.values) && filter.values.length === 0)) - ) { + const operatorRequiresValue = !['is_present', 'is_not_present'].includes( + filter.filter_operator + ); + + if (operatorRequiresValue && isEmptyValue(filter.values)) { return VALUE_REQUIRED; } diff --git a/app/javascript/dashboard/i18n/locale/en/advancedFilters.json b/app/javascript/dashboard/i18n/locale/en/advancedFilters.json index a382aec2e..a991cb25b 100644 --- a/app/javascript/dashboard/i18n/locale/en/advancedFilters.json +++ b/app/javascript/dashboard/i18n/locale/en/advancedFilters.json @@ -22,14 +22,23 @@ "OPERATOR_LABELS": { "equal_to": "Equal to", "not_equal_to": "Not equal to", - "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 lesser than", "days_before": "Is x days before", - "starts_with": "Starts with" + "starts_with": "Starts with", + "equalTo": "Equal to", + "notEqualTo": "Not equal to", + "contains": "Contains", + "doesNotContain": "Does not contain", + "isPresent": "Is present", + "isNotPresent": "Is not present", + "isGreaterThan": "Is greater than", + "isLessThan": "Is lesser than", + "daysBefore": "Is x days before", + "startsWith": "Starts with" }, "ATTRIBUTE_LABELS": { "TRUE": "True", @@ -56,7 +65,10 @@ "LAST_ACTIVITY": "Last activity" }, "ERRORS": { - "VALUE_REQUIRED": "Value is required" + "VALUE_REQUIRED": "Value is required", + "ATTRIBUTE_KEY_REQUIRED": "Attribute key is required", + "FILTER_OPERATOR_REQUIRED": "Filter operator is required", + "VALUE_MUST_BE_BETWEEN_1_AND_998": "Value must be between 1 and 998" }, "GROUPS": { "STANDARD_FILTERS": "Standard filters", diff --git a/app/javascript/dashboard/store/modules/attributes.js b/app/javascript/dashboard/store/modules/attributes.js index af6d3ce4f..c7cb771a5 100644 --- a/app/javascript/dashboard/store/modules/attributes.js +++ b/app/javascript/dashboard/store/modules/attributes.js @@ -1,6 +1,7 @@ import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; import types from '../mutation-types'; import AttributeAPI from '../../api/attributes'; +import camelcaseKeys from 'camelcase-keys'; export const state = { records: [], @@ -19,6 +20,16 @@ export const getters = { getAttributes: _state => { return _state.records; }, + getConversationAttributes: _state => { + return _state.records + .filter(record => record.attribute_model === 'conversation_attribute') + .map(camelcaseKeys); + }, + getContactAttributes: _state => { + return _state.records + .filter(record => record.attribute_model === 'contact_attribute') + .map(camelcaseKeys); + }, getAttributesByModel: _state => attributeModel => { return _state.records.filter( record => record.attribute_model === attributeModel diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js index 1a905c3c5..085766bfd 100644 --- a/app/javascript/dashboard/store/modules/conversations/getters.js +++ b/app/javascript/dashboard/store/modules/conversations/getters.js @@ -1,6 +1,7 @@ import { MESSAGE_TYPE } from 'shared/constants/messages'; import { applyPageFilters, sortComparator } from './helpers'; import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator'; +import camelcaseKeys from 'camelcase-keys'; export const getSelectedChatConversation = ({ allConversations, @@ -54,6 +55,10 @@ const getters = { return isChatMine; }); }, + getAppliedConversationFiltersV2: _state => { + // TODO: Replace existing one with V2 after migrating the filters to use camelcase + return _state.appliedFilters.map(camelcaseKeys); + }, getAppliedConversationFilters: _state => { return _state.appliedFilters; }, diff --git a/package.json b/package.json index a2dac35df..a6258c842 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "mitt": "^3.0.1", "opus-recorder": "^8.0.5", "semver": "7.6.3", + "snakecase-keys": "^8.0.1", "timezone-phone-codes": "^0.0.2", "tinykeys": "^3.0.0", "turbolinks": "^5.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ba6b2391..ebb88a272 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: semver: specifier: 7.6.3 version: 7.6.3 + snakecase-keys: + specifier: ^8.0.1 + version: 8.0.1 timezone-phone-codes: specifier: ^0.0.2 version: 0.0.2 @@ -3485,6 +3488,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + map-obj@4.3.0: + resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} + engines: {node: '>=8'} + map-obj@5.0.0: resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4420,6 +4427,10 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + snakecase-keys@8.0.1: + resolution: {integrity: sha512-Sj51kE1zC7zh6TDlNNz0/Jn1n5HiHdoQErxO8jLtnyrkJW/M5PrI7x05uDgY3BO7OUQYKCvmeMurW6BPUdwEOw==} + engines: {node: '>=18'} + sortablejs@1.14.0: resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==} @@ -5847,7 +5858,7 @@ snapshots: '@material/mwc-icon@0.25.3': dependencies: lit: 2.2.6 - tslib: 2.6.2 + tslib: 2.8.1 '@nodelib/fs.scandir@2.1.5': dependencies: @@ -5932,7 +5943,7 @@ snapshots: dependencies: '@lukeed/uuid': 2.0.0 dset: 3.1.4 - tslib: 2.6.2 + tslib: 2.8.1 '@segment/analytics.js-video-plugins@0.2.1': dependencies: @@ -7041,7 +7052,7 @@ snapshots: aria-hidden@1.2.4: dependencies: - tslib: 2.7.0 + tslib: 2.8.1 array-buffer-byte-length@1.0.0: dependencies: @@ -7209,7 +7220,7 @@ snapshots: camel-case@4.1.2: dependencies: pascal-case: 3.1.2 - tslib: 2.7.0 + tslib: 2.8.1 camelcase-css@2.0.1: {} @@ -7227,7 +7238,7 @@ snapshots: capital-case@1.0.4: dependencies: no-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 upper-case-first: 2.0.2 chai@5.1.1: @@ -7384,7 +7395,7 @@ snapshots: constant-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 upper-case: 2.0.2 core-js@3.38.1: {} @@ -7578,7 +7589,7 @@ snapshots: dot-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 dset@3.1.4: {} @@ -8249,7 +8260,7 @@ snapshots: header-case@2.0.4: dependencies: capital-case: 1.0.4 - tslib: 2.7.0 + tslib: 2.8.1 highlight.js@11.10.0: {} @@ -8776,7 +8787,7 @@ snapshots: lower-case@2.0.2: dependencies: - tslib: 2.7.0 + tslib: 2.8.1 lru-cache@10.4.3: {} @@ -8814,6 +8825,8 @@ snapshots: dependencies: semver: 7.6.3 + map-obj@4.3.0: {} + map-obj@5.0.0: {} markdown-it-anchor@8.6.7(@types/markdown-it@12.2.3)(markdown-it@12.3.2): @@ -8970,7 +8983,7 @@ snapshots: no-case@3.0.4: dependencies: lower-case: 2.0.2 - tslib: 2.7.0 + tslib: 2.8.1 node-fetch@2.6.11: dependencies: @@ -9117,7 +9130,7 @@ snapshots: param-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 parent-module@1.0.1: dependencies: @@ -9132,12 +9145,12 @@ snapshots: pascal-case@3.1.2: dependencies: no-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 path-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 path-exists@4.0.0: {} @@ -9748,7 +9761,7 @@ snapshots: sentence-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 upper-case-first: 2.0.2 set-function-length@1.2.2: @@ -9823,7 +9836,13 @@ snapshots: snake-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 + + snakecase-keys@8.0.1: + dependencies: + map-obj: 4.3.0 + snake-case: 3.0.4 + type-fest: 4.26.1 sortablejs@1.14.0: {} @@ -10196,11 +10215,11 @@ snapshots: upper-case-first@2.0.2: dependencies: - tslib: 2.7.0 + tslib: 2.8.1 upper-case@2.0.2: dependencies: - tslib: 2.7.0 + tslib: 2.8.1 uri-js@4.4.1: dependencies: