Compare commits

...
Author SHA1 Message Date
Shivam Mishra bad834f677 fix: typo 2024-10-10 16:48:38 +05:30
Shivam Mishra 17f66f6f9b fix: Cannot read properties of null (reading 'id') 2024-10-10 16:47:54 +05:30
Shivam Mishra 5e2fb59302 fix: Cannot read properties of undefined (reading 'read_at') 2024-10-10 16:43:48 +05:30
Shivam Mishra 3ceb27c575 chore: log more data 2024-10-10 16:33:59 +05:30
PranavandGitHub 220a947380 feat: Add histoire for component playground (#10256)
We will use
[histoire](https://histoire.dev/guide/vue3/getting-started.html) for
component development. I've locked the version to 0.17.15 as it had
issues in the latest versions.

Run the following commands to start the development server.

```bash
# Start the development server
pnpm story:dev

# Build the assets to deploy it to website
pnpm story:build

# View the production version of the build
pnpm story:preview
```
2024-10-09 22:10:53 -07:00
PranavandGitHub 8505aa48c3 fix: Use native a tag for https URL in the sidebar (#10254)
This PR updates the sidebar component to use a native <a> tag for the Help Center URL component. It also updates the build pipeline to use the esbuild options minifyIdentifiers and keepNames set to true.
2024-10-09 21:04:04 -07:00
Muhsin KelothandGitHub b49eaa5c45 fix: Search linear issues (#10253)
In the `DropdownList.vue` component, the `onSearch` function was not properly passing the search value to the parent component. This resulted in the `onSearch` event in parent components (such as `LinkIssue.vue`) receiving an undefined value instead of the actual search term.


https://github.com/chatwoot/chatwoot/blob/f18ed01eb7725954fc9c0d45201ac3c53cd9855b/app/javascript/dashboard/components/ui/Dropdown/DropdownList.vue#L45-L52

The issue was resolved by modifying the `onSearch` function in `DropdownList.vue` to correctly pass the search value to the `debouncedEmit` function:
2024-10-09 20:47:50 -07:00
Shivam MishraandGitHub f18ed01eb7 feat: use of imap login as default if present (#10249)
When moving form using Gmail Legacy auth to using OAuth, we need the
email address that will be used to connect. This is because we need to
store this email address in the cache and reuse when we get the callback
to find the associated inbox.

However there are cases where the imap login might be
`support@company.com` and the email used to communicate will be
`contact@company.com` (Probably an alias) In that case, we need to send
the correct email address to Chatwoot when re-authenticating

At the moment, we used the inbox email. This PR adds a check that
defaults to to `imap_login` if that is available and imap is enabled

This PR also fixes an unrelated problem where the email inbox creation
flow was not working

---

Tested it, it is working correctly

![CleanShot 2024-10-09 at 14 23
47@2x](https://github.com/user-attachments/assets/0e2cb6c8-1224-4b45-b34a-7b19611249bc)
2024-10-09 15:01:11 +05:30
aa5fa0c758 feat: Use CJS build for SDK (#10247)
The UMD build was causing issues for a few customers, this PR reverts to
using CJS like used in Webpack 4 before the vite migration

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-10-09 14:09:52 +05:30
Shivam MishraandGitHub 42eca69763 fix: Import for vue-letter (#10246) 2024-10-08 09:40:37 -07:00
Shivam MishraandGitHub 3a78192e74 fix: Resolve accountId from the route, initialize route-sync before the app is loaded (#10245)
On production on multiple instances it may happen that the UI is
rendered in correctly, with a lot of options in the sidebar not
available. On further investigation I found out that the feature flag
checks were disabling multiple of those, and also we could see many
correlated errors that pointed towards missing information.

So, there were two problems here

1. The `vuex-router-sync` was not very reliable in some cases
2. In `App.vue` the watch on `currentAccountId` didn't always trigger.

## Fix Tested on Staging

Basically tried to reload the page ~50 times with cache enabled,
disabled, throttling, navigating different pages.


https://www.loom.com/share/1bb27294aa364ac4acfb647780d6385a?sid=87e31330-8cb7-4ded-8616-5e95e2ae3516

<details><summary>

#### What I thought was the fix

</summary>
<p>

### My chain of actions

Replacing vuex-router-sync at first worked fine, but then I saw it was
still failing in some cases, I assumed (I was half-correct tho) that the
rendering of the `App.vue` and syncing of the route to the store was not
happening in a synchronous pattern. So I decided, let's not rely on the
store when the route is directly available in the App context.

Following this, I refactored `useAccount` composable to use `useRoute`
directly, instead of the store, and then replaced the getter inside
`App.vue`. What this did was surface the issue but more consistently 🤯

I saw the watcher, added some console logs, and turns out it was not
getting triggered in all those cases. So I added an `immediate` to it.
And viola, it works!

At the moment, this is deployed to staging and seems to be working
correctly. But we still need to verify it for sure, since how this issue
was surfaced is still a mystery. All we know is that it shows up when
the widget is also loaded alongside the app (if it loads before or after
the app, it works fine)

### What about the route in the store?

Well I have used the `route` usage there with fallback to the store
state. Since Vuex exists in the app context, the route should always be
available to it. But after today I have lost all trust in JavaScript and
will worship rails until end of my life, so I added that in a
`try-catch` block, logged the error to Sentry

</p>
</details> 

## Here's the real fix

If you read the explanation I wrote earlier, I thought I fixed the
issue, but then the chat list navigation completely broke. So I removed
the custom route sync implementation and added the original package
back. Turns out the vuex-router-sync earlier was placed after the app
was initalized, however for it to work, the vue app context is not
required. And it's best to run it before the app is even bootstrapped,
so I added it back and placed it correctly.

So the following changes fixes this problem

1. Hoisting the `sync` function call to before we call `createApp` this
ensures that the stores and route hooks are in place before even the app
is created
2. Ensuring the `initializeAccount` is run immediately when watching
`currentAccountId`
4. Source `currentAccountId` for critical top of the tree components
directly from the route instead of the store
2024-10-08 09:25:51 -07:00
26 changed files with 1133 additions and 47 deletions
@@ -1,6 +1,15 @@
class Google::CallbacksController < OauthCallbackController
include GoogleConcern
def find_channel_by_email
# find by imap_login first, and then by email
# this ensures the legacy users can migrate correctly even if inbox email address doesn't match
imap_channel = Channel::Email.find_by(imap_login: users_data['email'], account: account)
return imap_channel if imap_channel
Channel::Email.find_by(email: users_data['email'], account: account)
end
private
def provider_name
+5 -1
View File
@@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
end
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
channel_email = find_channel_by_email
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
@@ -39,6 +39,10 @@ class OauthCallbackController < ApplicationController
[channel_email.inbox, channel_exists]
end
def find_channel_by_email
Channel::Email.find_by(email: users_data['email'], account: account)
end
def update_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address,
+10 -6
View File
@@ -13,6 +13,7 @@ import { useStore } from 'dashboard/composables/store';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -35,8 +36,9 @@ export default {
setup() {
const router = useRouter();
const store = useStore();
const { accountId } = useAccount();
return { router, store };
return { router, store, currentAccountId: accountId };
},
data() {
return {
@@ -52,7 +54,6 @@ export default {
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
currentAccountId: 'getCurrentAccountId',
}),
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
@@ -69,10 +70,13 @@ export default {
this.showAddAccountModal = true;
}
},
currentAccountId() {
if (this.currentAccountId) {
this.initializeAccount();
}
currentAccountId: {
immediate: true,
handler() {
if (this.currentAccountId) {
this.initializeAccount();
}
},
},
},
mounted() {
@@ -2,6 +2,7 @@
import { mapGetters } from 'vuex';
import { getSidebarItems } from './config/default-sidebar';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAccount } from 'dashboard/composables/useAccount';
import { useRoute, useRouter } from 'vue-router';
import PrimarySidebar from './sidebarComponents/Primary.vue';
@@ -33,6 +34,7 @@ export default {
setup(props, { emit }) {
const route = useRoute();
const router = useRouter();
const { accountId } = useAccount();
const toggleKeyShortcutModal = () => {
emit('openKeyShortcutModal');
@@ -72,6 +74,7 @@ export default {
return {
toggleKeyShortcutModal,
accountId,
};
},
data() {
@@ -82,7 +85,6 @@ export default {
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
inboxes: 'inboxes/getInboxes',
@@ -90,13 +90,17 @@ export default {
/>
</div>
<div class="flex flex-col items-center justify-end pb-6">
<PrimaryNavItem
<a
v-if="!isACustomBrandedInstance"
icon="book-open-globe"
name="DOCS"
open-in-new-page
:to="helpDocsURL"
/>
v-tooltip.right="$t(`SIDEBAR.DOCS`)"
:href="helpDocsURL"
class="text-slate-700 dark:text-slate-100 w-10 h-10 my-2 flex items-center justify-center rounded-lg hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600 relative"
rel="noopener noreferrer nofollow"
target="_blank"
>
<fluent-icon icon="book-open-globe" />
<span class="sr-only">{{ $t(`SIDEBAR.DOCS`) }}</span>
</a>
<NotificationBell @open-notification-panel="openNotificationPanel" />
<AgentDetails @toggle-menu="toggleOptions" />
<OptionsMenu
@@ -48,7 +48,7 @@ const debouncedEmit = debounce(value => {
const onSearch = value => {
searchTerm.value = value;
debouncedEmit();
debouncedEmit(value);
};
const filteredListItems = computed(() => {
@@ -1,5 +1,5 @@
<script>
import Letter from 'vue-letter';
import { Letter } from 'vue-letter';
import GalleryView from '../components/GalleryView.vue';
export default {
@@ -1,14 +1,15 @@
import { ref } from 'vue';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAccount } from '../useAccount';
import { useStoreGetters } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
vi.mock('dashboard/composables/store');
vi.mock('vue-router');
describe('useAccount', () => {
beforeEach(() => {
useStoreGetters.mockReturnValue({
getCurrentAccountId: ref(123),
useRoute.mockReturnValue({
params: {
accountId: 123,
},
});
});
@@ -1,18 +1,20 @@
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
/**
* Composable for account-related operations.
* @returns {Object} An object containing account-related properties and methods.
*/
export function useAccount() {
const getters = useStoreGetters();
/**
* Computed property for the current account ID.
* @type {import('vue').ComputedRef<number>}
*/
const accountId = computed(() => getters.getCurrentAccountId.value);
const route = useRoute();
const accountId = computed(() => {
return Number(route.params.accountId);
});
/**
* Generates an account-scoped URL.
@@ -5,6 +5,7 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import semver from 'semver';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
@@ -13,9 +14,10 @@ export default {
setup() {
const { updateUISettings } = useUISettings();
const { enabledLanguages } = useConfig();
const { accountId } = useAccount();
const v$ = useVuelidate();
return { updateUISettings, v$, enabledLanguages };
return { updateUISettings, v$, enabledLanguages, accountId };
},
data() {
return {
@@ -46,7 +48,6 @@ export default {
globalConfig: 'globalConfig/get',
getAccount: 'accounts/getAccount',
uiFlags: 'accounts/getUIFlags',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
showAutoResolutionConfig() {
@@ -47,7 +47,7 @@ const emailProviderList = computed(() => {
function onClick(emailProvider) {
if (emailProvider.isEnabled) {
this.provider = emailProvider.key;
provider.value = emailProvider.key;
}
}
</script>
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import googleClient from 'dashboard/api/channel/googleClient';
@@ -17,11 +17,18 @@ const { t } = useI18n();
const isRequestingAuthorization = ref(false);
const inboxEmail = computed(() => {
if (props.inbox.imap_login && props.inbox.imap_enabled) {
return props.inbox.imap_login;
}
return props.inbox.email;
});
async function requestAuthorization() {
try {
isRequestingAuthorization.value = true;
const response = await googleClient.generateAuthorization({
email: props.inbox.email,
email: inboxEmail.value,
});
const {
@@ -125,7 +125,7 @@ export default {
"
data-testid="app-title"
@input="v$.app.title.$touch"
@blur="v$.slug.$touch"
@blur="v$.app.title.$touch"
/>
<woot-input
v-model="app.content.url"
@@ -1,4 +1,5 @@
import types from '../../mutation-types';
import * as Sentry from '@sentry/vue';
export const mutations = {
[types.SET_CONTACT_UI_FLAG]($state, data) {
@@ -48,12 +49,23 @@ export const mutations = {
[types.DELETE_CONTACT]: ($state, id) => {
const index = $state.sortOrder.findIndex(item => item === id);
$state.sortOrder.splice(index, 1);
$state.records[id] = null;
delete $state.records[id];
},
[types.UPDATE_CONTACTS_PRESENCE]: ($state, data) => {
Object.values($state.records).forEach(element => {
const availabilityStatus = data[element.id];
let availabilityStatus;
try {
availabilityStatus = data[element.id];
} catch (error) {
Sentry.setContext('contact is undefined', {
records: $state.records,
data: data,
});
Sentry.captureException(error);
return;
}
if (availabilityStatus) {
$state.records[element.id].availability_status = availabilityStatus;
} else {
@@ -33,7 +33,7 @@ export const mutations = {
// On reconnect, if there is existing notification with same primary_actor_id,
// it will be deleted and the new one will be added. So it will solve with duplicate notification
if (existingNotification) {
$state.records[existingNotification.id] = undefined;
delete $state.records[existingNotification.id];
}
$state.records[notification.id] = {
@@ -72,7 +72,7 @@ export const mutations = {
},
[types.DELETE_NOTIFICATION]($state, data) {
const { notification, unread_count: unreadCount, count } = data;
$state.records[notification.id] = undefined;
delete $state.records[notification.id];
$state.meta.unreadCount = unreadCount;
$state.meta.count = count;
},
@@ -83,7 +83,7 @@ export const mutations = {
[types.DELETE_READ_NOTIFICATIONS]: $state => {
Object.values($state.records).forEach(item => {
if (item.read_at) {
$state.records[item.id] = undefined;
delete $state.records[item.id];
}
});
},
@@ -0,0 +1,21 @@
@import 'dashboard/assets/scss/app';
*,
::before,
::after {
--_histoire-color-primary-50: 235 245 255;
--_histoire-color-primary-100: 194 225 255;
--_histoire-color-primary-200: 153 206 255;
--_histoire-color-primary-300: 112 186 255;
--_histoire-color-primary-400: 71 166 255;
--_histoire-color-primary-500: 31 147 255;
--_histoire-color-primary-600: 25 118 204;
--_histoire-color-primary-700: 19 88 153;
--_histoire-color-primary-800: 12 59 102;
--_histoire-color-primary-900: 6 29 51;
}
html,
body {
font-family: 'Inter', sans-serif;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512px" height="512px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 59.1 (86144) - https://sketch.com -->
<title>woot-log</title>
<desc>Created with Sketch.</desc>
<g id="Logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="woot-log" fill-rule="nonzero">
<circle id="Oval" fill="#47A7F6" cx="256" cy="256" r="256"></circle>
<path d="M362.807947,368.807947 L244.122956,368.807947 C178.699407,368.807947 125.456954,315.561812 125.456954,250.12177 C125.456954,184.703089 178.699407,131.456954 244.124143,131.456954 C309.565494,131.456954 362.807947,184.703089 362.807947,250.12177 L362.807947,368.807947 Z" id="Fill-1" stroke="#FFFFFF" stroke-width="6" fill="#FFFFFF"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 916 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+3 -2
View File
@@ -10,7 +10,6 @@ import Multiselect from 'vue-multiselect';
import { plugin, defaultConfig } from '@formkit/vue';
import WootSwitch from 'components/ui/Switch.vue';
import WootWizard from 'components/ui/Wizard.vue';
import { sync } from 'vuex-router-sync';
import FloatingVue from 'floating-vue';
import WootUiKit from 'dashboard/components';
import App from 'dashboard/App.vue';
@@ -18,6 +17,7 @@ import i18nMessages from 'dashboard/i18n';
import createAxios from 'dashboard/helper/APIHelper';
import commonHelpers, { isJSONValid } from 'dashboard/helper/commons';
import { sync } from 'vuex-router-sync';
import router, { initalizeRouter } from 'dashboard/routes';
import store from 'dashboard/store';
import constants from 'dashboard/constants/globals';
@@ -42,6 +42,8 @@ const i18n = createI18n({
messages: i18nMessages,
});
sync(store, router);
const app = createApp(App);
app.use(i18n);
app.use(store);
@@ -97,7 +99,6 @@ app.component('fluent-icon', FluentIcon);
app.directive('resize', vResizeObserver);
app.directive('on-clickaway', onClickaway);
sync(store, router);
// load common helpers into js
commonHelpers();
window.WOOT_STORE = store;
+1
View File
@@ -0,0 +1 @@
import './design-system/histoire.scss';
+43
View File
@@ -0,0 +1,43 @@
import { defineConfig } from 'histoire';
import { HstVue } from '@histoire/plugin-vue';
export default defineConfig({
setupFile: './histoire.setup.ts',
plugins: [HstVue()],
vite: {
server: {
port: 6179,
},
},
viteIgnorePlugins: ['vite-plugin-ruby'],
theme: {
darkClass: 'dark',
title: '@chatwoot/design',
logo: {
square: './design-system/images/logo-thumbnail.svg',
light: './design-system/images/logo.png',
dark: './design-system/images/logo-dark.png',
},
},
defaultStoryProps: {
icon: 'carbon:cube',
iconColor: '#1F93FF',
layout: {
type: 'grid',
width: '80%',
},
},
tree: {
groups: [
{
id: 'top',
title: '',
},
{
id: 'components',
title: 'Components',
include: () => true,
},
],
},
});
+6 -1
View File
@@ -14,7 +14,10 @@
"ruby:prettier": "bundle exec rubocop -a",
"build:sdk": "BUILD_MODE=library vite build",
"prepare": "husky install",
"size": "size-limit"
"size": "size-limit",
"story:dev": "histoire dev",
"story:build": "histoire build",
"story:preview": "histoire preview"
},
"size-limit": [
{
@@ -97,6 +100,7 @@
},
"devDependencies": {
"@egoist/tailwindcss-icons": "^1.8.1",
"@histoire/plugin-vue": "0.17.15",
"@iconify-json/logos": "^1.2.0",
"@iconify-json/lucide": "^1.2.5",
"@size-limit/file": "^8.2.4",
@@ -113,6 +117,7 @@
"eslint-plugin-vitest-globals": "^1.5.0",
"eslint-plugin-vue": "^9.28.0",
"fake-indexeddb": "^6.0.0",
"histoire": "0.17.15",
"husky": "^7.0.0",
"jsdom": "^24.1.3",
"lint-staged": "14.0.1",
+953 -3
View File
File diff suppressed because it is too large Load Diff
@@ -39,8 +39,9 @@ RSpec.describe 'Google::CallbacksController', type: :request do
expect(Redis::Alfred.get(cache_key)).to be_nil
end
it 'creates updates inbox channel config if inbox exists and authentication is successful' do
inbox = create(:channel_email, account: account, email: email)&.inbox
it 'updates inbox channel config if inbox exists with imap_login and authentication is successful' do
channel_email = create(:channel_email, account: account, imap_login: email)
inbox = channel_email.inbox
expect(inbox.channel.provider_config).to eq({})
stub_request(:post, 'https://accounts.google.com/o/oauth2/token')
@@ -50,7 +51,7 @@ RSpec.describe 'Google::CallbacksController', type: :request do
get google_callback_url, params: { code: code }
expect(response).to redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(response).to redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
expect(account.inboxes.count).to be 1
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
+7 -1
View File
@@ -42,6 +42,11 @@ if (isLibraryMode) {
plugins = [vue(vueOptions)];
}
const esbuildOptions = {
minifyIdentifiers: false,
keepNames: true,
};
export default defineConfig({
plugins: plugins,
build: {
@@ -66,7 +71,7 @@ export default defineConfig({
lib: isLibraryMode
? {
entry: path.resolve(__dirname, './app/javascript/entrypoints/sdk.js'),
formats: ['umd'], // UMD format for single file
formats: ['cjs'], // CJS format for single file
name: 'sdk',
}
: undefined,
@@ -107,4 +112,5 @@ export default defineConfig({
mockReset: true,
clearMocks: true,
},
esbuild: isLibraryMode ? esbuildOptions : undefined,
});