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
23 changed files with 618 additions and 1348 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
@@ -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);
});
}
-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
+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,
},
});