Compare commits

...
Author SHA1 Message Date
Shivam Mishra 509b8a6e01 fix: update stale mock data in component stories
These stories were written against older component APIs and broke under
the new harness:

- copilot: nest message content/reasoning and use message_type; pass the
  required conversationInboxType
- ArticlesPage: pass the required categories, allowedLocales, portalName
  and meta props
- CategoryPage: use the real category shape (name/icon/slug/meta) so cards
  render titles instead of "undefined undefined"
- PortalSettings: provide a portals array matching the seeded route so the
  settings forms render
2026-06-03 14:46:06 +05:30
Shivam Mishra 4ede0f58d2 feat: replace histoire with a minimal in-house stories harness
Histoire is unmaintained and blocks upgrading Vite and other dev
dependencies. Replace it with a small standalone Vite app that keeps the
same `<Story>`/`<Variant>` template API so story files need no changes.

- Add vite.stories.config.ts and app/javascript/stories/ (sidebar tree
  with collapsible groups, search, dark-mode and RTL/LTR toggles, and a
  per-story error boundary)
- Globally register Story/Variant; migrate the histoire setup (store,
  i18n, plugins/directives) and seed a memory router for route-aware
  components
- Repoint story:dev/build/preview scripts and drop the histoire deps
- Remove histoire.config.ts, histoire.setup.ts and histoire.scss
2026-06-03 14:45:51 +05:30
7acbe8b3ff fix(whatsapp): truncate location fallback_title to 255 chars to avoid silent message drop (#14517)
## Summary

`Whatsapp::IncomingMessageBaseService#attach_location` builds a
`fallback_title` by concatenating `location['name']` and
`location['address']` with no length cap, then stores it directly into
`Attachment#fallback_title`. `ApplicationRecord` enforces a generic
255-character limit on string columns, so any WhatsApp location whose
`"#{name}, #{address}"` exceeds 255 chars (a common case for Google
Places that include a long full address) raises
`ActiveRecord::RecordInvalid` deep inside the Sidekiq job. The message
and attachment INSERTs are part of the same transaction, so the whole
thing rolls back. Sidekiq retries once; the retry dedup-skips the wamid
silently and exits without an error. **Result: the message is
irrecoverably lost — no row in `messages`, no entry in the UI, no
outgoing webhook, no clue for the operator.**

Confirmed in `v4.13.0`, `v4.14.0`, and `develop` (commit `f33e469`,
2026-05-20). No upstream issue found before opening this PR.

## How to reproduce

1. From WhatsApp, share a Google Place whose `name + ", " + address` is
> 255 chars. The Spanish business address `Gremi de Fusters, 33,
Edificio VIP Asima, Piso 2, Local 2, Norte, 07009 Polígon industrial de
Son Castelló, Illes Balears, España` (132 chars) used as both `name` and
`address` is enough.
2. Sidekiq logs:
   ```
   ERROR ActiveRecord::RecordInvalid: Validation failed:
   Attachments fallback title is too long (maximum is 255 characters)
   ```
3. The `messages` table has no row. The conversation UI shows nothing
for that timestamp.
4. The first retry "Performed" successfully but creates nothing — the
dedup-by-source-id silently swallows the failure.

## Fix

Cap the existing concatenated title at 255 chars via `.first(255)`.
Minimal change, no behavioural difference for any message shorter than
the limit, prevents the silent data loss for any longer ones.

```diff
-    location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
+    location_name = (location['name'] ? "#{location['name']}, #{location['address']}" : '').first(255)
```

## Alternatives considered

- **Increase the validation limit on `Attachment#fallback_title`**: more
invasive; would touch other inbound channels and possibly require a DB
column change.
- **Use `name` alone (no concat)**: cleaner semantically (in many real
payloads `name == address`), but changes user-visible behaviour. Left as
a follow-up if desired.
- **Truncate with ellipsis**: cosmetic only; deferred.

This PR is intentionally minimal so it can be merged on its own.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-06-03 12:50:21 +05:30
Vinícius FitznerandGitHub b791d75b30 fix(microsoft): prevent OAuth admin consent loop (#13962)
Fixes #9775

## Description

This fixes a repeated admin consent loop in the Microsoft OAuth flow
when connecting a Microsoft email inbox.

Chatwoot was always sending `prompt=consent` in the Microsoft
authorization URL. In the current code path, this parameter is only used
when building the authorization URL and is not required by the callback,
token exchange, token persistence, or refresh flow.

By removing the forced consent prompt, the OAuth flow can proceed
normally without repeatedly sending users back through the admin consent
screen.

## What changed

- removed `prompt: 'consent'` from the Microsoft authorization URL
- added a regression assertion to ensure `prompt` is not included in the
generated URL

## Why this is safe

- `redirect_uri`, `scope`, and `state` remain unchanged
- callback and token exchange flow remain unchanged
- refresh token flow remains unchanged
- no other part of the current Microsoft inbox flow depends on forcing a
consent screen

## Testing

- updated controller spec to assert that the generated authorization URL
does not include `prompt`
2026-06-03 12:05:25 +05:30
27 changed files with 647 additions and 1351 deletions
+11
View File
@@ -28,6 +28,17 @@ module.exports = {
'no-console': 'off',
},
},
{
// The stories harness is dev-only tooling, not user-facing UI.
files: ['app/javascript/stories/**/*.{js,vue}'],
rules: {
'vue/no-bare-strings-in-template': 'off',
'@intlify/vue-i18n/no-raw-text': 'off',
// `group` is declared on <Story> only to accept Histoire's API.
'vue/no-unused-properties': 'off',
'no-console': 'off',
},
},
],
plugins: ['html', 'prettier'],
parserOptions: {
+1 -1
View File
@@ -100,7 +100,7 @@ CLAUDE.local.md
# Histoire deployment
.netlify
.histoire
.stories
.pnpm-store/*
local/
Procfile.worktree
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state,
prompt: 'consent'
state: state
}
)
if redirect_url
@@ -59,13 +59,38 @@ const articles = [
views: 400,
},
];
const categories = [
{ id: 1, name: 'Getting started', icon: '🚀' },
{ id: 2, name: 'Marketing', icon: '⚡️' },
{ id: 3, name: 'Development', icon: '🛠️' },
];
const allowedLocales = [{ code: 'en', name: 'English' }];
const portalName = 'Chatwoot Help Center';
const meta = {
currentPage: 1,
allArticlesCount: articles.length,
articlesCount: articles.length,
mineArticlesCount: 2,
draftArticlesCount: 1,
archivedArticlesCount: 2,
};
</script>
<template>
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
<Variant title="All Articles">
<div class="w-full min-h-screen bg-n-background">
<ArticlesPage :articles="articles" />
<ArticlesPage
:articles="articles"
:categories="categories"
:allowed-locales="allowedLocales"
:portal-name="portalName"
:meta="meta"
/>
</div>
</Variant>
</Story>
@@ -3,206 +3,62 @@ import CategoriesPage from './CategoriesPage.vue';
const categories = [
{
id: 'getting-started',
title: '🚀 Getting started',
id: 1,
name: 'Getting started',
icon: '🚀',
slug: 'getting-started',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '2',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
],
meta: { articles_count: 12 },
},
{
id: 'marketing',
title: 'Marketing',
id: 2,
name: 'Marketing',
icon: '⚡️',
slug: 'marketing',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support.',
articlesCount: '4',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
variant: 'Archived article',
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
variant: 'Published article',
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
],
meta: { articles_count: 4 },
},
{
id: 'development',
title: 'Development',
id: 3,
name: 'Development',
icon: '🛠️',
slug: 'development',
description: '',
articlesCount: '5',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
variant: 'Archived article',
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
variant: 'Archived article',
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
variant: 'Published article',
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
],
meta: { articles_count: 5 },
},
{
id: 'roadmap',
title: '🛣️ Roadmap',
id: 4,
name: 'Roadmap',
icon: '🛣️',
slug: 'roadmap',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '3',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
],
meta: { articles_count: 3 },
},
{
id: 'finance',
title: '💰 Finance',
id: 5,
name: 'Finance',
icon: '💰',
slug: 'finance',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '2',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
],
meta: { articles_count: 2 },
},
];
const allowedLocales = [{ code: 'en', name: 'English' }];
</script>
<template>
<Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }">
<Variant title="All Categories">
<div class="w-full min-h-screen bg-n-background">
<CategoriesPage :categories="categories" />
<CategoriesPage
:categories="categories"
:allowed-locales="allowedLocales"
/>
</div>
</Variant>
</Story>
@@ -1,12 +1,28 @@
<script setup>
import PortalSettings from './PortalSettings.vue';
// `slug` matches the route the stories harness seeds (portals/chatwoot), so
// PortalSettings resolves an active portal and renders the settings forms.
const portals = [
{
id: 1,
name: 'Chatwoot Help Center',
slug: 'chatwoot',
header_text: 'How can we help you today?',
page_title: 'Chatwoot Help Center',
homepage_link: 'https://www.chatwoot.com',
custom_domain: '',
config: {},
ssl_settings: {},
},
];
</script>
<template>
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
<Variant title="Default">
<div class="w-[1000px] min-h-screen bg-n-background">
<PortalSettings />
<PortalSettings :portals="portals" :is-fetching="false" />
</div>
</Variant>
</Story>
@@ -2,44 +2,51 @@
import { ref } from 'vue';
import Copilot from './Copilot.vue';
const supportAgent = {
available_name: 'Pranav Raj',
avatar_url:
'https://app.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBd3FodGc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--d218a325af0ef45061eefd352f8efb9ac84275e8/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--533c3ad7218e24c4b0e8f8959dc1953ce1d279b9/1707423736896.jpeg',
};
const messages = ref([
{
id: 1,
role: 'user',
content: 'Hi there! How can I help you today?',
message_type: 'user',
message: { content: 'Hi there! How can I help you today?' },
},
{
id: 2,
role: 'assistant',
content:
"Hello! I'm the AI assistant. I'll be helping the support team today.",
message_type: 'assistant_thinking',
message: {
content: 'Analyzing the conversation',
reasoning: 'Breaking down the request into actionable steps',
},
},
{
id: 3,
message_type: 'assistant_thinking',
message: {
content: 'Searching the knowledge base',
reasoning: 'Looking for relevant articles and past replies',
},
},
{
id: 4,
message_type: 'assistant',
message: {
content:
"Hello! I'm the AI assistant. I'll be helping the support team today.",
},
},
]);
const isCaptainTyping = ref(false);
const sendMessage = message => {
// Add user message
messages.value.push({
id: messages.value.length + 1,
role: 'user',
content: message,
message_type: 'user',
message: { content: message },
});
// Simulate AI response
isCaptainTyping.value = true;
setTimeout(() => {
isCaptainTyping.value = false;
messages.value.push({
id: messages.value.length + 1,
role: 'assistant',
content: 'This is a simulated AI response.',
message_type: 'assistant',
message: { content: 'This is a simulated AI response.' },
});
}, 2000);
};
@@ -51,9 +58,8 @@ const sendMessage = message => {
:layout="{ type: 'grid', width: '400px', height: '800px' }"
>
<Copilot
:support-agent="supportAgent"
:messages="messages"
:is-captain-typing="isCaptainTyping"
conversation-inbox-type="Channel::WebWidget"
@send-message="sendMessage"
/>
</Story>
@@ -4,18 +4,24 @@ import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
const messages = [
{
id: 1,
content: 'Analyzing the user query',
reasoning: 'Breaking down the request into actionable steps',
message: {
content: 'Analyzing the user query',
reasoning: 'Breaking down the request into actionable steps',
},
},
{
id: 2,
content: 'Searching codebase',
reasoning: 'Looking for relevant files and functions',
message: {
content: 'Searching codebase',
reasoning: 'Looking for relevant files and functions',
},
},
{
id: 3,
content: 'Generating response',
reasoning: 'Composing a helpful and accurate answer',
message: {
content: 'Generating response',
reasoning: 'Composing a helpful and accurate answer',
},
},
];
</script>
@@ -1,21 +0,0 @@
@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;
}
@@ -0,0 +1,6 @@
@import 'dashboard/assets/scss/app';
html,
body {
font-family: 'Inter', sans-serif;
}
+125
View File
@@ -0,0 +1,125 @@
<script setup>
import { ref, computed, defineAsyncComponent } from 'vue';
import { stories, buildTree } from './registry';
import TreeNode from './TreeNode.vue';
import StoryFrame from './StoryFrame.vue';
const query = ref('');
const filteredStories = computed(() => {
const term = query.value.trim().toLowerCase();
if (!term) return stories;
return stories.filter(story => story.title.toLowerCase().includes(term));
});
const tree = computed(() => buildTree(filteredStories.value));
const isSearching = computed(() => query.value.trim().length > 0);
function pathFromHash() {
const hash = decodeURIComponent(window.location.hash.replace(/^#/, ''));
return stories.find(story => story.path === hash)?.path;
}
const selectedPath = ref(pathFromHash() || stories[0]?.path || '');
const current = computed(() =>
stories.find(story => story.path === selectedPath.value)
);
const StoryComponent = computed(() =>
current.value ? defineAsyncComponent(current.value.loader) : null
);
function select(path) {
selectedPath.value = path;
window.location.hash = encodeURIComponent(path);
}
const isDark = ref(false);
function toggleDark() {
isDark.value = !isDark.value;
document.documentElement.classList.toggle('dark', isDark.value);
}
// Scoped to the preview canvas only (see the <main :dir> below), so the
// sidebar stays LTR while stories can be inspected in RTL.
const isRtl = ref(false);
function toggleDir() {
isRtl.value = !isRtl.value;
}
// Remount the active story on every hot update so a fixed file clears the
// error boundary (otherwise a caught error stays latched until a full reload).
const hmrTick = ref(0);
if (import.meta.hot) {
import.meta.hot.on('vite:afterUpdate', () => {
hmrTick.value += 1;
});
}
</script>
<template>
<div
class="flex w-screen h-screen overflow-hidden bg-n-background text-n-slate-12"
>
<aside
dir="ltr"
class="flex flex-col border-r w-72 shrink-0 border-n-weak bg-n-solid-1"
>
<div
class="flex items-center justify-between h-12 gap-2 px-4 border-b shrink-0 border-n-weak"
>
<span class="text-sm font-semibold text-n-slate-12">
@chatwoot/design
</span>
<div class="flex items-center gap-1">
<button
type="button"
class="px-2 py-1 text-xs font-medium uppercase rounded-md text-n-slate-11 hover:bg-n-alpha-1"
@click="toggleDir"
>
{{ isRtl ? 'RTL' : 'LTR' }}
</button>
<button
type="button"
class="px-2 py-1 text-sm rounded-md text-n-slate-11 hover:bg-n-alpha-1"
@click="toggleDark"
>
{{ isDark ? 'Light' : 'Dark' }}
</button>
</div>
</div>
<div class="p-3 border-b shrink-0 border-n-weak">
<input
v-model="query"
type="search"
placeholder="Search stories"
class="w-full px-3 py-1.5 text-sm rounded-md border outline-none border-n-weak bg-n-background text-n-slate-12 placeholder:text-n-slate-10 focus:border-n-brand"
/>
</div>
<nav class="flex-1 p-2 overflow-y-auto">
<TreeNode
:nodes="tree"
:selected-path="selectedPath"
:force-expand="isSearching"
@select="select"
/>
<p
v-if="!filteredStories.length"
class="px-2 py-4 text-sm text-n-slate-10"
>
No stories match "{{ query }}".
</p>
</nav>
</aside>
<main :dir="isRtl ? 'rtl' : 'ltr'" class="flex-1 min-w-0 overflow-hidden">
<StoryFrame
v-if="StoryComponent"
:key="`${selectedPath}:${hmrTick}`"
:component="StoryComponent"
/>
<div
v-else
class="flex items-center justify-center h-full text-n-slate-10"
>
Select a story to preview.
</div>
</main>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup>
import { computed, provide } from 'vue';
const props = defineProps({
title: { type: String, required: true },
layout: {
type: Object,
default: () => ({ type: 'grid', width: '80%' }),
},
// Accepted for API compatibility with Histoire; not used by the harness.
group: { type: String, default: '' },
});
const layout = computed(() => ({ type: 'grid', ...props.layout }));
provide('storyLayout', layout);
const name = computed(() => props.title.split('/').pop());
const containerClass = computed(() =>
layout.value.type === 'single'
? 'flex flex-col gap-6'
: 'flex flex-wrap items-start gap-6'
);
</script>
<template>
<div class="flex flex-col h-full min-h-0 bg-n-background">
<header
class="flex flex-col gap-0.5 px-6 py-4 border-b border-n-weak shrink-0"
>
<h1 class="text-lg font-semibold text-n-slate-12">{{ name }}</h1>
<p class="text-xs text-n-slate-10">{{ title }}</p>
</header>
<div class="flex-1 min-h-0 overflow-auto p-6">
<div :class="containerClass">
<slot />
</div>
</div>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import { ref, onErrorCaptured } from 'vue';
// Per-story error boundary. Keyed by the story path in App.vue, so it remounts
// (and resets) on navigation. Containing the error here keeps one broken story
// from tearing down the whole harness, mirroring Histoire's per-story isolation.
defineProps({
component: { type: [Object, Function], default: null },
});
const error = ref(null);
onErrorCaptured(err => {
error.value = err;
return false;
});
</script>
<template>
<div v-if="error" class="flex flex-col h-full gap-3 p-6 overflow-auto">
<p class="text-sm font-semibold text-n-ruby-11">
This story failed to render.
</p>
<div
class="p-3 font-mono text-xs whitespace-pre-wrap rounded-md text-n-slate-11 bg-n-alpha-1"
>
{{ error.stack || error.message }}
</div>
</div>
<component :is="component" v-else />
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup>
import { reactive } from 'vue';
// Recursive sidebar node. Each instance owns the collapsed state of its own
// direct child groups, so nesting just works. Plain buttons/divs (no <ul>/<li>)
// avoid the list markers the dashboard stylesheet injects.
const props = defineProps({
nodes: { type: Array, required: true },
selectedPath: { type: String, default: '' },
forceExpand: { type: Boolean, default: false },
depth: { type: Number, default: 0 },
});
defineEmits(['select']);
// Default: top-level (L1) groups open, everything deeper collapsed.
// `overrides` holds the explicit open/closed state once the user toggles a group.
const overrides = reactive({});
const isOpen = name => {
if (props.forceExpand) return true;
if (name in overrides) return overrides[name];
return props.depth === 0;
};
const toggle = name => {
overrides[name] = !isOpen(name);
};
</script>
<template>
<div class="flex flex-col gap-px">
<template v-for="node in nodes" :key="node.name">
<div v-if="node.type === 'group'" class="flex flex-col">
<button
type="button"
class="flex items-center w-full gap-1.5 px-2 py-1 rounded-md group text-n-slate-11 hover:bg-n-alpha-1"
@click="toggle(node.name)"
>
<span
class="transition-transform i-lucide-chevron-right size-3.5 shrink-0 text-n-slate-10"
:class="{ 'rotate-90': isOpen(node.name) }"
/>
<span
class="text-xs font-semibold tracking-wide uppercase truncate text-n-slate-10"
>
{{ node.name }}
</span>
</button>
<div
v-show="isOpen(node.name)"
class="ml-2.5 pl-2 border-l border-n-weak"
>
<TreeNode
:nodes="node.children"
:selected-path="selectedPath"
:force-expand="forceExpand"
:depth="depth + 1"
@select="$emit('select', $event)"
/>
</div>
</div>
<button
v-else
type="button"
class="block w-full px-2 py-1 ml-1 text-sm text-left truncate transition-colors rounded-md"
:class="
node.path === selectedPath
? 'bg-n-brand/10 text-n-brand font-medium'
: 'text-n-slate-11 hover:bg-n-alpha-1 hover:text-n-slate-12'
"
@click="$emit('select', node.path)"
>
{{ node.name }}
</button>
</template>
</div>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup>
import { computed, inject } from 'vue';
defineProps({
title: { type: String, default: '' },
});
const layout = inject('storyLayout', null);
// Normalizes layout sizes: numbers and bare numeric strings -> px,
// everything else (e.g. '100%', '800px') passes through unchanged.
function normalizeSize(value) {
if (value === undefined || value === null || value === '') return undefined;
if (typeof value === 'number') return `${value}px`;
return /^\d+$/.test(value) ? `${value}px` : value;
}
const cellStyle = computed(() => {
const current = layout?.value ?? {};
return {
width: normalizeSize(current.width),
height: normalizeSize(current.height),
};
});
</script>
<template>
<section
class="flex flex-col overflow-hidden rounded-lg border border-n-weak bg-n-solid-1"
:style="cellStyle"
>
<div
class="px-3 py-2 text-sm font-medium border-b text-n-slate-12 border-n-weak bg-n-alpha-1"
>
{{ title }}
</div>
<div class="flex-1 p-4 bg-n-background">
<slot />
</div>
</section>
</template>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@chatwoot/design</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.js"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import { createApp } from 'vue';
import App from './App.vue';
import Story from './Story.vue';
import Variant from './Variant.vue';
import { setupApp } from './setup';
const app = createApp(App);
// Story files reference <Story> and <Variant> as globals (same as Histoire).
app.component('Story', Story);
app.component('Variant', Variant);
setupApp(app);
app.mount('#app');
+61
View File
@@ -0,0 +1,61 @@
// Lazy component loaders for every *.story.vue file under app/javascript.
const storyModules = import.meta.glob('../**/*.story.vue');
// Raw sources, eagerly loaded, so we can read each story's title without
// instantiating the (potentially heavy) component just to build the sidebar.
const storySources = import.meta.glob('../**/*.story.vue', {
query: '?raw',
import: 'default',
eager: true,
});
function parseTitle(source) {
const match = source.match(/<Story\b[^>]*?\btitle\s*=\s*"([^"]+)"/);
return match ? match[1] : null;
}
export const stories = Object.keys(storyModules)
.map(filePath => ({
path: filePath,
title: parseTitle(storySources[filePath] || ''),
loader: storyModules[filePath],
}))
.filter(story => story.title)
.sort((a, b) => a.title.localeCompare(b.title));
function insert(nodes, parts, story) {
const [head, ...rest] = parts;
if (rest.length === 0) {
nodes.push({ type: 'story', name: head, path: story.path });
return;
}
let group = nodes.find(node => node.type === 'group' && node.name === head);
if (!group) {
group = { type: 'group', name: head, children: [] };
nodes.push(group);
}
insert(group.children, rest, story);
}
// Sorts each level so groups come before individual stories, both alphabetically.
function sortNodes(nodes) {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === 'group' ? -1 : 1;
return a.name.localeCompare(b.name);
});
nodes.forEach(node => {
if (node.type === 'group') sortNodes(node.children);
});
return nodes;
}
// Builds a nested tree from the slash-delimited story titles
// (e.g. "Components/Button" -> group "Components" > story "Button").
export function buildTree(items) {
const roots = [];
items.forEach(story => {
const parts = story.title.split('/').map(part => part.trim());
insert(roots, parts, story);
});
return sortNodes(roots);
}
@@ -1,14 +1,33 @@
import './design-system/histoire.scss';
import { defineSetupVue3 } from '@histoire/plugin-vue';
import dashboardI18n from 'dashboard/i18n';
import widgetI18n from 'widget/i18n';
import '../design-system/stories.scss';
import { createI18n } from 'vue-i18n';
import { createRouter, createMemoryHistory } from 'vue-router';
import { vResizeObserver } from '@vueuse/components';
import store from 'dashboard/store';
import FloatingVue from 'floating-vue';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
import { directive as onClickaway } from 'vue3-click-away';
import dashboardI18n from 'dashboard/i18n';
import widgetI18n from 'widget/i18n';
import store from 'dashboard/store';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
// A throwaway router so composables like useRoute()/useAccount() resolve a real
// route object instead of throwing. We seed realistic Help Center params
// (accountId/portalSlug/locale) so page-level stories that key off the route
// render instead of bailing out. Story selection itself is hash-based and
// independent of this router.
const noop = { render: () => null };
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/app/accounts/:accountId/portals/:portalSlug/:locale?',
name: 'stories',
component: noop,
},
{ path: '/:pathMatch(.*)*', name: 'fallback', component: noop },
],
});
router.replace('/app/accounts/1/portals/chatwoot/en');
function mergeMessages(...sources) {
return sources.reduce((acc, src) => {
@@ -36,9 +55,12 @@ const i18n = createI18n({
),
});
export const setupVue3 = defineSetupVue3(({ app }) => {
// Registers the same global plugins/directives the dashboard components expect
// at runtime, so stories render identically to the real app.
export function setupApp(app) {
app.use(store);
app.use(i18n);
app.use(router);
app.use(FloatingVue, {
instantMove: true,
arrowOverflow: false,
@@ -48,4 +70,4 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
app.directive('resize', vResizeObserver);
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.directive('on-clickaway', onClickaway);
});
}
@@ -147,7 +147,7 @@ class Whatsapp::IncomingMessageBaseService
def attach_location
location = messages_data.first['location']
location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
location_name = (location['name'] ? "#{location['name']}, #{location['address']}" : '').first(255)
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type(message_type),
-44
View File
@@ -1,44 +0,0 @@
import { defineConfig } from 'histoire';
import { HstVue } from '@histoire/plugin-vue';
export default defineConfig({
setupFile: './histoire.setup.ts',
plugins: [HstVue()],
collectMaxThreads: 4,
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,
},
],
},
});
+3 -5
View File
@@ -15,9 +15,9 @@
"build:sdk": "vite build --config vite.lib.config.ts",
"prepare": "husky install",
"size": "size-limit",
"story:dev": "histoire dev",
"story:build": "histoire build",
"story:preview": "histoire preview",
"story:dev": "vite --config vite.stories.config.ts",
"story:build": "vite build --config vite.stories.config.ts",
"story:preview": "vite preview --config vite.stories.config.ts",
"sync:i18n": "bin/sync_i18n_file_change"
},
"size-limit": [
@@ -114,7 +114,6 @@
},
"devDependencies": {
"@egoist/tailwindcss-icons": "^1.9.2",
"@histoire/plugin-vue": "0.17.15",
"@iconify-json/logos": "^1.2.10",
"@iconify-json/lucide": "^1.2.82",
"@iconify-json/ph": "^1.2.2",
@@ -136,7 +135,6 @@
"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": "^27.2.0",
"lint-staged": "^16.2.7",
+24 -1064
View File
File diff suppressed because it is too large Load Diff
@@ -43,6 +43,7 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
]
expect(params['scope']).to eq(expected_scope)
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
expect(url).not_to match(/(?:\?|&)prompt=/)
# Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present
@@ -381,6 +381,32 @@ describe Whatsapp::IncomingMessageService do
expect(location_attachment.coordinates_long).to eq(-122.3895553)
expect(location_attachment.external_url).to eq('http://location_url.test')
end
it 'truncates long fallback titles to avoid dropping location messages' do
long_place_name = [
'Gremi de Fusters, 33, Edificio VIP Asima, Piso 2, Local 2, Norte',
'07009 Poligon industrial de Son Castello, Illes Balears, Espana'
].join(', ')
source_id = 'wamid.long-location-fallback-title'
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{ 'from' => '2423423243', 'id' => source_id,
'location' => { 'id' => 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
:address => long_place_name,
:latitude => 37.7893768,
:longitude => -122.3895553,
:name => long_place_name,
:url => 'http://location_url.test' },
'timestamp' => '1633034394', 'type' => 'location' }]
}.with_indifferent_access
expect { described_class.new(inbox: whatsapp_channel.inbox, params: params).perform }
.to change { Message.where(source_id: source_id).count }.from(0).to(1)
location_attachment = Message.find_by!(source_id: source_id).attachments.first
expect(location_attachment.fallback_title).to eq("#{long_place_name}, #{long_place_name}".first(255))
expect(location_attachment.fallback_title.length).to eq(255)
end
end
context 'when valid contact message params' do
+1
View File
@@ -34,6 +34,7 @@ const tailwindConfig = {
'./app/javascript/dashboard/composables/**/*.js',
'./app/javascript/dashboard/components-next/**/*.js',
'./app/javascript/dashboard/routes/dashboard/**/**/*.js',
'./app/javascript/stories/**/*.{js,vue}',
'./app/views/**/*.erb',
],
theme: {
+29
View File
@@ -0,0 +1,29 @@
import path from 'path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import yaml from '@rollup/plugin-yaml';
import { aliases, vueOptions } from './vite.shared';
// Standalone Vite app that renders the component stories (replaces Histoire).
// It reuses the dashboard aliases/plugins but drops vite-plugin-ruby, which is
// specific to the Rails asset pipeline and not needed here.
export default defineConfig({
root: path.resolve('app/javascript/stories'),
plugins: [vue(vueOptions), yaml()],
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
},
},
},
resolve: { alias: aliases },
server: {
port: 6179,
fs: { allow: [path.resolve('.')] },
},
build: {
outDir: path.resolve('.stories'),
emptyOutDir: true,
},
});