-
+
@@ -151,7 +134,7 @@ export default {
diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/index.vue b/app/javascript/dashboard/components/widgets/conversation/linear/index.vue
index 012e43a67..f4fe672ba 100644
--- a/app/javascript/dashboard/components/widgets/conversation/linear/index.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/linear/index.vue
@@ -16,6 +16,10 @@ const props = defineProps({
type: [Number, String],
required: true,
},
+ parentWidth: {
+ type: Number,
+ default: 10000,
+ },
});
defineOptions({
@@ -73,6 +77,14 @@ const unlinkIssue = async linkId => {
}
};
+const shouldShowIssueIdentifier = computed(() => {
+ if (!linkedIssue.value) {
+ return false;
+ }
+
+ return props.parentWidth > 600;
+});
+
const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true;
@@ -119,7 +131,10 @@ onMounted(() => {
class="text-[#5E6AD2] flex-shrink-0"
view-box="0 0 19 19"
/>
-
+
{{ linkedIssue.issue.identifier }}
@@ -127,7 +142,7 @@ onMounted(() => {
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
- class="absolute right-0 top-[36px] invisible group-hover:visible"
+ class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
({
- emitter: {
- emit: vi.fn(),
- on: vi.fn(),
- off: vi.fn(),
- },
-}));
-
-const mockDirective = {
- mounted: () => {},
-};
-
-import { emitter } from 'shared/helpers/mitt';
-
-describe('MoveActions', () => {
- let currentChat = { id: 8, muted: false };
- let store = null;
- let muteConversation = null;
- let unmuteConversation = null;
-
- beforeEach(() => {
- muteConversation = vi.fn(() => Promise.resolve());
- unmuteConversation = vi.fn(() => Promise.resolve());
-
- store = createStore({
- state: {
- authenticated: true,
- currentChat,
- },
- getters: {
- getSelectedChat: () => currentChat,
- },
- modules: {
- conversations: {
- namespaced: false,
- actions: { muteConversation, unmuteConversation },
- },
- },
- });
- });
-
- const createWrapper = () =>
- mount(MoreActions, {
- global: {
- plugins: [store],
- components: {
- 'fluent-icon': FluentIcon,
- },
- directives: {
- 'on-clickaway': mockDirective,
- },
- },
- });
-
- describe('muting discussion', () => {
- it('triggers "muteConversation"', async () => {
- const wrapper = createWrapper();
- await wrapper.find('button:first-child').trigger('click');
-
- expect(muteConversation).toHaveBeenCalledTimes(1);
- expect(muteConversation).toHaveBeenCalledWith(
- expect.any(Object), // First argument is the Vuex context object
- currentChat.id // Second argument is the ID of the conversation
- );
- });
-
- it('shows alert', async () => {
- const wrapper = createWrapper();
- await wrapper.find('button:first-child').trigger('click');
-
- expect(emitter.emit).toBeCalledWith('newToastMessage', {
- message:
- 'This contact is blocked successfully. You will not be notified of any future conversations.',
- action: null,
- });
- });
- });
-
- describe('unmuting discussion', () => {
- beforeEach(() => {
- currentChat.muted = true;
- });
-
- it('triggers "unmuteConversation"', async () => {
- const wrapper = createWrapper();
- await wrapper.find('button:first-child').trigger('click');
-
- expect(unmuteConversation).toHaveBeenCalledTimes(1);
- expect(unmuteConversation).toHaveBeenCalledWith(
- expect.any(Object), // First argument is the Vuex context object
- currentChat.id // Second argument is the ID of the conversation
- );
- });
-
- it('shows alert', async () => {
- const wrapper = createWrapper();
- await wrapper.find('button:first-child').trigger('click');
-
- expect(emitter.emit).toBeCalledWith('newToastMessage', {
- message: 'This contact is unblocked successfully.',
- action: null,
- });
- });
- });
-});
diff --git a/app/javascript/dashboard/composables/spec/useFontSize.spec.js b/app/javascript/dashboard/composables/spec/useFontSize.spec.js
index 9253a3988..52d22478f 100644
--- a/app/javascript/dashboard/composables/spec/useFontSize.spec.js
+++ b/app/javascript/dashboard/composables/spec/useFontSize.spec.js
@@ -43,7 +43,7 @@ describe('useFontSize', () => {
it('returns fontSizeOptions with correct structure', () => {
const { fontSizeOptions } = useFontSize();
- expect(fontSizeOptions).toHaveLength(6);
+ expect(fontSizeOptions).toHaveLength(5);
expect(fontSizeOptions[0]).toHaveProperty('value');
expect(fontSizeOptions[0]).toHaveProperty('label');
@@ -59,12 +59,6 @@ describe('useFontSize', () => {
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
});
-
- expect(fontSizeOptions.find(option => option.value === '22px')).toEqual({
- value: '22px',
- label:
- 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE',
- });
});
it('returns currentFontSize from UI settings', () => {
@@ -84,9 +78,6 @@ describe('useFontSize', () => {
applyFontSize('14px');
expect(document.documentElement.style.fontSize).toBe('14px');
- applyFontSize('22px');
- expect(document.documentElement.style.fontSize).toBe('22px');
-
applyFontSize('16px');
expect(document.documentElement.style.fontSize).toBe('16px');
});
@@ -145,8 +136,6 @@ describe('useFontSize', () => {
'Smaller',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
'Default',
- 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE':
- 'Extra Large',
};
return translations[key] || key;
});
@@ -160,9 +149,6 @@ describe('useFontSize', () => {
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
'Default'
);
- expect(fontSizeOptions.find(option => option.value === '22px').label).toBe(
- 'Extra Large'
- );
// Verify translation function was called with correct keys
expect(mockTranslate).toHaveBeenCalledWith(
diff --git a/app/javascript/dashboard/composables/useFontSize.js b/app/javascript/dashboard/composables/useFontSize.js
index 9bb8d4841..d7177a5fb 100644
--- a/app/javascript/dashboard/composables/useFontSize.js
+++ b/app/javascript/dashboard/composables/useFontSize.js
@@ -19,7 +19,6 @@ const FONT_SIZE_OPTIONS = {
DEFAULT: '16px',
LARGE: '18px',
LARGER: '20px',
- EXTRA_LARGE: '22px',
};
/**
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index da2f64b01..26ec6dc16 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -70,6 +70,7 @@
"RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen",
"OPEN_ACTION": "Open",
+ "MORE_ACTIONS": "More actions",
"OPEN": "More",
"CLOSE": "Close",
"DETAILS": "details",
From 8bbf6c75e399d39551ae4c633be7344320dd7b54 Mon Sep 17 00:00:00 2001
From: Sojan Jose
Date: Thu, 29 May 2025 21:18:02 -0600
Subject: [PATCH 06/11] chore: Improvements to codespaces (#11635)
- Remove the deprecated extensions
- Install claude code in base image
- Fix rails deprecation warning
---
.devcontainer/Dockerfile.base | 4 ++--
.devcontainer/devcontainer.json | 17 +++++++++++++----
.devcontainer/scripts/setup.sh | 8 ++++++++
Makefile | 1 +
config/environments/development.rb | 2 +-
5 files changed, 25 insertions(+), 7 deletions(-)
diff --git a/.devcontainer/Dockerfile.base b/.devcontainer/Dockerfile.base
index 2b74602b8..dc7d4eb8c 100644
--- a/.devcontainer/Dockerfile.base
+++ b/.devcontainer/Dockerfile.base
@@ -77,8 +77,8 @@ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmi
WORKDIR /workspace
RUN chown vscode:vscode /workspace
-# set up node js and pnpm in single layer
-RUN npm install -g pnpm@${PNPM_VERSION} \
+# set up node js, pnpm and claude code in single layer
+RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \
&& npm cache clean --force
# Switch to vscode user
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index c51338b51..2e237bbcb 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -4,17 +4,26 @@
"dockerComposeFile": "docker-compose.yml",
"settings": {
- "terminal.integrated.shell.linux": "/bin/zsh"
+ "terminal.integrated.shell.linux": "/bin/zsh",
+ "extensions.showRecommendationsOnlyOnDemand": true,
+ "editor.formatOnSave": true,
+ "files.trimTrailingWhitespace": true,
+ "files.insertFinalNewline": true,
+ "search.exclude": {
+ "**/node_modules": true,
+ "**/tmp": true,
+ "**/log": true,
+ "**/coverage": true,
+ "**/public/packs": true
+ }
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
- "rebornix.Ruby",
+ "Shopify.ruby-lsp",
"misogi.ruby-rubocop",
- "wingrunr21.vscode-ruby",
"davidpallinder.rails-test-runner",
- "eamodio.gitlens",
"github.copilot",
"mrmlnc.vscode-duplicate"
],
diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh
index 6beb2ff57..36db5cfd9 100755
--- a/.devcontainer/scripts/setup.sh
+++ b/.devcontainer/scripts/setup.sh
@@ -4,5 +4,13 @@ sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
+# Setup Claude Code API key if available
+if [ -n "$CLAUDE_CODE_API_KEY" ]; then
+ mkdir -p ~/.claude
+ echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json
+ echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
+ chmod +x ~/.claude/anthropic_key.sh
+fi
+
# codespaces make the ports public
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
diff --git a/Makefile b/Makefile
index 1c5ce297c..b7a936dc4 100644
--- a/Makefile
+++ b/Makefile
@@ -41,6 +41,7 @@ run:
force_run:
rm -f ./.overmind.sock
+ rm -f tmp/pids/*.pid
overmind start -f Procfile.dev
debug:
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 7f72e6d2f..822a67939 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -66,7 +66,7 @@ Rails.application.configure do
# GitHub Codespaces configuration
if ENV['CODESPACES']
# Allow web console access from any IP
- config.web_console.whitelisted_ips = %w(0.0.0.0/0 ::/0)
+ config.web_console.allowed_ips = %w(0.0.0.0/0 ::/0)
# Allow CSRF from codespace URLs
config.force_ssl = false
config.action_controller.forgery_protection_origin_check = false
From 07a39f4b42b1286949ad3cb7f80942c004287b79 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Fri, 30 May 2025 15:19:42 +0530
Subject: [PATCH 07/11] feat: Enforce role permissions on filtered page
(#11638)
Fixes: https://github.com/chatwoot/chatwoot/issues/11610
Demo:
https://www.loom.com/share/c9181b42619044379ba01e0ac913801d?sid=e306fe30-ce80-47ac-83e5-92132a99f464
---
.../store/modules/conversations/getters.js | 33 +-
.../specs/conversations/getters.spec.js | 304 ++++++++++++++++++
2 files changed, 331 insertions(+), 6 deletions(-)
diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js
index f5b83e546..9f5744fbb 100644
--- a/app/javascript/dashboard/store/modules/conversations/getters.js
+++ b/app/javascript/dashboard/store/modules/conversations/getters.js
@@ -18,13 +18,34 @@ const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
},
- getFilteredConversations: ({
- allConversations,
- chatSortFilter,
- appliedFilters,
- }) => {
+ getFilteredConversations: (
+ { allConversations, chatSortFilter, appliedFilters },
+ _,
+ __,
+ rootGetters
+ ) => {
+ const currentUser = rootGetters.getCurrentUser;
+ const currentUserId = rootGetters.getCurrentUser.id;
+ const currentAccountId = rootGetters.getCurrentAccountId;
+
+ const permissions = getUserPermissions(currentUser, currentAccountId);
+ const userRole = getUserRole(currentUser, currentAccountId);
+
return allConversations
- .filter(conversation => matchesFilters(conversation, appliedFilters))
+ .filter(conversation => {
+ const matchesFilterResult = matchesFilters(
+ conversation,
+ appliedFilters
+ );
+ const allowedForRole = applyRoleFilter(
+ conversation,
+ userRole,
+ permissions,
+ currentUserId
+ );
+
+ return matchesFilterResult && allowedForRole;
+ })
.sort((a, b) => sortComparator(a, b, chatSortFilter));
},
getSelectedChat: ({ selectedChatId, allConversations }) => {
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
index 8ac89f49a..7b6c38456 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js
@@ -325,4 +325,308 @@ describe('#getters', () => {
});
});
});
+
+ describe('#getFilteredConversations', () => {
+ const mockConversations = [
+ {
+ id: 1,
+ status: 'open',
+ meta: { assignee: { id: 1 } },
+ last_activity_at: 1000,
+ },
+ {
+ id: 2,
+ status: 'open',
+ meta: {},
+ last_activity_at: 2000,
+ },
+ {
+ id: 3,
+ status: 'resolved',
+ meta: { assignee: { id: 2 } },
+ last_activity_at: 3000,
+ },
+ ];
+
+ const mockRootGetters = {
+ getCurrentUser: {
+ id: 1,
+ accounts: [{ id: 1, role: 'agent', permissions: [] }],
+ },
+ getCurrentAccountId: 1,
+ };
+
+ it('filters conversations based on role permissions for administrator', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [{ id: 1, role: 'administrator', permissions: [] }],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ expect(result).toEqual([
+ mockConversations[2],
+ mockConversations[1],
+ mockConversations[0],
+ ]);
+ });
+
+ it('filters conversations based on role permissions for agent', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [{ id: 1, role: 'agent', permissions: [] }],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ expect(result).toEqual([
+ mockConversations[2],
+ mockConversations[1],
+ mockConversations[0],
+ ]);
+ });
+
+ it('filters conversations for custom role with conversation_manage permission', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [
+ {
+ id: 1,
+ custom_role_id: 5,
+ permissions: ['conversation_manage'],
+ },
+ ],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ expect(result).toEqual([
+ mockConversations[2],
+ mockConversations[1],
+ mockConversations[0],
+ ]);
+ });
+
+ it('filters conversations for custom role with conversation_unassigned_manage permission', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [
+ {
+ id: 1,
+ custom_role_id: 5,
+ permissions: ['conversation_unassigned_manage'],
+ },
+ ],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ // Should include conversation assigned to user (id: 1) and unassigned conversation
+ expect(result).toEqual([mockConversations[1], mockConversations[0]]);
+ });
+
+ it('filters conversations for custom role with conversation_participating_manage permission', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [
+ {
+ id: 1,
+ custom_role_id: 5,
+ permissions: ['conversation_participating_manage'],
+ },
+ ],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ // Should only include conversation assigned to user (id: 1)
+ expect(result).toEqual([mockConversations[0]]);
+ });
+
+ it('filters conversations for custom role with no permissions', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [
+ {
+ id: 1,
+ custom_role_id: 5,
+ permissions: [],
+ },
+ ],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ // Should return empty array as user has no permissions
+ expect(result).toEqual([]);
+ });
+
+ it('applies filters and role permissions together', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: ['open'],
+ query_operator: 'and',
+ },
+ ],
+ };
+
+ const rootGetters = {
+ ...mockRootGetters,
+ getCurrentUser: {
+ ...mockRootGetters.getCurrentUser,
+ accounts: [
+ {
+ id: 1,
+ custom_role_id: 5,
+ permissions: ['conversation_participating_manage'],
+ },
+ ],
+ },
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ rootGetters
+ );
+
+ // Should only include open conversation assigned to user (id: 1)
+ expect(result).toEqual([mockConversations[0]]);
+ });
+
+ it('returns empty array when no conversations match filters', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_desc',
+ appliedFilters: [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: ['pending'],
+ query_operator: 'and',
+ },
+ ],
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ mockRootGetters
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('sorts filtered conversations according to chatSortFilter', () => {
+ const state = {
+ allConversations: mockConversations,
+ chatSortFilter: 'last_activity_at_asc',
+ appliedFilters: [],
+ };
+
+ const result = getters.getFilteredConversations(
+ state,
+ {},
+ {},
+ mockRootGetters
+ );
+
+ expect(result).toEqual([
+ mockConversations[0],
+ mockConversations[1],
+ mockConversations[2],
+ ]);
+ });
+ });
});
From f064b097765a5a8c318794b6214c2abc6893a086 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Fri, 30 May 2025 16:26:40 +0530
Subject: [PATCH 08/11] feat: move embedding config to a yaml file (#11611)
Co-authored-by: Muhsin Keloth
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
---
config/markdown_embeds.yml | 132 ++++++++++++++++++++++++++++
lib/custom_markdown_renderer.rb | 95 ++++++++------------
lib/embed_renderer.rb | 102 ---------------------
spec/config/markdown_embeds_spec.rb | 103 ++++++++++++++++++++++
4 files changed, 270 insertions(+), 162 deletions(-)
create mode 100644 config/markdown_embeds.yml
delete mode 100644 lib/embed_renderer.rb
create mode 100644 spec/config/markdown_embeds_spec.rb
diff --git a/config/markdown_embeds.yml b/config/markdown_embeds.yml
new file mode 100644
index 000000000..878276c5a
--- /dev/null
+++ b/config/markdown_embeds.yml
@@ -0,0 +1,132 @@
+# Markdown Embed Configuration
+#
+# This file defines patterns and templates for converting URLs into embedded content
+# in markdown rendering. Each embed type has:
+# - regex: Pattern with named capture groups (?...)
+# - template: HTML template with %{capture_group_name} placeholders
+#
+# To add a new embed type:
+# 1. Add a new top-level key
+# 2. Define the regex pattern with named capture groups: (?pattern)
+# 3. Create an HTML template using %{name} placeholders matching the capture groups
+
+youtube:
+ regex: 'https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)(?[^&/]+)'
+ template: |
+
+
+
+
+loom:
+ regex: 'https?://(?:www\.)?loom\.com/share/(?[^&/]+)'
+ template: |
+
+
+
+
+vimeo:
+ regex: 'https?://(?:www\.)?vimeo\.com/(?\d+)'
+ template: |
+
+
+
+
+mp4:
+ regex: '(?https?://(?:www\.)?.+\.mp4)'
+ template: |
+
+
+ Your browser does not support the video tag.
+
+
+arcade:
+ regex: 'https?://(?:www\.)?app\.arcade\.software/share/(?[^&/]+)'
+ template: |
+
+
+
+
+wistia:
+ regex: 'https?://(?:www\.)?[^/]+\.wistia\.com/medias/(?[^&/]+)'
+ template: |
+
+
+
+
+
+
+
+
+bunny:
+ regex: 'https?://iframe\.mediadelivery\.net/play/(?\d+)/(?[^&/?]+)'
+ template: |
+
+
+
+
+codepen:
+ regex: 'https?://(?:www\.)?codepen\.io/(?[^/]+)/pen/(?[^/?]+)'
+ template: |
+
+
+
+
+github_gist:
+ regex: 'https?://gist\.github\.com/(?[^/]+)/(?[a-f0-9]+)'
+ template: |
+
+
+
+
diff --git a/lib/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb
index 902fc20a3..fea10388c 100644
--- a/lib/custom_markdown_renderer.rb
+++ b/lib/custom_markdown_renderer.rb
@@ -1,13 +1,13 @@
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
- # TODO: let move this regex from here to a config file where we can update this list much more easily
- # the config file will also have the matching embed template as well.
- YOUTUBE_REGEX = %r{https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([^&/]+)}
- LOOM_REGEX = %r{https?://(?:www\.)?loom\.com/share/([^&/]+)}
- VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
- MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
- ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
- WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
- BUNNY_REGEX = %r{https?://iframe\.mediadelivery\.net/play/(\d+)/([^&/?]+)}
+ CONFIG_PATH = Rails.root.join('config/markdown_embeds.yml')
+
+ def self.config
+ @config ||= YAML.load_file(CONFIG_PATH)
+ end
+
+ def self.embed_regexes
+ @embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) }
+ end
def text(node)
content = node.string_content
@@ -23,7 +23,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
def link(node)
return if surrounded_by_empty_lines?(node) && render_embedded_content(node)
- # If it's not YouTube or Vimeo link, render normally
+ # If it's not a supported embed link, render normally
super
end
@@ -47,25 +47,35 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
def render_embedded_content(node)
link_url = node.url
- embedding_methods = {
- YOUTUBE_REGEX => :make_youtube_embed,
- VIMEO_REGEX => :make_vimeo_embed,
- MP4_REGEX => :make_video_embed,
- LOOM_REGEX => :make_loom_embed,
- ARCADE_REGEX => :make_arcade_embed,
- WISTIA_REGEX => :make_wistia_embed,
- BUNNY_REGEX => :make_bunny_embed
- }
+ embed_html = find_matching_embed(link_url)
- embedding_methods.each do |regex, method|
+ return false unless embed_html
+
+ out(embed_html)
+ true
+ end
+
+ def find_matching_embed(link_url)
+ self.class.embed_regexes.each do |embed_key, regex|
match = link_url.match(regex)
- if match
- out(send(method, match))
- return true
- end
+ next unless match
+
+ return render_embed_from_match(embed_key, match)
end
- false
+ nil
+ end
+
+ def render_embed_from_match(embed_key, match_data)
+ embed_config = self.class.config[embed_key]
+ return nil unless embed_config
+
+ template = embed_config['template']
+ # Use Ruby's built-in named captures with gsub to handle CSS % values
+ match_data.named_captures.each do |var_name, value|
+ template = template.gsub("%{#{var_name}}", value)
+ end
+ template
end
def parse_sup(content)
@@ -77,39 +87,4 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
end
end
end
-
- def make_youtube_embed(youtube_match)
- video_id = youtube_match[1]
- EmbedRenderer.youtube(video_id)
- end
-
- def make_loom_embed(loom_match)
- video_id = loom_match[1]
- EmbedRenderer.loom(video_id)
- end
-
- def make_vimeo_embed(vimeo_match)
- video_id = vimeo_match[1]
- EmbedRenderer.vimeo(video_id)
- end
-
- def make_video_embed(link_url)
- EmbedRenderer.video(link_url)
- end
-
- def make_wistia_embed(wistia_match)
- video_id = wistia_match[2]
- EmbedRenderer.wistia(video_id)
- end
-
- def make_arcade_embed(arcade_match)
- video_id = arcade_match[1]
- EmbedRenderer.arcade(video_id)
- end
-
- def make_bunny_embed(bunny_match)
- library_id = bunny_match[1]
- video_id = bunny_match[2]
- EmbedRenderer.bunny(library_id, video_id)
- end
end
diff --git a/lib/embed_renderer.rb b/lib/embed_renderer.rb
deleted file mode 100644
index 78f620376..000000000
--- a/lib/embed_renderer.rb
+++ /dev/null
@@ -1,102 +0,0 @@
-module EmbedRenderer
- def self.youtube(video_id)
- %(
-
-
-
- )
- end
-
- def self.loom(video_id)
- %(
-
-
-
- )
- end
-
- def self.vimeo(video_id)
- %(
-
-
-
- )
- end
-
- def self.video(link_url)
- %(
-
-
- Your browser does not support the video tag.
-
- )
- end
-
- # Generates an HTML embed for a Wistia video.
- # @param wistia_match [MatchData] A match object from the WISTIA_REGEX regex, where wistia_match[2] contains the video ID.
- def self.wistia(video_id)
- %(
-
-
-
-
-
-
-
- )
- end
-
- def self.arcade(video_id)
- %(
-
-
-
- )
- end
-
- def self.bunny(library_id, video_id)
- %(
-
-
-
- )
- end
-end
diff --git a/spec/config/markdown_embeds_spec.rb b/spec/config/markdown_embeds_spec.rb
new file mode 100644
index 000000000..2eff75bcd
--- /dev/null
+++ b/spec/config/markdown_embeds_spec.rb
@@ -0,0 +1,103 @@
+require 'rails_helper'
+
+# rubocop:disable RSpec/DescribeClass
+describe 'Markdown Embeds Configuration' do
+ # rubocop:enable RSpec/DescribeClass
+ let(:config) { YAML.load_file(Rails.root.join('config/markdown_embeds.yml')) }
+
+ describe 'YAML structure' do
+ it 'loads valid YAML' do
+ expect(config).to be_a(Hash)
+ expect(config).not_to be_empty
+ end
+
+ it 'has required keys for each embed type' do
+ config.each do |embed_type, embed_config|
+ expect(embed_config).to have_key('regex'), "#{embed_type} missing regex"
+ expect(embed_config).to have_key('template'), "#{embed_type} missing template"
+ expect(embed_config['regex']).to be_a(String), "#{embed_type} regex should be string"
+ expect(embed_config['template']).to be_a(String), "#{embed_type} template should be string"
+ end
+ end
+
+ it 'contains expected embed types' do
+ expected_types = %w[youtube loom vimeo mp4 arcade wistia bunny codepen github_gist]
+ expect(config.keys).to match_array(expected_types)
+ end
+ end
+
+ describe 'regex patterns and named capture groups' do
+ let(:test_cases) do
+ {
+ 'youtube' => [
+ { url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', expected: { 'video_id' => 'dQw4w9WgXcQ' } },
+ { url: 'https://youtu.be/dQw4w9WgXcQ', expected: { 'video_id' => 'dQw4w9WgXcQ' } },
+ { url: 'https://youtube.com/watch?v=abc123XYZ', expected: { 'video_id' => 'abc123XYZ' } }
+ ],
+ 'loom' => [
+ { url: 'https://www.loom.com/share/abc123def456', expected: { 'video_id' => 'abc123def456' } },
+ { url: 'https://loom.com/share/xyz789', expected: { 'video_id' => 'xyz789' } }
+ ],
+ 'vimeo' => [
+ { url: 'https://vimeo.com/123456789', expected: { 'video_id' => '123456789' } },
+ { url: 'https://www.vimeo.com/987654321', expected: { 'video_id' => '987654321' } }
+ ],
+ 'mp4' => [
+ { url: 'https://example.com/video.mp4', expected: { 'link_url' => 'https://example.com/video.mp4' } },
+ { url: 'https://www.test.com/path/to/movie.mp4', expected: { 'link_url' => 'https://www.test.com/path/to/movie.mp4' } }
+ ],
+ 'arcade' => [
+ { url: 'https://app.arcade.software/share/arcade123', expected: { 'video_id' => 'arcade123' } },
+ { url: 'https://www.app.arcade.software/share/demo456', expected: { 'video_id' => 'demo456' } }
+ ],
+ 'wistia' => [
+ { url: 'https://chatwoot.wistia.com/medias/kjwjeq6f9i', expected: { 'video_id' => 'kjwjeq6f9i' } },
+ { url: 'https://www.company.wistia.com/medias/abc123def', expected: { 'video_id' => 'abc123def' } }
+ ],
+ 'bunny' => [
+ { url: 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c',
+ expected: { 'library_id' => '431789', 'video_id' => '1f105841-cad9-46fe-a70e-b7623c60797c' } },
+ { url: 'https://iframe.mediadelivery.net/play/12345/abcdef-ghijkl', expected: { 'library_id' => '12345', 'video_id' => 'abcdef-ghijkl' } }
+ ],
+ 'codepen' => [
+ { url: 'https://codepen.io/username/pen/abcdef', expected: { 'user' => 'username', 'pen_id' => 'abcdef' } },
+ { url: 'https://www.codepen.io/testuser/pen/xyz123', expected: { 'user' => 'testuser', 'pen_id' => 'xyz123' } }
+ ],
+ 'github_gist' => [
+ { url: 'https://gist.github.com/username/1234567890abcdef1234567890abcdef',
+ expected: { 'username' => 'username', 'gist_id' => '1234567890abcdef1234567890abcdef' } },
+ { url: 'https://gist.github.com/testuser/fedcba0987654321fedcba0987654321', expected: { 'username' => 'testuser', 'gist_id' => 'fedcba0987654321fedcba0987654321' } }
+ ]
+ }
+ end
+
+ it 'correctly captures named groups for all embed types' do
+ test_cases.each do |embed_type, cases|
+ regex = Regexp.new(config[embed_type]['regex'])
+
+ cases.each do |test_case|
+ match = regex.match(test_case[:url])
+ expect(match).not_to be_nil, "#{embed_type} regex failed to match URL: #{test_case[:url]}"
+ expect(match.named_captures).to eq(test_case[:expected]),
+ "#{embed_type} captured groups don't match expected for URL: #{test_case[:url]}"
+ end
+ end
+ end
+
+ it 'validates that template variables match capture group names' do
+ config.each do |embed_type, embed_config|
+ regex = Regexp.new(embed_config['regex'])
+ template = embed_config['template']
+
+ # Extract template variables like %{video_id}
+ template_vars = template.scan(/%\{(\w+)\}/).flatten.uniq
+
+ # Get named capture groups from regex
+ capture_names = regex.names
+
+ expect(capture_names).to match_array(template_vars),
+ "#{embed_type}: Template variables #{template_vars} don't match capture groups #{capture_names}"
+ end
+ end
+ end
+end
From 4061f99114c11c2393b260d53f1994393dc1b0e2 Mon Sep 17 00:00:00 2001
From: Pranav
Date: Sun, 1 Jun 2025 23:40:47 -0600
Subject: [PATCH 09/11] fix: Force re-render the CSAT component when data
changes (#11643)
---
.../dashboard/settings/reports/components/CsatTable.vue | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue
index b66ec20f6..10cdf5173 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue
@@ -90,7 +90,6 @@ const columns = [
const [ratingObject = {}] = CSAT_RATINGS.filter(
rating => rating.value === giveRating
);
-
return h(
'span',
{
@@ -110,7 +109,13 @@ const columns = [
columnHelper.accessor('conversationId', {
header: '',
width: 100,
- cell: cellProps => h(ConversationCell, cellProps),
+ cell: cellProps => {
+ const { row } = cellProps;
+ return h(ConversationCell, {
+ key: row.original.conversationId,
+ row,
+ });
+ },
}),
];
From 8fa039e1c5182cd8901b6398fca039318bfcc923 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Mon, 2 Jun 2025 13:57:42 +0530
Subject: [PATCH 10/11] fix: route params not reacting to changes (#11651)
---
.../settings/reports/components/ConversationCell.vue | 6 ++++--
.../dashboard/settings/reports/components/CsatTable.vue | 9 ++-------
2 files changed, 6 insertions(+), 9 deletions(-)
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ConversationCell.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ConversationCell.vue
index 5d6cbcb8d..5342d75e4 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ConversationCell.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ConversationCell.vue
@@ -1,4 +1,6 @@
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue
index 10cdf5173..b66ec20f6 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue
@@ -90,6 +90,7 @@ const columns = [
const [ratingObject = {}] = CSAT_RATINGS.filter(
rating => rating.value === giveRating
);
+
return h(
'span',
{
@@ -109,13 +110,7 @@ const columns = [
columnHelper.accessor('conversationId', {
header: '',
width: 100,
- cell: cellProps => {
- const { row } = cellProps;
- return h(ConversationCell, {
- key: row.original.conversationId,
- row,
- });
- },
+ cell: cellProps => h(ConversationCell, cellProps),
}),
];
From a5fda8e11876e55eac283d3976017dc82fdf2649 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Mon, 2 Jun 2025 14:08:01 +0530
Subject: [PATCH 11/11] fix: Style issue with CSAT reports table (#11653)
---
.../components/widgets/UserAvatarWithName.vue | 5 +---
.../settings/reports/components/CsatTable.vue | 23 +++++++++++++------
2 files changed, 17 insertions(+), 11 deletions(-)
diff --git a/app/javascript/dashboard/components/widgets/UserAvatarWithName.vue b/app/javascript/dashboard/components/widgets/UserAvatarWithName.vue
index 1e78c334d..f91f96711 100644
--- a/app/javascript/dashboard/components/widgets/UserAvatarWithName.vue
+++ b/app/javascript/dashboard/components/widgets/UserAvatarWithName.vue
@@ -25,10 +25,7 @@ defineProps({
:username="user.name"
:status="user.availability_status"
/>
-
+
{{ user.name }}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue
index b66ec20f6..8beeafd70 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/CsatTable.vue
@@ -47,14 +47,17 @@ const tableData = computed(() => {
}));
});
-const defaulSpanRender = cellProps =>
- h(
+const defaultSpanRender = cellProps => {
+ const value = cellProps.getValue() || '---';
+ return h(
'span',
{
- class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
+ class: 'line-clamp-5 break-words max-w-full text-n-slate-12',
+ title: value,
},
- cellProps.getValue() ? cellProps.getValue() : '---'
+ value
);
+};
const columnHelper = createColumnHelper();
@@ -65,7 +68,10 @@ const columns = [
cell: cellProps => {
const { contact } = cellProps.row.original;
if (contact) {
- return h(UserAvatarWithName, { user: contact });
+ return h(UserAvatarWithName, {
+ user: contact,
+ class: 'max-w-[200px] overflow-hidden',
+ });
}
return '--';
},
@@ -76,7 +82,10 @@ const columns = [
cell: cellProps => {
const { assignedAgent } = cellProps.row.original;
if (assignedAgent) {
- return h(UserAvatarWithName, { user: assignedAgent });
+ return h(UserAvatarWithName, {
+ user: assignedAgent,
+ class: 'max-w-[200px] overflow-hidden',
+ });
}
return '--';
},
@@ -105,7 +114,7 @@ const columns = [
columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
width: 400,
- cell: defaulSpanRender,
+ cell: defaultSpanRender,
}),
columnHelper.accessor('conversationId', {
header: '',