Merge branch 'feat/saml-controllers' into feat/saml-ui

This commit is contained in:
Shivam Mishra
2025-09-10 10:47:16 +05:30
committed by GitHub
22 changed files with 347 additions and 112 deletions
+9 -2
View File
@@ -19,6 +19,7 @@ import {
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
name: 'App',
@@ -38,12 +39,14 @@ export default {
const { accountId } = useAccount();
// Use the font size composable (it automatically sets up the watcher)
const { currentFontSize } = useFontSize();
const { uiSettings } = useUISettings();
return {
router,
store,
currentAccountId: accountId,
currentFontSize,
uiSettings,
};
},
data() {
@@ -88,7 +91,10 @@ export default {
mounted() {
this.initializeColorTheme();
this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale);
// If user locale is set, use it; otherwise use account locale
this.setLocale(
this.uiSettings?.locale || window.chatwootConfig.selectedLocale
);
},
unmounted() {
if (this.reconnectService) {
@@ -114,7 +120,8 @@ export default {
const { locale, latest_chatwoot_version: latestChatwootVersion } =
this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale);
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router);
@@ -8,6 +8,10 @@ const props = defineProps({
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['toggleMode']);
@@ -20,9 +24,12 @@ const privateModeSize = useElementSize(wootEditorPrivateMode);
/**
* Computed boolean indicating if the editor is in private note mode
* When disabled, always show NOTE mode regardless of actual mode prop
* @type {ComputedRef<boolean>}
*/
const isPrivate = computed(() => props.mode === REPLY_EDITOR_MODES.NOTE);
const isPrivate = computed(() => {
return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
* Computes the width of the sliding background chip in pixels
@@ -53,6 +60,10 @@ const translateValue = computed(() => {
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
:disabled="disabled"
:class="{
'cursor-not-allowed': disabled,
}"
@click="$emit('toggleMode')"
>
<div ref="wootEditorReplyMode" class="flex items-center gap-1 px-2 z-20">
@@ -62,7 +73,10 @@ const translateValue = computed(() => {
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</div>
<div
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] transition-all duration-300 ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="{
'transition-all duration-300': !disabled,
}"
:style="{
'--chip-width': width,
'--translate-x': translateValue,
@@ -10,7 +10,6 @@ import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { REPLY_EDITOR_MODES } from './constants';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -20,9 +19,9 @@ export default {
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [inboxMixin],
props: {
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
isNote: {
type: Boolean,
default: false,
},
onSend: {
type: Function,
@@ -168,9 +167,6 @@ export default {
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
uiFlags: 'integrations/getUIFlags',
}),
isNote() {
return this.mode === REPLY_EDITOR_MODES.NOTE;
},
wrapClass() {
return {
'is-note-mode': this.isNote,
@@ -15,6 +15,10 @@ export default {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -30,6 +34,7 @@ export default {
emit('setReplyMode', mode);
};
const handleReplyClick = () => {
if (props.isReplyRestricted) return;
setReplyMode(REPLY_EDITOR_MODES.REPLY);
};
const handleNoteClick = () => {
@@ -88,6 +93,7 @@ export default {
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
<EditorModeToggle
:mode="mode"
:disabled="isReplyRestricted"
class="mt-3"
@toggle-mode="handleModeToggle"
/>
@@ -170,6 +170,9 @@ export default {
}
return true;
},
isReplyRestricted() {
return !this.currentChat?.can_reply && !this.isAWhatsAppChannel;
},
inboxId() {
return this.currentChat.inbox_id;
},
@@ -1070,6 +1073,7 @@ export default {
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
<ReplyTopPanel
:mode="replyType"
:is-reply-restricted="isReplyRestricted"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:popout-reply-box="popOutReplyBox"
@@ -1180,7 +1184,7 @@ export default {
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:mode="replyType"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
@@ -43,18 +43,22 @@ describe('useFontSize', () => {
it('returns fontSizeOptions with correct structure', () => {
const { fontSizeOptions } = useFontSize();
expect(fontSizeOptions).toHaveLength(5);
expect(fontSizeOptions[0]).toHaveProperty('value');
expect(fontSizeOptions[0]).toHaveProperty('label');
expect(fontSizeOptions.value).toHaveLength(5);
expect(fontSizeOptions.value[0]).toHaveProperty('value');
expect(fontSizeOptions.value[0]).toHaveProperty('label');
// Check specific options
expect(fontSizeOptions.find(option => option.value === '16px')).toEqual({
expect(
fontSizeOptions.value.find(option => option.value === '16px')
).toEqual({
value: '16px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT',
});
expect(fontSizeOptions.find(option => option.value === '14px')).toEqual({
expect(
fontSizeOptions.value.find(option => option.value === '14px')
).toEqual({
value: '14px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
@@ -143,12 +147,12 @@ describe('useFontSize', () => {
const { fontSizeOptions } = useFontSize();
// Check that translation is applied
expect(fontSizeOptions.find(option => option.value === '14px').label).toBe(
'Smaller'
);
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
'Default'
);
expect(
fontSizeOptions.value.find(option => option.value === '14px').label
).toBe('Smaller');
expect(
fontSizeOptions.value.find(option => option.value === '16px').label
).toBe('Default');
// Verify translation function was called with correct keys
expect(mockTranslate).toHaveBeenCalledWith(
@@ -77,8 +77,8 @@ export const useFontSize = () => {
* Font size options for select dropdown
* @type {Array<{value: string, label: string}>}
*/
const fontSizeOptions = FONT_SIZE_NAMES.map(name =>
createFontSizeOption(t, name)
const fontSizeOptions = computed(() =>
FONT_SIZE_NAMES.map(name => createFontSizeOption(t, name))
);
/**
@@ -51,6 +51,13 @@
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
},
"LANGUAGE": {
"TITLE": "Preferred Language",
"NOTE": "Choose the language you want to use.",
"UPDATE_SUCCESS": "Your Language settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the language settings, please try again",
"USE_ACCOUNT_DEFAULT": "Use account default"
}
},
"MESSAGE_SIGNATURE_SECTION": {
@@ -7,7 +7,6 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import NextInput from 'next/input/Input.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -33,12 +32,12 @@ export default {
NextInput,
},
setup() {
const { updateUISettings } = useUISettings();
const { updateUISettings, uiSettings } = useUISettings();
const { enabledLanguages } = useConfig();
const { accountId } = useAccount();
const v$ = useVuelidate();
return { updateUISettings, v$, enabledLanguages, accountId };
return { updateUISettings, uiSettings, v$, enabledLanguages, accountId };
},
data() {
return {
@@ -112,7 +111,7 @@ export default {
const { name, locale, id, domain, support_email, features } =
this.getAccount(this.accountId);
this.$root.$i18n.locale = locale;
this.$root.$i18n.locale = this.uiSettings?.locale || locale;
this.name = name;
this.locale = locale;
this.id = id;
@@ -137,21 +136,19 @@ export default {
domain: this.domain,
support_email: this.supportEmail,
});
this.$root.$i18n.locale = this.locale;
// If user locale is set, update the locale with user locale
if (this.uiSettings?.locale) {
this.$root.$i18n.locale = this.uiSettings?.locale;
} else {
// If user locale is not set, update the locale with account locale
this.$root.$i18n.locale = this.locale;
}
this.getAccount(this.id).locale = this.locale;
this.updateDirectionView(this.locale);
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
} catch (error) {
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR'));
}
},
updateDirectionView(locale) {
const isRTLSupported = getLanguageDirection(locale);
this.updateUISettings({
rtl_view: isRTLSupported,
});
},
},
};
</script>
@@ -34,6 +34,7 @@ const {
isAWhatsAppChannel,
isAFacebookInbox,
isATelegramChannel,
isATwilioWhatsAppChannel,
} = useInbox(route.params.inbox_id);
const hasDuplicateInstagramInbox = computed(() => {
@@ -168,7 +169,7 @@ onMounted(() => {
</script>
<template>
<div class="w-full h-full col-span-6 p-6 overflow-auto">
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.NEW_INBOX_SUGGESTION')"
@@ -187,7 +188,7 @@ onMounted(() => {
</div>
<div class="w-[50%] max-w-[50%] ml-[25%]">
<woot-code
v-if="isATwilioChannel"
v-if="isATwilioWhatsAppChannel"
lang="html"
:script="currentInbox.callback_webhook_url"
/>
@@ -11,6 +11,7 @@ import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import FontSize from './FontSize.vue';
import UserLanguageSelect from './UserLanguageSelect.vue';
import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
@@ -28,6 +29,7 @@ export default {
MessageSignature,
FormSection,
FontSize,
UserLanguageSelect,
UserProfilePicture,
Policy,
UserBasicDetails,
@@ -230,6 +232,12 @@ export default {
"
@change="updateFontSize"
/>
<UserLanguageSelect
:label="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.NOTE')
"
/>
</FormSection>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE')"
@@ -0,0 +1,103 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig';
import { useAccount } from 'dashboard/composables/useAccount';
import { useUISettings } from 'dashboard/composables/useUISettings';
import FormSelect from 'v3/components/Form/Select.vue';
defineProps({
label: { type: String, default: '' },
description: { type: String, default: '' },
});
const { t, locale } = useI18n();
const { updateUISettings, uiSettings } = useUISettings();
const { enabledLanguages } = useConfig();
const { currentAccount } = useAccount();
const currentLanguage = computed(() => uiSettings.value?.locale ?? '');
const languageOptions = computed(() => [
{
name: t(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.USE_ACCOUNT_DEFAULT'
),
iso_639_1_code: '',
},
...(enabledLanguages ?? []),
]);
const updateLanguage = async languageCode => {
try {
if (!languageCode) {
// Clear preference to use account default
await updateUISettings({ locale: null });
locale.value = currentAccount.value.locale;
useAlert(
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_SUCCESS')
);
return;
}
const valid = (enabledLanguages || []).some(
l => l.iso_639_1_code === languageCode
);
if (!valid) {
throw new Error(`Invalid language code: ${languageCode}`);
}
await updateUISettings({ locale: languageCode });
// Apply immediately if the user explicitly chose a preference
locale.value = languageCode;
useAlert(
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_SUCCESS')
);
} catch (error) {
useAlert(
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_ERROR')
);
throw error;
}
};
const selectedValue = computed({
get: () => currentLanguage.value,
set: value => {
updateLanguage(value);
},
});
</script>
<template>
<div class="flex gap-2 justify-between w-full items-start">
<div>
<label class="text-n-gray-12 font-medium leading-6 text-sm">
{{ label }}
</label>
<p class="text-n-gray-11">
{{ description }}
</p>
</div>
<FormSelect
v-model="selectedValue"
name="language"
spacing="compact"
class="min-w-28 mt-px"
:options="languageOptions"
label=""
>
<option
v-for="option in languageOptions"
:key="option.iso_639_1_code || 'default'"
:value="option.iso_639_1_code"
:selected="option.iso_639_1_code === selectedValue"
>
{{ option.name }}
</option>
</FormSelect>
</div>
</template>
@@ -28,12 +28,16 @@ export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
isRTL: ($state, _, rootState) => {
const accountId = rootState.route?.params?.accountId;
if (!accountId) return false;
isRTL: ($state, _getters, rootState, rootGetters) => {
const accountId = Number(rootState.route?.params?.accountId);
const userLocale = rootGetters?.getUISettings?.locale;
const accountLocale =
accountId && findRecordById($state, accountId)?.locale;
const { locale } = findRecordById($state, Number(accountId));
return locale ? getLanguageDirection(locale) : false;
// Prefer user locale; fallback to account locale
const effectiveLocale = userLocale ?? accountLocale;
return effectiveLocale ? getLanguageDirection(effectiveLocale) : false;
},
isTrialAccount: $state => id => {
const account = findRecordById($state, id);
@@ -49,35 +49,74 @@ describe('#getters', () => {
});
describe('isRTL', () => {
it('returns false when accountId is not present', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('returns false when accountId is not present and userLocale is not set', () => {
const state = { records: [accountData] };
const rootState = { route: { params: {} } };
expect(getters.isRTL({}, null, rootState)).toBe(false);
const rootGetters = {};
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
});
it('returns true for RTL language', () => {
const state = {
records: [{ id: 1, locale: 'ar' }],
};
const rootState = { route: { params: { accountId: '1' } } };
vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(true);
expect(getters.isRTL(state, null, rootState)).toBe(true);
it('uses userLocale when present (no accountId)', () => {
const state = { records: [accountData] };
const rootState = { route: { params: {} } };
const rootGetters = { getUISettings: { locale: 'ar' } };
const spy = vi
.spyOn(languageHelpers, 'getLanguageDirection')
.mockReturnValue(true);
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
expect(spy).toHaveBeenCalledWith('ar');
});
it('returns false for LTR language', () => {
const state = {
records: [{ id: 1, locale: 'en' }],
};
it('prefers userLocale over account locale when both are present', () => {
const state = { records: [{ id: 1, locale: 'en' }] };
const rootState = { route: { params: { accountId: '1' } } };
vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(false);
expect(getters.isRTL(state, null, rootState)).toBe(false);
const rootGetters = { getUISettings: { locale: 'ar' } };
const spy = vi
.spyOn(languageHelpers, 'getLanguageDirection')
.mockReturnValue(true);
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
expect(spy).toHaveBeenCalledWith('ar');
});
it('returns false when account is not found', () => {
const state = {
records: [],
};
it('falls back to account locale when userLocale is not provided', () => {
const state = { records: [{ id: 1, locale: 'ar' }] };
const rootState = { route: { params: { accountId: '1' } } };
expect(getters.isRTL(state, null, rootState)).toBe(false);
const rootGetters = {};
const spy = vi
.spyOn(languageHelpers, 'getLanguageDirection')
.mockReturnValue(true);
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
expect(spy).toHaveBeenCalledWith('ar');
});
it('returns false for LTR language when userLocale is provided', () => {
const state = { records: [{ id: 1, locale: 'en' }] };
const rootState = { route: { params: { accountId: '1' } } };
const rootGetters = { getUISettings: { locale: 'en' } };
const spy = vi
.spyOn(languageHelpers, 'getLanguageDirection')
.mockReturnValue(false);
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
expect(spy).toHaveBeenCalledWith('en');
});
it('returns false when accountId present but user locale is null', () => {
const state = { records: [{ id: 1, locale: 'en' }] };
const rootState = { route: { params: { accountId: '1' } } };
const rootGetters = { getUISettings: { locale: null } };
const spy = vi.spyOn(languageHelpers, 'getLanguageDirection');
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
expect(spy).toHaveBeenCalledWith('en');
});
});
});