feat: complete current GoChat updates

This commit is contained in:
2026-09-13 00:13:55 +08:00
parent 6e62f5094f
commit 0dd188c46f
58 changed files with 2662 additions and 1503 deletions
@@ -1,5 +1,4 @@
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { frontendURL } from '../../../helper/URLHelper';
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
@@ -21,19 +20,16 @@ import CustomToolsIndex from './tools/Index.vue';
const meta = {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const metaCustomTools = {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const metaV2 = {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const assistantRoutes = [
@@ -114,10 +110,6 @@ const assistantRoutes = [
name: 'captain_assistants_create_index',
meta: {
permissions: ['administrator'],
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
@@ -2,12 +2,10 @@ import { frontendURL } from '../../../helper/URLHelper';
import CompaniesIndex from './pages/CompaniesIndex.vue';
import CompanyDetailView from './pages/CompanyDetailView.vue';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
const commonMeta = {
featureFlag: FEATURE_FLAGS.COMPANIES,
permissions: ['administrator', 'agent'],
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
export const routes = [
@@ -75,9 +75,11 @@ const addAgent = async () => {
};
if (selectedRole.value.name.startsWith('custom_')) {
payload.role = 'agent';
payload.custom_role_id = selectedRole.value.id;
} else {
payload.role = selectedRole.value.name;
payload.custom_role_id = null;
}
const agent = await store.dispatch('agents/create', payload);
@@ -19,10 +19,6 @@ const props = defineProps({
type: String,
required: true,
},
email: {
type: String,
default: '',
},
type: {
type: String,
default: '',
@@ -126,7 +122,7 @@ const availabilityStatuses = computed(() =>
const editAgent = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
if (v$.value.$invalid || !selectedRole.value) return;
try {
const payload = {
@@ -137,6 +133,7 @@ const editAgent = async () => {
};
if (selectedRole.value.name.startsWith('custom_')) {
payload.role = 'agent';
payload.custom_role_id = selectedRole.value.id;
} else {
payload.role = selectedRole.value.name;
@@ -248,7 +245,7 @@ const resetPassword = async () => {
<Button
type="submit"
:label="$t('AGENT_MGMT.EDIT.FORM.SUBMIT')"
:disabled="v$.$invalid || uiFlags.isUpdating"
:disabled="v$.$invalid || uiFlags.isUpdating || !selectedRole"
:is-loading="uiFlags.isUpdating"
/>
</div>
@@ -49,10 +49,20 @@ const filteredAgentList = computed(() => {
const uiFlags = computed(() => getters['agents/getUIFlags'].value);
const currentUserId = computed(() => getters.getCurrentUserID.value);
const customRoles = useMapGetter('customRole/getCustomRoles');
const customRoleLoadError = ref(false);
const loadCustomRoles = async () => {
customRoleLoadError.value = false;
try {
await store.dispatch('customRole/getCustomRole');
} catch {
customRoleLoadError.value = true;
}
};
onMounted(() => {
store.dispatch('agents/get');
store.dispatch('customRole/getCustomRole');
loadCustomRoles();
});
const findCustomRole = agent =>
@@ -99,9 +109,11 @@ const showDeleteAction = agent => {
return true;
};
const showAlertMessage = message => {
loading.value[currentAgent.value.id] = false;
currentAgent.value = {};
const showAlertMessage = (message, id) => {
loading.value[id] = false;
if (currentAgent.value.id === id) {
currentAgent.value = {};
}
agentAPI.value.message = message;
useAlert(message);
};
@@ -132,15 +144,17 @@ const closeDeletePopup = () => {
const deleteAgent = async id => {
try {
await store.dispatch('agents/delete', id);
showAlertMessage(t('AGENT_MGMT.DELETE.API.SUCCESS_MESSAGE'));
showAlertMessage(t('AGENT_MGMT.DELETE.API.SUCCESS_MESSAGE'), id);
} catch (error) {
showAlertMessage(t('AGENT_MGMT.DELETE.API.ERROR_MESSAGE'));
showAlertMessage(t('AGENT_MGMT.DELETE.API.ERROR_MESSAGE'), id);
}
};
const confirmDeletion = () => {
loading.value[currentAgent.value.id] = true;
const id = currentAgent.value.id;
if (!id) return;
loading.value[id] = true;
closeDeletePopup();
deleteAgent(currentAgent.value.id);
deleteAgent(id);
};
</script>
@@ -175,6 +189,19 @@ const confirmDeletion = () => {
</BaseSettingsHeader>
</template>
<template #body>
<div
v-if="customRoleLoadError"
class="flex items-center justify-between gap-3 border-b border-n-weak px-4 py-3 text-sm text-n-slate-11"
>
<span>{{ $t('CUSTOM_ROLE.LIST.ERROR') }}</span>
<Button
slate
faded
size="sm"
:label="$t('CUSTOM_ROLE.LIST.RETRY')"
@click="loadCustomRoles"
/>
</div>
<span
v-if="!filteredAgentList.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
@@ -294,7 +321,6 @@ const confirmDeletion = () => {
:name="currentAgent.name"
:provider="currentAgent.provider"
:type="currentAgent.role"
:email="currentAgent.email"
:availability="currentAgent.availability_status"
:custom-role-id="currentAgent.custom_role_id"
:active="currentAgent.active"
@@ -1,5 +1,4 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
@@ -22,10 +21,6 @@ export default {
name: 'auditlogs_list',
meta: {
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
permissions: ['administrator'],
},
component: AuditLogsHome,
@@ -1,5 +1,5 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import store from 'dashboard/store';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
@@ -9,7 +9,13 @@ export default {
path: frontendURL('accounts/:accountId/settings/billing'),
meta: {
permissions: ['administrator'],
installationTypes: [INSTALLATION_TYPES.CLOUD],
},
beforeEnter: to => {
const isCloud = store.getters['globalConfig/isOnChatwootCloud'];
const isCustomBranded =
store.getters['globalConfig/isACustomBrandedInstance'];
if (isCloud && !isCustomBranded) return true;
return { name: 'home', params: { accountId: to.params.accountId } };
},
component: SettingsWrapper,
props: {
@@ -23,7 +29,6 @@ export default {
name: 'billing_settings_index',
component: Index,
meta: {
installationTypes: [INSTALLATION_TYPES.CLOUD],
permissions: ['administrator'],
},
},
@@ -22,6 +22,7 @@ const loading = ref({});
const showDeleteConfirmationPopup = ref(false);
const activeResponse = ref({});
const searchQuery = ref('');
const fetchError = ref(false);
const records = useMapGetter('customRole/getCustomRoles');
@@ -40,10 +41,6 @@ const deleteRejectText = computed(
() => `${t('CUSTOM_ROLE.DELETE.CONFIRM.NO')} ${activeResponse.value.name}`
);
const deleteMessage = computed(() => {
return ` ${activeResponse.value.name} ? `;
});
const isFeatureEnabledOnAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
@@ -58,10 +55,11 @@ const isBehindAPaywall = computed(() => {
});
const fetchCustomRoles = async () => {
fetchError.value = false;
try {
await store.dispatch('customRole/getCustomRole');
} catch (error) {
// Ignore Error
} catch {
fetchError.value = true;
}
};
@@ -78,9 +76,11 @@ const tableHeaders = computed(() => {
];
});
const showAlertMessage = message => {
loading.value[activeResponse.value.id] = false;
activeResponse.value = {};
const showAlertMessage = (message, id) => {
loading.value[id] = false;
if (activeResponse.value.id === id) {
activeResponse.value = {};
}
useAlert(message);
};
@@ -114,18 +114,20 @@ const closeDeletePopup = () => {
const deleteCustomRole = async id => {
try {
await store.dispatch('customRole/deleteCustomRole', id);
showAlertMessage(t('CUSTOM_ROLE.DELETE.API.SUCCESS_MESSAGE'));
showAlertMessage(t('CUSTOM_ROLE.DELETE.API.SUCCESS_MESSAGE'), id);
} catch (error) {
const errorMessage =
error?.message || t('CUSTOM_ROLE.DELETE.API.ERROR_MESSAGE');
showAlertMessage(errorMessage);
showAlertMessage(errorMessage, id);
}
};
const confirmDeletion = () => {
loading[activeResponse.value.id] = true;
const id = activeResponse.value.id;
if (!id) return;
loading.value[id] = true;
closeDeletePopup();
deleteCustomRole(activeResponse.value.id);
deleteCustomRole(id);
};
</script>
@@ -133,7 +135,7 @@ const confirmDeletion = () => {
<SettingsLayout
:is-loading="uiFlags.fetchingList"
:loading-message="$t('CUSTOM_ROLE.LOADING')"
:no-records-found="!records.length && !isBehindAPaywall"
:no-records-found="!records.length && !isBehindAPaywall && !fetchError"
:no-records-message="$t('CUSTOM_ROLE.LIST.404')"
>
<template #header>
@@ -141,13 +143,11 @@ const confirmDeletion = () => {
v-model:search-query="searchQuery"
:title="$t('CUSTOM_ROLE.HEADER')"
:description="$t('CUSTOM_ROLE.DESCRIPTION')"
:link-text="$t('CUSTOM_ROLE.LEARN_MORE')"
:search-placeholder="$t('CUSTOM_ROLE.SEARCH_PLACEHOLDER')"
feature-name="canned_responses"
>
<template v-if="records?.length" #count>
<template v-if="filteredRecords?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('CUSTOM_ROLE.COUNT', { n: records.length }) }}
{{ $t('CUSTOM_ROLE.COUNT', { n: filteredRecords.length }) }}
</span>
</template>
<template #actions>
@@ -162,7 +162,22 @@ const confirmDeletion = () => {
</template>
<template #body>
<CustomRolePaywall v-if="isBehindAPaywall" />
<div
v-if="fetchError && !isBehindAPaywall"
class="flex flex-col items-center justify-center gap-3 py-20"
>
<p class="text-base text-n-slate-12">
{{ $t('CUSTOM_ROLE.LIST.ERROR') }}
</p>
<Button
slate
faded
size="sm"
:label="$t('CUSTOM_ROLE.LIST.RETRY')"
@click="fetchCustomRoles"
/>
</div>
<CustomRolePaywall v-else-if="isBehindAPaywall" />
<BaseTable
v-else
:headers="tableHeaders"
@@ -201,7 +216,6 @@ const confirmDeletion = () => {
:on-confirm="confirmDeletion"
:title="$t('CUSTOM_ROLE.DELETE.CONFIRM.TITLE')"
:message="$t('CUSTOM_ROLE.DELETE.CONFIRM.MESSAGE')"
:message-value="deleteMessage"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
/>
@@ -0,0 +1,57 @@
import { shallowMount } from '@vue/test-utils';
import CustomRoleModal from './CustomRoleModal.vue';
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
const mockedStore = vi.hoisted(() => ({ dispatch: vi.fn() }));
vi.mock('dashboard/composables/store', () => ({
useStore: () => mockedStore,
}));
const Button = {
props: ['label', 'disabled', 'isLoading'],
template: '<button :disabled="disabled">{{ label }}</button>',
};
const mountModal = selectedRole =>
shallowMount(CustomRoleModal, {
props: { mode: 'edit', selectedRole },
global: {
stubs: {
Button,
'woot-modal-header': true,
},
},
});
describe('CustomRoleModal', () => {
it('preserves the existing conversation permissions while hydrating an edit form', async () => {
const selectedRole = {
name: 'Support',
description: 'Support role',
permissions: ['conversation_manage', 'contact_manage'],
};
const wrapper = mountModal(selectedRole);
await wrapper.vm.$nextTick();
expect(wrapper.find('#conversation_manage').element.checked).toBe(true);
expect(
wrapper.find('#conversation_unassigned_manage').element.checked
).toBe(false);
expect(
wrapper.find('#conversation_participating_manage').element.checked
).toBe(false);
});
it('does not mutate the role permissions when editing the form', async () => {
const selectedRole = {
name: 'Support',
description: 'Support role',
permissions: ['contact_manage'],
};
const wrapper = mountModal(selectedRole);
await wrapper.find('#report_manage').setValue(true);
expect(selectedRole.permissions).toEqual(['contact_manage']);
});
});
@@ -34,6 +34,7 @@ const { t } = useI18n();
const name = ref('');
const description = ref('');
const selectedPermissions = ref([]);
let isPopulatingEditForm = false;
const nameInput = ref(null);
@@ -58,14 +59,18 @@ const resetForm = () => {
};
const populateEditForm = () => {
isPopulatingEditForm = true;
name.value = props.selectedRole.name || '';
description.value = props.selectedRole.description || '';
selectedPermissions.value = props.selectedRole.permissions || [];
selectedPermissions.value = [...(props.selectedRole.permissions || [])];
isPopulatingEditForm = false;
};
watch(
selectedPermissions,
(newValue, oldValue) => {
if (isPopulatingEditForm) return;
// Check if manage all conversation permission is added or removed
const hasAddedManageAllConversation =
newValue.includes(MANAGE_ALL_CONVERSATION_PERMISSIONS) &&
@@ -92,7 +97,7 @@ watch(
);
}
},
{ deep: true }
{ deep: true, flush: 'sync' }
);
onMounted(() => {
@@ -1,5 +1,4 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { frontendURL } from 'dashboard/helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
@@ -20,10 +19,6 @@ export default {
name: 'custom_roles_list',
meta: {
featureFlag: FEATURE_FLAGS.CUSTOM_ROLES,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
permissions: ['administrator'],
},
component: CustomRolesHome,
@@ -1,5 +1,4 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
@@ -8,7 +7,6 @@ import Index from './Index.vue';
const meta = {
featureFlag: FEATURE_FLAGS.SLA,
permissions: ['administrator'],
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
export default {