diff --git a/apps/storybook/.storybook/components/ElementInspector/ElementInspector.tsx b/apps/storybook/.storybook/components/ElementInspector/ElementInspector.tsx deleted file mode 100644 index 29ca311c03d5d6..00000000000000 --- a/apps/storybook/.storybook/components/ElementInspector/ElementInspector.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect } from 'react'; -import { extractElementData } from './utils'; - -/** - * ElementInspector - Runs inside the Storybook iframe. - * - * Responsibilities: - * 1. Respond to parent mouse position messages with element data at those coordinates - * 2. Support the overlay-based element selection pattern - */ -export function ElementInspector() { - useEffect(() => { - // Only run if we're in an iframe - if (window.self === window.top) return; - - // Listen for mouse position from parent overlay - const handleMessage = (event: MessageEvent) => { - if (event.data?.type === 'parent-mouse-position') { - const { x, y } = event.data; - - const element = document.elementFromPoint(x, y); - - if (element) { - const elementData = extractElementData(element as HTMLElement); - - window.parent.postMessage( - { type: 'iframe-element-at-position', element: elementData }, - '*', - ); - } else { - window.parent.postMessage( - { type: 'iframe-element-at-position', element: null }, - '*', - ); - } - } - }; - - window.addEventListener('message', handleMessage); - - return () => { - window.removeEventListener('message', handleMessage); - }; - }, []); - - return null; -} diff --git a/apps/storybook/.storybook/components/ElementInspector/index.ts b/apps/storybook/.storybook/components/ElementInspector/index.ts deleted file mode 100644 index 688bd26f3032c3..00000000000000 --- a/apps/storybook/.storybook/components/ElementInspector/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ElementInspector } from './ElementInspector'; diff --git a/apps/storybook/.storybook/components/ElementInspector/utils/index.ts b/apps/storybook/.storybook/components/ElementInspector/utils/index.ts deleted file mode 100644 index 04bca77e0dec46..00000000000000 --- a/apps/storybook/.storybook/components/ElementInspector/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './utils'; diff --git a/apps/storybook/.storybook/components/ElementInspector/utils/utils.ts b/apps/storybook/.storybook/components/ElementInspector/utils/utils.ts deleted file mode 100644 index 1d74322e97fca7..00000000000000 --- a/apps/storybook/.storybook/components/ElementInspector/utils/utils.ts +++ /dev/null @@ -1,259 +0,0 @@ -import type { ElementData } from '../../../types'; - -type Fiber = { - type?: - | string - | { name?: string; displayName?: string } - | ((...args: unknown[]) => unknown); - return?: Fiber; -}; - -// Props to filter out (noise, not semantic) -const FILTERED_PROPS = new Set([ - 'className', - 'class', - 'style', - 'ref', - 'children', - 'key', - 'dangerouslySetInnerHTML', -]); - -// Max length for string props before filtering -const MAX_PROP_LENGTH = 50; - -// Get React props from DOM element via __reactProps$xxx -export function getReactProps(element: HTMLElement): Record | null { - const key = Object.keys(element).find((k) => k.startsWith('__reactProps$')); - return key - ? ((element as unknown as Record>)[key] ?? null) - : null; -} - -// Get React fiber from DOM element via __reactFiber$xxx -export function getReactFiber(element: HTMLElement): Fiber | null { - const key = Object.keys(element).find((k) => k.startsWith('__reactFiber$')); - return key ? ((element as unknown as Record)[key] ?? null) : null; -} - -// Extended fiber type with memoizedProps -type FiberWithProps = Fiber & { - memoizedProps?: Record; -}; - -// Get component name from fiber -function getComponentNameFromFiber(fiber: Fiber | null): string | null { - if (!fiber) return null; - const type = fiber.type; - if (typeof type === 'function' && 'name' in type && type.name) { - return type.name; - } - if (typeof type === 'object' && type !== null) { - if ('displayName' in type && type.displayName) { - return type.displayName; - } - if ('name' in type && type.name) { - return type.name; - } - } - return null; -} - -// Get component props from fiber tree (finds first named component's props) -function getComponentPropsFromFiber( - element: HTMLElement, -): Record | null { - let fiber: FiberWithProps | null | undefined = getReactFiber(element) as FiberWithProps; - while (fiber) { - const name = getComponentNameFromFiber(fiber); - if (name && fiber.memoizedProps) { - return fiber.memoizedProps; - } - fiber = fiber.return as FiberWithProps | undefined; - } - return null; -} - -// Walk up fiber tree to find component name -export function getComponentName(element: HTMLElement): string | null { - let fiber: Fiber | null | undefined = getReactFiber(element); - while (fiber) { - const name = getComponentNameFromFiber(fiber); - if (name) return name; - fiber = fiber.return; - } - return null; -} - -// Walk up fiber tree to find parent component name (skip the first one) -export function getParentComponentName(element: HTMLElement): string | null { - let fiber: Fiber | null | undefined = getReactFiber(element); - let foundFirst = false; - - while (fiber) { - const name = getComponentNameFromFiber(fiber); - if (name) { - if (foundFirst) { - return name; - } - foundFirst = true; - } - fiber = fiber.return; - } - return null; -} - -// Check if prop should be filtered -function shouldFilterProp(key: string, value: unknown): boolean { - // Skip internal props - if (key.startsWith('__')) return true; - - // Skip data-* attributes - if (key.startsWith('data-')) return true; - - // Skip known noise props - if (FILTERED_PROPS.has(key)) return true; - - // Skip long strings (likely className or similar) - if (typeof value === 'string' && value.length > MAX_PROP_LENGTH) return true; - - return false; -} - -// Serialize props with filtering and placeholders -export function serializeProps( - props: Record, -): Record | null { - const result: Record = {}; - - for (const [key, value] of Object.entries(props)) { - if (shouldFilterProp(key, value)) continue; - if (value === undefined) continue; - - if (typeof value === 'function') { - result[key] = '[Function]'; - } else if (value instanceof HTMLElement) { - result[key] = '[Element]'; - } else if (typeof value === 'object' && value !== null) { - result[key] = '[Object]'; - } else if (typeof value === 'boolean') { - result[key] = value ? 'true' : 'false'; - } else { - result[key] = String(value); - } - } - - return Object.keys(result).length > 0 ? result : null; -} - -// Get key computed styles -export function getStyles(element: HTMLElement): Record { - const computed = window.getComputedStyle(element); - return { - color: computed.color, - backgroundColor: computed.backgroundColor, - fontSize: computed.fontSize, - fontFamily: computed.fontFamily, - display: computed.display, - position: computed.position, - }; -} - -// Generate CSS selector path from element to root (for deduplication) -export function generateSelectorPath(element: HTMLElement): string { - const path: string[] = []; - let current: HTMLElement | null = element; - - while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) { - let selector = current.tagName.toLowerCase(); - - // Stop at ID (unique identifier) - if (current.id) { - selector = `#${current.id}`; - path.unshift(selector); - break; - } - - // Add nth-child for uniqueness - const parent = current.parentElement; - if (parent) { - const siblings = Array.from(parent.children); - const index = siblings.indexOf(current) + 1; - selector += `:nth-child(${index})`; - } - - path.unshift(selector); - current = current.parentElement; - } - - return path.join(' > '); -} - -// Parse source location from data attributes -function parseSource( - element: HTMLElement | null, -): ElementData extends null ? never : NonNullable['source'] { - if (!element) return null; - - const file = element.getAttribute('data-component-file'); - const start = element.getAttribute('data-component-start'); - const end = element.getAttribute('data-component-end'); - - if (!file || !start || !end) return null; - - // Parse "52:4" format - const [startLine, startColumn] = start.split(':').map(Number); - const [endLine, endColumn] = end.split(':').map(Number); - - return { - file, - startLine, - startColumn, - endLine, - endColumn, - }; -} - -// Extract all element data in one call -export function extractElementData(target: HTMLElement): NonNullable { - const componentElement = target.closest('[data-component-file]') as HTMLElement | null; - const elementToInspect = componentElement || target; - - // Get component props from fiber tree (memoizedProps on the component fiber) - const componentProps = getComponentPropsFromFiber(elementToInspect); - const rect = elementToInspect.getBoundingClientRect(); - - return { - // React Component - component: getComponentName(elementToInspect), - - // Filtered semantic props from component's memoizedProps - props: componentProps ? serializeProps(componentProps) : null, - - // Source location - source: parseSource(componentElement), - - // DOM element - element: { - tag: elementToInspect.tagName.toLowerCase(), - id: elementToInspect.id || undefined, - }, - - // Parent component for context - parentComponent: getParentComponentName(elementToInspect), - - // Selector path for deduplication - selectorPath: generateSelectorPath(elementToInspect), - - // Computed styles - computedStyles: getStyles(elementToInspect), - - // Bounding rect - rect: { - x: rect.left, - y: rect.top, - width: rect.width, - height: rect.height, - }, - }; -} diff --git a/apps/storybook/.storybook/components/IframeKeyboardRelay/IframeKeyboardRelay.tsx b/apps/storybook/.storybook/components/IframeKeyboardRelay/IframeKeyboardRelay.tsx deleted file mode 100644 index df2c653480dcfa..00000000000000 --- a/apps/storybook/.storybook/components/IframeKeyboardRelay/IframeKeyboardRelay.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useEffect } from 'react'; - -/** - * Relays keyboard events from iframe to parent window. - * This allows the parent to handle shortcuts even when iframe has focus. - */ -export function IframeKeyboardRelay() { - useEffect(() => { - // Only run if we're in an iframe - if (window.self === window.top) return; - - const handleKeyDown = (e: KeyboardEvent) => { - const isMod = e.metaKey || e.ctrlKey; - - // Only relay modifier key combinations to avoid breaking normal typing - if (!isMod) return; - - // Relay the keyboard event to parent - e.preventDefault(); - e.stopPropagation(); - - window.parent.postMessage( - { - type: 'iframe-keydown', - key: e.key, - code: e.code, - metaKey: e.metaKey, - ctrlKey: e.ctrlKey, - shiftKey: e.shiftKey, - altKey: e.altKey, - }, - '*', - ); - }; - - document.addEventListener('keydown', handleKeyDown, { capture: true }); - - return () => { - document.removeEventListener('keydown', handleKeyDown, { capture: true }); - }; - }, []); - - return null; -} diff --git a/apps/storybook/.storybook/components/IframeKeyboardRelay/index.ts b/apps/storybook/.storybook/components/IframeKeyboardRelay/index.ts deleted file mode 100644 index 2bbb635782c26b..00000000000000 --- a/apps/storybook/.storybook/components/IframeKeyboardRelay/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IframeKeyboardRelay } from './IframeKeyboardRelay'; diff --git a/apps/storybook/.storybook/components/IframeReloadListener/IframeReloadListener.tsx b/apps/storybook/.storybook/components/IframeReloadListener/IframeReloadListener.tsx deleted file mode 100644 index 8c0a14abeffbba..00000000000000 --- a/apps/storybook/.storybook/components/IframeReloadListener/IframeReloadListener.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect } from 'react'; - -/** - * Listens for reload messages from the parent window and reloads the iframe. - * Used when Storybook is embedded in an iframe to allow manual refresh. - */ -export function IframeReloadListener() { - useEffect(() => { - const handleReloadMessage = (e: MessageEvent) => { - console.log('[Storybook] Received message:', e.data, 'from origin:', e.origin); - if (e.data.type === 'reload') { - console.log('[Storybook] Reloading iframe...'); - window.location.reload(); - } - }; - - window.addEventListener('message', handleReloadMessage); - console.log('[Storybook] Reload message listener registered'); - - return () => { - window.removeEventListener('message', handleReloadMessage); - }; - }, []); - - return null; // This component only adds event listeners -} diff --git a/apps/storybook/.storybook/components/IframeReloadListener/index.ts b/apps/storybook/.storybook/components/IframeReloadListener/index.ts deleted file mode 100644 index 124b5d1e9e4403..00000000000000 --- a/apps/storybook/.storybook/components/IframeReloadListener/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IframeReloadListener } from './IframeReloadListener'; diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 8b0e7c2020fd29..eeee3008f650a1 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -1,10 +1,10 @@ +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; import type { StorybookConfig } from '@storybook/nextjs-vite'; -import { storybookOnlookPlugin } from './storybook-onlook-plugin/index'; -import componentLocPlugin from './vite-plugin-component-loc'; +import { storybookOnlookPlugin } from '@onlook/storybook-plugin'; -// Disable custom plugins for Chromatic/CI static builds -// eslint-disable-next-line turbo/no-undeclared-env-vars -const isStaticBuild = Boolean(process.env.CHROMATIC || process.env.CI); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); const config: StorybookConfig = { stories: [ @@ -12,6 +12,10 @@ const config: StorybookConfig = { '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)', // Include stories from packages/ui (components directory only, excludes node_modules) '../../../packages/ui/components/**/*.stories.@(js|jsx|mjs|ts|tsx)', + // Include stories from packages/features + '../../../packages/features/**/*.stories.@(js|jsx|mjs|ts|tsx)', + // Include stories from apps/web/components + '../../../apps/web/components/**/*.stories.@(js|jsx|mjs|ts|tsx)', ], addons: [ '@chromatic-com/storybook', @@ -27,27 +31,26 @@ const config: StorybookConfig = { async viteFinal(config) { const { mergeConfig } = await import('vite'); - const merged = mergeConfig(config, { - plugins: isStaticBuild ? [] : [storybookOnlookPlugin], - server: isStaticBuild - ? {} - : { - hmr: { - // E2B sandboxes use HTTPS, so we need secure WebSocket - protocol: 'wss', - // E2B routes through standard HTTPS port 443 - clientPort: 443, - // The actual Storybook server port inside the sandbox - port: 6006, - }, - cors: true, // Allow cross-origin requests for iframe embedding - }, - }); + // Path aliases for apps/web components + const webAppPath = resolve(__dirname, '../../../apps/web'); - // componentLocPlugin must run BEFORE Storybook's plugins so it can inject - // data-component-loc attributes into JSX before other transforms run - merged.plugins = [componentLocPlugin(), ...(merged.plugins ?? [])]; - return merged; + // storybookOnlookPlugin handles: + // - Component location injection (data-component-file, etc.) + // - HMR configuration for E2B sandboxes + // - CORS configuration + // - Static build detection (returns [] for CI/Chromatic) + return mergeConfig(config, { + plugins: [storybookOnlookPlugin()], + resolve: { + alias: { + '@components': join(webAppPath, 'components'), + '@lib': join(webAppPath, 'lib'), + '@server': join(webAppPath, 'server'), + '@pages': join(webAppPath, 'pages'), + '~': join(webAppPath, 'modules'), + }, + }, + }); }, }; export default config; diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx index e4c37e9b618fc8..0bd5aeca9175bd 100644 --- a/apps/storybook/.storybook/preview.tsx +++ b/apps/storybook/.storybook/preview.tsx @@ -2,15 +2,8 @@ import type { Preview } from '@storybook/nextjs-vite'; import '../src/styles/globals.css'; import { withThemeByClassName } from '@storybook/addon-themes'; import { Provider as TooltipProvider } from '@radix-ui/react-tooltip'; -import { ElementInspector } from './components/ElementInspector'; -import { IframeKeyboardRelay } from './components/IframeKeyboardRelay'; -import { IframeReloadListener } from './components/IframeReloadListener'; import SVG from 'react-inlinesvg'; -// Disable iframe communication components for static builds (Chromatic/CI) -// eslint-disable-next-line turbo/no-undeclared-env-vars -const isStaticBuild = Boolean(process.env.CHROMATIC || process.env.CI); - // Load cal.com icon sprites function IconSprites() { return ; @@ -28,9 +21,6 @@ const preview: Preview = { (Story) => ( - {!isStaticBuild && } - {!isStaticBuild && } - {!isStaticBuild && }
diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/handlers/handleStoryFileChange/handleStoryFileChange.ts b/apps/storybook/.storybook/storybook-onlook-plugin/handlers/handleStoryFileChange/handleStoryFileChange.ts deleted file mode 100644 index 9b5a788e98febc..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/handlers/handleStoryFileChange/handleStoryFileChange.ts +++ /dev/null @@ -1,127 +0,0 @@ -import path from 'node:path'; -import type { HmrContext } from 'vite'; -import { generateScreenshot } from '../../screenshot-service/index'; -import { computeFileHash } from '../../utils/fileSystem/index'; -import { updateManifest } from '../../utils/manifest/index'; - -// Cache for Storybook's index.json -let cachedIndex: { - entries: Record; -} | null = null; -let indexFetchPromise: Promise | null = null; - -// Debounce state -const pendingFiles = new Set(); -let debounceTimer: ReturnType | null = null; -const DEBOUNCE_MS = 500; - -async function fetchStorybookIndex(): Promise { - try { - const response = await fetch('http://localhost:6006/index.json'); - if (response.ok) { - cachedIndex = await response.json(); - console.log('[Screenshots] Cached Storybook index'); - } - } catch (error) { - console.error('[Screenshots] Failed to fetch Storybook index:', error); - } -} - -function getStoriesForFile(filePath: string): string[] { - if (!cachedIndex) return []; - - // Normalize the file path to match Storybook's importPath format - const fileName = path.basename(filePath); - - return Object.values(cachedIndex.entries) - .filter((entry) => entry.type === 'story' && entry.importPath.endsWith(fileName)) - .map((entry) => entry.id); -} - -async function regenerateScreenshotsForFiles(files: string[]): Promise { - // Refresh index before regenerating (in case new stories were added) - await fetchStorybookIndex(); - - const allStoryIds = new Set(); - const fileToStories = new Map(); - - for (const file of files) { - const storyIds = getStoriesForFile(file); - fileToStories.set(file, storyIds); - for (const id of storyIds) { - allStoryIds.add(id); - } - } - - if (allStoryIds.size === 0) { - console.log('[Screenshots] No stories found for changed files'); - return; - } - - console.log( - `[Screenshots] Regenerating ${allStoryIds.size} stories from ${files.length} files`, - ); - - const storybookUrl = 'http://localhost:6006'; - - // Regenerate screenshots for each story (both themes) and collect bounding boxes - const storyBoundingBoxes = new Map(); - - await Promise.all( - Array.from(allStoryIds).map(async (storyId) => { - const [lightResult, _darkResult] = await Promise.all([ - generateScreenshot(storyId, 'light', storybookUrl), - generateScreenshot(storyId, 'dark', storybookUrl), - ]); - // Use bounding box from light theme - if (lightResult) { - storyBoundingBoxes.set(storyId, lightResult.boundingBox); - } - }), - ); - - // Update manifest with new hashes and bounding boxes - for (const [file, storyIds] of fileToStories) { - const fileHash = computeFileHash(file); - storyIds.forEach((storyId) => { - updateManifest(storyId, file, fileHash, storyBoundingBoxes.get(storyId)); - }); - } - - console.log(`[Screenshots] ✓ Regenerated ${allStoryIds.size} stories`); -} - -export function handleStoryFileChange({ file, modules }: HmrContext) { - // Detect story file changes - if (file.endsWith('.stories.tsx') || file.endsWith('.stories.ts')) { - console.log(`[Screenshots] Story file changed: ${file}`); - - // Initialize index cache on first change - if (!cachedIndex && !indexFetchPromise) { - indexFetchPromise = fetchStorybookIndex(); - } - - // Add to pending files - pendingFiles.add(file); - - // Debounce regeneration - if (debounceTimer) { - clearTimeout(debounceTimer); - } - - debounceTimer = setTimeout(async () => { - const files = Array.from(pendingFiles); - pendingFiles.clear(); - debounceTimer = null; - - try { - await regenerateScreenshotsForFiles(files); - } catch (error) { - console.error('[Screenshots] Error regenerating screenshots:', error); - } - }, DEBOUNCE_MS); - - // Return modules to trigger HMR update in Storybook - return modules; - } -} diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/handlers/handleStoryFileChange/index.ts b/apps/storybook/.storybook/storybook-onlook-plugin/handlers/handleStoryFileChange/index.ts deleted file mode 100644 index 3799506b2e54ce..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/handlers/handleStoryFileChange/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { handleStoryFileChange } from './handleStoryFileChange'; diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/index.ts b/apps/storybook/.storybook/storybook-onlook-plugin/index.ts deleted file mode 100644 index a4f199a73a0d56..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { storybookOnlookPlugin } from './storybook-onlook-plugin'; diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/constants.ts b/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/constants.ts deleted file mode 100644 index 9b0b0e582ae58e..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -import path from 'node:path'; - -export const CACHE_DIR = path.join(process.cwd(), '.storybook-cache'); -export const SCREENSHOTS_DIR = path.join(CACHE_DIR, 'screenshots'); -export const MANIFEST_PATH = path.join(CACHE_DIR, 'manifest.json'); - -// Full HD viewport so page-level mocks render correctly -// Screenshot clips to actual component size, so this doesn't affect file size -export const VIEWPORT_WIDTH = 1920; -export const VIEWPORT_HEIGHT = 1080; - -// Minimum dimensions for components on canvas -export const MIN_COMPONENT_WIDTH = 420; -export const MIN_COMPONENT_HEIGHT = 280; diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/index.ts b/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/index.ts deleted file mode 100644 index b8c32ff78bb57d..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { generateAllScreenshots } from './screenshot-service'; -export { generateScreenshot, captureScreenshotBuffer } from './utils/screenshot/index'; diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/screenshot-service.ts b/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/screenshot-service.ts deleted file mode 100644 index c03d9aeb857979..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/screenshot-service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { computeFileHash } from '../utils/fileSystem/index'; -import { updateManifest } from '../utils/manifest/index'; -import { closeBrowser } from './utils/browser/index'; -import { generateScreenshot } from './utils/screenshot/index'; - -/** - * Generate screenshots for all stories (parallelized for speed) - */ -export async function generateAllScreenshots( - stories: Array<{ id: string; importPath: string }>, - storybookUrl: string = 'http://localhost:6006', -): Promise { - console.log(`Generating screenshots for ${stories.length} stories...`); - - // Process stories in batches for better performance - // Higher than CPU count since work is I/O-bound (waiting for page render) - const BATCH_SIZE = 10; - const batches: Array> = []; - - for (let i = 0; i < stories.length; i += BATCH_SIZE) { - batches.push(stories.slice(i, i + BATCH_SIZE)); - } - - let completed = 0; - for (const batch of batches) { - await Promise.all( - batch.map(async (story) => { - // Generate both light and dark in parallel for each story - const [lightResult, darkResult] = await Promise.all([ - generateScreenshot(story.id, 'light', storybookUrl), - generateScreenshot(story.id, 'dark', storybookUrl), - ]); - - if (lightResult && darkResult) { - const fileHash = computeFileHash(story.importPath); - // Use bounding box from light theme (should be same for both) - updateManifest(story.id, story.importPath, fileHash, lightResult.boundingBox); - } - - completed++; - console.log( - `[${completed}/${stories.length}] Generated screenshots for ${story.id}`, - ); - }), - ); - } - - await closeBrowser(); - console.log('Screenshot generation complete!'); -} diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/types.ts b/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/types.ts deleted file mode 100644 index aeb0f123430c22..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface BoundingBox { - width: number; - height: number; -} - -export interface ScreenshotMetadata { - fileHash: string; - lastGenerated: string; - sourcePath: string; - screenshots: { - light: string; - dark: string; - }; - boundingBox?: BoundingBox; -} - -export interface Manifest { - stories: Record; -} diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/browser/browser.ts b/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/browser/browser.ts deleted file mode 100644 index f190c10a36faf2..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/browser/browser.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { type Browser, chromium } from 'playwright'; - -let browser: Browser | null = null; - -/** - * Initialize browser instance - */ -export async function getBrowser(): Promise { - if (!browser) { - browser = await chromium.launch({ - headless: true, - }); - } - return browser; -} - -/** - * Close browser instance - */ -export async function closeBrowser() { - if (browser) { - try { - await browser.close(); - } catch (error) { - console.error('Error closing browser:', error); - } finally { - browser = null; - } - } -} diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/browser/index.ts b/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/browser/index.ts deleted file mode 100644 index a11c96c366e6b5..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/browser/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { getBrowser, closeBrowser } from './browser'; diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/screenshot/index.ts b/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/screenshot/index.ts deleted file mode 100644 index 52bf20ae390c1b..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/screenshot/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - getScreenshotPath, - screenshotExists, - captureScreenshotBuffer, - generateScreenshot, - type ScreenshotResult, - type GenerateScreenshotResult, -} from './screenshot'; diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/screenshot/screenshot.ts b/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/screenshot/screenshot.ts deleted file mode 100644 index 83815d52f11c47..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/screenshot-service/utils/screenshot/screenshot.ts +++ /dev/null @@ -1,179 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { ensureCacheDirectories } from '../../../utils/fileSystem/index'; -import { - MIN_COMPONENT_HEIGHT, - MIN_COMPONENT_WIDTH, - SCREENSHOTS_DIR, - VIEWPORT_HEIGHT, - VIEWPORT_WIDTH, -} from '../../constants'; -import type { BoundingBox } from '../../types'; -import { getBrowser } from '../browser/index'; - -export interface ScreenshotResult { - buffer: Buffer; - boundingBox: BoundingBox | null; -} - -export interface GenerateScreenshotResult { - path: string; - boundingBox: BoundingBox | null; -} - -/** - * Get screenshot file path - */ -export function getScreenshotPath(storyId: string, theme: 'light' | 'dark'): string { - const storyDir = path.join(SCREENSHOTS_DIR, storyId); - return path.join(storyDir, `${theme}.png`); -} - -/** - * Check if screenshot exists - */ -export function screenshotExists(storyId: string, theme: 'light' | 'dark'): boolean { - const screenshotPath = getScreenshotPath(storyId, theme); - return fs.existsSync(screenshotPath); -} - -/** - * Capture a screenshot and return it as a Buffer with bounding box info - */ -export async function captureScreenshotBuffer( - storyId: string, - theme: 'light' | 'dark', - width: number = VIEWPORT_WIDTH, - height: number = VIEWPORT_HEIGHT, - storybookUrl: string = 'http://localhost:6006', -): Promise { - const browser = await getBrowser(); - const context = await browser.newContext({ - viewport: { width, height }, - deviceScaleFactor: 2, - }); - const page = await context.newPage(); - - try { - // Navigate to story iframe URL - const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`; - await page.goto(url, { timeout: 15000 }); - - // Wait for page to be fully ready (matching @storybook/test-runner's waitForPageReady) - await page.waitForLoadState('domcontentloaded'); - await page.waitForLoadState('load'); - await page.waitForLoadState('networkidle'); - await page.evaluate(() => document.fonts.ready); - - // Wait for all images to be fully loaded and decoded - await page.evaluate(async () => { - const images = document.querySelectorAll('img'); - await Promise.all( - Array.from(images).map((img) => { - if (img.complete) return Promise.resolve(); - return new Promise((resolve) => { - img.addEventListener('load', resolve); - img.addEventListener('error', resolve); - }); - }), - ); - }); - - // Calculate the bounding box of all content inside #storybook-root - const contentBounds = await page.evaluate(() => { - const root = document.querySelector('#storybook-root'); - if (!root) return null; - - // Get the bounding rect of all children combined - const children = root.querySelectorAll('*'); - if (children.length === 0) return null; - - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - - children.forEach((child) => { - const rect = child.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) return; - minX = Math.min(minX, rect.left); - minY = Math.min(minY, rect.top); - maxX = Math.max(maxX, rect.right); - maxY = Math.max(maxY, rect.bottom); - }); - - if (minX === Infinity) return null; - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - }; - }); - - let screenshotBuffer: Buffer; - let resultBoundingBox: BoundingBox | null = null; - - if (contentBounds && contentBounds.width > 0 && contentBounds.height > 0) { - const PADDING = 20; // 10px each side - const clippedWidth = Math.min(width, contentBounds.width + PADDING); - const clippedHeight = Math.min(height, contentBounds.height + PADDING); - - // Store dimensions that match the actual screenshot (including padding) - resultBoundingBox = { - width: Math.max(MIN_COMPONENT_WIDTH, Math.round(clippedWidth)), - height: Math.max(MIN_COMPONENT_HEIGHT, Math.round(clippedHeight)), - }; - - screenshotBuffer = await page.screenshot({ - type: 'png', - clip: { - x: Math.max(0, contentBounds.x - 10), - y: Math.max(0, contentBounds.y - 10), - width: clippedWidth, - height: clippedHeight, - }, - }); - } else { - screenshotBuffer = await page.screenshot({ type: 'png' }); - } - - return { buffer: screenshotBuffer, boundingBox: resultBoundingBox }; - } finally { - await context.close(); - } -} - -/** - * Generate a screenshot for a story and save to disk - */ -export async function generateScreenshot( - storyId: string, - theme: 'light' | 'dark', - storybookUrl: string = 'http://localhost:6006', -): Promise { - try { - ensureCacheDirectories(); - - const storyDir = path.join(SCREENSHOTS_DIR, storyId); - if (!fs.existsSync(storyDir)) { - fs.mkdirSync(storyDir, { recursive: true }); - } - - const screenshotPath = getScreenshotPath(storyId, theme); - const { buffer, boundingBox } = await captureScreenshotBuffer( - storyId, - theme, - VIEWPORT_WIDTH, - VIEWPORT_HEIGHT, - storybookUrl, - ); - fs.writeFileSync(screenshotPath, buffer); - - return { path: screenshotPath, boundingBox }; - } catch (error) { - console.error(`Error generating screenshot for ${storyId} (${theme}):`, error); - return null; - } -} diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/storybook-onlook-plugin.ts b/apps/storybook/.storybook/storybook-onlook-plugin/storybook-onlook-plugin.ts deleted file mode 100644 index 08037f423ff4e5..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/storybook-onlook-plugin.ts +++ /dev/null @@ -1,140 +0,0 @@ -import fs from 'node:fs'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import path, { dirname, join, relative } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import type { Plugin, ViteDevServer } from 'vite'; -import { handleStoryFileChange } from './handlers/handleStoryFileChange/index'; -import { captureScreenshotBuffer } from './screenshot-service/utils/screenshot/index'; -import { findGitRoot } from './utils/findGitRoot/index'; - -// Calculate storybook location relative to git root -const __dirname = dirname(fileURLToPath(import.meta.url)); -const storybookDir = join(__dirname, '..'); -const gitRoot = findGitRoot(storybookDir); -const storybookLocation = gitRoot ? relative(gitRoot, storybookDir) : ''; -const repoRoot = gitRoot || process.cwd(); - -const serveMetadataAndScreenshots = ( - req: IncomingMessage, - res: ServerResponse, - next: () => void, -) => { - // Consolidated endpoint: Storybook index + metadata + boundingBox - if (req.url === '/onbook-index.json') { - const manifestPath = path.join(process.cwd(), '.storybook-cache', 'manifest.json'); - - // Fetch Storybook's index.json and enrich it - fetch('http://localhost:6006/index.json') - .then((response) => response.json()) - .then((indexData: Record) => { - // Load manifest for bounding box data - const manifest = fs.existsSync(manifestPath) - ? JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) - : { stories: {} }; - - // Default viewport size as fallback - const defaultBoundingBox = { width: 1920, height: 1080 }; - - // Add boundingBox to each entry - const entries = (indexData.entries || {}) as Record>; - for (const [storyId, entry] of Object.entries(entries)) { - const manifestEntry = manifest.stories?.[storyId]; - entry.boundingBox = manifestEntry?.boundingBox || defaultBoundingBox; - } - - // Add metadata - indexData.meta = { storybookLocation, repoRoot }; - - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.end(JSON.stringify(indexData)); - }) - .catch((error) => { - console.error('Failed to fetch/extend index.json:', error); - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end( - JSON.stringify({ error: 'Failed to fetch index', details: String(error) }), - ); - }); - return; - } - - // On-demand screenshot capture API - if (req.url?.startsWith('/api/capture-screenshot')) { - const url = new URL(req.url, 'http://localhost'); - const storyId = url.searchParams.get('storyId'); - const theme = (url.searchParams.get('theme') || 'light') as 'light' | 'dark'; - const width = parseInt(url.searchParams.get('width') || '800', 10); - const height = parseInt(url.searchParams.get('height') || '600', 10); - - if (!storyId) { - res.statusCode = 400; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: 'storyId is required' })); - return; - } - - // Validate theme - if (theme !== 'light' && theme !== 'dark') { - res.statusCode = 400; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: 'theme must be "light" or "dark"' })); - return; - } - - // Capture screenshot asynchronously - captureScreenshotBuffer(storyId, theme, width, height) - .then(({ buffer }) => { - res.setHeader('Content-Type', 'image/png'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'no-cache'); - res.end(buffer); - }) - .catch((error) => { - console.error('Screenshot capture error:', error); - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end( - JSON.stringify({ - error: 'Failed to capture screenshot', - details: String(error), - }), - ); - }); - return; - } - - // Serve screenshots from cache - if (req.url?.startsWith('/screenshots/')) { - const screenshotPath = path.join( - process.cwd(), - '.storybook-cache', - req.url.replace('/screenshots/', 'screenshots/'), - ); - - if (fs.existsSync(screenshotPath)) { - res.setHeader('Content-Type', 'image/png'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'public, max-age=3600'); - fs.createReadStream(screenshotPath).pipe(res); - } else { - res.statusCode = 404; - res.end('Screenshot not found'); - } - return; - } - - next(); -}; - -export const storybookOnlookPlugin: Plugin = { - name: 'storybook-onlook-plugin', - configureServer(server: ViteDevServer) { - server.middlewares.use(serveMetadataAndScreenshots); - }, - configurePreviewServer(server) { - server.middlewares.use(serveMetadataAndScreenshots); - }, - handleHotUpdate: handleStoryFileChange, -}; diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/utils/fileSystem/fileSystem.ts b/apps/storybook/.storybook/storybook-onlook-plugin/utils/fileSystem/fileSystem.ts deleted file mode 100644 index 8f9cd2e5177e62..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/utils/fileSystem/fileSystem.ts +++ /dev/null @@ -1,26 +0,0 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import { CACHE_DIR, SCREENSHOTS_DIR } from '../../screenshot-service/constants'; - -/** - * Ensure cache directories exist - */ -export function ensureCacheDirectories() { - if (!fs.existsSync(CACHE_DIR)) { - fs.mkdirSync(CACHE_DIR, { recursive: true }); - } - if (!fs.existsSync(SCREENSHOTS_DIR)) { - fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); - } -} - -/** - * Compute file hash - */ -export function computeFileHash(filePath: string): string { - if (!fs.existsSync(filePath)) { - return ''; - } - const content = fs.readFileSync(filePath, 'utf-8'); - return crypto.createHash('sha256').update(content).digest('hex'); -} diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/utils/fileSystem/index.ts b/apps/storybook/.storybook/storybook-onlook-plugin/utils/fileSystem/index.ts deleted file mode 100644 index 1cbf7ecf3f8806..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/utils/fileSystem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ensureCacheDirectories, computeFileHash } from './fileSystem'; diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/utils/findGitRoot/findGitRoot.ts b/apps/storybook/.storybook/storybook-onlook-plugin/utils/findGitRoot/findGitRoot.ts deleted file mode 100644 index eeb0d91d01b193..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/utils/findGitRoot/findGitRoot.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; - -/** - * Find the git repository root by walking up the directory tree - */ -export function findGitRoot(startPath: string): string | null { - let currentPath = startPath; - - while (currentPath !== dirname(currentPath)) { - if (existsSync(join(currentPath, '.git'))) { - return currentPath; - } - currentPath = dirname(currentPath); - } - - return null; -} diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/utils/findGitRoot/index.ts b/apps/storybook/.storybook/storybook-onlook-plugin/utils/findGitRoot/index.ts deleted file mode 100644 index f1b7d1a5a536b7..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/utils/findGitRoot/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { findGitRoot } from './findGitRoot'; diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/utils/manifest/index.ts b/apps/storybook/.storybook/storybook-onlook-plugin/utils/manifest/index.ts deleted file mode 100644 index 1aaf4bfd584f41..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/utils/manifest/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { loadManifest, saveManifest, getManifestEntry, updateManifest } from './manifest'; diff --git a/apps/storybook/.storybook/storybook-onlook-plugin/utils/manifest/manifest.ts b/apps/storybook/.storybook/storybook-onlook-plugin/utils/manifest/manifest.ts deleted file mode 100644 index b6dd369e2ec319..00000000000000 --- a/apps/storybook/.storybook/storybook-onlook-plugin/utils/manifest/manifest.ts +++ /dev/null @@ -1,56 +0,0 @@ -import fs from 'node:fs'; -import { MANIFEST_PATH } from '../../screenshot-service/constants'; -import type { Manifest, ScreenshotMetadata } from '../../screenshot-service/types'; -import { ensureCacheDirectories } from '../fileSystem/index'; - -/** - * Load manifest from disk - */ -export function loadManifest(): Manifest { - if (fs.existsSync(MANIFEST_PATH)) { - const content = fs.readFileSync(MANIFEST_PATH, 'utf-8'); - return JSON.parse(content); - } - return { stories: {} }; -} - -/** - * Save manifest to disk - */ -export function saveManifest(manifest: Manifest) { - ensureCacheDirectories(); - fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2)); -} - -/** - * Get manifest entry for a story - */ -export function getManifestEntry(storyId: string): ScreenshotMetadata | null { - const manifest = loadManifest(); - return manifest.stories[storyId] || null; -} - -/** - * Update manifest for a story - */ -export function updateManifest( - storyId: string, - sourcePath: string, - fileHash: string, - boundingBox?: { width: number; height: number } | null, -) { - const manifest = loadManifest(); - - manifest.stories[storyId] = { - fileHash, - lastGenerated: new Date().toISOString(), - sourcePath, - screenshots: { - light: `screenshots/${storyId}/light.png`, - dark: `screenshots/${storyId}/dark.png`, - }, - boundingBox: boundingBox ?? undefined, - }; - - saveManifest(manifest); -} diff --git a/apps/storybook/.storybook/types.ts b/apps/storybook/.storybook/types.ts deleted file mode 100644 index 3a4e02bc97310b..00000000000000 --- a/apps/storybook/.storybook/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Shared types for Storybook iframe communication - -export type ElementData = { - // React Component - component: string | null; - - // Semantic props (filtered - no className, data-*, style, ref) - props: Record | null; - - // Source location (definition) - source: { - file: string; - startLine: number; - startColumn: number; - endLine: number; - endColumn: number; - } | null; - - // DOM element - element: { - tag: string; - id?: string; - }; - - // Parent React component (for context) - parentComponent: string | null; - - // CSS selector path (for deduplication) - selectorPath: string; - - // Computed styles - computedStyles: Record; - - // Bounding rect - rect: { - x: number; - y: number; - width: number; - height: number; - }; - - // Story context (added by parent when storing) - story?: { - nodeId: string; - storyId: string; - name: string; - file?: string; - theme: 'light' | 'dark'; - }; -} | null; - -// Message types for iframe-parent communication -export type IframeMessage = - | { type: 'iframe-element-at-position'; element: ElementData } - | { type: 'parent-mouse-position'; x: number; y: number } - | { type: 'reload' }; diff --git a/apps/storybook/.storybook/vite-plugin-component-loc.ts b/apps/storybook/.storybook/vite-plugin-component-loc.ts deleted file mode 100644 index 5a976decbf7ba0..00000000000000 --- a/apps/storybook/.storybook/vite-plugin-component-loc.ts +++ /dev/null @@ -1,94 +0,0 @@ -import path from 'node:path'; -import generateModule from '@babel/generator'; -import { parse } from '@babel/parser'; -import traverseModule from '@babel/traverse'; -import * as t from '@babel/types'; -import type { Plugin } from 'vite'; - -type ComponentLocPluginOptions = { - include?: RegExp; -}; - -export function componentLocPlugin(options: ComponentLocPluginOptions = {}): Plugin { - const include = options.include ?? /\.(jsx|tsx)$/; - - // @babel/traverse and @babel/generator are CommonJS modules with default exports. - // When imported in an ESM context, the default export may be on `.default` or directly on the module. - // This workaround handles both cases to ensure compatibility across different bundler configurations. - const traverse = - (traverseModule as unknown as typeof import('@babel/traverse')).default ?? - (traverseModule as unknown as typeof import('@babel/traverse').default); - const generate = - (generateModule as unknown as typeof import('@babel/generator')).default ?? - (generateModule as unknown as typeof import('@babel/generator').default); - - let root: string; - - return { - name: 'calcom-component-loc', - enforce: 'pre', - apply: 'serve', - configResolved(config) { - root = config.root; - }, - transform(code, id) { - const [filepath] = id.split('?', 1); - if (filepath.includes('node_modules')) return null; - if (!include.test(filepath)) return null; - - const ast = parse(code, { - sourceType: 'module', - plugins: ['jsx', 'typescript'], - sourceFilename: filepath, - }); - - let mutated = false; - - // Get relative path from project root - const relativePath = path.relative(root, filepath); - - traverse(ast, { - JSXElement(nodePath) { - const opening = nodePath.node.openingElement; - const element = nodePath.node; - if (!opening.loc || !element.loc) return; - - const alreadyTagged = opening.attributes.some( - (attribute) => - t.isJSXAttribute(attribute) && - attribute.name.name === 'data-component-file', - ); - if (alreadyTagged) return; - - opening.attributes.push( - t.jsxAttribute( - t.jsxIdentifier('data-component-file'), - t.stringLiteral(relativePath), - ), - t.jsxAttribute( - t.jsxIdentifier('data-component-start'), - t.stringLiteral(`${element.loc.start.line}:${element.loc.start.column}`), - ), - t.jsxAttribute( - t.jsxIdentifier('data-component-end'), - t.stringLiteral(`${element.loc.end.line}:${element.loc.end.column}`), - ), - ); - - mutated = true; - }, - }); - - if (!mutated) return null; - - const output = generate( - ast, - { retainLines: true, sourceMaps: true, sourceFileName: id }, - code, - ); - return { code: output.code, map: output.map }; - }, - }; -} - -export default componentLocPlugin; diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 6dc435a4209eb1..a2f93fdd8ba136 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -3,8 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "storybook dev -p 6006", - "storybook": "storybook dev -p 6006", + "dev": "storybook dev", + "storybook": "storybook dev", "build": "storybook build", "build-storybook": "storybook build" }, @@ -16,12 +16,9 @@ "react-inlinesvg": "^4.1.3" }, "devDependencies": { - "@babel/generator": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/traverse": "^7.26.9", - "@babel/types": "^7.26.9", "@calcom/tsconfig": "workspace:*", "@chromatic-com/storybook": "^4.1.2", + "@onlook/storybook-plugin": "^0.1.0", "@storybook/addon-a11y": "^10.0.5", "@storybook/addon-docs": "^10.0.5", "@storybook/addon-themes": "^10.0.5", @@ -30,7 +27,6 @@ "@types/node": "^20.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", - "playwright": "^1.52.0", "storybook": "^10.0.5", "tailwindcss": "4.1.17", "typescript": "^5.9.0", diff --git a/apps/web/components/AddToHomescreen.stories.tsx b/apps/web/components/AddToHomescreen.stories.tsx new file mode 100644 index 00000000000000..9a058a93af4cf6 --- /dev/null +++ b/apps/web/components/AddToHomescreen.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import AddToHomescreen from "./AddToHomescreen"; + +const meta: Meta = { + title: "Components/AddToHomescreen", + component: AddToHomescreen, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/web/components/apps/AppPage.stories.tsx b/apps/web/components/apps/AppPage.stories.tsx new file mode 100644 index 00000000000000..508ff41291e10c --- /dev/null +++ b/apps/web/components/apps/AppPage.stories.tsx @@ -0,0 +1,330 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { AppPage } from "./AppPage"; +import type { AppPageProps } from "./AppPage"; + +const meta = { + title: "Components/Apps/AppPage", + component: AppPage, + tags: ["autodocs"], + parameters: { + layout: "centered", + nextjs: { + appDirectory: true, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const baseProps: AppPageProps = { + name: "Sample App", + description: "A comprehensive application for managing your calendar and bookings", + type: "sample_app", + logo: "https://via.placeholder.com/64", + slug: "sample-app", + variant: "calendar", + body: ( +
+

+ This is a sample application that demonstrates the capabilities of our app ecosystem. It + integrates seamlessly with your calendar and provides advanced scheduling features. +

+

Features

+
    +
  • Automatic calendar synchronization
  • +
  • Smart scheduling suggestions
  • +
  • Team collaboration tools
  • +
  • Custom notifications
  • +
+
+ ), + categories: ["calendar"], + author: "Cal.com Team", + email: "support@cal.com", + licenseRequired: false, + teamsPlanRequired: false, + concurrentMeetings: false, +}; + +export const Default: Story = { + args: baseProps, +}; + +export const WithDescriptionItems: Story = { + args: { + ...baseProps, + name: "Video Conferencing App", + type: "video_conferencing", + categories: ["conferencing"], + descriptionItems: [ + "https://via.placeholder.com/800x600/4A90E2/ffffff?text=Feature+Screenshot+1", + "https://via.placeholder.com/800x600/50C878/ffffff?text=Feature+Screenshot+2", + "https://via.placeholder.com/800x600/FF6B6B/ffffff?text=Feature+Screenshot+3", + ], + }, +}; + +export const WithIframeDescription: Story = { + args: { + ...baseProps, + name: "Interactive Demo App", + descriptionItems: [ + "https://via.placeholder.com/800x600/4A90E2/ffffff?text=Screenshot", + { + iframe: { + src: "https://www.youtube.com/embed/dQw4w9WgXcQ", + title: "App Demo Video", + width: "100%", + height: "315", + }, + }, + ], + }, +}; + +export const PaidApp: Story = { + args: { + ...baseProps, + name: "Premium Calendar Integration", + price: 9.99, + feeType: "monthly", + paid: { + priceInUsd: 9.99, + currency: "usd", + }, + }, +}; + +export const UsageBasedPricing: Story = { + args: { + ...baseProps, + name: "Usage-Based App", + price: 2.99, + commission: 10, + feeType: "usage-based", + }, +}; + +export const GlobalApp: Story = { + args: { + ...baseProps, + name: "Global System App", + isGlobal: true, + categories: ["other"], + }, +}; + +export const ProApp: Story = { + args: { + ...baseProps, + name: "Professional Suite", + pro: true, + price: 19.99, + feeType: "monthly", + }, +}; + +export const TeamsPlanRequired: Story = { + args: { + ...baseProps, + name: "Enterprise Team Tool", + teamsPlanRequired: true, + categories: ["automation"], + }, +}; + +export const WithAllContactInfo: Story = { + args: { + ...baseProps, + name: "Fully Documented App", + docs: "https://docs.example.com/app", + website: "https://example.com", + email: "support@example.com", + tos: "https://example.com/terms", + privacy: "https://example.com/privacy", + }, +}; + +export const TemplateApp: Story = { + args: { + ...baseProps, + name: "Template Application", + isTemplate: true, + categories: ["other"], + }, +}; + +export const WithDependencies: Story = { + args: { + ...baseProps, + name: "Dependent App", + dependencies: ["google-calendar", "google-meet"], + categories: ["calendar", "conferencing"], + }, +}; + +export const ConferencingApp: Story = { + args: { + ...baseProps, + name: "Zoom Integration", + type: "zoom_video", + categories: ["conferencing", "video"], + author: "Zoom", + website: "https://zoom.us", + descriptionItems: [ + "https://via.placeholder.com/800x600/2D8CFF/ffffff?text=Zoom+Integration", + ], + }, +}; + +export const ConcurrentMeetingsApp: Story = { + args: { + ...baseProps, + name: "Round Robin Scheduler", + categories: ["conferencing"], + concurrentMeetings: true, + body: ( +
+

+ Enable concurrent meetings with round-robin scheduling. Perfect for teams that need to + distribute meeting load across multiple team members. +

+
+ ), + }, +}; + +export const PaymentApp: Story = { + args: { + ...baseProps, + name: "Stripe Payment Gateway", + type: "stripe_payment", + categories: ["payment"], + author: "Stripe", + price: 0, + website: "https://stripe.com", + docs: "https://stripe.com/docs", + body: ( +
+

Accept payments for your bookings with Stripe integration.

+

Supported Features

+
    +
  • One-time payments
  • +
  • Subscription billing
  • +
  • Refunds and disputes
  • +
  • Multiple currencies
  • +
+
+ ), + }, +}; + +export const AnalyticsApp: Story = { + args: { + ...baseProps, + name: "Analytics Dashboard", + type: "analytics_app", + categories: ["analytics"], + author: "Cal.com", + descriptionItems: [ + "https://via.placeholder.com/800x600/6C5CE7/ffffff?text=Analytics+Dashboard", + "https://via.placeholder.com/800x600/A29BFE/ffffff?text=Reports+View", + "https://via.placeholder.com/800x600/FD79A8/ffffff?text=Insights", + ], + body: ( +
+

+ Get detailed insights into your booking performance with comprehensive analytics and + reporting. +

+

Key Metrics

+
    +
  • Booking conversion rates
  • +
  • Revenue tracking
  • +
  • User engagement
  • +
  • Popular time slots
  • +
+
+ ), + }, +}; + +export const WebhookApp: Story = { + args: { + ...baseProps, + name: "Webhook Integration", + type: "webhook_app", + categories: ["automation", "other"], + author: "Cal.com", + body: ( +
+

+ Connect your workflows with webhook integrations. Send booking data to external systems + in real-time. +

+

Webhook Events

+
    +
  • Booking created
  • +
  • Booking rescheduled
  • +
  • Booking cancelled
  • +
  • Booking completed
  • +
+
+ ), + }, +}; + +export const CRMApp: Story = { + args: { + ...baseProps, + name: "Salesforce CRM", + type: "salesforce_crm", + categories: ["crm"], + author: "Salesforce", + website: "https://salesforce.com", + docs: "https://developer.salesforce.com", + price: 15.99, + feeType: "monthly", + descriptionItems: [ + "https://via.placeholder.com/800x600/00A1E0/ffffff?text=CRM+Integration", + ], + body: ( +
+

+ Seamlessly integrate your bookings with Salesforce CRM. Automatically create leads, + contacts, and opportunities from your calendar events. +

+
+ ), + }, +}; + +export const MessagingApp: Story = { + args: { + ...baseProps, + name: "Slack Notifications", + type: "slack_messaging", + categories: ["messaging"], + author: "Slack", + website: "https://slack.com", + descriptionItems: [ + "https://via.placeholder.com/800x600/4A154B/ffffff?text=Slack+Integration", + ], + body: ( +
+

+ Get instant notifications in Slack when bookings are created, rescheduled, or cancelled. + Keep your team informed in real-time. +

+
+ ), + }, +}; diff --git a/apps/web/components/apps/MultiDisconnectIntegration.stories.tsx b/apps/web/components/apps/MultiDisconnectIntegration.stories.tsx new file mode 100644 index 00000000000000..0f6d0c008dd9ad --- /dev/null +++ b/apps/web/components/apps/MultiDisconnectIntegration.stories.tsx @@ -0,0 +1,250 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import { MultiDisconnectIntegration } from "./MultiDisconnectIntegration"; + +const meta = { + title: "Apps/MultiDisconnectIntegration", + component: MultiDisconnectIntegration, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + argTypes: { + credentials: { + description: "Array of credential objects to display in the disconnect dropdown", + control: "object", + }, + onSuccess: { + description: "Callback function called when disconnection is successful", + control: false, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock credentials data +const mockPersonalCredentials = [ + { + id: 1, + type: "google_calendar", + key: {}, + userId: 101, + teamId: null, + appId: "google-calendar", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + user: { + id: 101, + name: "John Doe", + email: "john.doe@example.com", + }, + team: null, + invalid: false, + }, + { + id: 2, + type: "google_calendar", + key: {}, + userId: 102, + teamId: null, + appId: "google-calendar", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + user: { + id: 102, + name: "Jane Smith", + email: "jane.smith@example.com", + }, + team: null, + invalid: false, + }, +]; + +const mockTeamCredentials = [ + { + id: 3, + type: "google_calendar", + key: {}, + userId: null, + teamId: 201, + appId: "google-calendar", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + user: null, + team: { + id: 201, + name: "Engineering Team", + }, + invalid: false, + }, + { + id: 4, + type: "google_calendar", + key: {}, + userId: null, + teamId: 202, + appId: "google-calendar", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + user: null, + team: { + id: 202, + name: "Marketing Team", + }, + invalid: false, + }, +]; + +const mockMixedCredentials = [ + ...mockPersonalCredentials.slice(0, 1), + ...mockTeamCredentials.slice(0, 1), +]; + +const mockCredentialWithoutName = [ + { + id: 5, + type: "google_calendar", + key: {}, + userId: 103, + teamId: null, + appId: "google-calendar", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + user: { + id: 103, + email: "nousername@example.com", + }, + team: null, + invalid: false, + }, +]; + +const mockCredentialWithOnlyEmail = [ + { + id: 6, + type: "google_calendar", + key: {}, + userId: 104, + teamId: null, + appId: "google-calendar", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + user: { + id: 104, + name: "", + email: "emailonly@example.com", + }, + team: null, + invalid: false, + }, +]; + +export const Default: Story = { + args: { + credentials: mockPersonalCredentials, + onSuccess: fn(), + }, +}; + +export const PersonalCredentials: Story = { + args: { + credentials: mockPersonalCredentials, + onSuccess: fn(), + }, +}; + +export const TeamCredentials: Story = { + args: { + credentials: mockTeamCredentials, + onSuccess: fn(), + }, +}; + +export const MixedCredentials: Story = { + args: { + credentials: mockMixedCredentials, + onSuccess: fn(), + }, +}; + +export const SingleCredential: Story = { + args: { + credentials: mockPersonalCredentials.slice(0, 1), + onSuccess: fn(), + }, +}; + +export const WithoutUserName: Story = { + args: { + credentials: mockCredentialWithoutName, + onSuccess: fn(), + }, +}; + +export const WithOnlyEmail: Story = { + args: { + credentials: mockCredentialWithOnlyEmail, + onSuccess: fn(), + }, +}; + +export const EmptyCredentials: Story = { + args: { + credentials: [], + onSuccess: fn(), + }, +}; + +export const ManyCredentials: Story = { + args: { + credentials: [ + ...mockPersonalCredentials, + ...mockTeamCredentials, + { + id: 7, + type: "google_calendar", + key: {}, + userId: 105, + teamId: null, + appId: "google-calendar", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + user: { + id: 105, + name: "Alice Johnson", + email: "alice.j@example.com", + }, + team: null, + invalid: false, + }, + { + id: 8, + type: "google_calendar", + key: {}, + userId: null, + teamId: 203, + appId: "google-calendar", + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + user: null, + team: { + id: 203, + name: "Sales Team", + }, + invalid: false, + }, + ], + onSuccess: fn(), + }, +}; diff --git a/apps/web/components/apps/alby/Setup.stories.tsx b/apps/web/components/apps/alby/Setup.stories.tsx new file mode 100644 index 00000000000000..fb40f68688a060 --- /dev/null +++ b/apps/web/components/apps/alby/Setup.stories.tsx @@ -0,0 +1,138 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import AlbySetup from "./Setup"; + +const meta = { + title: "Apps/Alby/Setup", + component: AlbySetup, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + clientId: "mock-client-id", + clientSecret: "mock-client-secret", + lightningAddress: "", + email: "", + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/alby/setup", + query: {}, + }, + }, + }, +}; + +export const NotConnected: Story = { + args: { + clientId: "mock-client-id", + clientSecret: "mock-client-secret", + lightningAddress: "", + email: "", + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/alby/setup", + query: {}, + }, + }, + }, +}; + +export const Connected: Story = { + args: { + clientId: "mock-client-id", + clientSecret: "mock-client-secret", + lightningAddress: "satoshi@getalby.com", + email: "satoshi@example.com", + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/alby/setup", + query: {}, + }, + }, + }, +}; + +export const CallbackMode: Story = { + args: { + clientId: "mock-client-id", + clientSecret: "mock-client-secret", + lightningAddress: "", + email: "", + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/alby/setup", + query: { + callback: "true", + }, + }, + }, + }, +}; + +export const CallbackModeWithCode: Story = { + args: { + clientId: "mock-client-id", + clientSecret: "mock-client-secret", + lightningAddress: "", + email: "", + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/alby/setup", + query: { + callback: "true", + code: "mock-authorization-code", + }, + }, + }, + }, +}; + +export const CallbackModeWithError: Story = { + args: { + clientId: "mock-client-id", + clientSecret: "mock-client-secret", + lightningAddress: "", + email: "", + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/alby/setup", + query: { + callback: "true", + error: "access_denied", + }, + }, + }, + }, +}; diff --git a/apps/web/components/apps/applecalendar/Setup.stories.tsx b/apps/web/components/apps/applecalendar/Setup.stories.tsx new file mode 100644 index 00000000000000..887d8939421809 --- /dev/null +++ b/apps/web/components/apps/applecalendar/Setup.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import AppleCalendarSetup from "./Setup"; + +const meta = { + title: "Apps/AppleCalendar/Setup", + component: AppleCalendarSetup, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithMockApiError: Story = { + parameters: { + nextjs: { + appDirectory: true, + navigation: { + push: (url: string) => console.log("Navigate to:", url), + }, + }, + mockData: [ + { + url: "/api/integrations/applecalendar/add", + method: "POST", + status: 400, + response: { + message: "invalid_credentials", + }, + }, + ], + }, +}; + +export const WithMockApiSuccess: Story = { + parameters: { + nextjs: { + appDirectory: true, + navigation: { + push: (url: string) => console.log("Navigate to:", url), + }, + }, + mockData: [ + { + url: "/api/integrations/applecalendar/add", + method: "POST", + status: 200, + response: { + url: "/apps/installed/calendar", + }, + }, + ], + }, +}; + +export const DarkMode: Story = { + parameters: { + backgrounds: { + default: "dark", + }, + theme: "dark", + }, +}; diff --git a/apps/web/components/apps/btcpayserver/Setup.stories.tsx b/apps/web/components/apps/btcpayserver/Setup.stories.tsx new file mode 100644 index 00000000000000..516d060671869b --- /dev/null +++ b/apps/web/components/apps/btcpayserver/Setup.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { SessionProvider } from "next-auth/react"; + +import BTCPaySetup from "./Setup"; + +const meta = { + component: BTCPaySetup, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const WithExistingCredentials: Story = { + args: { + serverUrl: "https://btcpay.example.com", + storeId: "ABC123XYZ456", + apiKey: "secret_api_key_example_1234567890", + webhookSecret: "webhook_secret_example_abcdef", + }, +}; + +export const NewCredential: Story = { + args: { + serverUrl: "", + storeId: "", + apiKey: "", + webhookSecret: "", + }, +}; + +export const PartialCredentials: Story = { + args: { + serverUrl: "https://btcpay.example.com", + storeId: "ABC123XYZ456", + apiKey: "", + webhookSecret: "", + }, +}; diff --git a/apps/web/components/apps/caldavcalendar/Setup.stories.tsx b/apps/web/components/apps/caldavcalendar/Setup.stories.tsx new file mode 100644 index 00000000000000..c435beeff83ac8 --- /dev/null +++ b/apps/web/components/apps/caldavcalendar/Setup.stories.tsx @@ -0,0 +1,206 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { expect, userEvent, within } from "storybook/test"; + +import Setup from "./Setup"; + +const meta = { + title: "Apps/CalDAV Calendar/Setup", + component: Setup, + parameters: { + layout: "centered", + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/caldavcalendar/setup", + }, + }, + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default state of the CalDAV Calendar setup form. + * Shows empty input fields ready for user configuration. + */ +export const Default: Story = {}; + +/** + * Form with partially filled data. + * Demonstrates the form in the middle of user input. + */ +export const PartiallyFilled: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Fill in URL field + const urlInput = canvas.getByLabelText(/calendar_url/i); + await userEvent.type(urlInput, "https://caldav.example.com/calendar"); + + // Fill in username field + const usernameInput = canvas.getByLabelText(/username/i); + await userEvent.type(usernameInput, "john.doe"); + }, +}; + +/** + * Form with all fields filled. + * Shows the complete state before submission. + */ +export const FilledForm: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Fill in all fields + const urlInput = canvas.getByLabelText(/calendar_url/i); + await userEvent.type(urlInput, "https://caldav.example.com/calendar"); + + const usernameInput = canvas.getByLabelText(/username/i); + await userEvent.type(usernameInput, "john.doe"); + + const passwordInput = canvas.getByLabelText(/password/i); + await userEvent.type(passwordInput, "secretpassword123"); + }, +}; + +/** + * Form in submitting state. + * Shows the loading state when the form is being submitted. + */ +export const Submitting: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Fill in all fields + const urlInput = canvas.getByLabelText(/calendar_url/i); + await userEvent.type(urlInput, "https://caldav.example.com/calendar"); + + const usernameInput = canvas.getByLabelText(/username/i); + await userEvent.type(usernameInput, "john.doe"); + + const passwordInput = canvas.getByLabelText(/password/i); + await userEvent.type(passwordInput, "secretpassword123"); + + // Click submit button + const submitButton = canvas.getByRole("button", { name: /save/i }); + await userEvent.click(submitButton); + }, + parameters: { + msw: { + handlers: [ + // Mock API call to simulate slow response + { + url: "/api/integrations/caldavcalendar/add", + method: "POST", + status: 200, + delay: 3000, + response: { + url: "/apps/installed/calendar", + }, + }, + ], + }, + }, +}; + +/** + * Form with validation error. + * Demonstrates required field validation by attempting to submit empty form. + */ +export const ValidationError: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Try to submit without filling fields + const submitButton = canvas.getByRole("button", { name: /save/i }); + await userEvent.click(submitButton); + + // Browser's HTML5 validation should prevent submission + }, +}; + +/** + * Form with server error response. + * Shows the error alert when the API returns an error. + */ +export const WithError: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Fill in all fields + const urlInput = canvas.getByLabelText(/calendar_url/i); + await userEvent.type(urlInput, "https://invalid-caldav.example.com/calendar"); + + const usernameInput = canvas.getByLabelText(/username/i); + await userEvent.type(usernameInput, "wronguser"); + + const passwordInput = canvas.getByLabelText(/password/i); + await userEvent.type(passwordInput, "wrongpassword"); + + // Click submit button + const submitButton = canvas.getByRole("button", { name: /save/i }); + await userEvent.click(submitButton); + + // Wait for error message to appear + await canvas.findByText(/authentication failed/i); + }, + parameters: { + msw: { + handlers: [ + { + url: "/api/integrations/caldavcalendar/add", + method: "POST", + status: 401, + response: { + message: "Authentication failed. Please check your credentials.", + }, + }, + ], + }, + }, +}; + +/** + * Form with error and action button. + * Shows error alert with an additional action button to go to admin panel. + */ +export const WithErrorAndAction: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Fill in all fields + const urlInput = canvas.getByLabelText(/calendar_url/i); + await userEvent.type(urlInput, "https://caldav.example.com/calendar"); + + const usernameInput = canvas.getByLabelText(/username/i); + await userEvent.type(usernameInput, "user"); + + const passwordInput = canvas.getByLabelText(/password/i); + await userEvent.type(passwordInput, "password"); + + // Click submit button + const submitButton = canvas.getByRole("button", { name: /save/i }); + await userEvent.click(submitButton); + + // Wait for error message and action button + await canvas.findByText(/calendar not found/i); + await canvas.findByRole("button", { name: /go to admin/i }); + }, + parameters: { + msw: { + handlers: [ + { + url: "/api/integrations/caldavcalendar/add", + method: "POST", + status: 404, + response: { + message: "Calendar not found. Please check your configuration in admin panel.", + actionUrl: "/settings/admin/apps/calendar", + }, + }, + ], + }, + }, +}; diff --git a/apps/web/components/apps/exchange2013calendar/Setup.stories.tsx b/apps/web/components/apps/exchange2013calendar/Setup.stories.tsx new file mode 100644 index 00000000000000..97c3ba5fbd8202 --- /dev/null +++ b/apps/web/components/apps/exchange2013calendar/Setup.stories.tsx @@ -0,0 +1,115 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import Exchange2013CalendarSetup from "./Setup"; + +const meta = { + title: "Components/Apps/Exchange2013Calendar/Setup", + component: Exchange2013CalendarSetup, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default state of the Exchange 2013 Calendar setup form. + * Shows the initial empty form with username, password, and URL fields. + */ +export const Default: Story = {}; + +/** + * Setup form with a pre-filled EWS URL. + * Useful for testing when the EXCHANGE_DEFAULT_EWS_URL environment variable is set. + */ +export const WithDefaultUrl: Story = { + parameters: { + mockData: [ + { + url: "/api/integrations/exchange2013calendar/add", + method: "POST", + status: 200, + response: { + url: "/apps/installed", + }, + }, + ], + }, +}; + +/** + * Setup form in submitting state. + * Shows the loading state when the form is being submitted. + */ +export const Submitting: Story = { + play: async ({ canvasElement }) => { + // This would show the loading state when submit is clicked + // In actual implementation, you'd need to interact with the form + }, +}; + +/** + * Setup form with an error message displayed. + * Shows how validation or API errors are displayed to the user. + */ +export const WithError: Story = { + parameters: { + mockData: [ + { + url: "/api/integrations/exchange2013calendar/add", + method: "POST", + status: 400, + response: { + message: "Invalid credentials. Please check your username and password.", + }, + }, + ], + }, +}; + +/** + * Setup form with network error. + * Simulates a scenario where the API request fails. + */ +export const WithNetworkError: Story = { + parameters: { + mockData: [ + { + url: "/api/integrations/exchange2013calendar/add", + method: "POST", + status: 500, + response: { + message: "Unable to connect to Exchange server. Please try again later.", + }, + }, + ], + }, +}; + +/** + * Setup form with authentication error. + * Shows the error state when authentication fails. + */ +export const WithAuthenticationError: Story = { + parameters: { + mockData: [ + { + url: "/api/integrations/exchange2013calendar/add", + method: "POST", + status: 401, + response: { + message: "Authentication failed. Invalid username or password.", + }, + }, + ], + }, +}; diff --git a/apps/web/components/apps/exchange2016calendar/Setup.stories.tsx b/apps/web/components/apps/exchange2016calendar/Setup.stories.tsx new file mode 100644 index 00000000000000..e6646756b01f8f --- /dev/null +++ b/apps/web/components/apps/exchange2016calendar/Setup.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import Exchange2016CalendarSetup from "./Setup"; + +const meta = { + title: "Components/Apps/Exchange2016Calendar/Setup", + component: Exchange2016CalendarSetup, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithDefaultURL: Story = { + parameters: { + nextjs: { + appDirectory: true, + }, + }, + play: async ({ canvasElement }) => { + // This story demonstrates the component with a default EWS URL pre-filled + // In actual usage, this would be populated from process.env.EXCHANGE_DEFAULT_EWS_URL + }, +}; + +export const MobileView: Story = { + parameters: { + viewport: { + defaultViewport: "mobile1", + }, + }, +}; + +export const TabletView: Story = { + parameters: { + viewport: { + defaultViewport: "tablet", + }, + }, +}; diff --git a/apps/web/components/apps/exchangecalendar/Setup.stories.tsx b/apps/web/components/apps/exchangecalendar/Setup.stories.tsx new file mode 100644 index 00000000000000..9957a08b756f7f --- /dev/null +++ b/apps/web/components/apps/exchangecalendar/Setup.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import ExchangeSetup from "./Setup"; + +const meta = { + title: "Apps/ExchangeCalendar/Setup", + component: ExchangeSetup, + tags: ["autodocs"], + parameters: { + layout: "centered", + nextjs: { + appDirectory: true, + navigation: { + push: fn(), + back: fn(), + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default state of the Exchange Calendar setup form. + * Shows the complete setup flow with all fields required to configure + * Microsoft Exchange calendar integration. + */ +export const Default: Story = {}; + +/** + * Setup form in a loading/submitting state. + * This demonstrates the form's behavior when the user + * has submitted their credentials and is waiting for validation. + */ +export const Submitting: Story = { + play: async ({ canvasElement }) => { + // Note: In a real implementation, you would interact with the form + // to trigger the submitting state. This is a visual representation. + }, +}; + +/** + * Setup form with an error message displayed. + * Shows how validation or connection errors are presented to users. + */ +export const WithError: Story = { + play: async ({ canvasElement }) => { + // Note: Error state would be triggered by form submission + // This story demonstrates the error alert styling + }, +}; + +/** + * Setup form with NTLM authentication selected (default). + * NTLM authentication is the default method and doesn't + * require the Exchange version selector. + */ +export const NTLMAuthentication: Story = {}; + +/** + * Setup form with Standard authentication selected. + * When using Standard authentication, users must also + * select their Exchange server version. + */ +export const StandardAuthentication: Story = { + play: async ({ canvasElement }) => { + // Note: In a real test, you would select the Standard + // authentication option from the dropdown + }, +}; diff --git a/apps/web/components/apps/hitpay/Setup.stories.tsx b/apps/web/components/apps/hitpay/Setup.stories.tsx new file mode 100644 index 00000000000000..8fb37d70c30582 --- /dev/null +++ b/apps/web/components/apps/hitpay/Setup.stories.tsx @@ -0,0 +1,166 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import HitPaySetup from "./Setup"; + +const meta = { + title: "Apps/HitPay/Setup", + component: HitPaySetup, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isSandbox: false, + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/hitpay/setup", + query: {}, + }, + }, + }, +}; + +export const SandboxMode: Story = { + args: { + isSandbox: true, + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/hitpay/setup", + query: {}, + }, + }, + }, +}; + +export const WithProductionKeys: Story = { + args: { + isSandbox: false, + prod: { + apiKey: "prod_api_key_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + saltKey: "prod_salt_key_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + }, + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/hitpay/setup", + query: {}, + }, + }, + }, +}; + +export const WithSandboxKeys: Story = { + args: { + isSandbox: true, + sandbox: { + apiKey: "sandbox_api_key_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + saltKey: "sandbox_salt_key_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + }, + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/hitpay/setup", + query: {}, + }, + }, + }, +}; + +export const WithBothKeys: Story = { + args: { + isSandbox: false, + prod: { + apiKey: "prod_api_key_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + saltKey: "prod_salt_key_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + }, + sandbox: { + apiKey: "sandbox_api_key_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + saltKey: "sandbox_salt_key_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + }, + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/hitpay/setup", + query: {}, + }, + }, + }, +}; + +export const CallbackMode: Story = { + args: { + isSandbox: false, + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/hitpay/setup", + query: { + callback: "true", + }, + }, + }, + }, +}; + +export const CallbackModeWithCode: Story = { + args: { + isSandbox: false, + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/hitpay/setup", + query: { + callback: "true", + code: "mock-authorization-code", + }, + }, + }, + }, +}; + +export const CallbackModeWithError: Story = { + args: { + isSandbox: false, + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/hitpay/setup", + query: { + callback: "true", + error: "access_denied", + }, + }, + }, + }, +}; diff --git a/apps/web/components/apps/ics-feedcalendar/Setup.stories.tsx b/apps/web/components/apps/ics-feedcalendar/Setup.stories.tsx new file mode 100644 index 00000000000000..8036d23d696d4f --- /dev/null +++ b/apps/web/components/apps/ics-feedcalendar/Setup.stories.tsx @@ -0,0 +1,254 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; + +import ICSFeedSetup from "./Setup"; + +const meta = { + title: "Apps/ICS Feed Calendar/Setup", + component: ICSFeedSetup, + tags: ["autodocs"], + parameters: { + layout: "centered", + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/ics-feedcalendar/setup", + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default view of the ICS Feed Calendar setup form. + * Shows a single URL input field with options to add more feeds. + */ +export const Default: Story = {}; + +/** + * Interactive story demonstrating the ability to add multiple ICS feed URLs. + * Users can click the "Add" button to add additional URL input fields. + */ +export const WithMultipleURLs: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for the component to render + await waitFor(() => { + expect(canvas.getByPlaceholderText("https://example.com/calendar.ics")).toBeInTheDocument(); + }); + + // Find and click the add button to add more URL fields + const addButton = canvas.getByRole("button", { name: /add/i }); + await userEvent.click(addButton); + + // Wait for the second input to appear + await waitFor(() => { + const inputs = canvas.getAllByPlaceholderText("https://example.com/calendar.ics"); + expect(inputs).toHaveLength(2); + }); + + // Fill in the first URL + const inputs = canvas.getAllByPlaceholderText("https://example.com/calendar.ics"); + await userEvent.type(inputs[0], "https://calendar1.example.com/feed.ics"); + await userEvent.type(inputs[1], "https://calendar2.example.com/feed.ics"); + }, +}; + +/** + * Story demonstrating the error state when form submission fails. + * Shows how error messages are displayed to the user. + */ +export const WithErrorMessage: Story = { + parameters: { + msw: { + handlers: [ + { + method: "POST", + url: "/api/integrations/ics-feedcalendar/add", + status: 400, + response: { + message: "Invalid calendar URL format", + }, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for the component to render + await waitFor(() => { + expect(canvas.getByPlaceholderText("https://example.com/calendar.ics")).toBeInTheDocument(); + }); + + // Fill in an invalid URL + const input = canvas.getByPlaceholderText("https://example.com/calendar.ics"); + await userEvent.type(input, "invalid-url"); + + // Submit the form + const saveButton = canvas.getByRole("button", { name: /save/i }); + await userEvent.click(saveButton); + }, +}; + +/** + * Story demonstrating error state with an admin action URL. + * Shows the error alert with a "Go to Admin" button. + */ +export const WithErrorAndActionUrl: Story = { + parameters: { + msw: { + handlers: [ + { + method: "POST", + url: "/api/integrations/ics-feedcalendar/add", + status: 403, + response: { + message: "Insufficient permissions to add calendar feeds", + actionUrl: "/settings/admin", + }, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for the component to render + await waitFor(() => { + expect(canvas.getByPlaceholderText("https://example.com/calendar.ics")).toBeInTheDocument(); + }); + + // Fill in a URL + const input = canvas.getByPlaceholderText("https://example.com/calendar.ics"); + await userEvent.type(input, "https://calendar.example.com/feed.ics"); + + // Submit the form + const saveButton = canvas.getByRole("button", { name: /save/i }); + await userEvent.click(saveButton); + }, +}; + +/** + * Story demonstrating successful form submission. + * After submission, the user should be redirected (mocked in Storybook). + */ +export const SuccessfulSubmission: Story = { + parameters: { + msw: { + handlers: [ + { + method: "POST", + url: "/api/integrations/ics-feedcalendar/add", + status: 200, + response: { + url: "/apps/installed/ics-feedcalendar", + }, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for the component to render + await waitFor(() => { + expect(canvas.getByPlaceholderText("https://example.com/calendar.ics")).toBeInTheDocument(); + }); + + // Fill in a valid URL + const input = canvas.getByPlaceholderText("https://example.com/calendar.ics"); + await userEvent.type(input, "https://calendar.example.com/feed.ics"); + + // Submit the form + const saveButton = canvas.getByRole("button", { name: /save/i }); + await userEvent.click(saveButton); + + // Button should show loading state + await waitFor(() => { + expect(saveButton).toHaveAttribute("data-loading", "true"); + }); + }, +}; + +/** + * Story demonstrating the ability to remove added URL fields. + * The first URL field cannot be removed, but additional ones can be. + */ +export const RemovingURLFields: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for the component to render + await waitFor(() => { + expect(canvas.getByPlaceholderText("https://example.com/calendar.ics")).toBeInTheDocument(); + }); + + // Add a second URL field + const addButton = canvas.getByRole("button", { name: /add/i }); + await userEvent.click(addButton); + + // Wait for the second input to appear + await waitFor(() => { + const inputs = canvas.getAllByPlaceholderText("https://example.com/calendar.ics"); + expect(inputs).toHaveLength(2); + }); + + // Add a third URL field + await userEvent.click(addButton); + + await waitFor(() => { + const inputs = canvas.getAllByPlaceholderText("https://example.com/calendar.ics"); + expect(inputs).toHaveLength(3); + }); + + // Find and click a delete button (trash icon) + const deleteButtons = canvas.getAllByRole("button").filter((btn) => { + const icon = btn.querySelector('[data-icon="trash"]'); + return icon !== null; + }); + + if (deleteButtons.length > 0) { + await userEvent.click(deleteButtons[0]); + + // Verify one input was removed + await waitFor(() => { + const inputs = canvas.getAllByPlaceholderText("https://example.com/calendar.ics"); + expect(inputs).toHaveLength(2); + }); + } + }, +}; + +/** + * Story demonstrating the cancel functionality. + * Clicking cancel should navigate back to the previous page. + */ +export const CancelAction: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for the component to render + await waitFor(() => { + expect(canvas.getByPlaceholderText("https://example.com/calendar.ics")).toBeInTheDocument(); + }); + + // Fill in some data + const input = canvas.getByPlaceholderText("https://example.com/calendar.ics"); + await userEvent.type(input, "https://calendar.example.com/feed.ics"); + + // Click cancel + const cancelButton = canvas.getByRole("button", { name: /cancel/i }); + expect(cancelButton).toBeInTheDocument(); + }, +}; diff --git a/apps/web/components/apps/installation/AccountsStepCard.stories.tsx b/apps/web/components/apps/installation/AccountsStepCard.stories.tsx new file mode 100644 index 00000000000000..d9ee66f3d0df70 --- /dev/null +++ b/apps/web/components/apps/installation/AccountsStepCard.stories.tsx @@ -0,0 +1,206 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import { AccountsStepCard } from "./AccountsStepCard"; + +const meta = { + component: AccountsStepCard, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + onSelect: fn(), + loading: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + personalAccount: { + id: 1, + name: "John Doe", + avatarUrl: "https://cal.com/avatar.png", + alreadyInstalled: false, + }, + installableOnTeams: false, + }, +}; + +export const WithTeams: Story = { + args: { + personalAccount: { + id: 1, + name: "John Doe", + avatarUrl: "https://cal.com/avatar.png", + alreadyInstalled: false, + }, + teams: [ + { + id: 2, + name: "Marketing Team", + logoUrl: "https://cal.com/team-logo.png", + alreadyInstalled: false, + }, + { + id: 3, + name: "Sales Team", + logoUrl: "https://cal.com/team-logo-2.png", + alreadyInstalled: false, + }, + { + id: 4, + name: "Engineering Team", + logoUrl: "https://cal.com/team-logo-3.png", + alreadyInstalled: false, + }, + ], + installableOnTeams: true, + }, +}; + +export const PersonalAccountAlreadyInstalled: Story = { + args: { + personalAccount: { + id: 1, + name: "John Doe", + avatarUrl: "https://cal.com/avatar.png", + alreadyInstalled: true, + }, + installableOnTeams: false, + }, +}; + +export const WithTeamsSomeInstalled: Story = { + args: { + personalAccount: { + id: 1, + name: "John Doe", + avatarUrl: "https://cal.com/avatar.png", + alreadyInstalled: false, + }, + teams: [ + { + id: 2, + name: "Marketing Team", + logoUrl: "https://cal.com/team-logo.png", + alreadyInstalled: true, + }, + { + id: 3, + name: "Sales Team", + logoUrl: "https://cal.com/team-logo-2.png", + alreadyInstalled: false, + }, + { + id: 4, + name: "Engineering Team", + logoUrl: "https://cal.com/team-logo-3.png", + alreadyInstalled: true, + }, + ], + installableOnTeams: true, + }, +}; + +export const AllInstalled: Story = { + args: { + personalAccount: { + id: 1, + name: "John Doe", + avatarUrl: "https://cal.com/avatar.png", + alreadyInstalled: true, + }, + teams: [ + { + id: 2, + name: "Marketing Team", + logoUrl: "https://cal.com/team-logo.png", + alreadyInstalled: true, + }, + { + id: 3, + name: "Sales Team", + logoUrl: "https://cal.com/team-logo-2.png", + alreadyInstalled: true, + }, + ], + installableOnTeams: true, + }, +}; + +export const Loading: Story = { + args: { + personalAccount: { + id: 1, + name: "John Doe", + avatarUrl: "https://cal.com/avatar.png", + alreadyInstalled: false, + }, + teams: [ + { + id: 2, + name: "Marketing Team", + logoUrl: "https://cal.com/team-logo.png", + alreadyInstalled: false, + }, + { + id: 3, + name: "Sales Team", + logoUrl: "https://cal.com/team-logo-2.png", + alreadyInstalled: false, + }, + ], + installableOnTeams: true, + loading: true, + }, +}; + +export const NoAvatar: Story = { + args: { + personalAccount: { + id: 1, + name: "Jane Smith", + avatarUrl: null, + alreadyInstalled: false, + }, + teams: [ + { + id: 2, + name: "Product Team", + logoUrl: null, + alreadyInstalled: false, + }, + ], + installableOnTeams: true, + }, +}; + +export const LongTeamNames: Story = { + args: { + personalAccount: { + id: 1, + name: "Alexander Maximilian Vanderbilt III", + avatarUrl: "https://cal.com/avatar.png", + alreadyInstalled: false, + }, + teams: [ + { + id: 2, + name: "Enterprise Solutions and Digital Transformation Team", + logoUrl: "https://cal.com/team-logo.png", + alreadyInstalled: false, + }, + { + id: 3, + name: "Customer Success and Support Operations Division", + logoUrl: "https://cal.com/team-logo-2.png", + alreadyInstalled: true, + }, + ], + installableOnTeams: true, + }, +}; diff --git a/apps/web/components/apps/installation/ConfigureStepCard.stories.tsx b/apps/web/components/apps/installation/ConfigureStepCard.stories.tsx new file mode 100644 index 00000000000000..4b483c12b8e17a --- /dev/null +++ b/apps/web/components/apps/installation/ConfigureStepCard.stories.tsx @@ -0,0 +1,260 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useRef } from "react"; +import { useForm, FormProvider } from "react-hook-form"; + +import { ConfigureStepCard } from "./ConfigureStepCard"; +import type { ConfigureStepCardProps } from "./ConfigureStepCard"; + +const meta = { + title: "Apps/Installation/ConfigureStepCard", + component: ConfigureStepCard, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Wrapper component to provide FormProvider and portal ref +const ConfigureStepCardWrapper = (props: ConfigureStepCardProps) => { + const formPortalRef = useRef(null); + const formMethods = useForm({ + defaultValues: { + eventTypeGroups: props.eventTypeGroups, + }, + }); + + return ( + +
+ +
+
+ + ); +}; + +const mockEventTypeGroups = [ + { + image: "https://via.placeholder.com/40", + slug: "john-doe", + eventTypes: [ + { + id: 1, + title: "30 Min Meeting", + slug: "30min", + selected: true, + metadata: {}, + locations: [ + { + type: "integrations:google:meet", + displayLocationPublicly: true, + }, + ], + bookingFields: [], + seatsPerTimeSlot: null, + team: null, + }, + { + id: 2, + title: "60 Min Meeting", + slug: "60min", + selected: true, + metadata: {}, + locations: [ + { + type: "integrations:zoom", + displayLocationPublicly: true, + }, + ], + bookingFields: [], + seatsPerTimeSlot: null, + team: null, + }, + ], + }, +]; + +const mockEventTypeGroupsWithTeam = [ + { + image: "https://via.placeholder.com/40", + slug: "acme-team", + eventTypes: [ + { + id: 3, + title: "Team Standup", + slug: "team-standup", + selected: true, + metadata: {}, + locations: [ + { + type: "integrations:google:meet", + displayLocationPublicly: true, + }, + ], + bookingFields: [], + seatsPerTimeSlot: null, + team: { + id: 1, + slug: "acme-team", + name: "Acme Team", + }, + }, + ], + }, +]; + +const baseArgs: ConfigureStepCardProps = { + slug: "google-meet", + userName: "john-doe", + categories: ["conferencing"], + credentialId: 123, + loading: false, + isConferencing: true, + formPortalRef: { current: null }, + eventTypeGroups: mockEventTypeGroups, + setConfigureStep: () => {}, + handleSetUpLater: () => { + console.log("Set up later clicked"); + }, +}; + +export const Default: Story = { + render: (args) => , + args: baseArgs, +}; + +export const ConferencingApp: Story = { + render: (args) => , + args: { + ...baseArgs, + slug: "zoom", + isConferencing: true, + categories: ["conferencing"], + }, +}; + +export const NonConferencingApp: Story = { + render: (args) => , + args: { + ...baseArgs, + slug: "google-calendar", + isConferencing: false, + categories: ["calendar"], + }, +}; + +export const WithTeamEventTypes: Story = { + render: (args) => , + args: { + ...baseArgs, + eventTypeGroups: mockEventTypeGroupsWithTeam, + }, +}; + +export const Loading: Story = { + render: (args) => , + args: { + ...baseArgs, + loading: true, + }, +}; + +export const MultipleGroups: Story = { + render: (args) => , + args: { + ...baseArgs, + eventTypeGroups: [ + ...mockEventTypeGroups, + { + image: "https://via.placeholder.com/40", + slug: "jane-smith", + eventTypes: [ + { + id: 4, + title: "Quick Chat", + slug: "quick-chat", + selected: true, + metadata: {}, + locations: [ + { + type: "integrations:zoom", + displayLocationPublicly: true, + }, + ], + bookingFields: [], + seatsPerTimeSlot: null, + team: null, + }, + ], + }, + ], + }, +}; + +export const SingleEventType: Story = { + render: (args) => , + args: { + ...baseArgs, + eventTypeGroups: [ + { + image: "https://via.placeholder.com/40", + slug: "john-doe", + eventTypes: [ + { + id: 1, + title: "30 Min Meeting", + slug: "30min", + selected: true, + metadata: {}, + locations: [ + { + type: "integrations:google:meet", + displayLocationPublicly: true, + }, + ], + bookingFields: [], + seatsPerTimeSlot: null, + team: null, + }, + ], + }, + ], + }, +}; + +export const NoSelectedEventTypes: Story = { + render: (args) => , + args: { + ...baseArgs, + eventTypeGroups: [ + { + image: "https://via.placeholder.com/40", + slug: "john-doe", + eventTypes: [ + { + id: 1, + title: "30 Min Meeting", + slug: "30min", + selected: false, + metadata: {}, + locations: [], + bookingFields: [], + seatsPerTimeSlot: null, + team: null, + }, + ], + }, + ], + }, +}; + +export const WithoutCredentialId: Story = { + render: (args) => , + args: { + ...baseArgs, + credentialId: undefined, + }, +}; diff --git a/apps/web/components/apps/installation/EventTypesStepCard.stories.tsx b/apps/web/components/apps/installation/EventTypesStepCard.stories.tsx new file mode 100644 index 00000000000000..1bd5642ed4d1e5 --- /dev/null +++ b/apps/web/components/apps/installation/EventTypesStepCard.stories.tsx @@ -0,0 +1,305 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { FormProvider, useForm } from "react-hook-form"; + +import { EventTypesStepCard } from "./EventTypesStepCard"; +import type { TEventTypesForm } from "~/apps/installation/[[...step]]/step-view"; + +const meta = { + title: "Components/Apps/Installation/EventTypesStepCard", + component: EventTypesStepCard, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story, context) => { + const methods = useForm({ + defaultValues: context.args.formDefaultValues || { + eventTypeGroups: [], + }, + }); + + return ( + +
+ +
+
+ ); + }, + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const mockEventTypeGroups = [ + { + teamId: undefined, + userId: 1, + slug: "user", + name: "User", + image: "", + isOrganisation: false, + eventTypes: [ + { + id: 1, + title: "30 Min Meeting", + description: "A quick 30 minute meeting to discuss your project needs", + slug: "30min", + length: 30, + selected: false, + team: null, + metadata: {}, + schedulingType: null, + requiresConfirmation: false, + position: 0, + destinationCalendar: null, + calVideoSettings: null, + locations: [], + }, + { + id: 2, + title: "60 Min Consultation", + description: "An hour-long consultation for in-depth discussion about your requirements", + slug: "60min", + length: 60, + selected: false, + team: null, + metadata: {}, + schedulingType: null, + requiresConfirmation: false, + position: 1, + destinationCalendar: null, + calVideoSettings: null, + locations: [], + }, + { + id: 3, + title: "Quick 15 Min Call", + description: "", + slug: "15min", + length: 15, + selected: false, + team: null, + metadata: {}, + schedulingType: null, + requiresConfirmation: false, + position: 2, + destinationCalendar: null, + calVideoSettings: null, + locations: [], + }, + ], + }, +]; + +const mockEventTypeGroupsWithMultipleDurations = [ + { + teamId: undefined, + userId: 1, + slug: "user", + name: "User", + image: "", + isOrganisation: false, + eventTypes: [ + { + id: 4, + title: "Flexible Duration Meeting", + description: "Choose from multiple duration options for this meeting", + slug: "flexible", + length: 30, + selected: false, + team: null, + metadata: { + multipleDuration: [15, 30, 45, 60], + }, + schedulingType: null, + requiresConfirmation: false, + position: 0, + destinationCalendar: null, + calVideoSettings: null, + locations: [], + }, + ], + }, +]; + +const mockEventTypeGroupsWithTeam = [ + { + teamId: 10, + userId: null, + slug: "engineering-team", + name: "Engineering Team", + image: "https://cal.com/team-avatar.png", + isOrganisation: false, + eventTypes: [ + { + id: 5, + title: "Team Standup", + description: "Daily team standup meeting", + slug: "standup", + length: 15, + selected: false, + team: { + id: 10, + name: "Engineering Team", + slug: "engineering-team", + }, + metadata: {}, + schedulingType: null, + requiresConfirmation: false, + position: 0, + destinationCalendar: null, + calVideoSettings: null, + locations: [], + }, + { + id: 6, + title: "Sprint Planning", + description: "Bi-weekly sprint planning session", + slug: "sprint-planning", + length: 120, + selected: false, + team: { + id: 10, + name: "Engineering Team", + slug: "engineering-team", + }, + metadata: {}, + schedulingType: null, + requiresConfirmation: false, + position: 1, + destinationCalendar: null, + calVideoSettings: null, + locations: [], + }, + ], + }, +]; + +const mockEventTypeGroupsWithSelectedEvents = [ + { + teamId: undefined, + userId: 1, + slug: "user", + name: "User", + image: "", + isOrganisation: false, + eventTypes: [ + { + id: 1, + title: "30 Min Meeting", + description: "A quick 30 minute meeting to discuss your project needs", + slug: "30min", + length: 30, + selected: true, + team: null, + metadata: {}, + schedulingType: null, + requiresConfirmation: false, + position: 0, + destinationCalendar: null, + calVideoSettings: null, + locations: [], + }, + { + id: 2, + title: "60 Min Consultation", + description: "An hour-long consultation for in-depth discussion about your requirements", + slug: "60min", + length: 60, + selected: true, + team: null, + metadata: {}, + schedulingType: null, + requiresConfirmation: false, + position: 1, + destinationCalendar: null, + calVideoSettings: null, + locations: [], + }, + ], + }, +]; + +const mockEmptyEventTypeGroup = [ + { + teamId: 10, + userId: null, + slug: "new-team", + name: "New Team", + image: "", + isOrganisation: false, + eventTypes: [], + }, +]; + +export const Default: Story = { + args: { + userName: "johndoe", + setConfigureStep: () => {}, + handleSetUpLater: () => {}, + formDefaultValues: { + eventTypeGroups: mockEventTypeGroups, + }, + }, +}; + +export const WithSelectedEvents: Story = { + args: { + userName: "johndoe", + setConfigureStep: () => {}, + handleSetUpLater: () => {}, + formDefaultValues: { + eventTypeGroups: mockEventTypeGroupsWithSelectedEvents, + }, + }, +}; + +export const WithTeamEvents: Story = { + args: { + userName: "johndoe", + setConfigureStep: () => {}, + handleSetUpLater: () => {}, + formDefaultValues: { + eventTypeGroups: mockEventTypeGroupsWithTeam, + }, + }, +}; + +export const WithMultipleDurations: Story = { + args: { + userName: "johndoe", + setConfigureStep: () => {}, + handleSetUpLater: () => {}, + formDefaultValues: { + eventTypeGroups: mockEventTypeGroupsWithMultipleDurations, + }, + }, +}; + +export const WithEmptyTeam: Story = { + args: { + userName: "johndoe", + setConfigureStep: () => {}, + handleSetUpLater: () => {}, + formDefaultValues: { + eventTypeGroups: mockEmptyEventTypeGroup, + }, + }, +}; + +export const WithMultipleGroups: Story = { + args: { + userName: "johndoe", + setConfigureStep: () => {}, + handleSetUpLater: () => {}, + formDefaultValues: { + eventTypeGroups: [ + ...mockEventTypeGroups, + ...mockEventTypeGroupsWithTeam, + ], + }, + }, +}; diff --git a/apps/web/components/apps/layouts/AppsLayout.stories.tsx b/apps/web/components/apps/layouts/AppsLayout.stories.tsx new file mode 100644 index 00000000000000..bcef5ae52caf29 --- /dev/null +++ b/apps/web/components/apps/layouts/AppsLayout.stories.tsx @@ -0,0 +1,240 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useRouter } from "next/navigation"; + +import AppsLayout from "./AppsLayout"; + +const meta: Meta = { + title: "Apps/Layouts/AppsLayout", + component: AppsLayout, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps", + }, + }, + }, + argTypes: { + children: { + control: false, + description: "The content to display inside the layout", + }, + isAdmin: { + control: "boolean", + description: "Whether the current user is an admin", + }, + actions: { + control: false, + description: "Optional action buttons to display in the shell", + }, + emptyStore: { + control: "boolean", + description: "Whether to show the empty state screen", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isAdmin: false, + emptyStore: false, + children: ( +
+

Apps Content

+

This is the main content area for apps.

+
+
+

Calendar App

+

Manage your calendar integrations

+
+
+

Video App

+

Configure video conferencing

+
+
+

Payment App

+

Set up payment integrations

+
+
+
+ ), + }, +}; + +export const WithActions: Story = { + args: { + isAdmin: false, + emptyStore: false, + actions: (className?: string) => ( + + ), + children: ( +
+

Apps with Actions

+

Layout with action buttons in the header.

+
+ ), + }, +}; + +export const EmptyStateAdmin: Story = { + args: { + isAdmin: true, + emptyStore: true, + children: null, + }, + parameters: { + docs: { + description: { + story: "Empty state shown to admin users when no apps are configured.", + }, + }, + }, +}; + +export const EmptyStateNonAdmin: Story = { + args: { + isAdmin: false, + emptyStore: true, + children: null, + }, + parameters: { + docs: { + description: { + story: "Empty state shown to non-admin users when no apps are configured.", + }, + }, + }, +}; + +export const WithCustomShellProps: Story = { + args: { + isAdmin: true, + emptyStore: false, + heading: "Custom Apps Heading", + subtitle: "Manage your application integrations", + children: ( +
+

Custom Shell Configuration

+

+ This story demonstrates using Shell component props like heading and subtitle. +

+
+ ), + }, + parameters: { + docs: { + description: { + story: "AppsLayout with custom Shell component properties.", + }, + }, + }, +}; + +export const WithComplexContent: Story = { + args: { + isAdmin: true, + emptyStore: false, + actions: (className?: string) => ( +
+ + +
+ ), + children: ( +
+
+

All Apps

+ +
+
+ {[ + { + name: "Google Calendar", + category: "Calendar", + status: "Installed", + color: "blue", + }, + { + name: "Zoom", + category: "Video", + status: "Available", + color: "gray", + }, + { + name: "Stripe", + category: "Payment", + status: "Installed", + color: "blue", + }, + { + name: "Microsoft Teams", + category: "Video", + status: "Available", + color: "gray", + }, + { + name: "PayPal", + category: "Payment", + status: "Available", + color: "gray", + }, + { + name: "Outlook Calendar", + category: "Calendar", + status: "Installed", + color: "blue", + }, + ].map((app, index) => ( +
+
+

{app.name}

+ + {app.status} + +
+

{app.category}

+ +
+ ))} +
+
+ ), + }, + parameters: { + docs: { + description: { + story: "AppsLayout with a complex content layout showing multiple apps.", + }, + }, + }, +}; diff --git a/apps/web/components/apps/make/Setup.stories.tsx b/apps/web/components/apps/make/Setup.stories.tsx new file mode 100644 index 00000000000000..46ec26120cc8b3 --- /dev/null +++ b/apps/web/components/apps/make/Setup.stories.tsx @@ -0,0 +1,262 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createTRPCReact, httpBatchLink } from "@trpc/react-query"; +import { useState } from "react"; + +import MakeSetup from "./Setup"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +// Mock tRPC client +const mockTrpc = createTRPCReact(); + +const mockTrpcClient = mockTrpc.createClient({ + links: [ + () => + ({ op, next }) => { + // Mock responses based on the operation path + if (op.path === "viewer.apps.integrations") { + return { + subscribe: (observer: any) => { + observer.next({ + result: { + data: { + items: [ + { + type: "make_automation", + userCredentialIds: [1], + }, + ], + }, + }, + }); + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + } + if (op.path === "viewer.apiKeys.findKeyOfType") { + return { + subscribe: (observer: any) => { + observer.next({ + result: { + data: [], + }, + }); + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + } + if (op.path === "viewer.teams.listOwnedTeams") { + return { + subscribe: (observer: any) => { + observer.next({ + result: { + data: op.context?.withTeams + ? [ + { id: 1, name: "Engineering Team" }, + { id: 2, name: "Marketing Team" }, + ] + : [], + }, + }); + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + } + return next(op); + }, + ] as any, +}); + +const meta: Meta = { + title: "Components/Apps/Make/Setup", + component: MakeSetup, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story, context) => { + const [trpcClient] = useState(() => + mockTrpc.createClient({ + links: [ + () => + ({ op, next }) => { + // Mock responses based on the operation path and story context + if (op.path === "viewer.apps.integrations") { + return { + subscribe: (observer: any) => { + if (context.args.isNotInstalled) { + observer.next({ + result: { + data: { + items: [], + }, + }, + }); + } else { + observer.next({ + result: { + data: { + items: [ + { + type: "make_automation", + userCredentialIds: [1], + }, + ], + }, + }, + }); + } + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + } + if (op.path === "viewer.apiKeys.findKeyOfType") { + return { + subscribe: (observer: any) => { + observer.next({ + result: { + data: [], + }, + }); + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + } + if (op.path === "viewer.teams.listOwnedTeams") { + return { + subscribe: (observer: any) => { + observer.next({ + result: { + data: context.args.withTeams + ? [ + { id: 1, name: "Engineering Team" }, + { id: 2, name: "Marketing Team" }, + ] + : [], + }, + }); + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + } + if (op.path === "viewer.apiKeys.create") { + return { + subscribe: (observer: any) => { + observer.next({ + result: { + data: "cal_test_" + Math.random().toString(36).substring(2, 15), + }, + }); + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + } + if (op.path === "viewer.apiKeys.delete") { + return { + subscribe: (observer: any) => { + observer.next({ + result: { + data: { success: true }, + }, + }); + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + } + return next(op); + }, + ] as any, + }) + ); + + return ( + + + + + + ); + }, + ], + argTypes: { + inviteLink: { + control: "text", + description: "The Make invite link URL", + }, + withTeams: { + control: "boolean", + description: "Show setup with team API keys", + }, + isNotInstalled: { + control: "boolean", + description: "Show app not installed state", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + inviteLink: "https://www.make.com/en/integrations/cal-com", + withTeams: false, + isNotInstalled: false, + }, +}; + +export const WithTeams: Story = { + args: { + inviteLink: "https://www.make.com/en/integrations/cal-com", + withTeams: true, + isNotInstalled: false, + }, +}; + +export const NotInstalled: Story = { + args: { + inviteLink: "https://www.make.com/en/integrations/cal-com", + withTeams: false, + isNotInstalled: true, + }, +}; + +export const CustomInviteLink: Story = { + args: { + inviteLink: "https://custom-make-invite-link.example.com", + withTeams: false, + isNotInstalled: false, + }, +}; diff --git a/apps/web/components/apps/paypal/Setup.stories.tsx b/apps/web/components/apps/paypal/Setup.stories.tsx new file mode 100644 index 00000000000000..f94920525db7e0 --- /dev/null +++ b/apps/web/components/apps/paypal/Setup.stories.tsx @@ -0,0 +1,160 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createTRPCReact } from "@trpc/react-query"; +import { useState } from "react"; + +import PayPalSetup from "./Setup"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +// Mock tRPC client +const mockTrpc = createTRPCReact(); + +const meta: Meta = { + title: "Components/Apps/PayPal/Setup", + component: PayPalSetup, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story, context) => { + const [trpcClient] = useState(() => + mockTrpc.createClient({ + links: [ + () => + ({ op, next }) => { + // Mock responses based on the operation path and story context + if (op.path === "viewer.apps.integrations") { + return { + subscribe: (observer: any) => { + if (context.args.isLoading) { + // Don't complete the observer to simulate loading state + return { + unsubscribe: () => {}, + }; + } + + if (context.args.isNotInstalled) { + observer.next({ + result: { + data: { + items: [], + }, + }, + }); + } else { + observer.next({ + result: { + data: { + items: [ + { + type: "paypal_payment", + userCredentialIds: [1], + }, + ], + }, + }, + }); + } + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + } + + if (op.path === "viewer.apps.updateAppCredentials") { + return { + subscribe: (observer: any) => { + if (context.args.saveError) { + observer.error(new Error("Failed to save credentials")); + } else { + observer.next({ + result: { + data: { success: true }, + }, + }); + observer.complete(); + } + return { + unsubscribe: () => {}, + }; + }, + } as any; + } + + return next(op); + }, + ] as any, + }) + ); + + return ( + + +
+ +
+
+
+ ); + }, + ], + argTypes: { + isNotInstalled: { + control: "boolean", + description: "Show app not installed state", + }, + isLoading: { + control: "boolean", + description: "Show loading state", + }, + saveError: { + control: "boolean", + description: "Simulate save error", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isNotInstalled: false, + isLoading: false, + saveError: false, + }, +}; + +export const Loading: Story = { + args: { + isNotInstalled: false, + isLoading: true, + saveError: false, + }, +}; + +export const NotInstalled: Story = { + args: { + isNotInstalled: true, + isLoading: false, + saveError: false, + }, +}; + +export const WithSaveError: Story = { + args: { + isNotInstalled: false, + isLoading: false, + saveError: true, + }, +}; diff --git a/apps/web/components/apps/routing-forms/FormActions.stories.tsx b/apps/web/components/apps/routing-forms/FormActions.stories.tsx new file mode 100644 index 00000000000000..99f008e0690643 --- /dev/null +++ b/apps/web/components/apps/routing-forms/FormActions.stories.tsx @@ -0,0 +1,531 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions"; +import type { NewFormDialogState } from "./FormActions"; + +type RoutingForm = { + id: string; + name: string; + disabled: boolean; + fields?: Array<{ + identifier?: string; + id: string; + type: string; + label: string; + routerId?: string | null; + }>; +}; + +const mockRoutingForm: RoutingForm = { + id: "form-123", + name: "Customer Intake Form", + disabled: false, + fields: [ + { + id: "field-1", + identifier: "customer_name", + type: "text", + label: "Customer Name", + routerId: null, + }, + { + id: "field-2", + identifier: "email", + type: "email", + label: "Email Address", + routerId: null, + }, + ], +}; + +const mockDisabledForm: RoutingForm = { + id: "form-456", + name: "Disabled Form", + disabled: true, +}; + +// Wrapper component to handle provider state +function FormActionsWrapper({ children }: { children: React.ReactNode }) { + const [newFormDialogState, setNewFormDialogState] = useState(null); + + return ( + + {children} + + ); +} + +const meta = { + title: "Components/Apps/RoutingForms/FormActions", + component: FormAction, + parameters: { + layout: "centered", + nextjs: { + appDirectory: true, + }, + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + Edit Form + + ), +}; + +export const EditAction: Story = { + render: () => ( + + Edit + + ), +}; + +export const PreviewAction: Story = { + render: () => ( + + Preview + + ), +}; + +export const CopyLinkAction: Story = { + render: () => ( + + Copy Link + + ), +}; + +export const DuplicateAction: Story = { + render: () => ( + + Duplicate + + ), +}; + +export const DeleteAction: Story = { + render: () => ( + + Delete + + ), +}; + +export const DownloadAction: Story = { + render: () => ( + + Download CSV + + ), +}; + +export const ViewResponsesAction: Story = { + render: () => ( + + View Responses + + ), +}; + +export const ToggleAction: Story = { + render: () => ( + + ), +}; + +export const ToggleDisabledForm: Story = { + render: () => ( + + ), +}; + +export const CopyRedirectUrlAction: Story = { + render: () => ( + + Copy Redirect URL + + ), +}; + +export const CreateFormAction: Story = { + render: () => ( + + Create New Form + + ), +}; + +export const ActionsDropdown: Story = { + render: () => ( + + + Edit + + + Preview + + + Copy Link + + + Duplicate + + + Download CSV + + + View Responses + + + Delete + + + ), +}; + +export const DisabledDropdown: Story = { + render: () => ( + + + Edit + + + Preview + + + ), +}; + +export const ActionButtons: Story = { + render: () => ( +
+ + Edit + + + Preview + + + Copy Link + + + Duplicate + + + Delete + +
+ ), +}; + +export const ActionButtonsWithIcons: Story = { + render: () => ( +
+ + Edit + + + Preview + + + Copy Link + + + Duplicate + + + Download + + + Delete + +
+ ), +}; + +export const IconOnlyActions: Story = { + render: () => ( +
+ + + + + +
+ ), +}; + +export const FormWithToggle: Story = { + render: () => ( +
+
+

Customer Intake Form

+

+ Form is currently enabled +

+
+ + + + Edit + + + Preview + + + Copy Link + + + Duplicate + + + Delete + + +
+ ), +}; + +export const CompleteFormCard: Story = { + render: () => ( +
+
+
+

Customer Intake Form

+

+ Collect customer information and route to the right team +

+
+ 2 fields + + 45 responses +
+
+
+ + + + Edit + + + Preview + + + Copy Link + + + Duplicate + + + Download CSV + + + View Responses + + + Delete + + +
+
+
+ + Edit Form + + + Preview + + + View Responses + +
+
+ ), +}; diff --git a/apps/web/components/apps/routing-forms/FormSettingsSlideover.stories.tsx b/apps/web/components/apps/routing-forms/FormSettingsSlideover.stories.tsx new file mode 100644 index 00000000000000..2fcdd91a1ebd28 --- /dev/null +++ b/apps/web/components/apps/routing-forms/FormSettingsSlideover.stories.tsx @@ -0,0 +1,357 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types"; +import { Button } from "@calcom/ui/components/button"; + +import { FormSettingsSlideover } from "./FormSettingsSlideover"; + +const meta = { + title: "Components/Apps/RoutingForms/FormSettingsSlideover", + component: FormSettingsSlideover, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock form data +const mockFormData: RoutingFormWithResponseCount = { + id: "form-123", + name: "Customer Feedback Form", + description: "A form to gather customer feedback and route to appropriate teams", + fields: [], + routes: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-15T00:00:00.000Z", + position: 0, + userId: 1, + teamId: 1, + disabled: false, + settings: { + sendUpdatesTo: [], + sendToAll: false, + emailOwnerOnSubmission: false, + }, + routers: [ + { + id: "router-1", + name: "Sales Router", + description: "Routes to sales team", + }, + { + id: "router-2", + name: "Support Router", + description: "Routes to support team", + }, + ], + connectedForms: [ + { + id: "form-456", + name: "Lead Qualification", + description: "Connected qualification form", + }, + ], + teamMembers: [ + { + id: 1, + name: "John Doe", + email: "john@example.com", + avatarUrl: null, + defaultScheduleId: null, + }, + { + id: 2, + name: "Jane Smith", + email: "jane@example.com", + avatarUrl: null, + defaultScheduleId: null, + }, + { + id: 3, + name: "Bob Johnson", + email: "bob@example.com", + avatarUrl: null, + defaultScheduleId: null, + }, + ], + team: { + slug: "example-team", + name: "Example Team", + }, + _count: { + responses: 42, + }, +}; + +// Mock form data without team +const mockPersonalFormData: RoutingFormWithResponseCount = { + id: "form-789", + name: "Personal Form", + description: "A personal routing form", + fields: [], + routes: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-15T00:00:00.000Z", + position: 0, + userId: 1, + teamId: null, + disabled: false, + settings: { + sendUpdatesTo: [], + sendToAll: false, + emailOwnerOnSubmission: false, + }, + routers: [], + connectedForms: [], + teamMembers: [], + team: null, + _count: { + responses: 5, + }, +}; + +// Mock form data without routers or connected forms +const mockMinimalFormData: RoutingFormWithResponseCount = { + id: "form-minimal", + name: "Minimal Form", + description: "", + fields: [], + routes: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-15T00:00:00.000Z", + position: 0, + userId: 1, + teamId: 1, + disabled: false, + settings: { + sendUpdatesTo: [], + sendToAll: false, + emailOwnerOnSubmission: false, + }, + routers: [], + connectedForms: [], + teamMembers: [ + { + id: 1, + name: "Admin User", + email: "admin@example.com", + avatarUrl: null, + defaultScheduleId: null, + }, + ], + team: { + slug: "minimal-team", + name: "Minimal Team", + }, + _count: { + responses: 0, + }, +}; + +// Wrapper component to handle state +function FormSettingsSlideoverWrapper({ + formData, + defaultOpen = false, +}: { + formData: RoutingFormWithResponseCount; + defaultOpen?: boolean; +}) { + const [isOpen, setIsOpen] = useState(defaultOpen); + const hookForm = useForm({ + defaultValues: formData, + }); + + return ( +
+ + +
+ ); +} + +// Default story with team form +export const Default: Story = { + render: () => , +}; + +// Story with slideover open by default +export const OpenByDefault: Story = { + render: () => , +}; + +// Personal form without team +export const PersonalForm: Story = { + render: () => , +}; + +// Personal form open by default +export const PersonalFormOpen: Story = { + render: () => , +}; + +// Minimal form without routers or connected forms +export const MinimalForm: Story = { + render: () => , +}; + +// Minimal form open by default +export const MinimalFormOpen: Story = { + render: () => , +}; + +// Form with pre-selected team members +export const WithSelectedMembers: Story = { + render: () => { + const formWithSelectedMembers = { + ...mockFormData, + settings: { + sendUpdatesTo: [1, 2], + sendToAll: false, + emailOwnerOnSubmission: false, + }, + }; + return ; + }, +}; + +// Form with "send to all" enabled +export const SendToAllEnabled: Story = { + render: () => { + const formWithSendToAll = { + ...mockFormData, + settings: { + sendUpdatesTo: [], + sendToAll: true, + emailOwnerOnSubmission: false, + }, + }; + return ; + }, +}; + +// Personal form with email owner enabled +export const EmailOwnerEnabled: Story = { + render: () => { + const formWithEmailOwner = { + ...mockPersonalFormData, + settings: { + sendUpdatesTo: [], + sendToAll: false, + emailOwnerOnSubmission: true, + }, + }; + return ; + }, +}; + +// Form with many routers +export const ManyRouters: Story = { + render: () => { + const formWithManyRouters = { + ...mockFormData, + routers: [ + { id: "router-1", name: "Sales Router", description: "Routes to sales team" }, + { id: "router-2", name: "Support Router", description: "Routes to support team" }, + { id: "router-3", name: "Marketing Router", description: "Routes to marketing team" }, + { id: "router-4", name: "Product Router", description: "Routes to product team" }, + { id: "router-5", name: "Engineering Router", description: "Routes to engineering team" }, + ], + }; + return ; + }, +}; + +// Form with many connected forms +export const ManyConnectedForms: Story = { + render: () => { + const formWithManyConnected = { + ...mockFormData, + connectedForms: [ + { id: "form-1", name: "Lead Qualification", description: "Qualification form" }, + { id: "form-2", name: "Product Interest", description: "Product interest form" }, + { id: "form-3", name: "Feedback Survey", description: "Customer feedback" }, + { id: "form-4", name: "Support Ticket", description: "Support form" }, + ], + }; + return ; + }, +}; + +// Form with long description +export const LongDescription: Story = { + render: () => { + const formWithLongDescription = { + ...mockFormData, + description: + "This is a comprehensive customer feedback form designed to gather detailed information about user experience, product satisfaction, feature requests, and overall sentiment. It includes multiple sections covering different aspects of the customer journey and routes responses to appropriate teams based on the feedback type and urgency level. The form is used across multiple departments and integrates with our CRM system.", + }; + return ; + }, +}; + +// Form with many team members +export const ManyTeamMembers: Story = { + render: () => { + const formWithManyMembers = { + ...mockFormData, + teamMembers: [ + { id: 1, name: "John Doe", email: "john@example.com", avatarUrl: null, defaultScheduleId: null }, + { + id: 2, + name: "Jane Smith", + email: "jane@example.com", + avatarUrl: null, + defaultScheduleId: null, + }, + { + id: 3, + name: "Bob Johnson", + email: "bob@example.com", + avatarUrl: null, + defaultScheduleId: null, + }, + { + id: 4, + name: "Alice Williams", + email: "alice@example.com", + avatarUrl: null, + defaultScheduleId: null, + }, + { + id: 5, + name: "Charlie Brown", + email: "charlie@example.com", + avatarUrl: null, + defaultScheduleId: null, + }, + { id: 6, name: "Diana Ross", email: "diana@example.com", avatarUrl: null, defaultScheduleId: null }, + { + id: 7, + name: "Edward Norton", + email: "edward@example.com", + avatarUrl: null, + defaultScheduleId: null, + }, + { + id: 8, + name: "Fiona Apple", + email: "fiona@example.com", + avatarUrl: null, + defaultScheduleId: null, + }, + ], + }; + return ; + }, +}; diff --git a/apps/web/components/apps/routing-forms/Header.stories.tsx b/apps/web/components/apps/routing-forms/Header.stories.tsx new file mode 100644 index 00000000000000..c58ef0f996f1de --- /dev/null +++ b/apps/web/components/apps/routing-forms/Header.stories.tsx @@ -0,0 +1,399 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; +import { FormProvider, useForm } from "react-hook-form"; + +import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types"; + +import { FormActionsProvider } from "./FormActions"; +import { Header } from "./Header"; + +const meta = { + title: "Components/Apps/RoutingForms/Header", + component: Header, + tags: ["autodocs"], + parameters: { + layout: "centered", + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/routing-forms/form-edit/cltest123", + }, + }, + }, + decorators: [ + (Story, context) => { + const methods = useForm({ + defaultValues: context.args.routingForm, + }); + + return ( + + +
+ +
+
+
+ ); + }, + ], + args: { + isSaving: false, + appUrl: "/apps/routing-forms", + setShowInfoLostDialog: fn(), + setIsTestPreviewOpen: fn(), + isTestPreviewOpen: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Base mock form data +const baseMockForm: RoutingFormWithResponseCount = { + id: "cltest123", + name: "Customer Inquiry Form", + description: "Route customers to the right team based on their inquiry type", + disabled: false, + userId: 1, + teamId: null, + position: 0, + createdAt: "2024-01-15T10:00:00.000Z", + updatedAt: "2024-01-20T14:30:00.000Z", + routes: [ + { + id: "route1", + action: { type: "eventTypeRedirectUrl", value: "team/sales" }, + queryValue: { + type: "group", + children1: { + rule1: { + type: "rule", + properties: { + field: "inquiry_type", + operator: "equal", + value: ["sales"], + valueSrc: ["value"], + }, + }, + }, + }, + isFallback: false, + }, + { + id: "fallback", + action: { type: "eventTypeRedirectUrl", value: "team/general" }, + isFallback: true, + }, + ], + fields: [ + { + id: "field1", + type: "text", + label: "Your Name", + required: true, + }, + { + id: "field2", + type: "email", + label: "Email Address", + required: true, + }, + { + id: "inquiry_type", + type: "select", + label: "Type of Inquiry", + required: true, + options: [ + { id: "opt1", label: "Sales" }, + { id: "opt2", label: "Support" }, + { id: "opt3", label: "General" }, + ], + }, + ], + settings: { + emailOwnerOnSubmission: false, + sendUpdatesTo: [], + }, + connectedForms: [], + routers: [], + teamMembers: [], + team: null, + _count: { + responses: 0, + }, +}; + +export const Default: Story = { + args: { + routingForm: baseMockForm, + permissions: { + canCreate: true, + canRead: true, + canEdit: true, + canDelete: true, + }, + }, +}; + +export const WithLongName: Story = { + args: { + routingForm: { + ...baseMockForm, + name: "This is a Very Long Form Name That Should Demonstrate How The Header Handles Text Truncation and Overflow", + }, + permissions: { + canCreate: true, + canRead: true, + canEdit: true, + canDelete: true, + }, + }, +}; + +export const SavingState: Story = { + args: { + routingForm: baseMockForm, + isSaving: true, + permissions: { + canCreate: true, + canRead: true, + canEdit: true, + canDelete: true, + }, + }, +}; + +export const PreviewOpen: Story = { + args: { + routingForm: baseMockForm, + isTestPreviewOpen: true, + permissions: { + canCreate: true, + canRead: true, + canEdit: true, + canDelete: true, + }, + }, +}; + +export const TeamForm: Story = { + args: { + routingForm: { + ...baseMockForm, + name: "Team Sales Routing Form", + teamId: 5, + team: { + slug: "sales-team", + name: "Sales Team", + }, + }, + permissions: { + canCreate: true, + canRead: true, + canEdit: true, + canDelete: true, + }, + }, +}; + +export const WithManyResponses: Story = { + args: { + routingForm: { + ...baseMockForm, + name: "Popular Contact Form", + _count: { + responses: 1247, + }, + }, + permissions: { + canCreate: true, + canRead: true, + canEdit: true, + canDelete: true, + }, + }, +}; + +export const ReadOnlyPermissions: Story = { + args: { + routingForm: baseMockForm, + permissions: { + canCreate: false, + canRead: true, + canEdit: false, + canDelete: false, + }, + }, +}; + +export const CanEditOnly: Story = { + args: { + routingForm: baseMockForm, + permissions: { + canCreate: false, + canRead: true, + canEdit: true, + canDelete: false, + }, + }, +}; + +export const DisabledForm: Story = { + args: { + routingForm: { + ...baseMockForm, + name: "Inactive Form", + disabled: true, + description: "This form is currently disabled and not accepting responses", + }, + permissions: { + canCreate: true, + canRead: true, + canEdit: true, + canDelete: true, + }, + }, +}; + +export const ComplexFormWithManyFields: Story = { + args: { + routingForm: { + ...baseMockForm, + name: "Advanced Lead Qualification", + description: "Multi-step form to qualify leads and route them to the appropriate sales representative", + fields: [ + { + id: "field1", + type: "text", + label: "Company Name", + required: true, + }, + { + id: "field2", + type: "text", + label: "Your Name", + required: true, + }, + { + id: "field3", + type: "email", + label: "Work Email", + required: true, + }, + { + id: "field4", + type: "phone", + label: "Phone Number", + required: true, + }, + { + id: "field5", + type: "select", + label: "Company Size", + required: true, + options: [ + { id: "opt1", label: "1-10 employees" }, + { id: "opt2", label: "11-50 employees" }, + { id: "opt3", label: "51-200 employees" }, + { id: "opt4", label: "201-500 employees" }, + { id: "opt5", label: "500+ employees" }, + ], + }, + { + id: "field6", + type: "multiselect", + label: "Products of Interest", + required: true, + options: [ + { id: "prod1", label: "Platform" }, + { id: "prod2", label: "Enterprise" }, + { id: "prod3", label: "Teams" }, + { id: "prod4", label: "API" }, + ], + }, + ], + routes: [ + { + id: "enterprise-route", + action: { type: "eventTypeRedirectUrl", value: "team/enterprise-sales" }, + queryValue: { + type: "group", + children1: { + rule1: { + type: "rule", + properties: { + field: "field5", + operator: "select_any_in", + value: ["opt4", "opt5"], + valueSrc: ["value"], + }, + }, + }, + }, + isFallback: false, + }, + { + id: "fallback", + action: { type: "eventTypeRedirectUrl", value: "team/general-sales" }, + isFallback: true, + }, + ], + _count: { + responses: 892, + }, + }, + permissions: { + canCreate: true, + canRead: true, + canEdit: true, + canDelete: true, + }, + }, +}; + +export const ShortFormName: Story = { + args: { + routingForm: { + ...baseMockForm, + name: "Form", + }, + permissions: { + canCreate: true, + canRead: true, + canEdit: true, + canDelete: true, + }, + }, +}; + +export const WithSpecialCharacters: Story = { + args: { + routingForm: { + ...baseMockForm, + name: "Form & Survey (2024) - Q1", + }, + permissions: { + canCreate: true, + canRead: true, + canEdit: true, + canDelete: true, + }, + }, +}; + +export const SavingWithReadOnlyPermissions: Story = { + args: { + routingForm: baseMockForm, + isSaving: true, + permissions: { + canCreate: false, + canRead: true, + canEdit: false, + canDelete: false, + }, + }, +}; diff --git a/apps/web/components/apps/routing-forms/SingleForm.stories.tsx b/apps/web/components/apps/routing-forms/SingleForm.stories.tsx new file mode 100644 index 00000000000000..4a2bcfd4704788 --- /dev/null +++ b/apps/web/components/apps/routing-forms/SingleForm.stories.tsx @@ -0,0 +1,519 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { FormProvider, useForm } from "react-hook-form"; + +import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types"; + +import SingleForm from "./SingleForm"; +import type { SingleFormComponentProps } from "./SingleForm"; + +const meta = { + title: "Components/Apps/RoutingForms/SingleForm", + component: SingleForm, + tags: ["autodocs"], + parameters: { + layout: "centered", + nextjs: { + appDirectory: true, + }, + }, + decorators: [ + (Story, context) => { + const methods = useForm({ + defaultValues: context.args.form, + }); + + return ( + +
+ +
+
+ ); + }, + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock Page component +const MockPage = ({ form }: { form: RoutingFormWithResponseCount }) => ( +
+

{form.name}

+

{form.description || "No description provided"}

+
+

Form ID: {form.id}

+

Fields: {form.fields?.length || 0}

+

Routes: {form.routes?.length || 0}

+

Responses: {form._count.responses}

+
+
+); + +// Base mock form data +const baseMockForm: RoutingFormWithResponseCount = { + id: "cltest123", + name: "Customer Inquiry Form", + description: "Route customers to the right team based on their inquiry type", + disabled: false, + userId: 1, + teamId: null, + position: 0, + createdAt: "2024-01-15T10:00:00.000Z", + updatedAt: "2024-01-20T14:30:00.000Z", + routes: [ + { + id: "route1", + action: { type: "eventTypeRedirectUrl", value: "team/sales" }, + queryValue: { + type: "group", + children1: { + rule1: { + type: "rule", + properties: { + field: "inquiry_type", + operator: "equal", + value: ["sales"], + valueSrc: ["value"], + }, + }, + }, + }, + isFallback: false, + }, + { + id: "route2", + action: { type: "eventTypeRedirectUrl", value: "team/support" }, + queryValue: { + type: "group", + children1: { + rule1: { + type: "rule", + properties: { + field: "inquiry_type", + operator: "equal", + value: ["support"], + valueSrc: ["value"], + }, + }, + }, + }, + isFallback: false, + }, + { + id: "fallback", + action: { type: "eventTypeRedirectUrl", value: "team/general" }, + isFallback: true, + }, + ], + fields: [ + { + id: "field1", + type: "text", + label: "Your Name", + required: true, + }, + { + id: "field2", + type: "email", + label: "Email Address", + required: true, + }, + { + id: "inquiry_type", + type: "select", + label: "Type of Inquiry", + required: true, + options: [ + { id: "opt1", label: "Sales" }, + { id: "opt2", label: "Support" }, + { id: "opt3", label: "General" }, + ], + }, + { + id: "field4", + type: "textarea", + label: "Message", + required: false, + }, + ], + settings: { + emailOwnerOnSubmission: false, + sendUpdatesTo: [], + }, + connectedForms: [], + routers: [], + teamMembers: [], + team: null, + _count: { + responses: 0, + }, +}; + +const baseProps: Omit = { + appUrl: "/apps/routing-forms", + Page: MockPage, + enrichedWithUserProfileForm: { + user: { + id: 1, + username: "johndoe", + name: "John Doe", + }, + team: null, + nonOrgUsername: "johndoe", + nonOrgTeamslug: null, + userOrigin: "https://cal.com/johndoe", + teamOrigin: null, + }, + permissions: { + canEditForm: true, + canDeleteForm: true, + canToggleForm: true, + }, +}; + +export const Default: Story = { + args: { + form: baseMockForm, + ...baseProps, + }, +}; + +export const WithTeam: Story = { + args: { + form: { + ...baseMockForm, + name: "Team Sales Inquiry Form", + teamId: 5, + team: { + slug: "sales-team", + name: "Sales Team", + }, + }, + enrichedWithUserProfileForm: { + ...baseProps.enrichedWithUserProfileForm, + team: { + id: 5, + slug: "sales-team", + name: "Sales Team", + }, + nonOrgTeamslug: "sales-team", + teamOrigin: "https://cal.com/team/sales-team", + }, + ...baseProps, + }, +}; + +export const WithManyResponses: Story = { + args: { + form: { + ...baseMockForm, + name: "Popular Contact Form", + _count: { + responses: 1247, + }, + }, + ...baseProps, + }, +}; + +export const ComplexForm: Story = { + args: { + form: { + ...baseMockForm, + name: "Advanced Lead Qualification", + description: "Multi-step form to qualify leads and route them to the appropriate sales representative", + fields: [ + { + id: "field1", + type: "text", + label: "Company Name", + required: true, + }, + { + id: "field2", + type: "text", + label: "Your Name", + required: true, + }, + { + id: "field3", + type: "email", + label: "Work Email", + required: true, + }, + { + id: "field4", + type: "phone", + label: "Phone Number", + required: true, + }, + { + id: "field5", + type: "select", + label: "Company Size", + required: true, + options: [ + { id: "opt1", label: "1-10 employees" }, + { id: "opt2", label: "11-50 employees" }, + { id: "opt3", label: "51-200 employees" }, + { id: "opt4", label: "201-500 employees" }, + { id: "opt5", label: "500+ employees" }, + ], + }, + { + id: "field6", + type: "multiselect", + label: "Products of Interest", + required: true, + options: [ + { id: "prod1", label: "Platform" }, + { id: "prod2", label: "Enterprise" }, + { id: "prod3", label: "Teams" }, + { id: "prod4", label: "API" }, + ], + }, + { + id: "field7", + type: "select", + label: "Budget Range", + required: true, + options: [ + { id: "budget1", label: "< $10,000" }, + { id: "budget2", label: "$10,000 - $50,000" }, + { id: "budget3", label: "$50,000 - $100,000" }, + { id: "budget4", label: "$100,000+" }, + ], + }, + { + id: "field8", + type: "select", + label: "Timeline", + required: true, + options: [ + { id: "time1", label: "Immediate (< 1 month)" }, + { id: "time2", label: "1-3 months" }, + { id: "time3", label: "3-6 months" }, + { id: "time4", label: "6+ months" }, + ], + }, + { + id: "field9", + type: "textarea", + label: "Additional Requirements", + required: false, + }, + ], + routes: [ + { + id: "enterprise-route", + action: { type: "eventTypeRedirectUrl", value: "team/enterprise-sales" }, + queryValue: { + type: "group", + children1: { + rule1: { + type: "rule", + properties: { + field: "field5", + operator: "select_any_in", + value: ["opt4", "opt5"], + valueSrc: ["value"], + }, + }, + }, + }, + isFallback: false, + }, + { + id: "smb-route", + action: { type: "eventTypeRedirectUrl", value: "team/smb-sales" }, + queryValue: { + type: "group", + children1: { + rule1: { + type: "rule", + properties: { + field: "field5", + operator: "select_any_in", + value: ["opt1", "opt2", "opt3"], + valueSrc: ["value"], + }, + }, + }, + }, + isFallback: false, + }, + { + id: "fallback", + action: { type: "eventTypeRedirectUrl", value: "team/general-sales" }, + isFallback: true, + }, + ], + _count: { + responses: 892, + }, + }, + ...baseProps, + }, +}; + +export const DisabledForm: Story = { + args: { + form: { + ...baseMockForm, + name: "Inactive Form", + disabled: true, + description: "This form is currently disabled and not accepting responses", + }, + ...baseProps, + }, +}; + +export const NoResponsesYet: Story = { + args: { + form: { + ...baseMockForm, + name: "Brand New Form", + description: "Just created, waiting for the first response", + _count: { + responses: 0, + }, + }, + ...baseProps, + }, +}; + +export const MinimalForm: Story = { + args: { + form: { + ...baseMockForm, + name: "Simple Contact Form", + description: null, + fields: [ + { + id: "field1", + type: "text", + label: "Name", + required: true, + }, + { + id: "field2", + type: "email", + label: "Email", + required: true, + }, + ], + routes: [ + { + id: "default", + action: { type: "eventTypeRedirectUrl", value: "team/general" }, + isFallback: true, + }, + ], + _count: { + responses: 23, + }, + }, + ...baseProps, + }, +}; + +export const WithConnectedForms: Story = { + args: { + form: { + ...baseMockForm, + name: "Multi-Form Router", + description: "Routes to other routing forms based on user selection", + connectedForms: [ + { id: "form1", name: "Sales Qualification", description: "Qualify sales leads" }, + { id: "form2", name: "Support Intake", description: "Technical support requests" }, + { id: "form3", name: "Partnership Inquiry", description: "Partnership opportunities" }, + ], + routers: [ + { id: "router1", name: "Regional Router", description: "Routes by region" }, + ], + }, + ...baseProps, + }, +}; + +export const WithTeamMembers: Story = { + args: { + form: { + ...baseMockForm, + name: "Team Routing Form", + description: "Routes inquiries to available team members", + teamId: 10, + team: { + slug: "customer-success", + name: "Customer Success Team", + }, + teamMembers: [ + { + id: 1, + name: "Alice Johnson", + email: "alice@example.com", + avatarUrl: "https://i.pravatar.cc/150?img=1", + defaultScheduleId: 1, + }, + { + id: 2, + name: "Bob Smith", + email: "bob@example.com", + avatarUrl: "https://i.pravatar.cc/150?img=2", + defaultScheduleId: 2, + }, + { + id: 3, + name: "Carol Williams", + email: "carol@example.com", + avatarUrl: "https://i.pravatar.cc/150?img=3", + defaultScheduleId: 3, + }, + ], + }, + ...baseProps, + }, +}; + +export const ReadOnlyPermissions: Story = { + args: { + form: baseMockForm, + appUrl: "/apps/routing-forms", + Page: MockPage, + enrichedWithUserProfileForm: baseProps.enrichedWithUserProfileForm, + permissions: { + canEditForm: false, + canDeleteForm: false, + canToggleForm: false, + }, + }, +}; + +export const PartialPermissions: Story = { + args: { + form: baseMockForm, + appUrl: "/apps/routing-forms", + Page: MockPage, + enrichedWithUserProfileForm: baseProps.enrichedWithUserProfileForm, + permissions: { + canEditForm: true, + canDeleteForm: false, + canToggleForm: true, + }, + }, +}; + +export const WithEmailNotifications: Story = { + args: { + form: { + ...baseMockForm, + name: "Form with Notifications", + settings: { + emailOwnerOnSubmission: true, + sendUpdatesTo: ["admin@example.com", "team@example.com"], + }, + }, + ...baseProps, + }, +}; diff --git a/apps/web/components/apps/routing-forms/TestForm.stories.tsx b/apps/web/components/apps/routing-forms/TestForm.stories.tsx new file mode 100644 index 00000000000000..c3c399c4e545f9 --- /dev/null +++ b/apps/web/components/apps/routing-forms/TestForm.stories.tsx @@ -0,0 +1,360 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import type { RoutingForm } from "@calcom/app-store/routing-forms/types/types"; + +import { TestForm } from "./TestForm"; + +const meta = { + title: "Components/Apps/RoutingForms/TestForm", + component: TestForm, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock routing form with basic fields +const mockBasicForm: RoutingForm = { + id: "test-form-1", + name: "Contact Form", + description: "A simple contact routing form", + position: 0, + routes: [ + { + id: "route-1", + action: { + type: "eventTypeRedirectUrl", + value: "https://cal.com/team/sales", + eventTypeId: 1, + }, + queryValue: { + id: "query-1", + type: "group", + }, + isFallback: false, + }, + ], + fields: [ + { + id: "field-1", + type: "text", + label: "Full Name", + identifier: "name", + required: true, + placeholder: "Enter your full name", + deleted: false, + }, + { + id: "field-2", + type: "email", + label: "Email Address", + identifier: "email", + required: true, + placeholder: "you@example.com", + deleted: false, + }, + { + id: "field-3", + type: "textarea", + label: "Message", + identifier: "message", + required: false, + placeholder: "Tell us how we can help", + deleted: false, + }, + ], + settings: { + emailOwnerOnSubmission: false, + sendUpdatesTo: [], + }, + disabled: false, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + userId: 1, + teamId: 1, + connectedForms: [], + routers: [], + teamMembers: [], + _count: { + responses: 0, + }, +}; + +// Mock form with multiple choice fields +const mockFormWithChoices: RoutingForm = { + ...mockBasicForm, + id: "test-form-2", + name: "Service Selection Form", + description: "Form with radio and select fields", + fields: [ + { + id: "field-1", + type: "text", + label: "Company Name", + identifier: "company", + required: true, + placeholder: "Your company", + deleted: false, + }, + { + id: "field-2", + type: "radio", + label: "Service Type", + identifier: "service_type", + required: true, + deleted: false, + options: [ + { id: "opt-1", label: "Consulting", value: "consulting" }, + { id: "opt-2", label: "Development", value: "development" }, + { id: "opt-3", label: "Support", value: "support" }, + ], + }, + { + id: "field-3", + type: "select", + label: "Team Size", + identifier: "team_size", + required: true, + deleted: false, + options: [ + { id: "size-1", label: "1-10", value: "small" }, + { id: "size-2", label: "11-50", value: "medium" }, + { id: "size-3", label: "51+", value: "large" }, + ], + }, + ], +}; + +// Mock form with multiselect +const mockFormWithMultiSelect: RoutingForm = { + ...mockBasicForm, + id: "test-form-3", + name: "Interest Survey", + description: "Form with multiselect field", + fields: [ + { + id: "field-1", + type: "text", + label: "Name", + identifier: "name", + required: true, + placeholder: "Your name", + deleted: false, + }, + { + id: "field-2", + type: "multiselect", + label: "Areas of Interest", + identifier: "interests", + required: true, + deleted: false, + options: [ + { id: "int-1", label: "Web Development", value: "web" }, + { id: "int-2", label: "Mobile Apps", value: "mobile" }, + { id: "int-3", label: "DevOps", value: "devops" }, + { id: "int-4", label: "AI/ML", value: "ai" }, + ], + }, + { + id: "field-3", + type: "textarea", + label: "Additional Comments", + identifier: "comments", + required: false, + placeholder: "Any other information", + deleted: false, + }, + ], +}; + +// Mock form with phone field +const mockFormWithPhone: RoutingForm = { + ...mockBasicForm, + id: "test-form-4", + name: "Contact Details Form", + description: "Form with phone field", + fields: [ + { + id: "field-1", + type: "text", + label: "Full Name", + identifier: "name", + required: true, + placeholder: "Enter your name", + deleted: false, + }, + { + id: "field-2", + type: "email", + label: "Email", + identifier: "email", + required: true, + placeholder: "you@example.com", + deleted: false, + }, + { + id: "field-3", + type: "phone", + label: "Phone Number", + identifier: "phone", + required: true, + placeholder: "+1 (555) 000-0000", + deleted: false, + }, + ], +}; + +// Default story with basic form +export const Default: Story = { + args: { + form: mockBasicForm, + supportsTeamMembersMatchingLogic: false, + isDialog: false, + showRRData: false, + }, +}; + +// Story with form in dialog mode +export const DialogMode: Story = { + args: { + form: mockBasicForm, + supportsTeamMembersMatchingLogic: false, + isDialog: true, + showRRData: false, + }, +}; + +// Story with team members matching logic enabled +export const WithTeamMembersMatching: Story = { + args: { + form: { + ...mockBasicForm, + team: { + id: 1, + name: "Sales Team", + slug: "sales", + parentId: 1, + }, + }, + supportsTeamMembersMatchingLogic: true, + isDialog: false, + showRRData: false, + }, +}; + +// Story with multiple choice fields +export const WithChoiceFields: Story = { + args: { + form: mockFormWithChoices, + supportsTeamMembersMatchingLogic: false, + isDialog: false, + showRRData: false, + }, +}; + +// Story with multiselect field +export const WithMultiSelectField: Story = { + args: { + form: mockFormWithMultiSelect, + supportsTeamMembersMatchingLogic: false, + isDialog: false, + showRRData: false, + }, +}; + +// Story with phone field +export const WithPhoneField: Story = { + args: { + form: mockFormWithPhone, + supportsTeamMembersMatchingLogic: false, + isDialog: false, + showRRData: false, + }, +}; + +// Story showing round robin data +export const ShowingRoundRobinData: Story = { + args: { + form: { + ...mockBasicForm, + team: { + id: 1, + name: "Support Team", + slug: "support", + parentId: 1, + }, + }, + supportsTeamMembersMatchingLogic: true, + isDialog: false, + showRRData: true, + }, +}; + +// Story with custom footer renderer +export const WithCustomFooter: Story = { + args: { + form: mockBasicForm, + supportsTeamMembersMatchingLogic: false, + isDialog: true, + showRRData: false, + renderFooter: (onClose, onSubmit, isValid) => ( +
+ + +
+ ), + }, +}; + +// Story with all optional fields +export const AllOptionalFields: Story = { + args: { + form: { + ...mockBasicForm, + fields: [ + { + id: "field-1", + type: "text", + label: "Name (Optional)", + identifier: "name", + required: false, + placeholder: "Your name", + deleted: false, + }, + { + id: "field-2", + type: "email", + label: "Email (Optional)", + identifier: "email", + required: false, + placeholder: "you@example.com", + deleted: false, + }, + { + id: "field-3", + type: "textarea", + label: "Comments (Optional)", + identifier: "comments", + required: false, + placeholder: "Any feedback", + deleted: false, + }, + ], + }, + supportsTeamMembersMatchingLogic: false, + isDialog: false, + showRRData: false, + }, +}; diff --git a/apps/web/components/apps/sendgrid/Setup.stories.tsx b/apps/web/components/apps/sendgrid/Setup.stories.tsx new file mode 100644 index 00000000000000..e0ac3c1d0d542a --- /dev/null +++ b/apps/web/components/apps/sendgrid/Setup.stories.tsx @@ -0,0 +1,152 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import SendgridSetup from "./Setup"; + +const meta = { + title: "Apps/Sendgrid/Setup", + component: SendgridSetup, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/sendgrid/setup", + query: {}, + }, + }, + }, +}; + +export const WithApiKeyInput: Story = { + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/sendgrid/setup", + query: {}, + }, + }, + }, +}; + +export const TestApiKeySuccess: Story = { + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/sendgrid/setup", + query: {}, + push: (url: string) => console.log("Navigate to:", url), + }, + }, + mockData: [ + { + url: "/api/integrations/sendgrid/check", + method: "POST", + status: 200, + response: {}, + }, + ], + }, +}; + +export const TestApiKeyFailed: Story = { + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/sendgrid/setup", + query: {}, + push: (url: string) => console.log("Navigate to:", url), + }, + }, + mockData: [ + { + url: "/api/integrations/sendgrid/check", + method: "POST", + status: 401, + response: { + message: "Invalid API key", + }, + }, + ], + }, +}; + +export const SaveApiKeySuccess: Story = { + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/sendgrid/setup", + query: {}, + push: (url: string) => console.log("Navigate to:", url), + }, + }, + mockData: [ + { + url: "/api/integrations/sendgrid/add", + method: "POST", + status: 200, + response: { + url: "/apps/installed/messaging", + }, + }, + ], + }, +}; + +export const SaveApiKeyError: Story = { + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/sendgrid/setup", + query: {}, + push: (url: string) => console.log("Navigate to:", url), + }, + }, + mockData: [ + { + url: "/api/integrations/sendgrid/add", + method: "POST", + status: 400, + response: { + message: "Failed to save API key. Please try again.", + }, + }, + ], + }, +}; + +export const DarkMode: Story = { + parameters: { + backgrounds: { + default: "dark", + }, + theme: "dark", + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps/sendgrid/setup", + query: {}, + }, + }, + }, +}; diff --git a/apps/web/components/apps/wipemycalother/ConfirmDialog.stories.tsx b/apps/web/components/apps/wipemycalother/ConfirmDialog.stories.tsx new file mode 100644 index 00000000000000..9843373cb4ae3a --- /dev/null +++ b/apps/web/components/apps/wipemycalother/ConfirmDialog.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; +import { useState } from "react"; + +import { Button } from "@calcom/ui/components/button"; + +import { ConfirmDialog } from "./ConfirmDialog"; + +const meta = { + title: "Components/Apps/WipeMyCalOther/ConfirmDialog", + component: ConfirmDialog, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + isOpenDialog: { + control: "boolean", + description: "Controls whether the dialog is open or closed", + }, + setIsOpenDialog: { + description: "Function to set the dialog open state", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Helper component to manage dialog state +const DialogWrapper = ({ initialOpen = false }: { initialOpen?: boolean }) => { + const [isOpen, setIsOpen] = useState(initialOpen); + + return ( +
+ + +
+ ); +}; + +export const Default: Story = { + render: () => , +}; + +export const ClosedState: Story = { + render: () => , +}; + +export const WithTriggerButton: Story = { + render: () => , + parameters: { + docs: { + description: { + story: "Click the button to open the dialog", + }, + }, + }, +}; diff --git a/apps/web/components/auth/BackupCode.stories.tsx b/apps/web/components/auth/BackupCode.stories.tsx new file mode 100644 index 00000000000000..445c52e8ee2305 --- /dev/null +++ b/apps/web/components/auth/BackupCode.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { FormProvider, useForm } from "react-hook-form"; + +import BackupCode from "./BackupCode"; + +const meta: Meta = { + title: "Components/Auth/BackupCode", + component: BackupCode, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => { + const methods = useForm({ + defaultValues: { + backupCode: "", + }, + }); + return ( + + + + ); + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Centered: Story = { + args: { + center: true, + }, +}; + +export const NotCentered: Story = { + args: { + center: false, + }, +}; diff --git a/apps/web/components/auth/TwoFactor.stories.tsx b/apps/web/components/auth/TwoFactor.stories.tsx new file mode 100644 index 00000000000000..1533d4b5b7464e --- /dev/null +++ b/apps/web/components/auth/TwoFactor.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { FormProvider, useForm } from "react-hook-form"; + +import TwoFactor from "./TwoFactor"; + +const meta: Meta = { + title: "Components/Auth/TwoFactor", + component: TwoFactor, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => { + const methods = useForm({ + defaultValues: { + totpCode: "", + }, + }); + return ( + + + + ); + }, + ], + argTypes: { + center: { + control: "boolean", + description: "Whether to center the component horizontally", + }, + autoFocus: { + control: "boolean", + description: "Whether to auto-focus the first input on mount", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Centered: Story = { + args: { + center: true, + }, +}; + +export const NotCentered: Story = { + args: { + center: false, + }, +}; + +export const WithAutoFocus: Story = { + args: { + autoFocus: true, + }, +}; + +export const WithoutAutoFocus: Story = { + args: { + autoFocus: false, + }, +}; diff --git a/apps/web/components/booking/CancelBooking.stories.tsx b/apps/web/components/booking/CancelBooking.stories.tsx new file mode 100644 index 00000000000000..9bca4fc1dd6370 --- /dev/null +++ b/apps/web/components/booking/CancelBooking.stories.tsx @@ -0,0 +1,227 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import CancelBooking from "./CancelBooking"; + +const meta = { + title: "Components/Booking/CancelBooking", + component: CancelBooking, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const baseBooking = { + title: "Team Meeting", + uid: "booking-uid-123", + id: 1, + startTime: new Date("2025-12-30T10:00:00Z"), +}; + +const baseProfile = { + name: "John Doe", + slug: "john-doe", +}; + +const baseBookingCancelledEventProps = { + booking: { + id: 1, + uid: "booking-uid-123", + title: "Team Meeting", + }, + organizer: { + name: "John Doe", + email: "john@example.com", + timeZone: "America/New_York", + }, + eventType: { + title: "Team Meeting", + length: 30, + }, +}; + +export const Default: Story = { + args: { + booking: baseBooking, + profile: baseProfile, + recurringEvent: null, + team: null, + setIsCancellationMode: () => {}, + theme: "light", + allRemainingBookings: false, + currentUserEmail: "user@example.com", + bookingCancelledEventProps: baseBookingCancelledEventProps, + isHost: false, + internalNotePresets: [], + renderContext: "booking-single-view", + eventTypeMetadata: null, + }, +}; + +export const DialogContext: Story = { + args: { + ...Default.args, + renderContext: "dialog", + }, +}; + +export const HostCancelling: Story = { + args: { + ...Default.args, + isHost: true, + currentUserEmail: "john@example.com", + }, +}; + +export const HostWithInternalNotePresets: Story = { + args: { + ...Default.args, + isHost: true, + currentUserEmail: "john@example.com", + internalNotePresets: [ + { + id: 1, + name: "No show", + cancellationReason: "Attendee did not show up for the meeting", + }, + { + id: 2, + name: "Rescheduled", + cancellationReason: "Meeting was rescheduled to a different time", + }, + { + id: 3, + name: "Technical issues", + cancellationReason: "Unable to proceed due to technical difficulties", + }, + ], + }, +}; + +export const WithPayment: Story = { + args: { + ...Default.args, + booking: { + ...baseBooking, + payment: { + amount: 5000, // $50.00 in cents + currency: "USD", + appId: "stripe", + }, + }, + }, +}; + +export const WithNoShowFee: Story = { + args: { + ...Default.args, + booking: { + ...baseBooking, + startTime: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes from now + payment: { + amount: 5000, // $50.00 in cents + currency: "USD", + appId: "stripe", + }, + }, + eventTypeMetadata: { + apps: { + stripe: { + autoChargeNoShowFeeTimeValue: 24, + autoChargeNoShowFeeTimeUnit: "hours", + autoChargeNoShowFee: true, + }, + }, + }, + }, +}; + +export const RecurringBooking: Story = { + args: { + ...Default.args, + allRemainingBookings: true, + recurringEvent: { + freq: 2, // WEEKLY + count: 10, + interval: 1, + }, + }, +}; + +export const WithTeam: Story = { + args: { + ...Default.args, + team: "Engineering Team", + teamId: 42, + }, +}; + +export const SeatReservation: Story = { + args: { + ...Default.args, + seatReferenceUid: "seat-ref-uid-456", + }, +}; + +export const AlreadyCancelled: Story = { + args: { + ...Default.args, + booking: { + ...baseBooking, + uid: undefined, + }, + }, +}; + +export const WithErrorAsToast: Story = { + args: { + ...Default.args, + showErrorAsToast: true, + }, +}; + +export const ComplexScenario: Story = { + args: { + ...Default.args, + isHost: true, + currentUserEmail: "john@example.com", + team: "Sales Team", + teamId: 123, + internalNotePresets: [ + { + id: 1, + name: "Client requested reschedule", + cancellationReason: "Client needs to reschedule to next week", + }, + { + id: 2, + name: "Emergency", + cancellationReason: "Unexpected emergency arose", + }, + ], + booking: { + ...baseBooking, + payment: { + amount: 10000, // $100.00 in cents + currency: "USD", + appId: "stripe", + }, + }, + recurringEvent: { + freq: 2, // WEEKLY + count: 5, + interval: 1, + }, + allRemainingBookings: true, + }, +}; diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.stories.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.stories.tsx new file mode 100644 index 00000000000000..c8a303b36d0921 --- /dev/null +++ b/apps/web/components/booking/actions/BookingActionsDropdown.stories.tsx @@ -0,0 +1,412 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { BookingActionsDropdown } from "./BookingActionsDropdown"; +import { BookingActionsStoreProvider } from "./BookingActionsStoreProvider"; +import type { BookingItemProps } from "../types"; + +// Mock booking data factory +const createMockBooking = (overrides?: Partial): BookingItemProps => ({ + id: 1, + uid: "booking-uid-123", + title: "30 Min Meeting", + description: "A quick meeting to discuss the project", + startTime: new Date("2025-12-24T10:00:00Z"), + endTime: new Date("2025-12-24T10:30:00Z"), + status: "ACCEPTED", + paid: false, + payment: [], + attendees: [ + { + id: 1, + name: "John Doe", + email: "john@example.com", + timeZone: "America/New_York", + locale: "en", + noShow: false, + phoneNumber: null, + }, + ], + user: { + id: 1, + name: "Jane Smith", + email: "jane@example.com", + username: "janesmith", + timeZone: "America/Los_Angeles", + }, + userPrimaryEmail: "jane@example.com", + eventType: { + id: 1, + title: "30 Min Meeting", + slug: "30min", + length: 30, + recurringEvent: null, + team: null, + parentId: null, + disableCancelling: false, + disableRescheduling: false, + metadata: null, + schedulingType: null, + }, + location: "integrations:daily", + recurringEventId: null, + fromReschedule: null, + seatsReferences: [], + metadata: null, + routedFromRoutingFormReponse: null, + isRecorded: false, + listingStatus: "upcoming", + recurringInfo: undefined, + loggedInUser: { + userId: 1, + userTimeZone: "America/Los_Angeles", + userTimeFormat: 12, + userEmail: "jane@example.com", + }, + isToday: false, + ...overrides, +}); + +const meta = { + component: BookingActionsDropdown, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + booking: createMockBooking(), + context: "list", + }, +}; + +export const DetailsContext: Story = { + args: { + booking: createMockBooking({ + status: "PENDING", + }), + context: "details", + }, +}; + +export const PendingBooking: Story = { + args: { + booking: createMockBooking({ + status: "PENDING", + listingStatus: "unconfirmed", + }), + context: "details", + }, +}; + +export const CancelledBooking: Story = { + args: { + booking: createMockBooking({ + status: "CANCELLED", + }), + context: "list", + }, +}; + +export const RejectedBooking: Story = { + args: { + booking: createMockBooking({ + status: "REJECTED", + }), + context: "list", + }, +}; + +export const PastBooking: Story = { + args: { + booking: createMockBooking({ + startTime: new Date("2025-12-20T10:00:00Z"), + endTime: new Date("2025-12-20T10:30:00Z"), + }), + context: "list", + }, +}; + +export const RecurringBooking: Story = { + args: { + booking: createMockBooking({ + recurringEventId: "recurring-123", + listingStatus: "recurring", + eventType: { + id: 1, + title: "Weekly Standup", + slug: "weekly-standup", + length: 30, + recurringEvent: { + freq: 2, + count: 12, + interval: 1, + }, + team: null, + parentId: null, + disableCancelling: false, + disableRescheduling: false, + metadata: null, + schedulingType: null, + }, + }), + context: "list", + }, +}; + +export const WithPayment: Story = { + args: { + booking: createMockBooking({ + paid: true, + payment: [ + { + id: 1, + success: true, + amount: 5000, + currency: "USD", + paymentOption: "ON_BOOKING", + }, + ], + }), + context: "list", + }, +}; + +export const WithPendingPayment: Story = { + args: { + booking: createMockBooking({ + paid: false, + payment: [ + { + id: 1, + success: false, + amount: 5000, + currency: "USD", + paymentOption: "ON_BOOKING", + }, + ], + }), + context: "list", + }, +}; + +export const TeamBooking: Story = { + args: { + booking: createMockBooking({ + eventType: { + id: 1, + title: "Team Meeting", + slug: "team-meeting", + length: 60, + recurringEvent: null, + team: { + id: 1, + name: "Engineering Team", + slug: "engineering", + }, + parentId: null, + disableCancelling: false, + disableRescheduling: false, + metadata: null, + schedulingType: "COLLECTIVE", + }, + }), + context: "list", + }, +}; + +export const DisabledCancelling: Story = { + args: { + booking: createMockBooking({ + eventType: { + id: 1, + title: "Important Meeting", + slug: "important-meeting", + length: 30, + recurringEvent: null, + team: null, + parentId: null, + disableCancelling: true, + disableRescheduling: false, + metadata: null, + schedulingType: null, + }, + }), + context: "list", + }, +}; + +export const DisabledRescheduling: Story = { + args: { + booking: createMockBooking({ + eventType: { + id: 1, + title: "Fixed Time Meeting", + slug: "fixed-time-meeting", + length: 30, + recurringEvent: null, + team: null, + parentId: null, + disableCancelling: false, + disableRescheduling: true, + metadata: null, + schedulingType: null, + }, + }), + context: "list", + }, +}; + +export const WithRecordings: Story = { + args: { + booking: createMockBooking({ + isRecorded: true, + startTime: new Date("2025-12-20T10:00:00Z"), + endTime: new Date("2025-12-20T10:30:00Z"), + }), + context: "list", + }, +}; + +export const MultipleAttendees: Story = { + args: { + booking: createMockBooking({ + attendees: [ + { + id: 1, + name: "John Doe", + email: "john@example.com", + timeZone: "America/New_York", + locale: "en", + noShow: false, + phoneNumber: "+1234567890", + }, + { + id: 2, + name: "Jane Smith", + email: "jane@example.com", + timeZone: "America/Los_Angeles", + locale: "en", + noShow: false, + phoneNumber: "+0987654321", + }, + { + id: 3, + name: "Bob Johnson", + email: "bob@example.com", + timeZone: "Europe/London", + locale: "en", + noShow: false, + phoneNumber: null, + }, + ], + }), + context: "list", + }, +}; + +export const RescheduledBooking: Story = { + args: { + booking: createMockBooking({ + fromReschedule: "original-booking-uid", + }), + context: "list", + }, +}; + +export const RoutingFormBooking: Story = { + args: { + booking: createMockBooking({ + routedFromRoutingFormReponse: { + id: 1, + formId: "form-123", + }, + eventType: { + id: 1, + title: "Sales Call", + slug: "sales-call", + length: 30, + recurringEvent: null, + team: { + id: 1, + name: "Sales Team", + slug: "sales", + }, + parentId: null, + disableCancelling: false, + disableRescheduling: false, + metadata: null, + schedulingType: null, + }, + }), + context: "list", + }, +}; + +export const SmallSize: Story = { + args: { + booking: createMockBooking(), + context: "list", + size: "xs", + }, +}; + +export const LargeSize: Story = { + args: { + booking: createMockBooking(), + context: "list", + size: "lg", + }, +}; + +export const WithoutPortal: Story = { + args: { + booking: createMockBooking(), + context: "list", + usePortal: false, + }, +}; + +export const CustomClassName: Story = { + args: { + booking: createMockBooking(), + context: "list", + className: "bg-brand-default hover:bg-brand-emphasis", + }, +}; + +export const AsAttendee: Story = { + args: { + booking: createMockBooking({ + seatsReferences: [ + { + id: 1, + referenceUid: "seat-ref-123", + attendee: { + id: 1, + email: "jane@example.com", + name: "Jane Smith", + }, + }, + ], + loggedInUser: { + userId: 2, + userTimeZone: "America/Los_Angeles", + userTimeFormat: 12, + userEmail: "jane@example.com", + }, + }), + context: "list", + }, +}; diff --git a/apps/web/components/dialog/AddGuestsDialog.stories.tsx b/apps/web/components/dialog/AddGuestsDialog.stories.tsx new file mode 100644 index 00000000000000..ca709105f1fc04 --- /dev/null +++ b/apps/web/components/dialog/AddGuestsDialog.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { AddGuestsDialog } from "./AddGuestsDialog"; + +const meta = { + title: "Components/Dialog/AddGuestsDialog", + component: AddGuestsDialog, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Wrapper component to handle state +const AddGuestsDialogWithState = (args: { isOpenDialog: boolean; bookingId: number }) => { + const [isOpenDialog, setIsOpenDialog] = useState(args.isOpenDialog); + + return ; +}; + +/** + * Default state of the AddGuestsDialog component. + * The dialog is open and ready for adding guests to a booking. + */ +export const Default: Story = { + render: (args) => , + args: { + isOpenDialog: true, + bookingId: 123, + }, +}; + +/** + * Dialog in a closed state. + * Click the trigger or set isOpenDialog to true to open it. + */ +export const Closed: Story = { + render: (args) => { + const [isOpenDialog, setIsOpenDialog] = useState(false); + + return ( +
+ + +
+ ); + }, + args: { + bookingId: 123, + }, +}; + +/** + * Dialog with a different booking ID. + * Shows the dialog configured for a different booking. + */ +export const DifferentBooking: Story = { + render: (args) => , + args: { + isOpenDialog: true, + bookingId: 456, + }, +}; + +/** + * Interactive example with controlled state. + * Demonstrates full interaction flow with state management. + */ +export const Interactive: Story = { + render: () => { + const [isOpenDialog, setIsOpenDialog] = useState(false); + const [bookingId] = useState(789); + + return ( +
+
+

Booking ID: {bookingId}

+ +
+ +
+ ); + }, +}; diff --git a/apps/web/components/dialog/CancelBookingDialog.stories.tsx b/apps/web/components/dialog/CancelBookingDialog.stories.tsx new file mode 100644 index 00000000000000..a87b40f36de759 --- /dev/null +++ b/apps/web/components/dialog/CancelBookingDialog.stories.tsx @@ -0,0 +1,252 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { CancelBookingDialog } from "./CancelBookingDialog"; + +const meta = { + title: "Components/Dialog/CancelBookingDialog", + component: CancelBookingDialog, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const CancelBookingDialogWrapper = (args: React.ComponentProps) => { + const [isOpen, setIsOpen] = useState(args.isOpenDialog); + + return ; +}; + +export const Default: Story = { + render: (args) => , + args: { + isOpenDialog: true, + booking: { + uid: "booking-uid-123", + id: 1, + title: "30 Min Meeting", + startTime: new Date("2025-12-25T10:00:00Z"), + }, + profile: { + name: "John Doe", + slug: "john-doe", + }, + recurringEvent: null, + team: null, + teamId: undefined, + allRemainingBookings: false, + seatReferenceUid: undefined, + currentUserEmail: "user@example.com", + bookingCancelledEventProps: { + booking: {}, + organizer: { + name: "John Doe", + email: "john@example.com", + timeZone: "America/New_York", + }, + eventType: {}, + }, + isHost: true, + internalNotePresets: [], + eventTypeMetadata: null, + }, +}; + +export const WithPayment: Story = { + render: (args) => , + args: { + ...Default.args, + booking: { + uid: "booking-uid-456", + id: 2, + title: "Consultation - 1 Hour", + startTime: new Date("2025-12-26T14:00:00Z"), + payment: [ + { + amount: 5000, + currency: "USD", + success: true, + paymentOption: "stripe", + appId: "stripe", + refunded: false, + }, + ], + }, + }, +}; + +export const RecurringEvent: Story = { + render: (args) => , + args: { + ...Default.args, + booking: { + uid: "booking-uid-789", + id: 3, + title: "Weekly Standup", + startTime: new Date("2025-12-24T09:00:00Z"), + }, + recurringEvent: { + freq: 2, + count: 12, + interval: 1, + }, + allRemainingBookings: false, + }, +}; + +export const CancelAllRemaining: Story = { + render: (args) => , + args: { + ...Default.args, + booking: { + uid: "booking-uid-101", + id: 4, + title: "Weekly Standup", + startTime: new Date("2025-12-24T09:00:00Z"), + }, + recurringEvent: { + freq: 2, + count: 12, + interval: 1, + }, + allRemainingBookings: true, + }, +}; + +export const TeamBooking: Story = { + render: (args) => , + args: { + ...Default.args, + booking: { + uid: "booking-uid-202", + id: 5, + title: "Team Discovery Call", + startTime: new Date("2025-12-27T15:00:00Z"), + }, + team: "engineering-team", + teamId: 100, + profile: { + name: "Engineering Team", + slug: "engineering-team", + }, + }, +}; + +export const WithInternalNotePresets: Story = { + render: (args) => , + args: { + ...Default.args, + booking: { + uid: "booking-uid-303", + id: 6, + title: "Sales Call", + startTime: new Date("2025-12-28T11:00:00Z"), + }, + internalNotePresets: [ + { id: 1, name: "No Show", cancellationReason: "Attendee did not show up" }, + { id: 2, name: "Rescheduled", cancellationReason: "Meeting was rescheduled" }, + { id: 3, name: "Client Request", cancellationReason: "Cancelled at client's request" }, + ], + }, +}; + +export const AsGuest: Story = { + render: (args) => , + args: { + ...Default.args, + booking: { + uid: "booking-uid-404", + id: 7, + title: "Interview - Frontend Engineer", + startTime: new Date("2025-12-29T16:00:00Z"), + }, + isHost: false, + currentUserEmail: "guest@example.com", + }, +}; + +export const WithSeatReference: Story = { + render: (args) => , + args: { + ...Default.args, + booking: { + uid: "booking-uid-505", + id: 8, + title: "Group Workshop - Design Systems", + startTime: new Date("2025-12-30T13:00:00Z"), + }, + seatReferenceUid: "seat-ref-abc-123", + }, +}; + +export const WithEventTypeMetadata: Story = { + render: (args) => , + args: { + ...Default.args, + booking: { + uid: "booking-uid-606", + id: 9, + title: "Custom Event", + startTime: new Date("2025-12-31T10:00:00Z"), + }, + eventTypeMetadata: { + customField1: "value1", + customField2: "value2", + requiresConfirmation: true, + }, + }, +}; + +export const ComplexScenario: Story = { + render: (args) => , + args: { + ...Default.args, + booking: { + uid: "booking-uid-707", + id: 10, + title: "Enterprise Consultation", + startTime: new Date("2026-01-02T14:00:00Z"), + payment: [ + { + amount: 25000, + currency: "USD", + success: true, + paymentOption: "stripe", + appId: "stripe", + refunded: false, + }, + ], + }, + profile: { + name: "Premium Consulting Team", + slug: "premium-consulting", + }, + team: "premium-consulting", + teamId: 200, + recurringEvent: { + freq: 2, + count: 4, + interval: 1, + }, + internalNotePresets: [ + { id: 1, name: "No Show", cancellationReason: "Attendee did not show up" }, + { id: 2, name: "Rescheduled", cancellationReason: "Meeting was rescheduled" }, + ], + eventTypeMetadata: { + requiresConfirmation: true, + priority: "high", + }, + isHost: true, + }, +}; diff --git a/apps/web/components/dialog/ChargeCardDialog.stories.tsx b/apps/web/components/dialog/ChargeCardDialog.stories.tsx new file mode 100644 index 00000000000000..3a3d6fd5494d68 --- /dev/null +++ b/apps/web/components/dialog/ChargeCardDialog.stories.tsx @@ -0,0 +1,110 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { ChargeCardDialog } from "./ChargeCardDialog"; + +const meta = { + title: "Components/Dialog/ChargeCardDialog", + component: ChargeCardDialog, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Default story with controlled state +export const Default: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(true); + return ; + }, + args: { + bookingId: 12345, + paymentAmount: 5000, // $50.00 in cents + paymentCurrency: "USD", + }, +}; + +// Story showing EUR currency +export const EuroCurrency: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(true); + return ; + }, + args: { + bookingId: 12346, + paymentAmount: 7500, // €75.00 in cents + paymentCurrency: "EUR", + }, +}; + +// Story showing GBP currency +export const PoundCurrency: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(true); + return ; + }, + args: { + bookingId: 12347, + paymentAmount: 10000, // £100.00 in cents + paymentCurrency: "GBP", + }, +}; + +// Story showing a small amount +export const SmallAmount: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(true); + return ; + }, + args: { + bookingId: 12348, + paymentAmount: 999, // $9.99 in cents + paymentCurrency: "USD", + }, +}; + +// Story showing a large amount +export const LargeAmount: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(true); + return ; + }, + args: { + bookingId: 12349, + paymentAmount: 250000, // $2,500.00 in cents + paymentCurrency: "USD", + }, +}; + +// Story demonstrating closed state +export const Closed: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + ); + }, + args: { + bookingId: 12350, + paymentAmount: 5000, + paymentCurrency: "USD", + }, +}; diff --git a/apps/web/components/dialog/EditLocationDialog.stories.tsx b/apps/web/components/dialog/EditLocationDialog.stories.tsx new file mode 100644 index 00000000000000..78e693d4c65da0 --- /dev/null +++ b/apps/web/components/dialog/EditLocationDialog.stories.tsx @@ -0,0 +1,173 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; +import { useState } from "react"; + +import { LocationType } from "@calcom/app-store/locations"; + +import { EditLocationDialog } from "./EditLocationDialog"; + +const meta = { + title: "Components/Dialog/EditLocationDialog", + component: EditLocationDialog, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Wrapper component to manage dialog state +const DialogWrapper = (args: any) => { + const [isOpen, setIsOpen] = useState(true); + return ( + { + console.log("Saving location:", data); + args.saveLocation?.(data); + }} + /> + ); +}; + +export const Default: Story = { + args: { + saveLocation: fn(), + booking: { + location: "integrations:zoom", + }, + isOpenDialog: true, + setShowLocationModal: fn(), + setSelectedLocation: fn(), + setEditingLocationType: fn(), + }, + render: (args) => , +}; + +export const WithPhoneLocation: Story = { + args: { + saveLocation: fn(), + booking: { + location: "+1234567890", + }, + isOpenDialog: true, + setShowLocationModal: fn(), + setSelectedLocation: fn(), + setEditingLocationType: fn(), + }, + render: (args) => , +}; + +export const WithInPersonLocation: Story = { + args: { + saveLocation: fn(), + booking: { + location: "123 Main St, New York, NY 10001", + }, + defaultValues: [ + { + type: LocationType.InPerson, + address: "123 Main St, New York, NY 10001", + displayLocationPublicly: true, + }, + ], + isOpenDialog: true, + setShowLocationModal: fn(), + setSelectedLocation: fn(), + setEditingLocationType: fn(), + }, + render: (args) => , +}; + +export const WithGoogleMeet: Story = { + args: { + saveLocation: fn(), + booking: { + location: "integrations:google:meet", + }, + isOpenDialog: true, + setShowLocationModal: fn(), + setSelectedLocation: fn(), + setEditingLocationType: fn(), + }, + render: (args) => , +}; + +export const WithLink: Story = { + args: { + saveLocation: fn(), + booking: { + location: "https://example.com/meeting", + }, + defaultValues: [ + { + type: LocationType.Link, + link: "https://example.com/meeting", + }, + ], + isOpenDialog: true, + setShowLocationModal: fn(), + setSelectedLocation: fn(), + setEditingLocationType: fn(), + }, + render: (args) => , +}; + +export const WithTeamId: Story = { + args: { + saveLocation: fn(), + booking: { + location: "integrations:zoom", + }, + teamId: 123, + isOpenDialog: true, + setShowLocationModal: fn(), + setSelectedLocation: fn(), + setEditingLocationType: fn(), + }, + render: (args) => , +}; + +export const WithPreselectedLocation: Story = { + args: { + saveLocation: fn(), + booking: { + location: "integrations:zoom", + }, + selection: { + label: "Zoom Video", + value: "integrations:zoom", + credentialId: 456, + }, + isOpenDialog: true, + setShowLocationModal: fn(), + setSelectedLocation: fn(), + setEditingLocationType: fn(), + }, + render: (args) => , +}; + +export const NoCurrentLocation: Story = { + args: { + saveLocation: fn(), + booking: { + location: null, + }, + isOpenDialog: true, + setShowLocationModal: fn(), + setSelectedLocation: fn(), + setEditingLocationType: fn(), + }, + render: (args) => , +}; diff --git a/apps/web/components/dialog/ReassignDialog.stories.tsx b/apps/web/components/dialog/ReassignDialog.stories.tsx new file mode 100644 index 00000000000000..c7aa77ae900404 --- /dev/null +++ b/apps/web/components/dialog/ReassignDialog.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { ReassignDialog } from "./ReassignDialog"; + +const meta = { + title: "Components/Dialog/ReassignDialog", + component: ReassignDialog, + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: + "A dialog component for reassigning bookings to different team members. Supports both automatic reassignment and manual selection of specific team members. Used for both managed events and round-robin events.", + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Wrapper component to manage dialog state +function ReassignDialogWrapper(props: Omit, "isOpenDialog" | "setIsOpenDialog">) { + const [isOpenDialog, setIsOpenDialog] = useState(true); + + return ( + <> + + + + ); +} + +export const Default: Story = { + render: (args) => , + args: { + teamId: 1, + bookingId: 123, + bookingFromRoutingForm: false, + isManagedEvent: false, + }, + parameters: { + docs: { + description: { + story: "Default reassign dialog for round-robin events with auto-reassign option.", + }, + }, + }, +}; + +export const ManagedEvent: Story = { + render: (args) => , + args: { + teamId: 1, + bookingId: 123, + bookingFromRoutingForm: false, + isManagedEvent: true, + }, + parameters: { + docs: { + description: { + story: "Reassign dialog for managed events, showing auto-reassign and specific team member options.", + }, + }, + }, +}; + +export const FromRoutingForm: Story = { + render: (args) => , + args: { + teamId: 1, + bookingId: 123, + bookingFromRoutingForm: true, + isManagedEvent: false, + }, + parameters: { + docs: { + description: { + story: "Reassign dialog for bookings from routing forms. Only shows manual team member selection (no auto-reassign option).", + }, + }, + }, +}; + +export const ManagedEventFromRoutingForm: Story = { + render: (args) => , + args: { + teamId: 1, + bookingId: 123, + bookingFromRoutingForm: true, + isManagedEvent: true, + }, + parameters: { + docs: { + description: { + story: "Reassign dialog for managed events from routing forms. Only manual selection is available.", + }, + }, + }, +}; + +export const RoundRobinEvent: Story = { + render: (args) => , + args: { + teamId: 1, + bookingId: 456, + bookingFromRoutingForm: false, + isManagedEvent: false, + }, + parameters: { + docs: { + description: { + story: "Reassign dialog for round-robin events with both auto and manual reassignment options.", + }, + }, + }, +}; + +export const WithDifferentTeamId: Story = { + render: (args) => , + args: { + teamId: 999, + bookingId: 789, + bookingFromRoutingForm: false, + isManagedEvent: false, + }, + parameters: { + docs: { + description: { + story: "Reassign dialog with a different team ID to test team-specific behavior.", + }, + }, + }, +}; diff --git a/apps/web/components/dialog/RejectionReasonDialog.stories.tsx b/apps/web/components/dialog/RejectionReasonDialog.stories.tsx new file mode 100644 index 00000000000000..60b5b49752767e --- /dev/null +++ b/apps/web/components/dialog/RejectionReasonDialog.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; +import { fn } from "storybook/test"; + +import { RejectionReasonDialog } from "./RejectionReasonDialog"; + +const meta: Meta = { + title: "Components/Dialog/RejectionReasonDialog", + component: RejectionReasonDialog, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(true); + return ; + }, + args: { + onConfirm: fn(), + isPending: false, + }, +}; + +export const WithPendingState: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(true); + return ; + }, + args: { + onConfirm: fn(), + isPending: true, + }, +}; + +export const Closed: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + ); + }, + args: { + onConfirm: fn(), + isPending: false, + }, +}; + +export const Interactive: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + const handleConfirm = (reason: string) => { + console.log("Rejection reason:", reason); + args.onConfirm?.(reason); + setIsOpen(false); + }; + return ( + <> + + + + ); + }, + args: { + onConfirm: fn(), + isPending: false, + }, +}; diff --git a/apps/web/components/dialog/ReportBookingDialog.stories.tsx b/apps/web/components/dialog/ReportBookingDialog.stories.tsx new file mode 100644 index 00000000000000..a81bdd53d0c66f --- /dev/null +++ b/apps/web/components/dialog/ReportBookingDialog.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { ReportBookingDialog } from "./ReportBookingDialog"; + +const meta = { + title: "Components/Dialog/ReportBookingDialog", + component: ReportBookingDialog, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Wrapper component to handle dialog state +const ReportBookingDialogWrapper = (args: any) => { + const [isOpen, setIsOpen] = useState(args.isOpenDialog); + + return ( + + ); +}; + +export const Default: Story = { + render: (args) => , + args: { + isOpenDialog: true, + bookingUid: "booking-123", + isRecurring: false, + status: "upcoming", + }, +}; + +export const UpcomingBooking: Story = { + render: (args) => , + args: { + isOpenDialog: true, + bookingUid: "booking-upcoming-123", + isRecurring: false, + status: "upcoming", + }, +}; + +export const PastBooking: Story = { + render: (args) => , + args: { + isOpenDialog: true, + bookingUid: "booking-past-123", + isRecurring: false, + status: "past", + }, +}; + +export const CancelledBooking: Story = { + render: (args) => , + args: { + isOpenDialog: true, + bookingUid: "booking-cancelled-123", + isRecurring: false, + status: "cancelled", + }, +}; + +export const RejectedBooking: Story = { + render: (args) => , + args: { + isOpenDialog: true, + bookingUid: "booking-rejected-123", + isRecurring: false, + status: "rejected", + }, +}; + +export const RecurringBooking: Story = { + render: (args) => , + args: { + isOpenDialog: true, + bookingUid: "booking-recurring-123", + isRecurring: true, + status: "upcoming", + }, +}; + +export const Closed: Story = { + render: (args) => , + args: { + isOpenDialog: false, + bookingUid: "booking-123", + isRecurring: false, + status: "upcoming", + }, +}; diff --git a/apps/web/components/dialog/RerouteDialog.stories.tsx b/apps/web/components/dialog/RerouteDialog.stories.tsx new file mode 100644 index 00000000000000..49bf86cad742c1 --- /dev/null +++ b/apps/web/components/dialog/RerouteDialog.stories.tsx @@ -0,0 +1,179 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import { RerouteDialog } from "./RerouteDialog"; + +const meta = { + title: "Components/Dialog/RerouteDialog", + component: RerouteDialog, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + argTypes: { + isOpenDialog: { + control: "boolean", + description: "Controls whether the dialog is open", + }, + setIsOpenDialog: { + action: "setIsOpenDialog", + description: "Callback to control dialog open state", + }, + booking: { + description: "Booking object with routing information", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const mockBooking = { + id: 1, + uid: "booking-uid-123", + title: "Team Meeting", + status: "ACCEPTED" as const, + startTime: new Date(Date.now() + 86400000).toISOString(), // Tomorrow + metadata: { + videoCallUrl: "https://example.com/call", + }, + responses: { + name: { value: "John Doe" }, + email: { value: "john.doe@example.com" }, + notes: { value: "Looking forward to the meeting" }, + }, + routedFromRoutingFormReponse: { + id: 1, + }, + attendees: [ + { + name: "John Doe", + email: "john.doe@example.com", + timeZone: "America/New_York", + locale: "en", + }, + ], + eventType: { + id: 1, + slug: "team-meeting", + title: "Team Meeting", + length: 30, + schedulingType: "ROUND_ROBIN" as const, + team: { + slug: "engineering", + }, + }, + user: { + id: 1, + name: "Jane Smith", + email: "jane.smith@example.com", + }, +}; + +const mockBookingWithCollectiveScheduling = { + ...mockBooking, + eventType: { + ...mockBooking.eventType, + schedulingType: "COLLECTIVE" as const, + }, +}; + +const mockBookingPastTimeslot = { + ...mockBooking, + startTime: new Date(Date.now() - 86400000).toISOString(), // Yesterday +}; + +export const Default: Story = { + args: { + isOpenDialog: true, + setIsOpenDialog: fn(), + booking: mockBooking, + }, +}; + +export const Closed: Story = { + args: { + isOpenDialog: false, + setIsOpenDialog: fn(), + booking: mockBooking, + }, +}; + +export const CollectiveScheduling: Story = { + args: { + isOpenDialog: true, + setIsOpenDialog: fn(), + booking: mockBookingWithCollectiveScheduling, + }, +}; + +export const PastTimeslot: Story = { + args: { + isOpenDialog: true, + setIsOpenDialog: fn(), + booking: mockBookingPastTimeslot, + }, +}; + +export const LongEventDuration: Story = { + args: { + isOpenDialog: true, + setIsOpenDialog: fn(), + booking: { + ...mockBooking, + eventType: { + ...mockBooking.eventType, + length: 120, // 2 hours + title: "Extended Strategy Session", + }, + }, + }, +}; + +export const MultipleAttendees: Story = { + args: { + isOpenDialog: true, + setIsOpenDialog: fn(), + booking: { + ...mockBooking, + attendees: [ + { + name: "John Doe", + email: "john.doe@example.com", + timeZone: "America/New_York", + locale: "en", + }, + { + name: "Alice Johnson", + email: "alice.johnson@example.com", + timeZone: "America/Los_Angeles", + locale: "en", + }, + { + name: "Bob Williams", + email: "bob.williams@example.com", + timeZone: "Europe/London", + locale: "en", + }, + ], + }, + }, +}; + +export const DifferentTimezones: Story = { + args: { + isOpenDialog: true, + setIsOpenDialog: fn(), + booking: { + ...mockBooking, + attendees: [ + { + name: "Tokyo User", + email: "tokyo@example.com", + timeZone: "Asia/Tokyo", + locale: "ja", + }, + ], + }, + }, +}; diff --git a/apps/web/components/dialog/RescheduleDialog.stories.tsx b/apps/web/components/dialog/RescheduleDialog.stories.tsx new file mode 100644 index 00000000000000..b67b8112c5a3c0 --- /dev/null +++ b/apps/web/components/dialog/RescheduleDialog.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { RescheduleDialog } from "./RescheduleDialog"; + +const meta = { + title: "Components/Dialog/RescheduleDialog", + component: RescheduleDialog, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Wrapper component to handle state +const RescheduleDialogWrapper = (args: { bookingUid: string; initialOpen?: boolean }) => { + const [isOpen, setIsOpen] = useState(args.initialOpen ?? true); + + return ( +
+ + +
+ ); +}; + +export const Default: Story = { + render: () => , + parameters: { + docs: { + description: { + story: "Default reschedule dialog with standard booking UID.", + }, + }, + }, +}; + +export const WithButton: Story = { + render: () => , + parameters: { + docs: { + description: { + story: "Reschedule dialog that can be opened via a button click.", + }, + }, + }, +}; + +export const LongBookingUid: Story = { + render: () => ( + + ), + parameters: { + docs: { + description: { + story: "Reschedule dialog with a longer booking UID to test edge cases.", + }, + }, + }, +}; diff --git a/apps/web/components/error/BookingPageErrorBoundary.stories.tsx b/apps/web/components/error/BookingPageErrorBoundary.stories.tsx new file mode 100644 index 00000000000000..c504318e7b881d --- /dev/null +++ b/apps/web/components/error/BookingPageErrorBoundary.stories.tsx @@ -0,0 +1,126 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import BookingPageErrorBoundary from "./BookingPageErrorBoundary"; + +const meta = { + title: "Components/Error/BookingPageErrorBoundary", + component: BookingPageErrorBoundary, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Component that throws a generic error +const GenericErrorComponent = () => { + throw new Error("Failed to load booking page"); +}; + +// Component that throws a network error +const NetworkErrorComponent = () => { + const error = new Error("Network request failed"); + error.name = "NetworkError"; + throw error; +}; + +// Component that throws a validation error +const ValidationErrorComponent = () => { + const error = new Error("Invalid booking parameters: eventTypeId is required"); + error.name = "ValidationError"; + throw error; +}; + +// Component that throws an error with a long stack trace +const DetailedErrorComponent = () => { + const error = new Error("Detailed error with stack trace"); + error.stack = `Error: Detailed error with stack trace + at DetailedErrorComponent (BookingPageErrorBoundary.stories.tsx:45:19) + at renderWithHooks (react-dom.development.js:14985:18) + at mountIndeterminateComponent (react-dom.development.js:17811:13) + at beginWork (react-dom.development.js:19049:16) + at HTMLUnknownElement.callCallback (react-dom.development.js:3945:14) + at Object.invokeGuardedCallbackDev (react-dom.development.js:3994:16)`; + throw error; +}; + +// Component that renders successfully +const SuccessfulComponent = () => { + return ( +
+

Booking Page

+

+ This is a successful booking page render. No errors occurred. +

+
+
+

Event Type: 30 Min Meeting

+
+
+

Duration: 30 minutes

+
+
+

Status: Available

+
+
+
+ ); +}; + +export const Default: Story = { + render: () => ( + + + + ), +}; + +export const NetworkError: Story = { + render: () => ( + + + + ), +}; + +export const ValidationError: Story = { + render: () => ( + + + + ), +}; + +export const DetailedError: Story = { + render: () => ( + + + + ), +}; + +export const NoError: Story = { + render: () => ( + + + + ), +}; + +export const MultipleChildrenWithError: Story = { + render: () => ( + +
+
+

Header Section

+
+ +
+

Footer Section (won't render due to error above)

+
+
+
+ ), +}; diff --git a/apps/web/components/error/ErrorPage.stories.tsx b/apps/web/components/error/ErrorPage.stories.tsx new file mode 100644 index 00000000000000..1daf6ce3c7c7fc --- /dev/null +++ b/apps/web/components/error/ErrorPage.stories.tsx @@ -0,0 +1,169 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { HttpError } from "@calcom/lib/http-error"; + +import { ErrorPage } from "./error-page"; + +const meta = { + title: "Components/Error/ErrorPage", + component: ErrorPage, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + statusCode: 500, + message: "Internal Server Error: Unable to process your request at this time.", + }, +}; + +export const Error404: Story = { + args: { + statusCode: 404, + message: "The page you are looking for could not be found.", + }, +}; + +export const Error403: Story = { + args: { + statusCode: 403, + message: "You do not have permission to access this resource.", + }, +}; + +export const Error400: Story = { + args: { + statusCode: 400, + message: "Bad Request: The request could not be understood by the server.", + }, +}; + +export const Error503: Story = { + args: { + statusCode: 503, + message: "Service Unavailable: The server is currently unable to handle the request.", + }, +}; + +export const WithStandardError: Story = { + args: { + statusCode: 500, + message: "An unexpected error occurred while processing your booking.", + error: new Error("Database connection timeout"), + }, +}; + +export const WithHttpError: Story = { + args: { + statusCode: 502, + message: "Bad Gateway: Unable to connect to the upstream server.", + error: new HttpError({ + statusCode: 502, + message: "Bad Gateway", + url: "https://api.cal.com/v1/bookings", + cause: new Error("Connection refused"), + }), + }, +}; + +export const WithDebugPanel: Story = { + args: { + statusCode: 500, + message: "Internal Server Error: Failed to create booking due to validation error.", + error: new Error("Validation failed: Invalid event type configuration"), + displayDebug: true, + }, +}; + +export const WithHttpErrorAndDebug: Story = { + args: { + statusCode: 504, + message: "Gateway Timeout: The server took too long to respond.", + error: new HttpError({ + statusCode: 504, + message: "Gateway Timeout", + url: "https://api.cal.com/v1/availability", + cause: new Error("Request timeout after 30000ms"), + }), + displayDebug: true, + }, +}; + +export const LongErrorMessage: Story = { + args: { + statusCode: 500, + message: + "Error ID: ERR-2025-12-23-ABC123 | Timestamp: 2025-12-23T10:30:00Z | Service: booking-service | Database: Connection pool exhausted after 5000ms | Stack Trace: at BookingService.create (/app/services/booking.js:142:15) | Request ID: req_abc123xyz789 | User ID: usr_456def | Please include this information when contacting support.", + }, +}; + +export const WithResetCallback: Story = { + args: { + statusCode: 500, + message: "An error occurred while loading your calendar data. You can try again to reload the page.", + error: new Error("Failed to fetch calendar events"), + reset: () => { + console.log("Reset callback triggered - page will reload"); + }, + }, +}; + +export const MinimalError: Story = { + args: { + statusCode: 500, + }, +}; + +export const WithoutStatusCode: Story = { + args: { + message: "An unexpected error occurred. Please try again later.", + error: new Error("Unknown error"), + }, +}; + +export const ComplexHttpErrorWithDebug: Story = { + args: { + statusCode: 422, + message: + "Unprocessable Entity: The booking request contains invalid data. Event ID: evt_123abc | User ID: usr_456def | Timestamp: 2025-12-23T15:45:00Z", + error: new HttpError({ + statusCode: 422, + message: "Validation Error: Event type does not accept bookings at this time", + url: "https://api.cal.com/v1/bookings/create", + cause: new Error("Event type is disabled or archived"), + }), + displayDebug: true, + }, +}; + +export const NetworkError: Story = { + args: { + statusCode: 0, + message: + "Network Error: Unable to reach the server. Please check your internet connection and try again.", + error: new Error("Network request failed"), + }, +}; + +export const DatabaseError: Story = { + args: { + statusCode: 500, + message: + "Database Error: Unable to retrieve booking information. Error Code: DB_001 | Connection: primary-db-pool | Timestamp: 2025-12-23T12:00:00Z", + error: new Error("ECONNREFUSED: Connection refused to database"), + displayDebug: true, + }, +}; diff --git a/apps/web/components/integrations/SubHeadingTitleWithConnections.stories.tsx b/apps/web/components/integrations/SubHeadingTitleWithConnections.stories.tsx new file mode 100644 index 00000000000000..1de0b72f48a4d7 --- /dev/null +++ b/apps/web/components/integrations/SubHeadingTitleWithConnections.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import SubHeadingTitleWithConnections from "./SubHeadingTitleWithConnections"; + +const meta = { + component: SubHeadingTitleWithConnections, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + title: { + description: "The title text to display", + control: "text", + }, + numConnections: { + description: "Number of connections to display in the badge", + control: { type: "number", min: 0 }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Calendar Integration", + }, +}; + +export const WithOneConnection: Story = { + args: { + title: "Calendar Integration", + numConnections: 1, + }, +}; + +export const WithMultipleConnections: Story = { + args: { + title: "Calendar Integration", + numConnections: 3, + }, +}; + +export const WithZeroConnections: Story = { + args: { + title: "Calendar Integration", + numConnections: 0, + }, +}; + +export const LongTitle: Story = { + args: { + title: "This is a very long integration title to test layout", + numConnections: 5, + }, +}; + +export const WithReactNodeTitle: Story = { + args: { + title: ( + + Bold Title with some emphasis + + ), + numConnections: 2, + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+

No Connections

+
+ +
+
+
+

With Connections

+
+
+ +
+
+ +
+
+ +
+
+
+
+ ), + parameters: { + layout: "padded", + }, +}; diff --git a/apps/web/components/security/DisableUserImpersonation.stories.tsx b/apps/web/components/security/DisableUserImpersonation.stories.tsx new file mode 100644 index 00000000000000..599a64c8216734 --- /dev/null +++ b/apps/web/components/security/DisableUserImpersonation.stories.tsx @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; + +// Create a presentational-only version for Storybook +// The actual component uses TRPC for mutations +const DisableUserImpersonationPreview = ({ + disableImpersonation, +}: { + disableImpersonation: boolean; +}) => { + const [disabled, setDisabled] = useState(disableImpersonation); + + return ( +
+
+
+

User Impersonation

+ + {!disabled ? "Enabled" : "Disabled"} + +
+

+ Allow Cal.com support to temporarily access your account for troubleshooting +

+
+
+ +
+
+ ); +}; + +const meta = { + component: DisableUserImpersonationPreview, + title: "Web/Security/DisableUserImpersonation", + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ImpersonationEnabled: Story = { + args: { + disableImpersonation: false, + }, +}; + +export const ImpersonationDisabled: Story = { + args: { + disableImpersonation: true, + }, +}; + +export const Interactive: Story = { + render: function InteractiveStory() { + const [disabled, setDisabled] = useState(false); + + return ( +
+
+
+

User Impersonation

+ + {!disabled ? "Enabled" : "Disabled"} + +
+

+ Allow Cal.com support to temporarily access your account for troubleshooting +

+
+
+ +
+
+ ); + }, +}; diff --git a/apps/web/components/security/TwoFactorAuthSection.stories.tsx b/apps/web/components/security/TwoFactorAuthSection.stories.tsx new file mode 100644 index 00000000000000..ad99c92b4a28e1 --- /dev/null +++ b/apps/web/components/security/TwoFactorAuthSection.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; + +// Create a presentational-only version for Storybook +// The actual component uses modals with API calls +const TwoFactorAuthSectionPreview = ({ twoFactorEnabled }: { twoFactorEnabled: boolean }) => { + const [enabled, setEnabled] = useState(twoFactorEnabled); + + return ( +
+
+
+

Two-Factor Authentication

+ + {enabled ? "Enabled" : "Disabled"} + +
+

Add an extra layer of security to your account

+
+
+ +
+
+ ); +}; + +const meta = { + component: TwoFactorAuthSectionPreview, + title: "Web/Security/TwoFactorAuthSection", + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Disabled: Story = { + args: { + twoFactorEnabled: false, + }, +}; + +export const Enabled: Story = { + args: { + twoFactorEnabled: true, + }, +}; + +export const Interactive: Story = { + render: function InteractiveStory() { + const [enabled, setEnabled] = useState(false); + + return ( +
+
+
+

+ Two-Factor Authentication +

+ + {enabled ? "Enabled" : "Disabled"} + +
+

Add an extra layer of security to your account

+
+
+ +
+
+ ); + }, +}; diff --git a/apps/web/components/security/TwoFactorModalHeader.stories.tsx b/apps/web/components/security/TwoFactorModalHeader.stories.tsx new file mode 100644 index 00000000000000..a0c916d3048596 --- /dev/null +++ b/apps/web/components/security/TwoFactorModalHeader.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import TwoFactorModalHeader from "./TwoFactorModalHeader"; + +const meta = { + component: TwoFactorModalHeader, + title: "Web/Security/TwoFactorModalHeader", + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const EnableTwoFactor: Story = { + args: { + title: "Enable Two-Factor Authentication", + description: "Add an extra layer of security to your account by enabling two-factor authentication.", + }, +}; + +export const ConfirmPassword: Story = { + args: { + title: "Confirm Password", + description: "Please enter your current password to continue with two-factor authentication setup.", + }, +}; + +export const ScanQRCode: Story = { + args: { + title: "Scan QR Code", + description: "Use your authenticator app to scan the QR code or enter the secret key manually.", + }, +}; + +export const EnterCode: Story = { + args: { + title: "Enter Verification Code", + description: "Enter the 6-digit code from your authenticator app to verify setup.", + }, +}; + +export const DisableTwoFactor: Story = { + args: { + title: "Disable Two-Factor Authentication", + description: "We recommend keeping two-factor authentication enabled for maximum security.", + }, +}; + +export const SuccessMessage: Story = { + args: { + title: "Two-Factor Authentication Enabled", + description: "Your account is now protected with an additional layer of security.", + }, +}; + +export const LongDescription: Story = { + args: { + title: "Security Verification Required", + description: + "For your security, we require additional verification before making changes to your account. Please enter your password and the verification code from your authenticator app.", + }, +}; diff --git a/apps/web/components/setup/AdminUser.stories.tsx b/apps/web/components/setup/AdminUser.stories.tsx new file mode 100644 index 00000000000000..b76ae407de026d --- /dev/null +++ b/apps/web/components/setup/AdminUser.stories.tsx @@ -0,0 +1,208 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import { AdminUser, AdminUserContainer } from "./AdminUser"; + +const meta: Meta = { + title: "Components/Setup/AdminUser", + component: AdminUser, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + onSubmit: { + description: "Callback fired when the form is submitted", + action: "submitted", + }, + onError: { + description: "Callback fired when the form encounters an error", + action: "error", + }, + onSuccess: { + description: "Callback fired when the form submission is successful", + action: "success", + }, + nav: { + description: "Navigation callbacks for wizard navigation", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + onSubmit: fn(), + onError: fn(), + onSuccess: fn(), + nav: { + onNext: fn(), + onPrev: fn(), + }, + }, +}; + +export const WithLongWebsiteUrl: Story = { + args: { + onSubmit: fn(), + onError: fn(), + onSuccess: fn(), + nav: { + onNext: fn(), + onPrev: fn(), + }, + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/setup", + }, + }, + }, + decorators: [ + (Story) => { + // Mock a long WEBSITE_URL to trigger the long URL UI + const originalUrl = process.env.NEXT_PUBLIC_WEBSITE_URL; + process.env.NEXT_PUBLIC_WEBSITE_URL = "https://very-long-company-name-for-testing.example.com"; + const result = ( +
+ +
+ ); + process.env.NEXT_PUBLIC_WEBSITE_URL = originalUrl; + return result; + }, + ], +}; + +export const Interactive: Story = { + args: { + onSubmit: fn(), + onError: fn(), + onSuccess: fn(), + nav: { + onNext: fn(), + onPrev: fn(), + }, + }, + parameters: { + docs: { + description: { + story: + "Interactive story showing the admin user creation form with validation. Try filling in the form fields to see validation in action. The password field requires uppercase, lowercase, numbers, and at least 7 characters.", + }, + }, + }, +}; + +// AdminUserContainer stories +const containerMeta: Meta = { + title: "Components/Setup/AdminUserContainer", + component: AdminUserContainer, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + userCount: { + control: "number", + description: "Number of existing users - shows success state if > 0", + }, + onSubmit: { + description: "Callback fired when the form is submitted", + action: "submitted", + }, + onError: { + description: "Callback fired when the form encounters an error", + action: "error", + }, + onSuccess: { + description: "Callback fired when the form submission is successful", + action: "success", + }, + nav: { + description: "Navigation callbacks for wizard navigation", + }, + }, +}; + +export const ContainerDefault = { + render: (args: React.ComponentProps) => , + args: { + userCount: 0, + onSubmit: fn(), + onError: fn(), + onSuccess: fn(), + nav: { + onNext: fn(), + onPrev: fn(), + }, + }, + parameters: { + docs: { + description: { + story: "Default state of the container when no admin user exists yet (userCount = 0).", + }, + }, + }, +} satisfies StoryObj; + +export const ContainerWithExistingUser = { + render: (args: React.ComponentProps) => , + args: { + userCount: 1, + onSubmit: fn(), + onError: fn(), + onSuccess: fn(), + nav: { + onNext: fn(), + onPrev: fn(), + }, + }, + parameters: { + docs: { + description: { + story: + "Container state when an admin user already exists (userCount > 0). Shows a success screen instead of the form.", + }, + }, + }, +} satisfies StoryObj; + +export const ContainerMultipleUsers = { + render: (args: React.ComponentProps) => , + args: { + userCount: 5, + onSubmit: fn(), + onError: fn(), + onSuccess: fn(), + nav: { + onNext: fn(), + onPrev: fn(), + }, + }, + parameters: { + docs: { + description: { + story: "Container with multiple existing users - shows the same success screen as with one user.", + }, + }, + }, +} satisfies StoryObj; diff --git a/apps/web/components/ui/AuthContainer.stories.tsx b/apps/web/components/ui/AuthContainer.stories.tsx new file mode 100644 index 00000000000000..ff00aa3059bdf1 --- /dev/null +++ b/apps/web/components/ui/AuthContainer.stories.tsx @@ -0,0 +1,167 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { Button } from "@calcom/ui/components/button"; +import { TextField } from "@calcom/ui/components/form"; + +import AuthContainer from "./AuthContainer"; + +const meta = { + component: AuthContainer, + title: "Web/UI/AuthContainer", + parameters: { + layout: "fullscreen", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + showLogo: true, + heading: "Sign in to your account", + children: ( +
+ + + + + ), + footerText: ( + <> + Don't have an account?{" "} + + Sign up + + + ), + }, +}; + +export const WithoutLogo: Story = { + args: { + showLogo: false, + heading: "Welcome back", + children: ( +
+ + + + + ), + }, +}; + +export const Loading: Story = { + args: { + showLogo: true, + heading: "Sign in", + loading: true, + children: ( +
+ + + + + ), + }, +}; + +export const SignUp: Story = { + args: { + showLogo: true, + heading: "Create your account", + children: ( +
+ + + + + + + ), + footerText: ( + <> + Already have an account?{" "} + + Sign in + + + ), + }, +}; + +export const ForgotPassword: Story = { + args: { + showLogo: true, + heading: "Reset your password", + children: ( +
+

+ Enter your email address and we'll send you a link to reset your password. +

+ + + + ), + footerText: ( + + Back to sign in + + ), + }, +}; + +export const TwoFactorAuth: Story = { + args: { + showLogo: true, + heading: "Two-factor authentication", + children: ( +
+

+ Enter the 6-digit code from your authenticator app. +

+ + + + ), + footerText: ( + + Use backup code instead + + ), + }, +}; + +export const MinimalContent: Story = { + args: { + showLogo: true, + children: ( +
+

Check your email

+

+ We've sent a verification link to your email address. +

+
+ ), + }, +}; diff --git a/apps/web/components/ui/LinkIconButton.stories.tsx b/apps/web/components/ui/LinkIconButton.stories.tsx new file mode 100644 index 00000000000000..8d3dc1646b5105 --- /dev/null +++ b/apps/web/components/ui/LinkIconButton.stories.tsx @@ -0,0 +1,175 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import LinkIconButton from "./LinkIconButton"; + +const meta = { + component: LinkIconButton, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + Icon: { + control: "text", + description: "Icon name from the Cal.com icon set", + }, + children: { + control: "text", + description: "Button text content", + }, + onClick: { + action: "clicked", + description: "Click handler", + }, + disabled: { + control: "boolean", + description: "Whether the button is disabled", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + Icon: "link", + children: "Link Button", + }, +}; + +export const WithCalendarIcon: Story = { + args: { + Icon: "calendar", + children: "Calendar", + }, +}; + +export const WithUserIcon: Story = { + args: { + Icon: "user", + children: "Profile", + }, +}; + +export const WithSettingsIcon: Story = { + args: { + Icon: "settings", + children: "Settings", + }, +}; + +export const WithPlusIcon: Story = { + args: { + Icon: "plus", + children: "Add New", + }, +}; + +export const WithEditIcon: Story = { + args: { + Icon: "edit", + children: "Edit", + }, +}; + +export const WithTrashIcon: Story = { + args: { + Icon: "trash", + children: "Delete", + }, +}; + +export const WithCopyIcon: Story = { + args: { + Icon: "copy", + children: "Copy", + }, +}; + +export const WithExternalLinkIcon: Story = { + args: { + Icon: "external-link", + children: "Open External", + }, +}; + +export const Disabled: Story = { + args: { + Icon: "link", + children: "Disabled Button", + disabled: true, + }, +}; + +export const LongText: Story = { + args: { + Icon: "file-text", + children: "Button with longer text content", + }, +}; + +export const ShortText: Story = { + args: { + Icon: "check", + children: "OK", + }, +}; + +export const CommonVariants: Story = { + render: () => ( +
+ Calendar + Profile + Settings + Add New + Edit + Copy + Delete + Open Link +
+ ), +}; + +export const WithClickHandlers: Story = { + render: () => ( +
+ alert("Saved!")}> + Save + + alert("Cancelled!")}> + Cancel + + alert("Copied!")}> + Copy to Clipboard + +
+ ), +}; + +export const NavigationButtons: Story = { + render: () => ( +
+ Back + Next + Home + Sign Out +
+ ), +}; + +export const DisabledState: Story = { + render: () => ( +
+ + Disabled Link + + + Disabled Calendar + + + Disabled Settings + +
+ ), +}; diff --git a/apps/web/components/ui/ModalContainer.stories.tsx b/apps/web/components/ui/ModalContainer.stories.tsx new file mode 100644 index 00000000000000..85c00a81382112 --- /dev/null +++ b/apps/web/components/ui/ModalContainer.stories.tsx @@ -0,0 +1,221 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { Button } from "@calcom/ui/components/button"; + +import ModalContainer from "./ModalContainer"; + +const meta = { + component: ModalContainer, + title: "Web/UI/ModalContainer", + parameters: { + layout: "fullscreen", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: function DefaultStory() { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ + setIsOpen(false)}> +
+

Modal Title

+

+ This is the modal content. You can put any content here. +

+
+ + +
+
+
+
+ ); + }, +}; + +export const Wide: Story = { + render: function WideStory() { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ + setIsOpen(false)} wide> +
+

Wide Modal

+

+ This is a wide modal that can contain more content side by side. +

+
+
+

Left Column

+

Some content here

+
+
+

Right Column

+

Some content here

+
+
+
+ + +
+
+
+
+ ); + }, +}; + +export const Scrollable: Story = { + render: function ScrollableStory() { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ + setIsOpen(false)} scroll> +
+

Scrollable Content

+ {Array.from({ length: 20 }).map((_, i) => ( +

+ This is paragraph {i + 1}. The modal content is scrollable when it exceeds the container height. +

+ ))} +
+ + +
+
+
+
+ ); + }, +}; + +export const NoPadding: Story = { + render: function NoPaddingStory() { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ + setIsOpen(false)} noPadding> +
+

Full Width Header

+

This modal has no padding for custom layouts

+
+
+

Content with custom padding applied manually.

+
+ +
+
+
+
+ ); + }, +}; + +export const FormModal: Story = { + render: function FormModalStory() { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ + setIsOpen(false)}> +
+

Edit Profile

+
+
+ + +
+
+ + +
+
+ +