HH-555: remove advanced assignment commercial gate (#138)

* fix(HH-555): remove advanced assignment paywall

* fix(HH-555): gate assignment entries with assignment v2

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-23 23:19:51 +08:00
committed by GitHub
co-authored by rogee
parent 60a6ac4785
commit cb0b554e94
10 changed files with 223 additions and 171 deletions
@@ -54,10 +54,10 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const hasAdvancedAssignment = computed(() => {
const hasAssignmentV2 = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
FEATURE_FLAGS.ASSIGNMENT_V2
);
});
@@ -690,7 +690,7 @@ const menuItems = computed(() => {
],
to: accountScopedRoute('settings_teams_list'),
},
...(hasAdvancedAssignment.value
...(hasAssignmentV2.value
? [
{
name: 'Settings Agent Assignment',
@@ -869,8 +869,6 @@
"ROUND_ROBIN": "Round robin",
"BALANCED": "Balanced assignment"
},
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
"DEFAULT_POLICY_LINKED": "Default policy linked",
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
"LINK_EXISTING_POLICY": "Link existing policy",
@@ -764,9 +764,7 @@
},
"BALANCED": {
"LABEL": "Balanced",
"DESCRIPTION": "Assign conversations based on available capacity.",
"PREMIUM_MESSAGE": "Upgrade to access balanced assignment and agent capacity management.",
"PREMIUM_BADGE": "Premium"
"DESCRIPTION": "Assign conversations based on available capacity."
}
},
"ASSIGNMENT_PRIORITY": {
@@ -869,8 +869,6 @@
"ROUND_ROBIN": "轮询分配",
"BALANCED": "均衡分配"
},
"UPGRADE_PROMPT": "自定义分配策略适用于商业版套餐",
"UPGRADE_TO_BUSINESS": "升级到商业版",
"DEFAULT_POLICY_LINKED": "已关联默认策略",
"DEFAULT_POLICY_DESCRIPTION": "关联自定义分配策略,以配置此收件箱如何将对话分配给客服。",
"LINK_EXISTING_POLICY": "关联现有策略",
@@ -764,9 +764,7 @@
},
"BALANCED": {
"LABEL": "均衡分配",
"DESCRIPTION": "根据可用容量分配会话。",
"PREMIUM_MESSAGE": "升级以使用均衡分配和客服容量管理。",
"PREMIUM_BADGE": "高级"
"DESCRIPTION": "根据可用容量分配会话。"
}
},
"ASSIGNMENT_PRIORITY": {
@@ -39,18 +39,12 @@ const agentAssignments = computed(() => {
},
];
// Only show Agent Capacity if BOTH assignment_v2 AND advanced_assignment are enabled
// advanced_assignment identifies premium users
const hasAssignmentV2 = isFeatureEnabledonAccount.value(
accountId.value,
'assignment_v2'
);
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
accountId.value,
'advanced_assignment'
);
if (hasAssignmentV2 && hasAdvancedAssignment) {
if (hasAssignmentV2) {
assignments.push({
key: 'agent_capacity_policy_index',
title: t('ASSIGNMENT_POLICY.INDEX.AGENT_CAPACITY_POLICY.TITLE'),
@@ -1,23 +1,211 @@
import { readFileSync } from 'node:fs';
import { flushPromises, shallowMount } from '@vue/test-utils';
import { createMemoryHistory, createRouter } from 'vue-router';
import AgentAssignmentPolicyForm from './pages/components/AgentAssignmentPolicyForm.vue';
import AssignmentPolicyIndex from './Index.vue';
import assignmentPolicyRoutes from './assignmentPolicy.routes';
import CollaboratorsPage from '../inbox/settingsPage/CollaboratorsPage.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import AssignmentCard from 'dashboard/components-next/AssignmentPolicy/AssignmentCard/AssignmentCard.vue';
import Sidebar from 'dashboard/components-next/sidebar/Sidebar.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
describe('advanced assignment paywalls', () => {
it.each([
[
'../inbox/settingsPage/CollaboratorsPage.vue',
'v-if="showAdvancedAssignmentPaywall"',
],
[
'./pages/components/AgentAssignmentPolicyForm.vue',
'disabled && showAdvancedAssignmentPaywall.value',
],
])('uses the deployment-aware paywall policy in %s', (path, condition) => {
const source = readFileSync(
`app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/${path}`,
'utf8'
const SidebarGroupStub = {
name: 'SidebarGroup',
inheritAttrs: false,
props: ['name', 'children'],
template: '<div />',
};
const mocked = vi.hoisted(() => ({
features: ['assignment_v2'],
dispatch: vi.fn(),
getInboxPolicy: vi.fn(),
getPolicies: vi.fn(),
push: vi.fn(),
}));
const isFeatureEnabled = (_accountId, feature) =>
mocked.features.includes(feature);
const store = {
dispatch: (...args) => mocked.dispatch(...args),
getters: {
'agents/getAgents': [],
'accounts/isFeatureEnabledonAccount': isFeatureEnabled,
getUISettings: {},
},
};
vi.mock('vue-router', async () => ({
...(await vi.importActual('vue-router')),
useRoute: () => ({ params: { accountId: '1' } }),
useRouter: () => ({ push: mocked.push }),
}));
vi.mock('vuex', async () => ({
...(await vi.importActual('vuex')),
useStore: () => store,
}));
vi.mock('dashboard/composables/useUISettings', async () => {
const { ref } = await vi.importActual('vue');
return {
useUISettings: () => ({
uiSettings: ref({ sidebar_width: 200 }),
updateUISettings: vi.fn(),
}),
};
});
vi.mock('dashboard/composables/store', async () => {
const { computed } = await vi.importActual('vue');
const emptyListGetters = new Set([
'inboxes/getInboxes',
'labels/getLabelsOnSidebar',
'teams/getMyTeams',
'customViews/getContactCustomViews',
'customViews/getConversationCustomViews',
]);
const unreadCountGetters = new Set([
'conversationUnreadCounts/getInboxUnreadCount',
'conversationUnreadCounts/getLabelUnreadCount',
'conversationUnreadCounts/getTeamUnreadCount',
]);
return {
...(await vi.importActual('dashboard/composables/store')),
useMapGetter: key =>
computed(() => {
if (key === 'accounts/isFeatureEnabledonAccount') {
return isFeatureEnabled;
}
if (key === 'accounts/getAccount') return () => ({ id: 1 });
if (key === 'getCurrentAccountId') return 1;
if (emptyListGetters.has(key)) return [];
if (unreadCountGetters.has(key)) return () => 0;
return false;
}),
};
});
vi.mock('dashboard/api/assignmentPolicies', () => ({
default: {
getInboxPolicy: (...args) => mocked.getInboxPolicy(...args),
get: (...args) => mocked.getPolicies(...args),
},
}));
describe('self-hosted advanced assignment', () => {
beforeEach(() => {
mocked.features = ['assignment_v2'];
mocked.dispatch.mockResolvedValue({ data: { payload: [] } });
mocked.getInboxPolicy.mockResolvedValue({ data: null });
mocked.getPolicies.mockResolvedValue({ data: [] });
});
it('keeps balanced assignment available without a premium label', () => {
const wrapper = shallowMount(AgentAssignmentPolicyForm, {
props: { mode: 'CREATE' },
global: {
stubs: {
WithLabel: { template: '<div><slot /></div>' },
},
},
});
const balancedOption = wrapper
.findAllComponents(RadioCard)
.find(option => option.props('id') === 'balanced');
expect(balancedOption.props()).toMatchObject({
disabled: false,
disabledLabel: '',
disabledMessage: '',
});
});
it('shows agent capacity when assignment v2 is enabled', () => {
const wrapper = shallowMount(AssignmentPolicyIndex, {
global: {
stubs: {
SettingsLayout: {
template: '<main><slot name="header" /><slot name="body" /></main>',
},
},
},
});
const cards = wrapper.findAllComponents(AssignmentCard);
expect(cards).toHaveLength(2);
expect(cards.map(card => card.props('title'))).toContain(
'Agent capacity policy'
);
});
expect(source).toContain('shouldShowPaywall');
expect(source).toContain('showAdvancedAssignmentPaywall');
expect(source).toContain(condition);
it('shows the Agent Assignment sidebar entry without the legacy flag', () => {
const wrapper = shallowMount(Sidebar, {
global: {
mocks: { $store: store },
stubs: { RouterLink: true, SidebarGroup: SidebarGroupStub },
},
});
const settings = wrapper
.findAllComponents(SidebarGroupStub)
.find(group => group.props('name') === 'Settings');
expect(settings.props('children')).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'Settings Agent Assignment' }),
])
);
});
it.each([
'agent_capacity_policy_index',
'agent_capacity_policy_create',
'agent_capacity_policy_edit',
])('allows the %s route with assignment v2', async routeName => {
const router = createRouter({
history: createMemoryHistory(),
routes: assignmentPolicyRoutes.routes,
});
const params = {
accountId: 1,
...(routeName === 'agent_capacity_policy_edit' ? { id: 1 } : {}),
};
await router.push({ name: routeName, params });
expect(router.currentRoute.value).toMatchObject({
name: routeName,
meta: { featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2 },
});
});
it('shows policy controls instead of a Business plan upgrade', async () => {
const wrapper = shallowMount(CollaboratorsPage, {
props: {
inbox: {
id: 1,
enable_auto_assignment: true,
},
},
global: {
stubs: {
SettingsAccordion: { template: '<section><slot /></section>' },
SettingsToggleSection: {
template: '<section><slot name="editor" /></section>',
},
NextButton: {
props: ['label'],
template: '<button>{{ label }}<slot /></button>',
},
WootInput: true,
},
},
});
await flushPromises();
expect(wrapper.text()).toContain('Create new policy');
expect(wrapper.text()).not.toContain('Upgrade to Business');
});
});
@@ -62,7 +62,7 @@ export default {
name: 'agent_capacity_policy_index',
component: AgentCapacityIndex,
meta: {
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
@@ -71,7 +71,7 @@ export default {
name: 'agent_capacity_policy_create',
component: AgentCapacityCreate,
meta: {
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
@@ -80,7 +80,7 @@ export default {
name: 'agent_capacity_policy_edit',
component: AgentCapacityEdit,
meta: {
featureFlag: FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
@@ -1,10 +1,6 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
@@ -68,18 +64,8 @@ const emit = defineEmits([
]);
const { t } = useI18n();
const route = useRoute();
const { shouldShowPaywall } = usePolicy();
const accountId = computed(() => Number(route.params.accountId));
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const showAdvancedAssignmentPaywall = computed(() =>
shouldShowPaywall(FEATURE_FLAGS.ADVANCED_ASSIGNMENT)
);
const state = reactive({
name: '',
@@ -112,34 +98,11 @@ const createOption = (
disabledLabel,
});
const assignmentOrderOptions = computed(() => {
const hasAdvancedAssignment = isFeatureEnabledonAccount.value(
accountId.value,
'advanced_assignment'
);
return OPTIONS.ORDER.map(key => {
const isBalanced = key === 'balanced';
const disabled = isBalanced && !hasAdvancedAssignment;
const disabledMessage =
disabled && showAdvancedAssignmentPaywall.value
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_MESSAGE`)
: '';
const disabledLabel =
disabled && showAdvancedAssignmentPaywall.value
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE`)
: '';
return createOption(
'ASSIGNMENT_ORDER',
key,
'assignmentOrder',
disabled,
disabledMessage,
disabledLabel
);
});
});
const assignmentOrderOptions = computed(() =>
OPTIONS.ORDER.map(key =>
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
)
);
const assignmentPriorityOptions = computed(() =>
OPTIONS.PRIORITY.map(key =>
@@ -14,8 +14,6 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
import Icon from 'dashboard/components-next/icon/Icon.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import assignmentPoliciesAPI from 'dashboard/api/assignmentPolicies';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useI18n } from 'vue-i18n';
const props = defineProps({
@@ -29,7 +27,6 @@ const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { shouldShowPaywall } = usePolicy();
const selectedAgentIds = ref([]);
const isAgentListUpdating = ref(false);
@@ -81,22 +78,10 @@ const isFeatureEnabled = feature => {
);
};
const hasAdvancedAssignment = computed(() => {
return isFeatureEnabled('advanced_assignment');
});
const hasAssignmentV2 = computed(() => {
return isFeatureEnabled('assignment_v2');
});
const showAdvancedAssignmentUI = computed(() => {
return hasAdvancedAssignment.value && hasAssignmentV2.value;
});
const showAdvancedAssignmentPaywall = computed(() =>
shouldShowPaywall(FEATURE_FLAGS.ADVANCED_ASSIGNMENT)
);
const assignmentOrderLabel = computed(() => {
if (!assignmentPolicy.value) return '';
const priority = assignmentPolicy.value.conversation_priority;
@@ -315,14 +300,6 @@ const navigateToAssignmentPolicyEdit = () => {
});
};
const navigateToBilling = () => {
const accountId = route.params.accountId;
router.push({
name: 'billing_settings_index',
params: { accountId },
});
};
const confirmDeletePolicy = () => {
showDeleteConfirmModal.value = true;
};
@@ -351,7 +328,7 @@ const setDefaults = () => {
maxAssignmentLimit.value =
props.inbox.auto_assignment_config?.max_assignment_limit || null;
fetchAttachedAgents();
if (showAdvancedAssignmentUI.value) {
if (hasAssignmentV2.value) {
fetchAssignmentPolicy();
fetchAvailablePolicies();
}
@@ -416,7 +393,7 @@ onMounted(() => {
<template v-if="hasAssignmentV2">
<!-- Policy Card - When policy is attached -->
<div
v-if="showAdvancedAssignmentUI && assignmentPolicy"
v-if="assignmentPolicy"
class="ltr:pr-0 rtl:pl-0 ltr:pl-4 rtl:pr-4 py-4"
>
<div class="flex items-start gap-4">
@@ -480,13 +457,7 @@ onMounted(() => {
</div>
<!-- Default Policy - When no custom policy attached but feature enabled -->
<div
v-else-if="
showAdvancedAssignmentUI &&
!assignmentPolicy &&
!isLoadingPolicy
"
>
<div v-else-if="!assignmentPolicy && !isLoadingPolicy">
<!-- Default Policy Header -->
<div class="p-4">
<div class="flex items-start gap-4">
@@ -579,62 +550,6 @@ onMounted(() => {
</div>
</div>
</div>
<!-- Default Rules Card - Feature not enabled (no advanced_assignment) -->
<div
v-else-if="!showAdvancedAssignmentUI"
class="ltr:pr-0 rtl:pl-0 ltr:pl-4 rtl:pr-4 py-4"
>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 size-10 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
>
<Icon icon="i-lucide-zap" class="text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<h4 class="text-heading-3 text-n-slate-12 mb-0.5">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_TITLE') }}
</h4>
<p class="text-body-main text-n-slate-11 mb-4">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_DESCRIPTION') }}
</p>
<ul class="space-y-2 mb-6">
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
</span>
</li>
</ul>
<div class="w-full h-px bg-n-weak my-4" />
<div v-if="showAdvancedAssignmentPaywall">
<p class="text-body-main text-n-slate-11 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.UPGRADE_PROMPT') }}
</p>
<NextButton
:label="$t('INBOX_MGMT.ASSIGNMENT.UPGRADE_TO_BUSINESS')"
icon="i-lucide-arrow-right"
trailing-icon
link
@click="navigateToBilling"
/>
</div>
</div>
</div>
</div>
</template>
<!-- Old UI for non-assignment_v2 -->