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', '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'], plugins: ['html', 'prettier'],
parserOptions: { parserOptions: {
+1 -1
View File
@@ -100,7 +100,7 @@ CLAUDE.local.md
# Histoire deployment # Histoire deployment
.netlify .netlify
.histoire .stories
.pnpm-store/* .pnpm-store/*
local/ local/
Procfile.worktree Procfile.worktree
@@ -59,13 +59,38 @@ const articles = [
views: 400, 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> </script>
<template> <template>
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }"> <Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
<Variant title="All Articles"> <Variant title="All Articles">
<div class="w-full min-h-screen bg-n-background"> <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> </div>
</Variant> </Variant>
</Story> </Story>
@@ -3,206 +3,62 @@ import CategoriesPage from './CategoriesPage.vue';
const categories = [ const categories = [
{ {
id: 'getting-started', id: 1,
title: '🚀 Getting started', name: 'Getting started',
icon: '🚀',
slug: 'getting-started',
description: description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.', 'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '2', meta: { articles_count: 12 },
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,
},
],
}, },
{ {
id: 'marketing', id: 2,
title: 'Marketing', name: 'Marketing',
icon: '⚡️',
slug: 'marketing',
description: description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support.', 'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support.',
articlesCount: '4', meta: { articles_count: 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,
},
],
}, },
{ {
id: 'development', id: 3,
title: 'Development', name: 'Development',
icon: '🛠️',
slug: 'development',
description: '', description: '',
articlesCount: '5', meta: { articles_count: 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,
},
],
}, },
{ {
id: 'roadmap', id: 4,
title: '🛣️ Roadmap', name: 'Roadmap',
icon: '🛣️',
slug: 'roadmap',
description: description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.', 'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '3', meta: { articles_count: 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,
},
],
}, },
{ {
id: 'finance', id: 5,
title: '💰 Finance', name: 'Finance',
icon: '💰',
slug: 'finance',
description: description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.', 'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '2', meta: { articles_count: 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,
},
],
}, },
]; ];
const allowedLocales = [{ code: 'en', name: 'English' }];
</script> </script>
<template> <template>
<Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }"> <Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }">
<Variant title="All Categories"> <Variant title="All Categories">
<div class="w-full min-h-screen bg-n-background"> <div class="w-full min-h-screen bg-n-background">
<CategoriesPage :categories="categories" /> <CategoriesPage
:categories="categories"
:allowed-locales="allowedLocales"
/>
</div> </div>
</Variant> </Variant>
</Story> </Story>
@@ -1,12 +1,28 @@
<script setup> <script setup>
import PortalSettings from './PortalSettings.vue'; 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> </script>
<template> <template>
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }"> <Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
<Variant title="Default"> <Variant title="Default">
<div class="w-[1000px] min-h-screen bg-n-background"> <div class="w-[1000px] min-h-screen bg-n-background">
<PortalSettings /> <PortalSettings :portals="portals" :is-fetching="false" />
</div> </div>
</Variant> </Variant>
</Story> </Story>
@@ -2,44 +2,51 @@
import { ref } from 'vue'; import { ref } from 'vue';
import Copilot from './Copilot.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([ const messages = ref([
{ {
id: 1, id: 1,
role: 'user', message_type: 'user',
content: 'Hi there! How can I help you today?', message: { content: 'Hi there! How can I help you today?' },
}, },
{ {
id: 2, id: 2,
role: 'assistant', message_type: 'assistant_thinking',
content: message: {
"Hello! I'm the AI assistant. I'll be helping the support team today.", 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 => { const sendMessage = message => {
// Add user message
messages.value.push({ messages.value.push({
id: messages.value.length + 1, id: messages.value.length + 1,
role: 'user', message_type: 'user',
content: message, message: { content: message },
}); });
// Simulate AI response // Simulate AI response
isCaptainTyping.value = true;
setTimeout(() => { setTimeout(() => {
isCaptainTyping.value = false;
messages.value.push({ messages.value.push({
id: messages.value.length + 1, id: messages.value.length + 1,
role: 'assistant', message_type: 'assistant',
content: 'This is a simulated AI response.', message: { content: 'This is a simulated AI response.' },
}); });
}, 2000); }, 2000);
}; };
@@ -51,9 +58,8 @@ const sendMessage = message => {
:layout="{ type: 'grid', width: '400px', height: '800px' }" :layout="{ type: 'grid', width: '400px', height: '800px' }"
> >
<Copilot <Copilot
:support-agent="supportAgent"
:messages="messages" :messages="messages"
:is-captain-typing="isCaptainTyping" conversation-inbox-type="Channel::WebWidget"
@send-message="sendMessage" @send-message="sendMessage"
/> />
</Story> </Story>
@@ -4,18 +4,24 @@ import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
const messages = [ const messages = [
{ {
id: 1, id: 1,
content: 'Analyzing the user query', message: {
reasoning: 'Breaking down the request into actionable steps', content: 'Analyzing the user query',
reasoning: 'Breaking down the request into actionable steps',
},
}, },
{ {
id: 2, id: 2,
content: 'Searching codebase', message: {
reasoning: 'Looking for relevant files and functions', content: 'Searching codebase',
reasoning: 'Looking for relevant files and functions',
},
}, },
{ {
id: 3, id: 3,
content: 'Generating response', message: {
reasoning: 'Composing a helpful and accurate answer', content: 'Generating response',
reasoning: 'Composing a helpful and accurate answer',
},
}, },
]; ];
</script> </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 '../design-system/stories.scss';
import { defineSetupVue3 } from '@histoire/plugin-vue';
import dashboardI18n from 'dashboard/i18n';
import widgetI18n from 'widget/i18n';
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import { createRouter, createMemoryHistory } from 'vue-router';
import { vResizeObserver } from '@vueuse/components'; import { vResizeObserver } from '@vueuse/components';
import store from 'dashboard/store';
import FloatingVue from 'floating-vue'; import FloatingVue from 'floating-vue';
import VueDOMPurifyHTML from 'vue-dompurify-html'; import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
import { directive as onClickaway } from 'vue3-click-away'; 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) { function mergeMessages(...sources) {
return sources.reduce((acc, src) => { 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(store);
app.use(i18n); app.use(i18n);
app.use(router);
app.use(FloatingVue, { app.use(FloatingVue, {
instantMove: true, instantMove: true,
arrowOverflow: false, arrowOverflow: false,
@@ -48,4 +70,4 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
app.directive('resize', vResizeObserver); app.directive('resize', vResizeObserver);
app.use(VueDOMPurifyHTML, domPurifyConfig); app.use(VueDOMPurifyHTML, domPurifyConfig);
app.directive('on-clickaway', onClickaway); 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", "build:sdk": "vite build --config vite.lib.config.ts",
"prepare": "husky install", "prepare": "husky install",
"size": "size-limit", "size": "size-limit",
"story:dev": "histoire dev", "story:dev": "vite --config vite.stories.config.ts",
"story:build": "histoire build", "story:build": "vite build --config vite.stories.config.ts",
"story:preview": "histoire preview", "story:preview": "vite preview --config vite.stories.config.ts",
"sync:i18n": "bin/sync_i18n_file_change" "sync:i18n": "bin/sync_i18n_file_change"
}, },
"size-limit": [ "size-limit": [
@@ -114,7 +114,6 @@
}, },
"devDependencies": { "devDependencies": {
"@egoist/tailwindcss-icons": "^1.9.2", "@egoist/tailwindcss-icons": "^1.9.2",
"@histoire/plugin-vue": "0.17.15",
"@iconify-json/logos": "^1.2.10", "@iconify-json/logos": "^1.2.10",
"@iconify-json/lucide": "^1.2.82", "@iconify-json/lucide": "^1.2.82",
"@iconify-json/ph": "^1.2.2", "@iconify-json/ph": "^1.2.2",
@@ -136,7 +135,6 @@
"eslint-plugin-vitest-globals": "^1.5.0", "eslint-plugin-vitest-globals": "^1.5.0",
"eslint-plugin-vue": "^9.28.0", "eslint-plugin-vue": "^9.28.0",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
"histoire": "0.17.15",
"husky": "^7.0.0", "husky": "^7.0.0",
"jsdom": "^27.2.0", "jsdom": "^27.2.0",
"lint-staged": "^16.2.7", "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/composables/**/*.js',
'./app/javascript/dashboard/components-next/**/*.js', './app/javascript/dashboard/components-next/**/*.js',
'./app/javascript/dashboard/routes/dashboard/**/**/*.js', './app/javascript/dashboard/routes/dashboard/**/**/*.js',
'./app/javascript/stories/**/*.{js,vue}',
'./app/views/**/*.erb', './app/views/**/*.erb',
], ],
theme: { 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,
},
});