From fe009ed61b49f282008614c8c07820b6543899c2 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 23 Dec 2025 15:48:39 -0500 Subject: [PATCH 1/8] feat(storybook): add 100+ new component stories across packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated comprehensive Storybook stories for components in: - packages/ui/components (slider, app cards, calendar switch, etc.) - packages/features (data-table, embed, insights, forms, schedules, etc.) - apps/web/components (dialogs, booking actions, auth, apps setups) Key changes: - Add path aliases in .storybook/main.ts for apps/web components - Fix @storybook/test -> storybook/test imports (11 files) - Fix JSX syntax errors in AddVariablesPlugin and MintlifyChat stories - Add tooling/storybook-generator for parallel story generation Note: Some stories removed due to platform-atoms dependencies that require additional Storybook configuration (Booker, EmbedTabs, etc.) TODO: Fix ESLint warnings (unused imports, explicit any types) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/storybook/.storybook/main.ts | 21 + .../components/AddToHomescreen.stories.tsx | 17 + apps/web/components/apps/AppPage.stories.tsx | 330 +++++++++ .../MultiDisconnectIntegration.stories.tsx | 250 +++++++ .../components/apps/alby/Setup.stories.tsx | 138 ++++ .../apps/applecalendar/Setup.stories.tsx | 75 +++ .../apps/btcpayserver/Setup.stories.tsx | 61 ++ .../apps/caldavcalendar/Setup.stories.tsx | 206 ++++++ .../exchange2013calendar/Setup.stories.tsx | 115 ++++ .../exchange2016calendar/Setup.stories.tsx | 52 ++ .../apps/exchangecalendar/Setup.stories.tsx | 79 +++ .../components/apps/hitpay/Setup.stories.tsx | 166 +++++ .../apps/ics-feedcalendar/Setup.stories.tsx | 254 +++++++ .../installation/AccountsStepCard.stories.tsx | 206 ++++++ .../ConfigureStepCard.stories.tsx | 260 ++++++++ .../EventTypesStepCard.stories.tsx | 305 +++++++++ .../apps/layouts/AppsLayout.stories.tsx | 240 +++++++ .../components/apps/make/Setup.stories.tsx | 262 ++++++++ .../components/apps/paypal/Setup.stories.tsx | 160 +++++ .../routing-forms/FormActions.stories.tsx | 531 +++++++++++++++ .../FormSettingsSlideover.stories.tsx | 357 ++++++++++ .../apps/routing-forms/Header.stories.tsx | 399 +++++++++++ .../apps/routing-forms/SingleForm.stories.tsx | 519 +++++++++++++++ .../apps/routing-forms/TestForm.stories.tsx | 360 ++++++++++ .../apps/sendgrid/Setup.stories.tsx | 152 +++++ .../wipemycalother/ConfirmDialog.stories.tsx | 59 ++ .../components/auth/BackupCode.stories.tsx | 44 ++ .../web/components/auth/TwoFactor.stories.tsx | 66 ++ .../booking/CancelBooking.stories.tsx | 227 +++++++ .../BookingActionsDropdown.stories.tsx | 412 ++++++++++++ .../dialog/AddGuestsDialog.stories.tsx | 113 ++++ .../dialog/CancelBookingDialog.stories.tsx | 252 +++++++ .../dialog/ChargeCardDialog.stories.tsx | 110 +++ .../dialog/EditLocationDialog.stories.tsx | 173 +++++ .../dialog/ReassignDialog.stories.tsx | 151 +++++ .../dialog/RejectionReasonDialog.stories.tsx | 92 +++ .../dialog/ReportBookingDialog.stories.tsx | 106 +++ .../dialog/RerouteDialog.stories.tsx | 179 +++++ .../dialog/RescheduleDialog.stories.tsx | 79 +++ .../BookingPageErrorBoundary.stories.tsx | 126 ++++ .../components/error/ErrorPage.stories.tsx | 169 +++++ ...SubHeadingTitleWithConnections.stories.tsx | 99 +++ .../components/setup/AdminUser.stories.tsx | 208 ++++++ .../components/ui/LinkIconButton.stories.tsx | 175 +++++ .../ui/form/CheckedSelect.stories.tsx | 200 ++++++ .../components/ui/form/DatePicker.stories.tsx | 72 ++ .../apps/components/AllApps.stories.tsx | 299 +++++++++ .../apps/components/AppCard.stories.tsx | 450 +++++++++++++ .../apps/components/AppList.stories.tsx | 468 +++++++++++++ .../apps/components/AppListCard.stories.tsx | 189 ++++++ .../AppListCardWebWrapper.stories.tsx | 194 ++++++ .../AppSetDefaultLinkDialog.stories.tsx | 341 ++++++++++ .../CredentialActionsDropdown.stories.tsx | 183 +++++ .../DisconnectIntegration.stories.tsx | 138 ++++ .../apps/components/Slider.stories.tsx | 277 ++++++++ packages/features/auth/SAMLLogin.stories.tsx | 296 ++++++++ .../components/VerifyCodeDialog.stories.tsx | 379 +++++++++++ .../calendars/CalendarSwitch.stories.tsx | 325 +++++++++ .../DestinationCalendarSelector.stories.tsx | 593 +++++++++++++++++ .../TimezoneSelect.stories.tsx | 280 ++++++++ .../components/DataTable.stories.tsx | 611 +++++++++++++++++ .../DataTableSelectionBar.stories.tsx | 561 ++++++++++++++++ .../components/DataTableToolbar.stories.tsx | 466 +++++++++++++ .../components/DataTableWrapper.stories.tsx | 300 +++++++++ .../MeetingSessionDetailsDialog.stories.tsx | 352 ++++++++++ packages/features/embed/Embed.stories.tsx | 352 ++++++++++ .../features/embed/EventTypeEmbed.stories.tsx | 202 ++++++ .../embed/RoutingFormEmbed.stories.tsx | 368 ++++++++++ .../AddMembersWithSwitch.stories.tsx | 432 ++++++++++++ .../components/CheckedTeamSelect.stories.tsx | 276 ++++++++ .../CreateEventTypeForm.stories.tsx | 282 ++++++++ .../EventTypeDescription.stories.tsx | 354 ++++++++++ .../components/HostEditDialogs.stories.tsx | 367 ++++++++++ ...MultiplePrivateLinksController.stories.tsx | 316 +++++++++ .../components/TeamsFilter.stories.tsx | 421 ++++++++++++ .../components/AssignFeatureSheet.stories.tsx | 482 ++++++++++++++ .../components/FlagAdminList.stories.tsx | 393 +++++++++++ .../flags/pages/flag-listing-view.stories.tsx | 75 +++ .../form-builder/Components.stories.tsx | 630 ++++++++++++++++++ .../form-builder/FormBuilder.stories.tsx | 421 ++++++++++++ .../form/components/Select.stories.tsx | 234 +++++++ .../components/BookedByCell.stories.tsx | 141 ++++ .../components/BookingAtCell.stories.tsx | 237 +++++++ .../insights/components/ChartCard.stories.tsx | 330 +++++++++ .../components/ResponseValueCell.stories.tsx | 122 ++++ .../filters/DateTargetSelector.stories.tsx | 128 ++++ .../filters/OrgTeamsFilter.stories.tsx | 303 +++++++++ .../mintlify-chat/MintlifyChat.stories.tsx | 164 +++++ .../components/ScheduleListItem.stories.tsx | 318 +++++++++ .../settings/BookerLayoutSelector.stories.tsx | 303 +++++++++ .../settings/TimezoneChangeDialog.stories.tsx | 365 ++++++++++ .../appDir/SettingsHeader.stories.tsx | 175 +++++ .../SettingsHeaderWithBackButton.stories.tsx | 190 ++++++ .../components/EventTypeSelect.stories.tsx | 321 +++++++++ .../components/LargeCalendar.stories.tsx | 620 +++++++++++++++++ .../TroubleshooterHeader.stories.tsx | 219 ++++++ ...roubleshooterListItemContainer.stories.tsx | 299 +++++++++ .../ui/components/address/Fields.stories.tsx | 315 +++++++++ .../avatar/UserAvatarGroup.stories.tsx | 226 +++++++ .../plugins/AddVariablesDropdown.stories.tsx | 117 ++++ .../plugins/AddVariablesPlugin.stories.tsx | 330 +++++++++ .../editor/plugins/ToolbarPlugin.stories.tsx | 387 +++++++++++ .../inputs/InputFieldWithSelect.stories.tsx | 236 +++++++ .../image-uploader/Common.stories.tsx | 185 +++++ .../layout/WizardLayout.stories.tsx | 354 ++++++++++ .../discover-components.ts | 154 +++++ .../storybook-generator/generate-stories.sh | 202 ++++++ .../storybook-generator/generate-stories.ts | 197 ++++++ .../storybook-generator/logs/BackupCode.log | 7 + .../storybook-generator/logs/TwoFactor.log | 11 + tooling/storybook-generator/package.json | 17 + .../prompts/story-prompt.md | 151 +++++ yarn.lock | 11 + 113 files changed, 28069 insertions(+) create mode 100644 apps/web/components/AddToHomescreen.stories.tsx create mode 100644 apps/web/components/apps/AppPage.stories.tsx create mode 100644 apps/web/components/apps/MultiDisconnectIntegration.stories.tsx create mode 100644 apps/web/components/apps/alby/Setup.stories.tsx create mode 100644 apps/web/components/apps/applecalendar/Setup.stories.tsx create mode 100644 apps/web/components/apps/btcpayserver/Setup.stories.tsx create mode 100644 apps/web/components/apps/caldavcalendar/Setup.stories.tsx create mode 100644 apps/web/components/apps/exchange2013calendar/Setup.stories.tsx create mode 100644 apps/web/components/apps/exchange2016calendar/Setup.stories.tsx create mode 100644 apps/web/components/apps/exchangecalendar/Setup.stories.tsx create mode 100644 apps/web/components/apps/hitpay/Setup.stories.tsx create mode 100644 apps/web/components/apps/ics-feedcalendar/Setup.stories.tsx create mode 100644 apps/web/components/apps/installation/AccountsStepCard.stories.tsx create mode 100644 apps/web/components/apps/installation/ConfigureStepCard.stories.tsx create mode 100644 apps/web/components/apps/installation/EventTypesStepCard.stories.tsx create mode 100644 apps/web/components/apps/layouts/AppsLayout.stories.tsx create mode 100644 apps/web/components/apps/make/Setup.stories.tsx create mode 100644 apps/web/components/apps/paypal/Setup.stories.tsx create mode 100644 apps/web/components/apps/routing-forms/FormActions.stories.tsx create mode 100644 apps/web/components/apps/routing-forms/FormSettingsSlideover.stories.tsx create mode 100644 apps/web/components/apps/routing-forms/Header.stories.tsx create mode 100644 apps/web/components/apps/routing-forms/SingleForm.stories.tsx create mode 100644 apps/web/components/apps/routing-forms/TestForm.stories.tsx create mode 100644 apps/web/components/apps/sendgrid/Setup.stories.tsx create mode 100644 apps/web/components/apps/wipemycalother/ConfirmDialog.stories.tsx create mode 100644 apps/web/components/auth/BackupCode.stories.tsx create mode 100644 apps/web/components/auth/TwoFactor.stories.tsx create mode 100644 apps/web/components/booking/CancelBooking.stories.tsx create mode 100644 apps/web/components/booking/actions/BookingActionsDropdown.stories.tsx create mode 100644 apps/web/components/dialog/AddGuestsDialog.stories.tsx create mode 100644 apps/web/components/dialog/CancelBookingDialog.stories.tsx create mode 100644 apps/web/components/dialog/ChargeCardDialog.stories.tsx create mode 100644 apps/web/components/dialog/EditLocationDialog.stories.tsx create mode 100644 apps/web/components/dialog/ReassignDialog.stories.tsx create mode 100644 apps/web/components/dialog/RejectionReasonDialog.stories.tsx create mode 100644 apps/web/components/dialog/ReportBookingDialog.stories.tsx create mode 100644 apps/web/components/dialog/RerouteDialog.stories.tsx create mode 100644 apps/web/components/dialog/RescheduleDialog.stories.tsx create mode 100644 apps/web/components/error/BookingPageErrorBoundary.stories.tsx create mode 100644 apps/web/components/error/ErrorPage.stories.tsx create mode 100644 apps/web/components/integrations/SubHeadingTitleWithConnections.stories.tsx create mode 100644 apps/web/components/setup/AdminUser.stories.tsx create mode 100644 apps/web/components/ui/LinkIconButton.stories.tsx create mode 100644 apps/web/components/ui/form/CheckedSelect.stories.tsx create mode 100644 apps/web/components/ui/form/DatePicker.stories.tsx create mode 100644 packages/features/apps/components/AllApps.stories.tsx create mode 100644 packages/features/apps/components/AppCard.stories.tsx create mode 100644 packages/features/apps/components/AppList.stories.tsx create mode 100644 packages/features/apps/components/AppListCard.stories.tsx create mode 100644 packages/features/apps/components/AppListCardWebWrapper.stories.tsx create mode 100644 packages/features/apps/components/AppSetDefaultLinkDialog.stories.tsx create mode 100644 packages/features/apps/components/CredentialActionsDropdown.stories.tsx create mode 100644 packages/features/apps/components/DisconnectIntegration.stories.tsx create mode 100644 packages/features/apps/components/Slider.stories.tsx create mode 100644 packages/features/auth/SAMLLogin.stories.tsx create mode 100644 packages/features/bookings/components/VerifyCodeDialog.stories.tsx create mode 100644 packages/features/calendars/CalendarSwitch.stories.tsx create mode 100644 packages/features/calendars/DestinationCalendarSelector.stories.tsx create mode 100644 packages/features/components/timezone-select/TimezoneSelect.stories.tsx create mode 100644 packages/features/data-table/components/DataTable.stories.tsx create mode 100644 packages/features/data-table/components/DataTableSelectionBar.stories.tsx create mode 100644 packages/features/data-table/components/DataTableToolbar.stories.tsx create mode 100644 packages/features/data-table/components/DataTableWrapper.stories.tsx create mode 100644 packages/features/ee/video/MeetingSessionDetailsDialog.stories.tsx create mode 100644 packages/features/embed/Embed.stories.tsx create mode 100644 packages/features/embed/EventTypeEmbed.stories.tsx create mode 100644 packages/features/embed/RoutingFormEmbed.stories.tsx create mode 100644 packages/features/eventtypes/components/AddMembersWithSwitch.stories.tsx create mode 100644 packages/features/eventtypes/components/CheckedTeamSelect.stories.tsx create mode 100644 packages/features/eventtypes/components/CreateEventTypeForm.stories.tsx create mode 100644 packages/features/eventtypes/components/EventTypeDescription.stories.tsx create mode 100644 packages/features/eventtypes/components/HostEditDialogs.stories.tsx create mode 100644 packages/features/eventtypes/components/MultiplePrivateLinksController.stories.tsx create mode 100644 packages/features/filters/components/TeamsFilter.stories.tsx create mode 100644 packages/features/flags/components/AssignFeatureSheet.stories.tsx create mode 100644 packages/features/flags/components/FlagAdminList.stories.tsx create mode 100644 packages/features/flags/pages/flag-listing-view.stories.tsx create mode 100644 packages/features/form-builder/Components.stories.tsx create mode 100644 packages/features/form-builder/FormBuilder.stories.tsx create mode 100644 packages/features/form/components/Select.stories.tsx create mode 100644 packages/features/insights/components/BookedByCell.stories.tsx create mode 100644 packages/features/insights/components/BookingAtCell.stories.tsx create mode 100644 packages/features/insights/components/ChartCard.stories.tsx create mode 100644 packages/features/insights/components/ResponseValueCell.stories.tsx create mode 100644 packages/features/insights/filters/DateTargetSelector.stories.tsx create mode 100644 packages/features/insights/filters/OrgTeamsFilter.stories.tsx create mode 100644 packages/features/mintlify-chat/MintlifyChat.stories.tsx create mode 100644 packages/features/schedules/components/ScheduleListItem.stories.tsx create mode 100644 packages/features/settings/BookerLayoutSelector.stories.tsx create mode 100644 packages/features/settings/TimezoneChangeDialog.stories.tsx create mode 100644 packages/features/settings/appDir/SettingsHeader.stories.tsx create mode 100644 packages/features/settings/appDir/SettingsHeaderWithBackButton.stories.tsx create mode 100644 packages/features/troubleshooter/components/EventTypeSelect.stories.tsx create mode 100644 packages/features/troubleshooter/components/LargeCalendar.stories.tsx create mode 100644 packages/features/troubleshooter/components/TroubleshooterHeader.stories.tsx create mode 100644 packages/features/troubleshooter/components/TroubleshooterListItemContainer.stories.tsx create mode 100644 packages/ui/components/address/Fields.stories.tsx create mode 100644 packages/ui/components/avatar/UserAvatarGroup.stories.tsx create mode 100644 packages/ui/components/editor/plugins/AddVariablesDropdown.stories.tsx create mode 100644 packages/ui/components/editor/plugins/AddVariablesPlugin.stories.tsx create mode 100644 packages/ui/components/editor/plugins/ToolbarPlugin.stories.tsx create mode 100644 packages/ui/components/form/inputs/InputFieldWithSelect.stories.tsx create mode 100644 packages/ui/components/image-uploader/Common.stories.tsx create mode 100644 packages/ui/components/layout/WizardLayout.stories.tsx create mode 100644 tooling/storybook-generator/discover-components.ts create mode 100755 tooling/storybook-generator/generate-stories.sh create mode 100644 tooling/storybook-generator/generate-stories.ts create mode 100644 tooling/storybook-generator/logs/BackupCode.log create mode 100644 tooling/storybook-generator/logs/TwoFactor.log create mode 100644 tooling/storybook-generator/package.json create mode 100644 tooling/storybook-generator/prompts/story-prompt.md diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 8b0e7c2020fd29..ac11fd05ee3823 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -1,7 +1,12 @@ +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'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + // 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); @@ -12,6 +17,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,8 +36,20 @@ const config: StorybookConfig = { async viteFinal(config) { const { mergeConfig } = await import('vite'); + // Path aliases for apps/web components + const webAppPath = resolve(__dirname, '../../../apps/web'); + const merged = mergeConfig(config, { plugins: isStaticBuild ? [] : [storybookOnlookPlugin], + resolve: { + alias: { + '@components': join(webAppPath, 'components'), + '@lib': join(webAppPath, 'lib'), + '@server': join(webAppPath, 'server'), + '@pages': join(webAppPath, 'pages'), + '~': join(webAppPath, 'modules'), + }, + }, server: isStaticBuild ? {} : { 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/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/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/form/CheckedSelect.stories.tsx b/apps/web/components/ui/form/CheckedSelect.stories.tsx new file mode 100644 index 00000000000000..c96b513d23c974 --- /dev/null +++ b/apps/web/components/ui/form/CheckedSelect.stories.tsx @@ -0,0 +1,200 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; +import { useState } from "react"; + +import { CheckedSelect } from "./CheckedSelect"; + +type CheckedSelectOption = { + avatar: string; + label: string; + value: string; + disabled?: boolean; +}; + +const sampleOptions: CheckedSelectOption[] = [ + { + avatar: "https://cal.com/stakeholder/peer.jpg", + label: "John Doe", + value: "john-doe", + }, + { + avatar: "https://cal.com/stakeholder/bailey.jpg", + label: "Jane Smith", + value: "jane-smith", + }, + { + avatar: "https://cal.com/stakeholder/alice.jpg", + label: "Alice Johnson", + value: "alice-johnson", + }, + { + avatar: "https://cal.com/stakeholder/peer.jpg", + label: "Bob Williams", + value: "bob-williams", + }, + { + avatar: "https://cal.com/stakeholder/bailey.jpg", + label: "Carol Brown", + value: "carol-brown", + disabled: true, + }, +]; + +const meta = { + component: CheckedSelect, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + onChange: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + options: sampleOptions, + value: [], + placeholder: "Select team members...", + name: "team-members", + }, +}; + +export const WithSelectedValues: Story = { + args: { + options: sampleOptions, + value: [sampleOptions[0], sampleOptions[1]], + placeholder: "Select team members...", + name: "team-members", + }, +}; + +export const SingleSelection: Story = { + args: { + options: sampleOptions, + value: [sampleOptions[0]], + placeholder: "Select team members...", + name: "team-members", + }, +}; + +export const AllSelected: Story = { + args: { + options: sampleOptions.filter((opt) => !opt.disabled), + value: sampleOptions.filter((opt) => !opt.disabled), + placeholder: "Select team members...", + name: "team-members", + }, +}; + +export const WithDisabledOptions: Story = { + args: { + options: sampleOptions, + value: [], + placeholder: "Select team members...", + name: "team-members", + }, +}; + +export const CustomPlaceholder: Story = { + args: { + options: sampleOptions, + value: [], + placeholder: "Choose collaborators for this project...", + name: "collaborators", + }, +}; + +export const Interactive: Story = { + render: () => { + const [selectedOptions, setSelectedOptions] = useState([ + sampleOptions[0], + ]); + + return ( +
+ +
+ Selected: {selectedOptions.length} member(s) +
+
+ ); + }, +}; + +export const LargeList: Story = { + args: { + options: Array.from({ length: 10 }, (_, i) => ({ + avatar: i % 2 === 0 ? "https://cal.com/stakeholder/peer.jpg" : "https://cal.com/stakeholder/bailey.jpg", + label: `Team Member ${i + 1}`, + value: `member-${i + 1}`, + })), + value: [], + placeholder: "Select team members...", + name: "team-members", + }, +}; + +export const WithManySelections: Story = { + render: () => { + const largeOptions = Array.from({ length: 8 }, (_, i) => ({ + avatar: i % 2 === 0 ? "https://cal.com/stakeholder/peer.jpg" : "https://cal.com/stakeholder/bailey.jpg", + label: `Team Member ${i + 1}`, + value: `member-${i + 1}`, + })); + + return ( + + ); + }, + parameters: { + layout: "padded", + }, +}; + +export const EventAssignment: Story = { + args: { + options: [ + { + avatar: "https://cal.com/stakeholder/peer.jpg", + label: "John Doe (Sales)", + value: "john-sales", + }, + { + avatar: "https://cal.com/stakeholder/bailey.jpg", + label: "Jane Smith (Support)", + value: "jane-support", + }, + { + avatar: "https://cal.com/stakeholder/alice.jpg", + label: "Alice Johnson (Engineering)", + value: "alice-eng", + }, + ], + value: [], + placeholder: "Assign team members to this event...", + name: "event-assignment", + }, +}; diff --git a/apps/web/components/ui/form/DatePicker.stories.tsx b/apps/web/components/ui/form/DatePicker.stories.tsx new file mode 100644 index 00000000000000..0d54fb0875fbac --- /dev/null +++ b/apps/web/components/ui/form/DatePicker.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { fn } from "storybook/test"; + +import { DatePicker } from "./DatePicker"; + +const meta = { + title: "UI/Form/DatePicker", + component: DatePicker, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + date: { control: "date" }, + minDate: { control: "date" }, + disabled: { control: "boolean" }, + className: { control: "text" }, + }, + args: { + onDatesChange: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + date: new Date(), + }, +}; + +export const WithMinDate: Story = { + args: { + date: new Date(), + minDate: new Date(), + }, +}; + +export const WithPastDate: Story = { + args: { + date: new Date(2024, 0, 1), // January 1, 2024 + }, +}; + +export const WithFutureDate: Story = { + args: { + date: new Date(2026, 11, 31), // December 31, 2026 + }, +}; + +export const Disabled: Story = { + args: { + date: new Date(), + disabled: true, + }, +}; + +export const WithMinDateRestriction: Story = { + args: { + date: new Date(2025, 0, 15), // January 15, 2025 + minDate: new Date(2025, 0, 1), // January 1, 2025 (restricts selection to dates after this) + }, +}; + +export const WithCustomClassName: Story = { + args: { + date: new Date(), + className: "bg-blue-50 border-blue-300", + }, +}; diff --git a/packages/features/apps/components/AllApps.stories.tsx b/packages/features/apps/components/AllApps.stories.tsx new file mode 100644 index 00000000000000..7794bad843dafd --- /dev/null +++ b/packages/features/apps/components/AllApps.stories.tsx @@ -0,0 +1,299 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import type { AppFrontendPayload as App } from "@calcom/types/App"; + +import { AllApps } from "./AllApps"; + +const mockApps: (App & { credentials?: any[] })[] = [ + { + name: "Google Calendar", + slug: "google-calendar", + type: "google_calendar", + logo: "https://cal.com/api/app-store/googlecalendar/icon.svg", + description: "Connect your Google Calendar to automatically check for busy times and add new events.", + categories: ["calendar"], + variant: "calendar", + publisher: "Cal.com", + url: "https://www.google.com/calendar", + email: "support@cal.com", + verified: true, + trending: true, + rating: 5, + reviews: 1250, + feeType: "free", + price: 0, + }, + { + name: "Zoom", + slug: "zoom", + type: "zoom_video", + logo: "https://cal.com/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing with Zoom. Automatically create Zoom meetings for your bookings.", + categories: ["conferencing"], + variant: "conferencing", + publisher: "Cal.com", + url: "https://zoom.us", + email: "support@cal.com", + verified: true, + trending: true, + rating: 4.8, + reviews: 980, + feeType: "free", + price: 0, + }, + { + name: "Stripe", + slug: "stripe", + type: "stripe_payment", + logo: "https://cal.com/api/app-store/stripepayment/icon.svg", + description: "Collect payments for your bookings using Stripe. Accept credit cards and more.", + categories: ["payment"], + variant: "payment", + publisher: "Cal.com", + url: "https://stripe.com", + email: "support@cal.com", + verified: true, + rating: 4.9, + reviews: 750, + feeType: "usage-based", + price: 0.5, + commission: 0.5, + }, + { + name: "Salesforce", + slug: "salesforce", + type: "salesforce_crm", + logo: "https://cal.com/api/app-store/salesforce/icon.svg", + description: "Sync your bookings with Salesforce CRM. Automatically create contacts and opportunities.", + categories: ["crm"], + variant: "crm", + publisher: "Cal.com", + url: "https://www.salesforce.com", + email: "support@cal.com", + verified: true, + rating: 4.7, + reviews: 420, + feeType: "free", + price: 0, + }, + { + name: "HubSpot", + slug: "hubspot", + type: "hubspot_crm", + logo: "https://cal.com/api/app-store/hubspot/icon.svg", + description: "Connect HubSpot CRM to track meetings and contacts automatically.", + categories: ["crm"], + variant: "crm", + publisher: "Cal.com", + url: "https://www.hubspot.com", + email: "support@cal.com", + verified: true, + trending: true, + rating: 4.6, + reviews: 530, + feeType: "free", + price: 0, + }, + { + name: "Google Meet", + slug: "google-meet", + type: "google_video", + logo: "https://cal.com/api/app-store/googlevideo/icon.svg", + description: "Video conferencing with Google Meet. Free and integrated with Google Calendar.", + categories: ["conferencing"], + variant: "conferencing", + publisher: "Cal.com", + url: "https://meet.google.com", + email: "support@cal.com", + verified: true, + rating: 4.5, + reviews: 890, + feeType: "free", + price: 0, + }, + { + name: "Microsoft Teams", + slug: "msteams", + type: "msteams_video", + logo: "https://cal.com/api/app-store/msteams/icon.svg", + description: "Video conferencing with Microsoft Teams. Integrated with Office 365.", + categories: ["conferencing"], + variant: "conferencing", + publisher: "Cal.com", + url: "https://www.microsoft.com/microsoft-teams", + email: "support@cal.com", + verified: true, + rating: 4.4, + reviews: 670, + feeType: "free", + price: 0, + }, + { + name: "Zapier", + slug: "zapier", + type: "zapier_automation", + logo: "https://cal.com/api/app-store/zapier/icon.svg", + description: "Connect Cal.com with 5000+ apps. Automate your workflow with Zapier.", + categories: ["automation"], + variant: "automation", + publisher: "Cal.com", + url: "https://zapier.com", + email: "support@cal.com", + verified: true, + trending: true, + rating: 4.8, + reviews: 1120, + feeType: "free", + price: 0, + }, +]; + +const meta = { + component: AllApps, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + apps: mockApps, + categories: ["calendar", "conferencing", "payment", "crm", "automation"], + }, +}; + +export const WithSearchText: Story = { + args: { + apps: mockApps, + searchText: "zoom", + categories: ["calendar", "conferencing", "payment", "crm", "automation"], + }, +}; + +export const SingleCategory: Story = { + args: { + apps: mockApps.filter((app) => app.categories.includes("conferencing")), + categories: ["conferencing"], + }, +}; + +export const WithInstalledApps: Story = { + args: { + apps: mockApps.map((app, index) => ({ + ...app, + credentials: index % 2 === 0 ? [{ id: 1, type: app.type }] : undefined, + })), + categories: ["calendar", "conferencing", "payment", "crm", "automation"], + }, +}; + +export const EmptyState: Story = { + args: { + apps: [], + categories: ["calendar", "conferencing", "payment", "crm", "automation"], + }, +}; + +export const EmptySearchResults: Story = { + args: { + apps: mockApps, + searchText: "nonexistent app", + categories: ["calendar", "conferencing", "payment", "crm", "automation"], + }, +}; + +export const FewApps: Story = { + args: { + apps: mockApps.slice(0, 3), + categories: ["calendar", "conferencing", "payment"], + }, +}; + +export const ManyCategoriesWithScroll: Story = { + args: { + apps: mockApps, + categories: [ + "calendar", + "conferencing", + "payment", + "crm", + "automation", + "analytics", + "messaging", + "other", + "video", + ], + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const WithUserAdminTeams: Story = { + args: { + apps: mockApps.map((app) => ({ + ...app, + credentials: [{ id: 1, type: app.type, teamId: 1 }], + })), + categories: ["calendar", "conferencing", "payment", "crm", "automation"], + userAdminTeams: [ + { id: 1, name: "Engineering Team", isOrganization: false }, + { id: 2, name: "Sales Team", isOrganization: false }, + ], + }, +}; + +export const CalendarAppsOnly: Story = { + args: { + apps: mockApps.filter((app) => app.categories.includes("calendar")), + categories: ["calendar"], + }, +}; + +export const ConferencingAppsOnly: Story = { + args: { + apps: mockApps.filter((app) => app.categories.includes("conferencing")), + categories: ["conferencing"], + }, +}; + +export const CRMAppsOnly: Story = { + args: { + apps: mockApps.filter((app) => app.categories.includes("crm")), + categories: ["crm"], + }, +}; + +export const LargeDataset: Story = { + args: { + apps: [ + ...mockApps, + ...mockApps.map((app) => ({ + ...app, + name: `${app.name} Alternative`, + slug: `${app.slug}-alt`, + })), + ...mockApps.map((app) => ({ + ...app, + name: `${app.name} Pro`, + slug: `${app.slug}-pro`, + })), + ], + categories: ["calendar", "conferencing", "payment", "crm", "automation"], + }, +}; diff --git a/packages/features/apps/components/AppCard.stories.tsx b/packages/features/apps/components/AppCard.stories.tsx new file mode 100644 index 00000000000000..8abeb5979e8ad2 --- /dev/null +++ b/packages/features/apps/components/AppCard.stories.tsx @@ -0,0 +1,450 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import type { AppFrontendPayload as App } from "@calcom/types/App"; + +import { AppCard } from "./AppCard"; + +const meta = { + component: AppCard, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + app: { + description: "The app configuration object", + control: { type: "object" }, + }, + credentials: { + description: "Array of credentials for the app", + control: { type: "object" }, + }, + searchText: { + description: "Text to highlight in the app name", + control: { type: "text" }, + }, + userAdminTeams: { + description: "Teams where user is admin", + control: { type: "object" }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock app data +const createMockApp = (overrides?: Partial): App => ({ + name: "Zoom", + slug: "zoom", + type: "zoom_video", + variant: "conferencing", + logo: "https://cal.com/integrations/zoom.svg", + description: "Video conferencing app that allows you to meet with others via video and audio.", + publisher: "Zoom Video Communications", + url: "https://zoom.us", + email: "support@zoom.us", + categories: ["video"], + ...overrides, +}); + +export const Default: Story = { + args: { + app: createMockApp(), + credentials: [], + }, +}; + +export const WithLongDescription: Story = { + args: { + app: createMockApp({ + name: "Google Calendar", + slug: "google-calendar", + type: "google_calendar", + variant: "calendar", + logo: "https://cal.com/integrations/google-calendar.svg", + description: + "Google Calendar is a time-management and scheduling calendar service developed by Google. It allows users to create and edit events, with event times and details being automatically synchronized across all devices. This integration helps prevent double bookings by checking your availability across multiple calendars.", + categories: ["calendar"], + }), + credentials: [], + }, +}; + +export const InstalledApp: Story = { + args: { + app: createMockApp({ + name: "Stripe", + slug: "stripe", + type: "stripe_payment", + variant: "payment", + logo: "https://cal.com/integrations/stripe.svg", + description: "Accept online payments securely with Stripe integration.", + categories: ["payment"], + }), + credentials: [ + { + id: 1, + type: "stripe_payment", + key: {}, + userId: 1, + teamId: null, + appId: "stripe", + invalid: false, + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + }, + ], + }, +}; + +export const MultipleInstallations: Story = { + args: { + app: createMockApp({ + name: "Google Calendar", + slug: "google-calendar", + type: "google_calendar", + variant: "calendar", + logo: "https://cal.com/integrations/google-calendar.svg", + description: "Sync your Google Calendar to prevent double bookings.", + categories: ["calendar"], + }), + credentials: [ + { + id: 1, + type: "google_calendar", + key: {}, + userId: 1, + teamId: null, + appId: "google-calendar", + invalid: false, + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + }, + { + id: 2, + type: "google_calendar", + key: {}, + userId: 1, + teamId: null, + appId: "google-calendar", + invalid: false, + subscriptionId: null, + paymentStatus: null, + billingCycleStart: null, + }, + ], + }, +}; + +export const GlobalApp: Story = { + args: { + app: createMockApp({ + name: "Cal Video", + slug: "cal-video", + type: "cal_video", + variant: "conferencing", + logo: "https://cal.com/api/app-store/cal-video/icon.svg", + description: "Cal.com's own video conferencing solution, built-in and ready to use.", + categories: ["video"], + isGlobal: true, + }), + }, +}; + +export const DefaultApp: Story = { + args: { + app: createMockApp({ + name: "Cal Video", + slug: "cal-video", + type: "cal_video", + variant: "conferencing", + logo: "https://cal.com/api/app-store/cal-video/icon.svg", + description: "Cal.com's own video conferencing solution.", + categories: ["video"], + isDefault: true, + }), + credentials: [], + }, +}; + +export const TemplateApp: Story = { + args: { + app: createMockApp({ + name: "Custom App Template", + slug: "custom-app-template", + type: "custom_other", + variant: "other", + logo: "https://cal.com/api/app-store/_example/icon.svg", + description: "A template for creating custom apps with Cal.com.", + categories: ["other"], + isTemplate: true, + }), + credentials: [], + }, +}; + +export const PaidApp: Story = { + args: { + app: createMockApp({ + name: "Cal.ai", + slug: "cal-ai", + type: "cal-ai_automation", + variant: "automation", + logo: "https://cal.com/api/app-store/cal-ai/icon.svg", + description: "AI-powered scheduling assistant that helps you manage your calendar intelligently.", + categories: ["automation"], + paid: { + priceInUsd: 29, + priceId: "price_123", + mode: "subscription", + }, + }), + credentials: [], + }, +}; + +export const PaidAppWithTrial: Story = { + args: { + app: createMockApp({ + name: "Cal.ai", + slug: "cal-ai", + type: "cal-ai_automation", + variant: "automation", + logo: "https://cal.com/api/app-store/cal-ai/icon.svg", + description: "AI-powered scheduling assistant with a 14-day free trial.", + categories: ["automation"], + paid: { + priceInUsd: 29, + priceId: "price_123", + trial: 14, + mode: "subscription", + }, + }), + credentials: [], + }, +}; + +export const WithSearchHighlight: Story = { + args: { + app: createMockApp({ + name: "Zoom Video", + slug: "zoom", + description: "Video conferencing made easy.", + }), + searchText: "Video", + credentials: [], + }, +}; + +export const TeamsPlanRequired: Story = { + args: { + app: createMockApp({ + name: "Advanced Features", + slug: "advanced-features", + type: "advanced_other", + variant: "other", + logo: "https://cal.com/api/app-store/_example/icon.svg", + description: "Premium features that require a teams plan subscription.", + categories: ["other"], + teamsPlanRequired: { + upgradeUrl: "https://cal.com/upgrade", + }, + }), + credentials: [], + }, +}; + +export const ConferencingApp: Story = { + args: { + app: createMockApp({ + name: "Microsoft Teams", + slug: "msteams", + type: "msteams_video", + variant: "conferencing", + logo: "https://cal.com/integrations/msteams.svg", + description: "Connect your Microsoft Teams account to use it for video meetings.", + categories: ["video"], + concurrentMeetings: true, + }), + credentials: [], + }, +}; + +export const CRMApp: Story = { + args: { + app: createMockApp({ + name: "HubSpot", + slug: "hubspot", + type: "hubspot_crm", + variant: "crm", + logo: "https://cal.com/api/app-store/hubspot/icon.svg", + description: "Sync your contacts and meetings with HubSpot CRM automatically.", + categories: ["crm"], + }), + credentials: [], + }, +}; + +export const AutomationApp: Story = { + args: { + app: createMockApp({ + name: "Zapier", + slug: "zapier", + type: "zapier_automation", + variant: "automation", + logo: "https://cal.com/api/app-store/zapier/icon.svg", + description: "Connect Cal.com with 5000+ apps to automate your workflows.", + categories: ["automation"], + }), + credentials: [], + }, +}; + +export const AnalyticsApp: Story = { + args: { + app: createMockApp({ + name: "Google Analytics", + slug: "ga4", + type: "ga4_analytics", + variant: "other", + logo: "https://cal.com/api/app-store/ga4/icon.svg", + description: "Track booking events and user behavior with Google Analytics 4.", + categories: ["analytics"], + }), + credentials: [], + }, +}; + +export const WithDependencies: Story = { + args: { + app: createMockApp({ + name: "Dependent App", + slug: "dependent-app", + type: "dependent_other", + variant: "other", + logo: "https://cal.com/api/app-store/_example/icon.svg", + description: "This app requires another app to be installed first.", + categories: ["other"], + dependencies: ["google-calendar"], + dependencyData: [ + { + name: "Google Calendar", + installed: false, + }, + ], + }), + credentials: [], + }, +}; + +export const DarkLogoApp: Story = { + args: { + app: createMockApp({ + name: "GitHub", + slug: "github", + type: "github_other", + variant: "other", + logo: "https://cal.com/api/app-store/_example/icon-dark.svg", + description: "Integrate with GitHub for developer workflows.", + categories: ["other"], + }), + credentials: [], + }, + parameters: { + backgrounds: { + default: "dark", + }, + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+

Not Installed

+
+ +
+
+
+

Installed

+
+ +
+
+
+

Default App

+
+ +
+
+
+

Template App

+
+ +
+
+
+

Paid App

+
+ +
+
+
+

With Search Highlight

+
+ +
+
+
+ ), + parameters: { + layout: "padded", + }, + decorators: [], +}; diff --git a/packages/features/apps/components/AppList.stories.tsx b/packages/features/apps/components/AppList.stories.tsx new file mode 100644 index 00000000000000..a9335c3bd4230e --- /dev/null +++ b/packages/features/apps/components/AppList.stories.tsx @@ -0,0 +1,468 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import type { AppCardApp } from "@calcom/app-store/types"; + +import { AppList } from "./AppList"; + +const meta = { + component: AppList, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock data helpers +const createMockApp = (overrides?: Partial): AppCardApp => { + const baseApp = { + name: "Zoom", + slug: "zoom", + type: "zoom_video", + variant: "conferencing", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing platform for online meetings and webinars.", + publisher: "Zoom Video Communications", + url: "https://zoom.us", + email: "support@zoom.us", + categories: ["video" as const], + userCredentialIds: [1], + invalidCredentialIds: [], + teams: [], + isInstalled: true, + dependencyData: [], + dirName: "zoomvideo", + ...overrides, + } as AppCardApp; + return baseApp; +}; + +const mockHandleDisconnect = fn(); +const mockHandleUpdateUserDefaultConferencingApp = fn(); +const mockHandleBulkUpdateDefaultLocation = fn(); +const mockHandleConnectDisconnectIntegrationMenuToggle = fn(); +const mockHandleBulkEditDialogToggle = fn(); + +const defaultArgs = { + handleDisconnect: mockHandleDisconnect, + handleUpdateUserDefaultConferencingApp: mockHandleUpdateUserDefaultConferencingApp, + handleBulkUpdateDefaultLocation: mockHandleBulkUpdateDefaultLocation, + handleConnectDisconnectIntegrationMenuToggle: mockHandleConnectDisconnectIntegrationMenuToggle, + handleBulkEditDialogToggle: mockHandleBulkEditDialogToggle, + defaultConferencingApp: { + appSlug: "daily-video", + appLink: null, + }, + isBulkUpdateDefaultLocationPending: false, + eventTypes: [], + isEventTypesFetching: false, +}; + +export const Default: Story = { + args: { + ...defaultArgs, + data: { + items: [ + createMockApp({ + name: "Zoom", + slug: "zoom", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing for online meetings", + userCredentialIds: [1], + }), + createMockApp({ + name: "Google Meet", + slug: "google-meet", + logo: "/api/app-store/googlevideo/icon.svg", + description: "Google's video conferencing solution", + userCredentialIds: [2], + }), + createMockApp({ + name: "Microsoft Teams", + slug: "msteams", + logo: "/api/app-store/office365video/icon.svg", + description: "Microsoft's collaboration platform", + userCredentialIds: [3], + }), + ], + }, + }, +}; + +export const ConferencingApps: Story = { + args: { + ...defaultArgs, + variant: "conferencing", + data: { + items: [ + createMockApp({ + name: "Cal Video", + slug: "daily-video", + logo: "/api/app-store/daily-video/icon.svg", + description: "Cal.com's built-in video conferencing solution", + userCredentialIds: [1], + isGlobal: true, + }), + createMockApp({ + name: "Zoom", + slug: "zoom", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing platform", + userCredentialIds: [2], + }), + createMockApp({ + name: "Google Meet", + slug: "google-meet", + logo: "/api/app-store/googlevideo/icon.svg", + description: "Google's video conferencing solution", + userCredentialIds: [3], + }), + ], + }, + defaultConferencingApp: { + appSlug: "daily-video", + appLink: null, + }, + }, +}; + +export const WithDefaultApp: Story = { + args: { + ...defaultArgs, + variant: "conferencing", + data: { + items: [ + createMockApp({ + name: "Zoom", + slug: "zoom", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing platform", + userCredentialIds: [1], + }), + ], + }, + defaultConferencingApp: { + appSlug: "zoom", + appLink: null, + }, + }, +}; + +export const WithInvalidCredentials: Story = { + args: { + ...defaultArgs, + data: { + items: [ + createMockApp({ + name: "Google Calendar", + slug: "google-calendar", + logo: "/api/app-store/google-calendar/icon.svg", + description: "Sync your Google Calendar", + userCredentialIds: [1], + invalidCredentialIds: [1], + }), + createMockApp({ + name: "Zoom", + slug: "zoom", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing platform", + userCredentialIds: [2], + }), + ], + }, + }, +}; + +export const WithTeamCredentials: Story = { + args: { + ...defaultArgs, + data: { + items: [ + createMockApp({ + name: "Zoom", + slug: "zoom", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing platform", + userCredentialIds: [1], + teams: [ + { + teamId: 1, + name: "Engineering Team", + logoUrl: null, + credentialId: 10, + isAdmin: true, + }, + { + teamId: 2, + name: "Sales Team", + logoUrl: "/team-logo.png", + credentialId: 11, + isAdmin: false, + }, + ], + }), + ], + }, + }, +}; + +export const WithReadOnlyTeamCredentials: Story = { + args: { + ...defaultArgs, + data: { + items: [ + createMockApp({ + name: "Google Meet", + slug: "google-meet", + logo: "/api/app-store/googlevideo/icon.svg", + description: "Google's video conferencing solution", + userCredentialIds: [], + teams: [ + { + teamId: 1, + name: "Engineering Team", + logoUrl: null, + credentialId: 10, + isAdmin: false, + }, + ], + }), + ], + }, + }, +}; + +export const MixedApps: Story = { + args: { + ...defaultArgs, + variant: "conferencing", + data: { + items: [ + createMockApp({ + name: "Cal Video", + slug: "daily-video", + logo: "/api/app-store/daily-video/icon.svg", + description: "Cal.com's built-in video conferencing", + userCredentialIds: [1], + isGlobal: true, + }), + createMockApp({ + name: "Zoom", + slug: "zoom", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing platform", + userCredentialIds: [2], + teams: [ + { + teamId: 1, + name: "Engineering Team", + logoUrl: null, + credentialId: 10, + isAdmin: true, + }, + ], + }), + createMockApp({ + name: "Google Meet", + slug: "google-meet", + logo: "/api/app-store/googlevideo/icon.svg", + description: "Google's video conferencing", + userCredentialIds: [3], + invalidCredentialIds: [3], + }), + createMockApp({ + name: "Microsoft Teams", + slug: "msteams", + logo: "/api/app-store/office365video/icon.svg", + description: "Microsoft's collaboration platform", + userCredentialIds: [], + teams: [ + { + teamId: 2, + name: "Sales Team", + logoUrl: null, + credentialId: 11, + isAdmin: false, + }, + ], + }), + ], + }, + defaultConferencingApp: { + appSlug: "daily-video", + appLink: null, + }, + }, +}; + +export const EmptyList: Story = { + args: { + ...defaultArgs, + data: { + items: [], + }, + }, +}; + +export const SingleApp: Story = { + args: { + ...defaultArgs, + data: { + items: [ + createMockApp({ + name: "Zoom", + slug: "zoom", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing platform for online meetings and webinars", + userCredentialIds: [1], + }), + ], + }, + }, +}; + +export const WithEventTypes: Story = { + args: { + ...defaultArgs, + variant: "conferencing", + data: { + items: [ + createMockApp({ + name: "Zoom", + slug: "zoom", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing platform", + userCredentialIds: [1], + }), + ], + }, + eventTypes: [ + { + id: 1, + title: "30 Min Meeting", + slug: "30min", + length: 30, + }, + { + id: 2, + title: "Discovery Call", + slug: "discovery", + length: 60, + }, + ], + }, +}; + +export const LoadingEventTypes: Story = { + args: { + ...defaultArgs, + variant: "conferencing", + data: { + items: [ + createMockApp({ + name: "Zoom", + slug: "zoom", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing platform", + userCredentialIds: [1], + }), + ], + }, + isEventTypesFetching: true, + }, +}; + +export const WithCustomClassName: Story = { + args: { + ...defaultArgs, + data: { + items: [ + createMockApp({ + name: "Zoom", + slug: "zoom", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing platform", + userCredentialIds: [1], + }), + createMockApp({ + name: "Google Meet", + slug: "google-meet", + logo: "/api/app-store/googlevideo/icon.svg", + description: "Google's video conferencing solution", + userCredentialIds: [2], + }), + ], + }, + listClassName: "border-2 border-blue-500 rounded-lg p-4", + }, +}; + +export const MultipleTeamInstallations: Story = { + args: { + ...defaultArgs, + variant: "conferencing", + data: { + items: [ + createMockApp({ + name: "Zoom", + slug: "zoom", + logo: "/api/app-store/zoomvideo/icon.svg", + description: "Video conferencing platform", + userCredentialIds: [1], + teams: [ + { + teamId: 1, + name: "Engineering Team", + logoUrl: null, + credentialId: 10, + isAdmin: true, + }, + { + teamId: 2, + name: "Marketing Team", + logoUrl: null, + credentialId: 11, + isAdmin: true, + }, + { + teamId: 3, + name: "Sales Team", + logoUrl: null, + credentialId: 12, + isAdmin: false, + }, + ], + }), + ], + }, + }, +}; + +export const GlobalApp: Story = { + args: { + ...defaultArgs, + data: { + items: [ + createMockApp({ + name: "Cal Video", + slug: "daily-video", + logo: "/api/app-store/daily-video/icon.svg", + description: "Cal.com's built-in video conferencing solution", + userCredentialIds: [], + isGlobal: true, + teams: [], + }), + ], + }, + }, +}; diff --git a/packages/features/apps/components/AppListCard.stories.tsx b/packages/features/apps/components/AppListCard.stories.tsx new file mode 100644 index 00000000000000..6457011689d90c --- /dev/null +++ b/packages/features/apps/components/AppListCard.stories.tsx @@ -0,0 +1,189 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { Button } from "@calcom/ui/components/button"; + +import AppListCard from "./AppListCard"; + +const meta = { + component: AppListCard, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Google Calendar", + description: "Sync your Google Calendar to check for conflicts and create events.", + }, +}; + +export const WithLogo: Story = { + args: { + title: "Zoom", + description: "Video conferencing platform for online meetings and webinars.", + logo: "/api/app-store/zoomvideo/icon.svg", + }, +}; + +export const WithDefaultBadge: Story = { + args: { + title: "Cal Video", + description: "Built-in video conferencing solution for your meetings.", + logo: "/api/app-store/daily-video/icon.svg", + isDefault: true, + }, +}; + +export const WithTemplateBadge: Story = { + args: { + title: "Custom Integration", + description: "A template for building custom integrations.", + isTemplate: true, + }, +}; + +export const WithActions: Story = { + args: { + title: "Google Meet", + description: "Add Google Meet video conferencing to your events.", + logo: "/api/app-store/around/icon.svg", + actions: ( +
+ + +
+ ), + }, +}; + +export const InvalidCredential: Story = { + args: { + title: "Outlook Calendar", + description: "Sync with Microsoft Outlook Calendar.", + logo: "/api/app-store/office365-calendar/icon.svg", + invalidCredential: true, + }, +}; + +export const WithCredentialOwner: Story = { + args: { + title: "Google Calendar", + description: "Sync your Google Calendar to check for conflicts and create events.", + logo: "/api/app-store/google-calendar/icon.svg", + credentialOwner: { + name: "John Doe", + avatar: null, + email: "john@example.com", + }, + }, +}; + +export const WithHighlight: Story = { + args: { + title: "New Integration", + description: "This integration was just added and is highlighted.", + logo: "/api/app-store/stripepayment/icon.svg", + slug: "new-integration", + shouldHighlight: true, + }, +}; + +export const WithChildren: Story = { + args: { + title: "Stripe", + description: "Accept payments for your bookings.", + logo: "/api/app-store/stripepayment/icon.svg", + children: ( +
+

Connected account: acct_1234567890

+
+ ), + }, +}; + +export const ComplexExample: Story = { + args: { + title: "Google Calendar", + description: "Sync your Google Calendar to check for conflicts and create events.", + logo: "/api/app-store/google-calendar/icon.svg", + isDefault: true, + credentialOwner: { + name: "Jane Smith", + avatar: null, + email: "jane@example.com", + }, + actions: ( +
+ + +
+ ), + children: ( +
+
+

Last synced: 2 minutes ago

+

Calendars connected: 3

+
+
+ ), + }, +}; + +export const MultipleApps: Story = { + render: () => ( +
+ + Configure + + } + /> + + Configure + + } + /> + + Reconnect + + } + /> +
+ ), +}; diff --git a/packages/features/apps/components/AppListCardWebWrapper.stories.tsx b/packages/features/apps/components/AppListCardWebWrapper.stories.tsx new file mode 100644 index 00000000000000..2e07a15f780aeb --- /dev/null +++ b/packages/features/apps/components/AppListCardWebWrapper.stories.tsx @@ -0,0 +1,194 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { Button } from "@calcom/ui/components/button"; +import AppListCardWebWrapper from "./AppListCardWebWrapper"; + +const meta = { + component: AppListCardWebWrapper, + parameters: { + layout: "centered", + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps", + query: {}, + }, + }, + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Google Calendar", + description: "Sync your Google Calendar to check for conflicts and create events.", + slug: "google-calendar", + }, +}; + +export const WithLogo: Story = { + args: { + title: "Zoom", + description: "Video conferencing platform for online meetings and webinars.", + logo: "https://app.cal.com/api/app-store/zoomvideo/icon.svg", + slug: "zoom", + }, +}; + +export const WithDefaultBadge: Story = { + args: { + title: "Cal Video", + description: "Built-in video conferencing solution for your meetings.", + isDefault: true, + slug: "cal-video", + }, +}; + +export const WithTemplateBadge: Story = { + args: { + title: "Custom Integration", + description: "A template for building custom integrations.", + isTemplate: true, + slug: "custom-integration", + }, +}; + +export const WithActions: Story = { + args: { + title: "Google Meet", + description: "Add Google Meet video conferencing to your events.", + slug: "google-meet", + actions: ( +
+ + +
+ ), + }, +}; + +export const InvalidCredential: Story = { + args: { + title: "Outlook Calendar", + description: "Sync with Microsoft Outlook Calendar.", + invalidCredential: true, + slug: "outlook-calendar", + }, +}; + +export const Highlighted: Story = { + args: { + title: "New Integration", + description: "This integration was just added and is highlighted.", + slug: "new-integration", + shouldHighlight: true, + }, + parameters: { + nextjs: { + appDirectory: true, + navigation: { + pathname: "/apps", + query: { + hl: "new-integration", + }, + }, + }, + }, +}; + +export const WithCredentialOwner: Story = { + args: { + title: "Stripe", + description: "Accept payments for your bookings.", + slug: "stripe", + credentialOwner: { + name: "John Doe", + avatar: "https://avatar.vercel.sh/john-doe.svg", + }, + }, +}; + +export const WithChildren: Story = { + args: { + title: "Stripe", + description: "Accept payments for your bookings.", + slug: "stripe", + children: ( +
+

Connected account: acct_1234567890

+
+ ), + }, +}; + +export const CompleteExample: Story = { + args: { + title: "Salesforce", + description: "Sync your bookings with Salesforce CRM to keep your sales team informed.", + logo: "https://app.cal.com/api/app-store/salesforce/icon.svg", + slug: "salesforce", + credentialOwner: { + name: "Jane Smith", + avatar: "https://avatar.vercel.sh/jane-smith.svg", + }, + actions: ( +
+ +
+ ), + }, +}; + +export const MultipleApps: Story = { + render: () => ( +
+ + Configure + + } + /> + + Configure + + } + /> + + Configure + + } + /> +
+ ), +}; diff --git a/packages/features/apps/components/AppSetDefaultLinkDialog.stories.tsx b/packages/features/apps/components/AppSetDefaultLinkDialog.stories.tsx new file mode 100644 index 00000000000000..53cd343a98e7e3 --- /dev/null +++ b/packages/features/apps/components/AppSetDefaultLinkDialog.stories.tsx @@ -0,0 +1,341 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; +import { useState } from "react"; + +import type { EventLocationType } from "@calcom/app-store/locations"; + +import { AppSetDefaultLinkDialog } from "./AppSetDefaultLinkDialog"; +import type { UpdateUsersDefaultConferencingAppParams } from "./AppSetDefaultLinkDialog"; + +const meta = { + component: AppSetDefaultLinkDialog, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock location type for Zoom +const mockZoomLocationType: EventLocationType & { slug: string } = { + default: false, + type: "integrations:zoom", + label: "Zoom Video", + organizerInputType: "text", + organizerInputPlaceholder: "https://zoom.us/j/1234567890", + variable: "locationLink", + defaultValueVariable: "link", + iconUrl: "/api/app-store/zoomvideo/icon.svg", + slug: "zoom", + urlRegExp: "^https:\\/\\/(.*)\\.?zoom\\.us\\/.*", + category: "conferencing", + linkType: "dynamic", + messageForOrganizer: "Use Zoom for this meeting", +}; + +// Mock location type for Google Meet +const mockGoogleMeetLocationType: EventLocationType & { slug: string } = { + default: false, + type: "integrations:google:meet", + label: "Google Meet", + organizerInputType: "text", + organizerInputPlaceholder: "https://meet.google.com/abc-defg-hij", + variable: "locationLink", + defaultValueVariable: "link", + iconUrl: "/api/app-store/googlevideo/icon.svg", + slug: "google-meet", + urlRegExp: "^https:\\/\\/meet\\.google\\.com\\/.*", + category: "conferencing", + linkType: "dynamic", + messageForOrganizer: "Use Google Meet for this meeting", +}; + +// Mock location type for Microsoft Teams +const mockTeamsLocationType: EventLocationType & { slug: string } = { + default: false, + type: "integrations:msteams", + label: "Microsoft Teams", + organizerInputType: "text", + organizerInputPlaceholder: "https://teams.microsoft.com/l/meetup-join/...", + variable: "locationLink", + defaultValueVariable: "link", + iconUrl: "/api/app-store/office365video/icon.svg", + slug: "msteams", + urlRegExp: "^https:\\/\\/teams\\.microsoft\\.com\\/.*", + category: "conferencing", + linkType: "dynamic", + messageForOrganizer: "Use Microsoft Teams for this meeting", +}; + +// Mock location type for a custom video link +const mockCustomVideoLocationType: EventLocationType & { slug: string } = { + default: true, + type: "link", + label: "Video Link", + organizerInputType: "text", + organizerInputPlaceholder: "https://example.com/meeting", + variable: "locationLink", + defaultValueVariable: "link", + iconUrl: "/link.svg", + slug: "link", + urlRegExp: "^https?:\\/\\/.*", + category: "conferencing", + linkType: "static", + messageForOrganizer: "Use custom video link for this meeting", +}; + +export const Default: Story = { + render: function DefaultStory() { + const [locationType, setLocationType] = useState< + (EventLocationType & { slug: string }) | undefined + >(mockZoomLocationType); + + return ( +
+ + {locationType && ( + + )} +
+ ); + }, +}; + +export const ZoomDialog: Story = { + render: function ZoomStory() { + const [locationType, setLocationType] = useState< + (EventLocationType & { slug: string }) | undefined + >(mockZoomLocationType); + + const handleUpdate = (params: UpdateUsersDefaultConferencingAppParams) => { + console.log("Updating Zoom link:", params); + params.onSuccessCallback(); + }; + + return ( +
+ + {locationType && ( + console.log("Success!")} + handleUpdateUserDefaultConferencingApp={handleUpdate} + /> + )} +
+ ); + }, +}; + +export const GoogleMeetDialog: Story = { + render: function GoogleMeetStory() { + const [locationType, setLocationType] = useState< + (EventLocationType & { slug: string }) | undefined + >(mockGoogleMeetLocationType); + + const handleUpdate = (params: UpdateUsersDefaultConferencingAppParams) => { + console.log("Updating Google Meet link:", params); + params.onSuccessCallback(); + }; + + return ( +
+ + {locationType && ( + console.log("Success!")} + handleUpdateUserDefaultConferencingApp={handleUpdate} + /> + )} +
+ ); + }, +}; + +export const MicrosoftTeamsDialog: Story = { + render: function TeamsStory() { + const [locationType, setLocationType] = useState< + (EventLocationType & { slug: string }) | undefined + >(mockTeamsLocationType); + + const handleUpdate = (params: UpdateUsersDefaultConferencingAppParams) => { + console.log("Updating Microsoft Teams link:", params); + params.onSuccessCallback(); + }; + + return ( +
+ + {locationType && ( + console.log("Success!")} + handleUpdateUserDefaultConferencingApp={handleUpdate} + /> + )} +
+ ); + }, +}; + +export const CustomVideoLinkDialog: Story = { + render: function CustomVideoStory() { + const [locationType, setLocationType] = useState< + (EventLocationType & { slug: string }) | undefined + >(mockCustomVideoLocationType); + + const handleUpdate = (params: UpdateUsersDefaultConferencingAppParams) => { + console.log("Updating custom video link:", params); + params.onSuccessCallback(); + }; + + return ( +
+ + {locationType && ( + console.log("Success!")} + handleUpdateUserDefaultConferencingApp={handleUpdate} + /> + )} +
+ ); + }, +}; + +export const WithErrorHandling: Story = { + render: function ErrorStory() { + const [locationType, setLocationType] = useState< + (EventLocationType & { slug: string }) | undefined + >(mockZoomLocationType); + + const handleUpdate = (params: UpdateUsersDefaultConferencingAppParams) => { + console.log("Simulating error for link:", params.appLink); + // Simulate error callback + params.onErrorCallback(); + }; + + return ( +
+ + {locationType && ( + console.log("Success!")} + handleUpdateUserDefaultConferencingApp={handleUpdate} + /> + )} +
+ ); + }, +}; + +export const AlreadyOpen: Story = { + render: function AlreadyOpenStory() { + const [locationType, setLocationType] = useState< + (EventLocationType & { slug: string }) | undefined + >(mockZoomLocationType); + + const handleUpdate = (params: UpdateUsersDefaultConferencingAppParams) => { + console.log("Updating link:", params); + setTimeout(() => { + params.onSuccessCallback(); + }, 1000); + }; + + return ( + console.log("Success!")} + handleUpdateUserDefaultConferencingApp={handleUpdate} + /> + ); + }, +}; + +export const MultipleApps: Story = { + render: function MultipleAppsStory() { + const [locationType, setLocationType] = useState< + (EventLocationType & { slug: string }) | undefined + >(); + + const handleUpdate = (params: UpdateUsersDefaultConferencingAppParams) => { + console.log("Updating link:", params); + params.onSuccessCallback(); + }; + + return ( +
+
+ + + + +
+ {locationType && ( + console.log("Success!")} + handleUpdateUserDefaultConferencingApp={handleUpdate} + /> + )} +
+ ); + }, +}; diff --git a/packages/features/apps/components/CredentialActionsDropdown.stories.tsx b/packages/features/apps/components/CredentialActionsDropdown.stories.tsx new file mode 100644 index 00000000000000..cf9ae1e1d2f1b7 --- /dev/null +++ b/packages/features/apps/components/CredentialActionsDropdown.stories.tsx @@ -0,0 +1,183 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createTRPCReact } from "@trpc/react-query"; +import { fn } from "storybook/test"; + +import CredentialActionsDropdown from "./CredentialActionsDropdown"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +// Mock tRPC client +const mockTrpc = createTRPCReact(); + +const mockTrpcClient = mockTrpc.createClient({ + links: [ + () => + ({ op }) => { + return { + subscribe: (observer: any) => { + observer.next({ + result: { + data: {}, + }, + }); + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + }, + ], +}); + +const meta = { + component: CredentialActionsDropdown, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + +
+ +
+
+
+ ), + ], + argTypes: { + credentialId: { + description: "The ID of the credential to manage", + control: "number", + }, + onSuccess: { + description: "Callback function called when the credential is successfully removed", + control: false, + }, + delegationCredentialId: { + description: "ID of the delegation credential (when present, dropdown is hidden)", + control: "text", + }, + disableConnectionModification: { + description: "Whether to disable the ability to disconnect the credential", + control: "boolean", + }, + }, + args: { + onSuccess: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + credentialId: 1, + delegationCredentialId: null, + disableConnectionModification: false, + }, +}; + +export const WithOnSuccessCallback: Story = { + args: { + credentialId: 2, + onSuccess: fn(() => console.log("Credential removed successfully")), + delegationCredentialId: null, + disableConnectionModification: false, + }, +}; + +export const WithDelegationCredential: Story = { + args: { + credentialId: 3, + delegationCredentialId: "delegation-123", + disableConnectionModification: false, + }, + parameters: { + docs: { + description: { + story: "When a delegation credential is present, the dropdown is hidden and returns null.", + }, + }, + }, +}; + +export const WithDisabledModification: Story = { + args: { + credentialId: 4, + delegationCredentialId: null, + disableConnectionModification: true, + }, + parameters: { + docs: { + description: { + story: "When connection modification is disabled, the dropdown is hidden and returns null.", + }, + }, + }, +}; + +export const DisabledByBothConditions: Story = { + args: { + credentialId: 5, + delegationCredentialId: "delegation-456", + disableConnectionModification: true, + }, + parameters: { + docs: { + description: { + story: + "When both delegation credential is present AND connection modification is disabled, the dropdown is hidden.", + }, + }, + }, +}; + +export const MultipleDropdowns: Story = { + render: () => ( +
+
+

Google Calendar

+ +
+
+

Outlook Calendar

+ +
+
+

Apple Calendar

+ +
+
+ ), + parameters: { + layout: "padded", + docs: { + description: { + story: "Example showing multiple credential action dropdowns in a list.", + }, + }, + }, +}; diff --git a/packages/features/apps/components/DisconnectIntegration.stories.tsx b/packages/features/apps/components/DisconnectIntegration.stories.tsx new file mode 100644 index 00000000000000..46a116149c7f57 --- /dev/null +++ b/packages/features/apps/components/DisconnectIntegration.stories.tsx @@ -0,0 +1,138 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import DisconnectIntegration from "./DisconnectIntegration"; + +const meta = { + component: DisconnectIntegration, + parameters: { + layout: "centered", + nextjs: { + appDirectory: true, + }, + }, + tags: ["autodocs"], + argTypes: { + credentialId: { + control: "number", + description: "The ID of the credential to disconnect", + }, + teamId: { + control: "number", + description: "Optional team ID if this is a team credential", + }, + label: { + control: "text", + description: "Optional button label text", + }, + trashIcon: { + control: "boolean", + description: "Whether to show trash icon", + }, + isGlobal: { + control: "boolean", + description: "Whether this is a global integration (disables disconnect)", + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + credentialId: 1, + onSuccess: fn(), + }, +}; + +export const WithLabel: Story = { + args: { + credentialId: 2, + label: "Disconnect", + onSuccess: fn(), + }, +}; + +export const WithTrashIcon: Story = { + args: { + credentialId: 3, + trashIcon: true, + onSuccess: fn(), + }, +}; + +export const WithTrashIconAndLabel: Story = { + args: { + credentialId: 4, + label: "Remove Integration", + trashIcon: true, + onSuccess: fn(), + }, +}; + +export const TeamIntegration: Story = { + args: { + credentialId: 5, + teamId: 123, + label: "Disconnect Team App", + trashIcon: true, + onSuccess: fn(), + }, +}; + +export const GlobalIntegration: Story = { + args: { + credentialId: 6, + label: "Global Integration", + trashIcon: true, + isGlobal: true, + onSuccess: fn(), + }, +}; + +export const CustomButtonProps: Story = { + args: { + credentialId: 7, + label: "Custom Style", + buttonProps: { + color: "secondary", + size: "sm", + }, + onSuccess: fn(), + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+ + Default (no label, no icon) +
+
+ + With label +
+
+ + Icon only +
+
+ + Icon with label +
+
+ + Global (disabled) +
+
+ ), +}; diff --git a/packages/features/apps/components/Slider.stories.tsx b/packages/features/apps/components/Slider.stories.tsx new file mode 100644 index 00000000000000..37da86a8227257 --- /dev/null +++ b/packages/features/apps/components/Slider.stories.tsx @@ -0,0 +1,277 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { Slider } from "./Slider"; + +const meta = { + component: Slider, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + title: { + description: "Title displayed above the slider", + control: "text", + }, + className: { + description: "Additional CSS classes for the slider container", + control: "text", + }, + items: { + description: "Array of items to display in the slider", + control: "object", + }, + options: { + description: "Glide.js options for carousel configuration", + control: "object", + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Sample card component for demo purposes +const SampleCard = ({ title, description }: { title: string; description: string }) => ( +
+

{title}

+

{description}

+
+); + +// Sample data for stories +const sampleApps = [ + { id: "1", name: "Google Calendar", description: "Sync with Google Calendar to manage your schedule" }, + { id: "2", name: "Zoom", description: "Automatically create Zoom meeting links for your bookings" }, + { id: "3", name: "Stripe", description: "Accept payments for your services" }, + { id: "4", name: "Slack", description: "Get notified in Slack when bookings are made" }, + { id: "5", name: "HubSpot", description: "Sync your contacts and deals with HubSpot CRM" }, + { id: "6", name: "Salesforce", description: "Connect with Salesforce to manage leads" }, +]; + +export const Default: Story = { + args: { + title: "Popular Apps", + items: sampleApps, + itemKey: (item) => item.id, + renderItem: (item) => , + options: { + perView: 3, + gap: 16, + }, + }, +}; + +export const WithoutTitle: Story = { + args: { + items: sampleApps.slice(0, 4), + itemKey: (item) => item.id, + renderItem: (item) => , + options: { + perView: 3, + gap: 16, + }, + }, +}; + +export const SingleItemPerView: Story = { + args: { + title: "Featured Integrations", + items: sampleApps, + itemKey: (item) => item.id, + renderItem: (item) => , + options: { + perView: 1, + gap: 20, + }, + }, +}; + +export const TwoItemsPerView: Story = { + args: { + title: "App Showcase", + items: sampleApps, + itemKey: (item) => item.id, + renderItem: (item) => , + options: { + perView: 2, + gap: 16, + }, + }, +}; + +export const FourItemsPerView: Story = { + args: { + title: "All Apps", + items: sampleApps, + itemKey: (item) => item.id, + renderItem: (item) => , + options: { + perView: 4, + gap: 12, + }, + }, +}; + +export const SmallGap: Story = { + args: { + title: "Compact View", + items: sampleApps, + itemKey: (item) => item.id, + renderItem: (item) => , + options: { + perView: 3, + gap: 8, + }, + }, +}; + +export const LargeGap: Story = { + args: { + title: "Spacious View", + items: sampleApps, + itemKey: (item) => item.id, + renderItem: (item) => , + options: { + perView: 3, + gap: 32, + }, + }, +}; + +export const WithAutoplay: Story = { + args: { + title: "Auto-rotating Apps", + items: sampleApps, + itemKey: (item) => item.id, + renderItem: (item) => , + options: { + perView: 3, + gap: 16, + autoplay: 3000, + hoverpause: true, + }, + }, +}; + +export const FewItems: Story = { + args: { + title: "Limited Apps", + items: sampleApps.slice(0, 2), + itemKey: (item) => item.id, + renderItem: (item) => , + options: { + perView: 3, + gap: 16, + }, + }, +}; + +export const StringItems: Story = { + args: { + title: "Simple Text Slider", + items: ["Feature 1", "Feature 2", "Feature 3", "Feature 4", "Feature 5"], + itemKey: (item: string) => item, + renderItem: (item: string) => ( +
+

{item}

+
+ ), + options: { + perView: 3, + gap: 16, + }, + }, +}; + +export const CustomStyling: Story = { + args: { + title: "Custom Styled Cards", + className: "custom-slider", + items: sampleApps.slice(0, 4), + itemKey: (item) => item.id, + renderItem: (item) => ( +
+

{item.name}

+

{item.description}

+
+ ), + options: { + perView: 2, + gap: 24, + }, + }, +}; + +export const ImageCards: Story = { + args: { + title: "App Gallery", + items: [ + { id: "1", name: "Google Calendar", image: "https://via.placeholder.com/200x120/4285F4/FFF?text=Google" }, + { id: "2", name: "Zoom", image: "https://via.placeholder.com/200x120/2D8CFF/FFF?text=Zoom" }, + { id: "3", name: "Stripe", image: "https://via.placeholder.com/200x120/635BFF/FFF?text=Stripe" }, + { id: "4", name: "Slack", image: "https://via.placeholder.com/200x120/4A154B/FFF?text=Slack" }, + { id: "5", name: "HubSpot", image: "https://via.placeholder.com/200x120/FF7A59/FFF?text=HubSpot" }, + ], + itemKey: (item) => item.id, + renderItem: (item) => ( +
+ {item.name} +
+

{item.name}

+
+
+ ), + options: { + perView: 3, + gap: 16, + }, + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+

Default (3 items per view)

+ item.id} + renderItem={(item) => } + options={{ perView: 3, gap: 16 }} + /> +
+
+

Single item per view

+ item.id} + renderItem={(item) => } + options={{ perView: 1, gap: 20 }} + /> +
+
+

Without title

+ item.id} + renderItem={(item) => } + options={{ perView: 2, gap: 16 }} + /> +
+
+ ), + parameters: { + layout: "padded", + }, + decorators: [], +}; diff --git a/packages/features/auth/SAMLLogin.stories.tsx b/packages/features/auth/SAMLLogin.stories.tsx new file mode 100644 index 00000000000000..3076550ba17b31 --- /dev/null +++ b/packages/features/auth/SAMLLogin.stories.tsx @@ -0,0 +1,296 @@ +import React from "react"; + +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; +import { FormProvider, useForm } from "react-hook-form"; + +import { SAMLLogin } from "./SAMLLogin"; + +// Wrapper component to provide FormProvider context +const SAMLLoginWrapper = (props: React.ComponentProps) => { + const methods = useForm({ + defaultValues: { + email: props.email || "", + }, + }); + + return ( + + + + ); +}; + +const meta = { + component: SAMLLoginWrapper, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + samlTenantID: "tenant-123", + samlProductID: "product-456", + setErrorMessage: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + email: "user@example.com", + }, +}; + +export const WithValidEmail: Story = { + args: { + email: "john.doe@company.com", + }, +}; + +export const WithInvalidEmail: Story = { + args: { + email: "invalid-email", + }, +}; + +export const EmptyEmail: Story = { + args: { + email: "", + }, +}; + +export const Disabled: Story = { + args: { + email: "user@example.com", + disabled: true, + }, +}; + +export const Loading: Story = { + args: { + email: "user@example.com", + loading: true, + }, +}; + +export const WithDifferentColors: Story = { + render: () => ( +
+ + + +
+ ), + parameters: { + layout: "padded", + }, +}; + +export const WithDifferentSizes: Story = { + render: () => ( +
+
+

Small

+ +
+
+

Base (Default)

+ +
+
+

Large

+ +
+
+ ), + parameters: { + layout: "padded", + }, +}; + +export const DifferentTenants: Story = { + render: () => ( +
+
+

Company A (SAML)

+ +
+
+

Company B (OIDC)

+ +
+
+

Enterprise Organization

+ +
+
+ ), + parameters: { + layout: "padded", + }, +}; + +export const InLoginForm: Story = { + render: () => { + const FormExample = () => { + const methods = useForm({ + defaultValues: { + email: "user@example.com", + }, + }); + + return ( + +
+

Sign In

+
+ + +
+
+ + +
+ +
+
+
+
+
+ Or continue with +
+
+ +
+ + ); + }; + + return ; + }, + parameters: { + layout: "centered", + }, +}; + +export const WithErrorHandling: Story = { + render: () => { + const ErrorHandlingExample = () => { + const methods = useForm({ + defaultValues: { + email: "", + }, + }); + const [errorMessage, setErrorMessage] = React.useState(null); + + return ( + +
+
+ + +
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + +

Try clicking without entering an email to see error handling

+
+
+ ); + }; + + return ; + }, + parameters: { + layout: "centered", + }, +}; diff --git a/packages/features/bookings/components/VerifyCodeDialog.stories.tsx b/packages/features/bookings/components/VerifyCodeDialog.stories.tsx new file mode 100644 index 00000000000000..475a67de08e5f0 --- /dev/null +++ b/packages/features/bookings/components/VerifyCodeDialog.stories.tsx @@ -0,0 +1,379 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; +import type { StoreApi } from "zustand"; + +import { BookerStoreContext } from "../Booker/BookerStoreProvider"; +import type { BookerStore } from "../Booker/store"; +import { VerifyCodeDialog } from "./VerifyCodeDialog"; + +// Mock BookerStore for Storybook +const createMockStore = (): StoreApi => { + let verificationCode: string | null = null; + + return { + getState: () => + ({ + verificationCode, + setVerificationCode: (code: string | null) => { + verificationCode = code; + }, + }) as BookerStore, + setState: () => {}, + subscribe: () => () => {}, + destroy: () => {}, + } as unknown as StoreApi; +}; + +const meta = { + component: VerifyCodeDialog, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => { + const mockStore = createMockStore(); + return ( + + + + ); + }, + ], + argTypes: { + isOpenDialog: { + control: "boolean", + description: "Controls whether the dialog is open", + }, + email: { + control: "text", + description: "Email address to verify", + }, + isUserSessionRequiredToVerify: { + control: "boolean", + description: "Whether user session is required for verification", + }, + isPending: { + control: "boolean", + description: "Loading state during verification", + }, + error: { + control: "text", + description: "Error message to display", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: function DefaultStory() { + const [isOpenDialog, setIsOpenDialog] = useState(true); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(""); + + const verifyCodeWithSessionNotRequired = (code: string, email: string) => { + console.log("Verifying code (no session required):", code, email); + setTimeout(() => { + setIsPending(false); + setIsOpenDialog(false); + }, 1000); + }; + + const verifyCodeWithSessionRequired = (code: string, email: string) => { + console.log("Verifying code (session required):", code, email); + setTimeout(() => { + setIsPending(false); + setIsOpenDialog(false); + }, 1000); + }; + + const resetErrors = () => { + setError(""); + }; + + return ( + <> + + + + ); + }, +}; + +export const WithError: Story = { + render: function WithErrorStory() { + const [isOpenDialog, setIsOpenDialog] = useState(true); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState("Invalid verification code. Please try again."); + + const verifyCodeWithSessionNotRequired = (code: string, email: string) => { + console.log("Verifying code (no session required):", code, email); + setIsPending(true); + setTimeout(() => { + setIsPending(false); + setError("Invalid verification code. Please try again."); + }, 1000); + }; + + const verifyCodeWithSessionRequired = (code: string, email: string) => { + console.log("Verifying code (session required):", code, email); + setIsPending(true); + setTimeout(() => { + setIsPending(false); + setError("Invalid verification code. Please try again."); + }, 1000); + }; + + const resetErrors = () => { + setError(""); + }; + + return ( + <> + + + + ); + }, +}; + +export const Loading: Story = { + render: function LoadingStory() { + const [isOpenDialog, setIsOpenDialog] = useState(true); + const [isPending, setIsPending] = useState(true); + const [error, setError] = useState(""); + + const verifyCodeWithSessionNotRequired = (code: string, email: string) => { + console.log("Verifying code (no session required):", code, email); + setIsPending(true); + }; + + const verifyCodeWithSessionRequired = (code: string, email: string) => { + console.log("Verifying code (session required):", code, email); + setIsPending(true); + }; + + const resetErrors = () => { + setError(""); + }; + + return ( + <> + + + + ); + }, +}; + +export const NoSessionRequired: Story = { + render: function NoSessionRequiredStory() { + const [isOpenDialog, setIsOpenDialog] = useState(true); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(""); + + const verifyCodeWithSessionNotRequired = (code: string, email: string) => { + console.log("Verifying code (no session required):", code, email); + setIsPending(true); + setTimeout(() => { + setIsPending(false); + setIsOpenDialog(false); + }, 1000); + }; + + const verifyCodeWithSessionRequired = (code: string, email: string) => { + console.log("Verifying code (session required):", code, email); + setIsPending(true); + setTimeout(() => { + setIsPending(false); + setIsOpenDialog(false); + }, 1000); + }; + + const resetErrors = () => { + setError(""); + }; + + return ( + <> + + + + ); + }, +}; + +export const LongEmail: Story = { + render: function LongEmailStory() { + const [isOpenDialog, setIsOpenDialog] = useState(true); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(""); + + const verifyCodeWithSessionNotRequired = (code: string, email: string) => { + console.log("Verifying code (no session required):", code, email); + setIsPending(true); + setTimeout(() => { + setIsPending(false); + setIsOpenDialog(false); + }, 1000); + }; + + const verifyCodeWithSessionRequired = (code: string, email: string) => { + console.log("Verifying code (session required):", code, email); + setIsPending(true); + setTimeout(() => { + setIsPending(false); + setIsOpenDialog(false); + }, 1000); + }; + + const resetErrors = () => { + setError(""); + }; + + return ( + <> + + + + ); + }, +}; + +export const AutoVerify: Story = { + render: function AutoVerifyStory() { + const [isOpenDialog, setIsOpenDialog] = useState(true); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + + const verifyCodeWithSessionNotRequired = (code: string, email: string) => { + console.log("Auto-verifying code (no session required):", code, email); + setIsPending(true); + setTimeout(() => { + setIsPending(false); + setIsOpenDialog(false); + setMessage(`Successfully verified code: ${code} for ${email}`); + }, 1500); + }; + + const verifyCodeWithSessionRequired = (code: string, email: string) => { + console.log("Auto-verifying code (session required):", code, email); + setIsPending(true); + setTimeout(() => { + setIsPending(false); + setIsOpenDialog(false); + setMessage(`Successfully verified code: ${code} for ${email}`); + }, 1500); + }; + + const resetErrors = () => { + setError(""); + }; + + return ( + <> +
+ + {message &&

{message}

} +
+ + + ); + }, +}; diff --git a/packages/features/calendars/CalendarSwitch.stories.tsx b/packages/features/calendars/CalendarSwitch.stories.tsx new file mode 100644 index 00000000000000..8f6e33002173df --- /dev/null +++ b/packages/features/calendars/CalendarSwitch.stories.tsx @@ -0,0 +1,325 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createTRPCReact } from "@trpc/react-query"; +import { fn } from "storybook/test"; + +import { CalendarSwitch, UserCalendarSwitch, EventCalendarSwitch } from "./CalendarSwitch"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +// Mock tRPC client +const mockTrpc = createTRPCReact(); + +const mockTrpcClient = mockTrpc.createClient({ + links: [ + () => + ({ op }) => { + return { + subscribe: (observer: any) => { + observer.next({ + result: { + data: {}, + }, + }); + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + }, + ], +}); + +const meta = { + component: CalendarSwitch, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + +
+ +
+
+
+ ), + ], + argTypes: { + title: { + description: "Title of the calendar", + control: "text", + }, + name: { + description: "Display name of the calendar", + control: "text", + }, + externalId: { + description: "External ID for the calendar", + control: "text", + }, + type: { + description: "Integration type", + control: "text", + }, + isChecked: { + description: "Whether the calendar is enabled", + control: "boolean", + }, + disabled: { + description: "Whether the switch is disabled", + control: "boolean", + }, + destination: { + description: "Whether this is the destination calendar for adding events", + control: "boolean", + }, + credentialId: { + description: "Credential ID for the calendar integration", + control: "number", + }, + }, + args: { + onCheckedChange: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Personal Calendar", + name: "Personal Calendar", + externalId: "personal-calendar-1", + type: "google_calendar", + isChecked: false, + credentialId: 1, + delegationCredentialId: null, + eventTypeId: null, + disabled: false, + }, +}; + +export const Checked: Story = { + args: { + title: "Work Calendar", + name: "Work Calendar", + externalId: "work-calendar-1", + type: "google_calendar", + isChecked: true, + credentialId: 2, + delegationCredentialId: null, + eventTypeId: null, + disabled: false, + }, +}; + +export const Disabled: Story = { + args: { + title: "Disabled Calendar", + name: "Disabled Calendar", + externalId: "disabled-calendar-1", + type: "google_calendar", + isChecked: false, + credentialId: 3, + delegationId: null, + eventTypeId: null, + disabled: true, + }, +}; + +export const DisabledChecked: Story = { + args: { + title: "Always Enabled Calendar", + name: "Always Enabled Calendar", + externalId: "always-enabled-1", + type: "google_calendar", + isChecked: true, + credentialId: 4, + delegationCredentialId: null, + eventTypeId: null, + disabled: true, + }, +}; + +export const WithDestination: Story = { + args: { + title: "Google Calendar", + name: "Google Calendar", + externalId: "google-calendar-1", + type: "google_calendar", + isChecked: true, + destination: true, + credentialId: 5, + delegationCredentialId: null, + eventTypeId: null, + disabled: false, + }, +}; + +export const OutlookCalendar: Story = { + args: { + title: "Outlook Calendar", + name: "Outlook Calendar", + externalId: "outlook-calendar-1", + type: "office365_calendar", + isChecked: true, + credentialId: 6, + delegationCredentialId: null, + eventTypeId: null, + disabled: false, + }, +}; + +export const AppleCalendar: Story = { + args: { + title: "Apple Calendar", + name: "Apple Calendar", + externalId: "apple-calendar-1", + type: "apple_calendar", + isChecked: false, + credentialId: 7, + delegationCredentialId: null, + eventTypeId: null, + disabled: false, + }, +}; + +export const CalDavCalendar: Story = { + args: { + title: "CalDAV Calendar", + name: "CalDAV Calendar", + externalId: "caldav-calendar-1", + type: "caldav_calendar", + isChecked: true, + destination: true, + credentialId: 8, + delegationCredentialId: null, + eventTypeId: null, + disabled: false, + }, +}; + +export const UserCalendarExample: Story = { + render: () => ( + + ), +}; + +export const EventCalendarExample: Story = { + render: () => ( + + ), +}; + +export const MultipleCalendars: Story = { + render: () => ( +
+ + + + +
+ ), + parameters: { + layout: "padded", + }, +}; + +export const LongCalendarName: Story = { + args: { + title: "My Very Long Calendar Name That Should Wrap Properly In The UI", + name: "My Very Long Calendar Name That Should Wrap Properly In The UI", + externalId: "long-name-calendar-1", + type: "google_calendar", + isChecked: true, + credentialId: 30, + delegationCredentialId: null, + eventTypeId: null, + disabled: false, + }, +}; + +export const WithDelegationCredential: Story = { + args: { + title: "Delegated Calendar", + name: "Delegated Calendar", + externalId: "delegated-calendar-1", + type: "google_calendar", + isChecked: true, + credentialId: 40, + delegationCredentialId: "delegation-cred-123", + eventTypeId: null, + disabled: false, + }, +}; diff --git a/packages/features/calendars/DestinationCalendarSelector.stories.tsx b/packages/features/calendars/DestinationCalendarSelector.stories.tsx new file mode 100644 index 00000000000000..40ff118ae421bf --- /dev/null +++ b/packages/features/calendars/DestinationCalendarSelector.stories.tsx @@ -0,0 +1,593 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import type { RouterOutputs } from "@calcom/trpc/react"; + +import DestinationCalendarSelector from "./DestinationCalendarSelector"; + +// Mock useLocale hook +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string) => { + const translations: Record = { + create_events_on: "Create events on", + you_can_override_calendar_in_advanced_tab: "You can override this setting on a per-event basis.", + }; + return translations[key] || key; + }, + }), +})); + +const meta = { + component: DestinationCalendarSelector, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + onChange: { + description: "Callback when calendar selection changes", + action: "changed", + }, + isPending: { + description: "Loading state", + control: "boolean", + }, + hidePlaceholder: { + description: "Hide the placeholder text", + control: "boolean", + }, + hideAdvancedText: { + description: "Hide the advanced settings hint text", + control: "boolean", + }, + value: { + description: "External ID of the selected calendar", + control: "text", + }, + maxWidth: { + description: "Maximum width of the select control", + control: "number", + }, + }, + args: { + onChange: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock calendar data +const mockGoogleCalendars: RouterOutputs["viewer"]["calendars"]["connectedCalendars"] = { + connectedCalendars: [ + { + credentialId: 1, + integration: { + type: "google_calendar", + title: "Google Calendar", + slug: "google-calendar", + }, + primary: { + name: "john@example.com", + email: "john@example.com", + integration: "google_calendar", + externalId: "john@example.com", + readOnly: false, + credentialId: 1, + isSelected: true, + }, + calendars: [ + { + externalId: "primary", + integration: "google_calendar", + name: "Personal", + readOnly: false, + email: "john@example.com", + isSelected: true, + credentialId: 1, + }, + { + externalId: "work-calendar-id", + integration: "google_calendar", + name: "Work", + readOnly: false, + email: "john@example.com", + isSelected: false, + credentialId: 1, + }, + { + externalId: "meetings-calendar-id", + integration: "google_calendar", + name: "Meetings", + readOnly: false, + email: "john@example.com", + isSelected: false, + credentialId: 1, + }, + ], + }, + ], + destinationCalendar: { + id: 1, + integration: "google_calendar", + externalId: "primary", + userId: 1, + eventTypeId: null, + credentialId: 1, + name: "Personal", + integrationTitle: "Google Calendar", + primaryEmail: "john@example.com", + }, +}; + +const mockMultipleProviders: RouterOutputs["viewer"]["calendars"]["connectedCalendars"] = { + connectedCalendars: [ + { + credentialId: 1, + integration: { + type: "google_calendar", + title: "Google Calendar", + slug: "google-calendar", + }, + primary: { + name: "john@example.com", + email: "john@example.com", + integration: "google_calendar", + externalId: "john@example.com", + readOnly: false, + credentialId: 1, + isSelected: true, + }, + calendars: [ + { + externalId: "google-primary", + integration: "google_calendar", + name: "Personal", + readOnly: false, + email: "john@example.com", + isSelected: true, + credentialId: 1, + }, + { + externalId: "google-work", + integration: "google_calendar", + name: "Work Calendar", + readOnly: false, + email: "john@example.com", + isSelected: false, + credentialId: 1, + }, + ], + }, + { + credentialId: 2, + integration: { + type: "office365_calendar", + title: "Office 365 Calendar", + slug: "office365-calendar", + }, + primary: { + name: "john@company.com", + email: "john@company.com", + integration: "office365_calendar", + externalId: "john@company.com", + readOnly: false, + credentialId: 2, + isSelected: false, + }, + calendars: [ + { + externalId: "office365-primary", + integration: "office365_calendar", + name: "Calendar", + readOnly: false, + email: "john@company.com", + isSelected: false, + credentialId: 2, + }, + { + externalId: "office365-team", + integration: "office365_calendar", + name: "Team Events", + readOnly: false, + email: "john@company.com", + isSelected: false, + credentialId: 2, + }, + ], + }, + { + credentialId: 3, + integration: { + type: "apple_calendar", + title: "Apple Calendar", + slug: "apple-calendar", + }, + primary: { + name: "iCloud", + email: "john@icloud.com", + integration: "apple_calendar", + externalId: "https://caldav.icloud.com/12345", + readOnly: false, + credentialId: 3, + isSelected: false, + }, + calendars: [ + { + externalId: "https://caldav.icloud.com/12345/calendars/home", + integration: "apple_calendar", + name: "Home", + readOnly: false, + email: "john@icloud.com", + isSelected: false, + credentialId: 3, + }, + ], + }, + ], + destinationCalendar: { + id: 1, + integration: "google_calendar", + externalId: "google-primary", + userId: 1, + eventTypeId: null, + credentialId: 1, + name: "Personal", + integrationTitle: "Google Calendar", + primaryEmail: "john@example.com", + }, +}; + +export const Default: Story = { + args: { + calendarsQueryData: mockGoogleCalendars, + value: "primary", + isPending: false, + hidePlaceholder: false, + hideAdvancedText: false, + }, +}; + +export const WithSelectedCalendar: Story = { + args: { + calendarsQueryData: mockGoogleCalendars, + value: "work-calendar-id", + isPending: false, + hidePlaceholder: false, + hideAdvancedText: false, + }, +}; + +export const Loading: Story = { + args: { + calendarsQueryData: mockGoogleCalendars, + value: "primary", + isPending: true, + hidePlaceholder: false, + hideAdvancedText: false, + }, +}; + +export const HiddenPlaceholder: Story = { + args: { + calendarsQueryData: mockGoogleCalendars, + value: undefined, + isPending: false, + hidePlaceholder: true, + hideAdvancedText: false, + }, +}; + +export const HiddenAdvancedText: Story = { + args: { + calendarsQueryData: mockGoogleCalendars, + value: "primary", + isPending: false, + hidePlaceholder: false, + hideAdvancedText: true, + }, +}; + +export const MultipleProviders: Story = { + args: { + calendarsQueryData: mockMultipleProviders, + value: "google-primary", + isPending: false, + hidePlaceholder: false, + hideAdvancedText: false, + }, +}; + +export const WithMaxWidth: Story = { + args: { + calendarsQueryData: mockGoogleCalendars, + value: "primary", + isPending: false, + hidePlaceholder: false, + hideAdvancedText: false, + maxWidth: 400, + }, +}; + +export const NoCalendars: Story = { + args: { + calendarsQueryData: { + connectedCalendars: [], + destinationCalendar: null, + }, + value: undefined, + isPending: false, + hidePlaceholder: false, + hideAdvancedText: false, + }, +}; + +export const SingleCalendar: Story = { + args: { + calendarsQueryData: { + connectedCalendars: [ + { + credentialId: 1, + integration: { + type: "google_calendar", + title: "Google Calendar", + slug: "google-calendar", + }, + primary: { + name: "john@example.com", + email: "john@example.com", + integration: "google_calendar", + externalId: "john@example.com", + readOnly: false, + credentialId: 1, + isSelected: true, + }, + calendars: [ + { + externalId: "primary", + integration: "google_calendar", + name: "Personal", + readOnly: false, + email: "john@example.com", + isSelected: true, + credentialId: 1, + }, + ], + }, + ], + destinationCalendar: { + id: 1, + integration: "google_calendar", + externalId: "primary", + userId: 1, + eventTypeId: null, + credentialId: 1, + name: "Personal", + integrationTitle: "Google Calendar", + primaryEmail: "john@example.com", + }, + }, + value: "primary", + isPending: false, + hidePlaceholder: false, + hideAdvancedText: false, + }, +}; + +export const Office365Calendar: Story = { + args: { + calendarsQueryData: { + connectedCalendars: [ + { + credentialId: 2, + integration: { + type: "office365_calendar", + title: "Office 365 Calendar", + slug: "office365-calendar", + }, + primary: { + name: "john@company.com", + email: "john@company.com", + integration: "office365_calendar", + externalId: "john@company.com", + readOnly: false, + credentialId: 2, + isSelected: true, + }, + calendars: [ + { + externalId: "office365-primary", + integration: "office365_calendar", + name: "Calendar", + readOnly: false, + email: "john@company.com", + isSelected: true, + credentialId: 2, + }, + { + externalId: "office365-team", + integration: "office365_calendar", + name: "Team Events", + readOnly: false, + email: "john@company.com", + isSelected: false, + credentialId: 2, + }, + ], + }, + ], + destinationCalendar: { + id: 2, + integration: "office365_calendar", + externalId: "office365-primary", + userId: 1, + eventTypeId: null, + credentialId: 2, + name: "Calendar", + integrationTitle: "Office 365 Calendar", + primaryEmail: "john@company.com", + }, + }, + value: "office365-primary", + isPending: false, + hidePlaceholder: false, + hideAdvancedText: false, + }, +}; + +export const AppleCalendar: Story = { + args: { + calendarsQueryData: { + connectedCalendars: [ + { + credentialId: 3, + integration: { + type: "apple_calendar", + title: "Apple Calendar", + slug: "apple-calendar", + }, + primary: { + name: "iCloud", + email: "john@icloud.com", + integration: "apple_calendar", + externalId: "https://caldav.icloud.com/12345", + readOnly: false, + credentialId: 3, + isSelected: true, + }, + calendars: [ + { + externalId: "https://caldav.icloud.com/12345/calendars/home", + integration: "apple_calendar", + name: "Home", + readOnly: false, + email: "john@icloud.com", + isSelected: true, + credentialId: 3, + }, + { + externalId: "https://caldav.icloud.com/12345/calendars/work", + integration: "apple_calendar", + name: "Work", + readOnly: false, + email: "john@icloud.com", + isSelected: false, + credentialId: 3, + }, + ], + }, + ], + destinationCalendar: { + id: 3, + integration: "apple_calendar", + externalId: "https://caldav.icloud.com/12345/calendars/home", + userId: 1, + eventTypeId: null, + credentialId: 3, + name: "Home", + integrationTitle: "Apple Calendar", + primaryEmail: "john@icloud.com", + }, + }, + value: "https://caldav.icloud.com/12345/calendars/home", + isPending: false, + hidePlaceholder: false, + hideAdvancedText: false, + }, +}; + +export const ManyCalendars: Story = { + args: { + calendarsQueryData: { + connectedCalendars: [ + { + credentialId: 1, + integration: { + type: "google_calendar", + title: "Google Calendar", + slug: "google-calendar", + }, + primary: { + name: "john@example.com", + email: "john@example.com", + integration: "google_calendar", + externalId: "john@example.com", + readOnly: false, + credentialId: 1, + isSelected: true, + }, + calendars: [ + { + externalId: "primary", + integration: "google_calendar", + name: "Personal", + readOnly: false, + email: "john@example.com", + isSelected: true, + credentialId: 1, + }, + { + externalId: "work-id", + integration: "google_calendar", + name: "Work", + readOnly: false, + email: "john@example.com", + isSelected: false, + credentialId: 1, + }, + { + externalId: "meetings-id", + integration: "google_calendar", + name: "Meetings", + readOnly: false, + email: "john@example.com", + isSelected: false, + credentialId: 1, + }, + { + externalId: "projects-id", + integration: "google_calendar", + name: "Projects", + readOnly: false, + email: "john@example.com", + isSelected: false, + credentialId: 1, + }, + { + externalId: "family-id", + integration: "google_calendar", + name: "Family", + readOnly: false, + email: "john@example.com", + isSelected: false, + credentialId: 1, + }, + ], + }, + ], + destinationCalendar: { + id: 1, + integration: "google_calendar", + externalId: "primary", + userId: 1, + eventTypeId: null, + credentialId: 1, + name: "Personal", + integrationTitle: "Google Calendar", + primaryEmail: "john@example.com", + }, + }, + value: "primary", + isPending: false, + hidePlaceholder: false, + hideAdvancedText: false, + }, +}; diff --git a/packages/features/components/timezone-select/TimezoneSelect.stories.tsx b/packages/features/components/timezone-select/TimezoneSelect.stories.tsx new file mode 100644 index 00000000000000..1d48d72cdf2a5a --- /dev/null +++ b/packages/features/components/timezone-select/TimezoneSelect.stories.tsx @@ -0,0 +1,280 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import type { ITimezoneOption } from "./TimezoneSelect"; +import { TimezoneSelectComponent } from "./TimezoneSelect"; + +const mockTimezones = [ + { label: "San Francisco", timezone: "America/Los_Angeles" }, + { label: "New York", timezone: "America/New_York" }, + { label: "Chicago", timezone: "America/Chicago" }, + { label: "Denver", timezone: "America/Denver" }, + { label: "London", timezone: "Europe/London" }, + { label: "Paris", timezone: "Europe/Paris" }, + { label: "Berlin", timezone: "Europe/Berlin" }, + { label: "Tokyo", timezone: "Asia/Tokyo" }, + { label: "Mumbai", timezone: "Asia/Kolkata" }, + { label: "Sydney", timezone: "Australia/Sydney" }, + { label: "Toronto", timezone: "America/Toronto" }, + { label: "Mexico City", timezone: "America/Mexico_City" }, + { label: "Dubai", timezone: "Asia/Dubai" }, + { label: "Singapore", timezone: "Asia/Singapore" }, + { label: "Hong Kong", timezone: "Asia/Hong_Kong" }, + { label: "Sao Paulo", timezone: "America/Sao_Paulo" }, + { label: "Buenos Aires", timezone: "America/Argentina/Buenos_Aires" }, + { label: "Lagos", timezone: "Africa/Lagos" }, + { label: "Cairo", timezone: "Africa/Cairo" }, + { label: "Moscow", timezone: "Europe/Moscow" }, +]; + +const meta = { + component: TimezoneSelectComponent, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + onChange: fn(), + isPending: false, + data: mockTimezones, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: "Select timezone...", + }, +}; + +export const WithValue: Story = { + args: { + placeholder: "Select timezone...", + value: { + value: "America/New_York", + label: "America/New_York", + } as ITimezoneOption, + }, +}; + +export const Loading: Story = { + args: { + placeholder: "Loading timezones...", + isPending: true, + }, +}; + +export const Disabled: Story = { + args: { + placeholder: "Select timezone...", + isDisabled: true, + }, +}; + +export const SmallSize: Story = { + args: { + placeholder: "Select timezone...", + size: "sm", + }, +}; + +export const MediumSize: Story = { + args: { + placeholder: "Select timezone...", + size: "md", + }, +}; + +export const WithGrow: Story = { + args: { + placeholder: "Select timezone...", + grow: true, + }, +}; + +export const WithCustomValue: Story = { + args: { + placeholder: "Select timezone...", + value: { + value: "Asia/Tokyo", + label: "Asia/Tokyo", + } as ITimezoneOption, + }, +}; + +export const MultiSelect: Story = { + args: { + placeholder: "Select timezones...", + isMulti: true, + }, +}; + +export const MultiSelectWithValues: Story = { + args: { + placeholder: "Select timezones...", + isMulti: true, + value: [ + { + value: "America/New_York", + label: "America/New_York", + }, + { + value: "Europe/London", + label: "Europe/London", + }, + { + value: "Asia/Tokyo", + label: "Asia/Tokyo", + }, + ] as ITimezoneOption[], + }, +}; + +export const SmallMultiSelect: Story = { + args: { + placeholder: "Select timezones...", + isMulti: true, + size: "sm", + value: [ + { + value: "America/Los_Angeles", + label: "America/Los_Angeles", + }, + { + value: "Europe/Paris", + label: "Europe/Paris", + }, + ] as ITimezoneOption[], + }, +}; + +export const WithoutWebTimezoneSelect: Story = { + args: { + placeholder: "Select timezone...", + isWebTimezoneSelect: false, + }, +}; + +export const FormExample: Story = { + render: () => ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ), + parameters: { + layout: "padded", + }, +}; + +export const ComparisonSizes: Story = { + render: () => ( +
+
+

Small Size

+ +
+
+

Medium Size (Default)

+ +
+
+ ), +}; + +export const WithPreselectedTimezones: Story = { + render: () => ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ), + parameters: { + layout: "padded", + }, +}; diff --git a/packages/features/data-table/components/DataTable.stories.tsx b/packages/features/data-table/components/DataTable.stories.tsx new file mode 100644 index 00000000000000..f3aee1336cbb35 --- /dev/null +++ b/packages/features/data-table/components/DataTable.stories.tsx @@ -0,0 +1,611 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useRef, useMemo } from "react"; +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + createColumnHelper, +} from "@tanstack/react-table"; + +import { DataTable } from "./DataTable"; + +const meta = { + title: "Features/DataTable", + component: DataTable, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Sample data types +type Person = { + id: number; + name: string; + email: string; + role: string; + status: "active" | "inactive" | "pending"; +}; + +type Booking = { + id: number; + title: string; + date: string; + attendee: string; + duration: number; + status: "confirmed" | "pending" | "cancelled"; +}; + +// Sample data +const samplePeople: Person[] = [ + { id: 1, name: "John Doe", email: "john@example.com", role: "Admin", status: "active" }, + { id: 2, name: "Jane Smith", email: "jane@example.com", role: "Member", status: "active" }, + { id: 3, name: "Bob Wilson", email: "bob@example.com", role: "Member", status: "inactive" }, + { id: 4, name: "Alice Johnson", email: "alice@example.com", role: "Editor", status: "active" }, + { id: 5, name: "Charlie Brown", email: "charlie@example.com", role: "Viewer", status: "pending" }, +]; + +const sampleBookings: Booking[] = [ + { + id: 1, + title: "Product Demo", + date: "Dec 20, 2024", + attendee: "john@company.com", + duration: 30, + status: "confirmed", + }, + { + id: 2, + title: "Sales Call", + date: "Dec 21, 2024", + attendee: "jane@company.com", + duration: 45, + status: "pending", + }, + { + id: 3, + title: "Technical Review", + date: "Dec 22, 2024", + attendee: "bob@company.com", + duration: 60, + status: "cancelled", + }, + { + id: 4, + title: "Team Sync", + date: "Dec 23, 2024", + attendee: "alice@company.com", + duration: 30, + status: "confirmed", + }, + { + id: 5, + title: "Client Meeting", + date: "Dec 24, 2024", + attendee: "charlie@company.com", + duration: 60, + status: "confirmed", + }, +]; + +// Generate large dataset for infinite scroll +const generateLargeDataset = (count: number): Person[] => { + return Array.from({ length: count }, (_, i) => ({ + id: i + 1, + name: `Person ${i + 1}`, + email: `person${i + 1}@example.com`, + role: ["Admin", "Member", "Editor", "Viewer"][i % 4], + status: (["active", "inactive", "pending"] as const)[i % 3], + })); +}; + +// Column helpers +const personColumnHelper = createColumnHelper(); +const bookingColumnHelper = createColumnHelper(); + +// Default story with basic table +export const Default: Story = { + render: () => { + const tableContainerRef = useRef(null); + + const columns = useMemo( + () => [ + personColumnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("status", { + header: "Status", + cell: (info) => { + const status = info.getValue(); + const statusColors = { + active: "bg-green-100 text-green-800", + inactive: "bg-gray-100 text-gray-800", + pending: "bg-yellow-100 text-yellow-800", + }; + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + }, + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( +
+ +
+ ); + }, +}; + +// Compact variant +export const CompactVariant: Story = { + render: () => { + const tableContainerRef = useRef(null); + + const columns = useMemo( + () => [ + personColumnHelper.accessor("id", { + header: "ID", + cell: (info) => `#${info.getValue()}`, + size: 60, + }), + personColumnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ +
+ ); + }, +}; + +// With sorting enabled +export const WithSorting: Story = { + render: () => { + const tableContainerRef = useRef(null); + + const columns = useMemo( + () => [ + bookingColumnHelper.accessor("title", { + header: "Title", + cell: (info) => {info.getValue()}, + enableSorting: true, + }), + bookingColumnHelper.accessor("date", { + header: "Date", + cell: (info) => info.getValue(), + enableSorting: true, + }), + bookingColumnHelper.accessor("duration", { + header: "Duration", + cell: (info) => `${info.getValue()} min`, + enableSorting: true, + }), + bookingColumnHelper.accessor("status", { + header: "Status", + cell: (info) => { + const status = info.getValue(); + const statusColors = { + confirmed: "bg-green-100 text-green-800", + pending: "bg-yellow-100 text-yellow-800", + cancelled: "bg-red-100 text-red-800", + }; + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + }, + enableSorting: true, + }), + ], + [] + ); + + const table = useReactTable({ + data: sampleBookings, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( +
+ +
+ ); + }, +}; + +// With column resizing +export const WithColumnResizing: Story = { + render: () => { + const tableContainerRef = useRef(null); + + const columns = useMemo( + () => [ + personColumnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + size: 200, + minSize: 100, + maxSize: 400, + }), + personColumnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + size: 250, + minSize: 150, + maxSize: 400, + }), + personColumnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + size: 150, + minSize: 100, + maxSize: 300, + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + enableColumnResizing: true, + columnResizeMode: "onChange", + }); + + return ( +
+ +
+ ); + }, +}; + +// With row click handler +export const WithRowClick: Story = { + render: () => { + const tableContainerRef = useRef(null); + + const columns = useMemo( + () => [ + personColumnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const handleRowClick = (row: any) => { + alert(`Clicked on: ${row.original.name}`); + }; + + return ( +
+ +
+ ); + }, +}; + +// Empty state +export const EmptyState: Story = { + render: () => { + const tableContainerRef = useRef(null); + + const columns = useMemo( + () => [ + personColumnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + }), + ], + [] + ); + + const table = useReactTable({ + data: [], + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ +
+ ); + }, +}; + +// Loading state +export const LoadingState: Story = { + render: () => { + const tableContainerRef = useRef(null); + + const columns = useMemo( + () => [ + personColumnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ +
+ ); + }, +}; + +// Infinite scroll mode +export const InfiniteScroll: Story = { + render: () => { + const tableContainerRef = useRef(null); + const largeDataset = useMemo(() => generateLargeDataset(100), []); + + const columns = useMemo( + () => [ + personColumnHelper.accessor("id", { + header: "ID", + cell: (info) => `#${info.getValue()}`, + size: 80, + }), + personColumnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("status", { + header: "Status", + cell: (info) => { + const status = info.getValue(); + const statusColors = { + active: "bg-green-100 text-green-800", + inactive: "bg-gray-100 text-gray-800", + pending: "bg-yellow-100 text-yellow-800", + }; + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + }, + }), + ], + [] + ); + + const table = useReactTable({ + data: largeDataset, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ +
+ ); + }, +}; + +// With custom row className +export const WithCustomRowStyling: Story = { + render: () => { + const tableContainerRef = useRef(null); + + const columns = useMemo( + () => [ + personColumnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("status", { + header: "Status", + cell: (info) => info.getValue(), + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const getRowClassName = (row: any) => { + if (row.original.status === "inactive") { + return "opacity-50"; + } + if (row.original.status === "pending") { + return "bg-yellow-50"; + } + return ""; + }; + + return ( +
+ +
+ ); + }, +}; + +// With column filtering +export const WithFiltering: Story = { + render: () => { + const tableContainerRef = useRef(null); + + const columns = useMemo( + () => [ + personColumnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + personColumnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + personColumnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + personColumnHelper.accessor("status", { + header: "Status", + cell: (info) => { + const status = info.getValue(); + const statusColors = { + active: "bg-green-100 text-green-800", + inactive: "bg-gray-100 text-gray-800", + pending: "bg-yellow-100 text-yellow-800", + }; + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + }, + enableColumnFilter: true, + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( +
+ +
+ ); + }, +}; diff --git a/packages/features/data-table/components/DataTableSelectionBar.stories.tsx b/packages/features/data-table/components/DataTableSelectionBar.stories.tsx new file mode 100644 index 00000000000000..ac624b547096e0 --- /dev/null +++ b/packages/features/data-table/components/DataTableSelectionBar.stories.tsx @@ -0,0 +1,561 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState, useMemo, useRef } from "react"; +import { + useReactTable, + getCoreRowModel, + createColumnHelper, + type RowSelectionState, +} from "@tanstack/react-table"; + +import { DataTableSelectionBar, type ActionItem } from "./DataTableSelectionBar"; + +const meta = { + title: "Features/DataTable/DataTableSelectionBar", + component: DataTableSelectionBar.Root, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Sample data type +type Person = { + id: number; + name: string; + email: string; + role: string; +}; + +// Sample data +const samplePeople: Person[] = [ + { id: 1, name: "John Doe", email: "john@example.com", role: "Admin" }, + { id: 2, name: "Jane Smith", email: "jane@example.com", role: "Member" }, + { id: 3, name: "Bob Wilson", email: "bob@example.com", role: "Member" }, + { id: 4, name: "Alice Johnson", email: "alice@example.com", role: "Editor" }, + { id: 5, name: "Charlie Brown", email: "charlie@example.com", role: "Viewer" }, +]; + +const personColumnHelper = createColumnHelper(); + +// Helper component to create a table with row selection +function SelectableTableWrapper({ children }: { children: (table: any) => React.ReactNode }) { + const [rowSelection, setRowSelection] = useState({ + "0": true, + "1": true, + "2": true, + }); + + const columns = useMemo( + () => [ + personColumnHelper.display({ + id: "select", + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ), + }), + personColumnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + state: { + rowSelection, + }, + onRowSelectionChange: setRowSelection, + enableRowSelection: true, + }); + + return <>{children(table)}; +} + +export const Default: Story = { + render: () => ( + + {(table) => ( +
+
+

Selected rows: {table.getSelectedRowModel().rows.length}

+
+ + + + {table.getFlatHeaders().map((header) => ( + + ))} + + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : header.column.columnDef.header?.(header.getContext())} +
+ {cell.column.columnDef.cell?.(cell.getContext())} +
+
+
+ {table.getSelectedRowModel().rows.length > 0 && ( + + + {table.getSelectedRowModel().rows.length} selected + + + Delete + + + )} +
+ )} +
+ ), +}; + +export const WithMultipleActions: Story = { + render: () => ( + + {(table) => ( +
+
+

Selected rows: {table.getSelectedRowModel().rows.length}

+
+ + + + {table.getFlatHeaders().map((header) => ( + + ))} + + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : header.column.columnDef.header?.(header.getContext())} +
+ {cell.column.columnDef.cell?.(cell.getContext())} +
+
+
+ {table.getSelectedRowModel().rows.length > 0 && ( + + + {table.getSelectedRowModel().rows.length} selected + + alert("Send email to selected users")}> + Email + + alert("Export selected users")}> + Export + + alert("Duplicate selected users")}> + Duplicate + + alert("Delete selected users")}> + Delete + + + )} +
+ )} +
+ ), +}; + +export const WithCustomContent: Story = { + render: () => ( + + {(table) => ( +
+
+

Selected rows: {table.getSelectedRowModel().rows.length}

+
+ + + + {table.getFlatHeaders().map((header) => ( + + ))} + + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : header.column.columnDef.header?.(header.getContext())} +
+ {cell.column.columnDef.cell?.(cell.getContext())} +
+
+
+ {table.getSelectedRowModel().rows.length > 0 && ( + +
+ + {table.getSelectedRowModel().rows.length} items + + + ({table.getSelectedRowModel().rows.map((r) => r.original.name).join(", ")}) + +
+ table.resetRowSelection()}> + Clear Selection + +
+ )} +
+ )} +
+ ), +}; + +export const SingleSelection: Story = { + render: () => { + function SingleSelectionWrapper() { + const [rowSelection, setRowSelection] = useState({ "0": true }); + + const columns = useMemo( + () => [ + personColumnHelper.display({ + id: "select", + header: "", + cell: ({ row }) => ( + + ), + }), + personColumnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + }), + personColumnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + state: { + rowSelection, + }, + onRowSelectionChange: setRowSelection, + enableRowSelection: true, + enableMultiRowSelection: false, + }); + + return ( +
+
+
+ + + + {table.getFlatHeaders().map((header) => ( + + ))} + + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : header.column.columnDef.header?.(header.getContext())} +
+ {cell.column.columnDef.cell?.(cell.getContext())} +
+
+
+ {table.getSelectedRowModel().rows.length > 0 && ( + + + {table.getSelectedRowModel().rows[0].original.name} selected + + alert("Edit user")}> + Edit + + alert("Delete user")}> + Delete + + + )} +
+ ); + } + + return ; + }, +}; + +export const ResponsiveButtons: Story = { + render: () => ( + + {(table) => ( +
+
+

+ Note: Resize your browser to see how the buttons adapt - they show only icons on mobile and + icons with text on larger screens. +

+

Selected rows: {table.getSelectedRowModel().rows.length}

+
+ + + + {table.getFlatHeaders().map((header) => ( + + ))} + + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : header.column.columnDef.header?.(header.getContext())} +
+ {cell.column.columnDef.cell?.(cell.getContext())} +
+
+
+ {table.getSelectedRowModel().rows.length > 0 && ( + + + {table.getSelectedRowModel().rows.length} selected + + + Send Email + + + Export CSV + + + Add to Group + + + Delete Selected + + + )} +
+ )} +
+ ), +}; + +export const BarOnly: Story = { + render: () => ( +
+ + 3 items selected + + Email + + + Export + + + Delete + + +
+ ), +}; + +export const MinimalActions: Story = { + render: () => ( + + {(table) => ( +
+
+

Selected rows: {table.getSelectedRowModel().rows.length}

+
+ + + + {table.getFlatHeaders().map((header) => ( + + ))} + + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : header.column.columnDef.header?.(header.getContext())} +
+ {cell.column.columnDef.cell?.(cell.getContext())} +
+
+
+ {table.getSelectedRowModel().rows.length > 0 && ( + + + {table.getSelectedRowModel().rows.length} selected + + alert("Delete selected")}> + Delete + + + )} +
+ )} +
+ ), +}; + +export const WithButtonVariants: Story = { + render: () => ( + + {(table) => ( +
+
+

Selected rows: {table.getSelectedRowModel().rows.length}

+
+ + + + {table.getFlatHeaders().map((header) => ( + + ))} + + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : header.column.columnDef.header?.(header.getContext())} +
+ {cell.column.columnDef.cell?.(cell.getContext())} +
+
+
+ {table.getSelectedRowModel().rows.length > 0 && ( + + + {table.getSelectedRowModel().rows.length} selected + + + Approve + + + Edit + + + Duplicate + + + Delete + + + )} +
+ )} +
+ ), +}; diff --git a/packages/features/data-table/components/DataTableToolbar.stories.tsx b/packages/features/data-table/components/DataTableToolbar.stories.tsx new file mode 100644 index 00000000000000..e646c0fafc4f37 --- /dev/null +++ b/packages/features/data-table/components/DataTableToolbar.stories.tsx @@ -0,0 +1,466 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState, useMemo } from "react"; +import { + useReactTable, + getCoreRowModel, + getFilteredRowModel, + createColumnHelper, +} from "@tanstack/react-table"; + +import { DataTableProvider } from "../DataTableProvider"; +import { DataTableToolbar } from "./DataTableToolbar"; + +const meta = { + title: "Features/DataTable/DataTableToolbar", + component: DataTableToolbar.Root, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Sample data type for stories +type Person = { + id: number; + name: string; + email: string; + role: string; + status: "active" | "inactive" | "pending"; +}; + +// Sample data +const samplePeople: Person[] = [ + { id: 1, name: "John Doe", email: "john@example.com", role: "Admin", status: "active" }, + { id: 2, name: "Jane Smith", email: "jane@example.com", role: "Member", status: "active" }, + { id: 3, name: "Bob Wilson", email: "bob@example.com", role: "Member", status: "inactive" }, + { id: 4, name: "Alice Johnson", email: "alice@example.com", role: "Editor", status: "active" }, + { id: 5, name: "Charlie Brown", email: "charlie@example.com", role: "Viewer", status: "pending" }, +]; + +// Column helper +const columnHelper = createColumnHelper(); + +// Default story with just the toolbar root +export const Default: Story = { + render: () => { + return ( + + +
Toolbar content goes here
+
+
+ ); + }, +}; + +// Toolbar with SearchBar +export const WithSearchBar: Story = { + render: () => { + return ( + + + + + + ); + }, +}; + +// Toolbar with SearchBar and custom className +export const WithSearchBarCustomClass: Story = { + render: () => { + return ( + + + + + + ); + }, +}; + +// Toolbar with ClearFiltersButton +export const WithClearFiltersButton: Story = { + render: () => { + const columns = useMemo( + () => [ + columnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + columnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + columnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + return ( + + + +
+ Note: The Clear Filters button only appears when filters are active. +
+
+
+ ); + }, +}; + +// Toolbar with CTA button +export const WithCTA: Story = { + render: () => { + const [clickCount, setClickCount] = useState(0); + + return ( + + + setClickCount(clickCount + 1)}> + Add New Item + + {clickCount > 0 && ( +
Button clicked {clickCount} times
+ )} +
+
+ ); + }, +}; + +// Toolbar with CTA button variants +export const CTAVariants: Story = { + render: () => { + return ( + +
+ + Primary CTA + + + Secondary CTA + + + Minimal CTA + + + Destructive CTA + +
+
+ ); + }, + parameters: { + layout: "padded", + }, +}; + +// Toolbar with CTA with icons +export const CTAWithIcons: Story = { + render: () => { + return ( + +
+ + Add Item + + + Continue + + + + Import Data + + +
+
+ ); + }, + parameters: { + layout: "padded", + }, +}; + +// Complete toolbar with all components +export const CompleteToolbar: Story = { + render: () => { + const columns = useMemo( + () => [ + columnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + columnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + columnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + columnHelper.accessor("status", { + header: "Status", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + return ( + + + + + Add Person + + + ); + }, +}; + +// Toolbar with search and CTA +export const SearchWithCTA: Story = { + render: () => { + const [searchValue, setSearchValue] = useState(""); + + return ( + + + + + Create New + + + + ); + }, +}; + +// Toolbar with multiple CTAs +export const MultipleCTAs: Story = { + render: () => { + return ( + + + + + Import + + + Export + + + Add New + + + + ); + }, +}; + +// Custom toolbar layout +export const CustomLayout: Story = { + render: () => { + const columns = useMemo( + () => [ + columnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + return ( + + +
+ + +
+
+ + Filters + + Add Person +
+
+
+ ); + }, +}; + +// Toolbar with custom styling +export const CustomStyling: Story = { + render: () => { + return ( + + +
+ + + Magic Action + +
+
+
+ ); + }, +}; + +// Responsive toolbar +export const ResponsiveToolbar: Story = { + render: () => { + const columns = useMemo( + () => [ + columnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + return ( + + + + + + Add New + + + + ); + }, +}; + +// Minimal toolbar +export const MinimalToolbar: Story = { + render: () => { + return ( + + + + + + ); + }, +}; + +// Toolbar states showcase +export const ToolbarStates: Story = { + render: () => { + const columns = useMemo( + () => [ + columnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + enableColumnFilter: true, + }), + ], + [] + ); + + const table = useReactTable({ + data: samplePeople, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + return ( +
+
+

Default State

+ + + + + Add Item + + +
+ +
+

With Loading CTA

+ + + + + Adding... + + + +
+ +
+

With Disabled CTA

+ + + + + Add Item + + + +
+
+ ); + }, + parameters: { + layout: "padded", + }, +}; diff --git a/packages/features/data-table/components/DataTableWrapper.stories.tsx b/packages/features/data-table/components/DataTableWrapper.stories.tsx new file mode 100644 index 00000000000000..8548c89e913d0a --- /dev/null +++ b/packages/features/data-table/components/DataTableWrapper.stories.tsx @@ -0,0 +1,300 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { useMemo } from "react"; + +import { DataTableProvider } from "../DataTableProvider"; +import { DataTableWrapper } from "./DataTableWrapper"; + +type SampleData = { + id: number; + name: string; + email: string; + role: string; + status: "active" | "inactive"; +}; + +const columnHelper = createColumnHelper(); + +const sampleData: SampleData[] = [ + { id: 1, name: "John Doe", email: "john@example.com", role: "Admin", status: "active" }, + { id: 2, name: "Jane Smith", email: "jane@example.com", role: "User", status: "active" }, + { id: 3, name: "Bob Johnson", email: "bob@example.com", role: "User", status: "inactive" }, + { id: 4, name: "Alice Williams", email: "alice@example.com", role: "Editor", status: "active" }, + { id: 5, name: "Charlie Brown", email: "charlie@example.com", role: "User", status: "inactive" }, +]; + +const sampleColumns = [ + columnHelper.accessor("name", { + header: "Name", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("email", { + header: "Email", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("role", { + header: "Role", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("status", { + header: "Status", + cell: (info) => ( + + {info.getValue()} + + ), + }), +]; + +const DataTableWrapperWithTable = ( + props: Omit>, "table"> & { + data?: SampleData[]; + } +) => { + const { data = sampleData, ...rest } = props; + const table = useReactTable({ + data, + columns: sampleColumns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + + + + ); +}; + +const meta = { + component: DataTableWrapper, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + render: (args) => , +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + paginationMode: "standard", + isPending: false, + }, +}; + +export const WithToolbars: Story = { + args: { + paginationMode: "standard", + isPending: false, + ToolbarLeft: ( +
+ + +
+ ), + ToolbarRight: ( +
+ + +
+ ), + }, +}; + +export const CompactVariant: Story = { + args: { + paginationMode: "standard", + variant: "compact", + isPending: false, + }, +}; + +export const LoadingState: Story = { + args: { + paginationMode: "standard", + isPending: true, + LoaderView: ( +
+
+
+ ), + }, +}; + +export const EmptyState: Story = { + args: { + paginationMode: "standard", + data: [], + isPending: false, + EmptyView: ( +
+ + + +

No users found

+

Get started by adding your first user

+
+ ), + }, +}; + +export const ErrorState: Story = { + args: { + paginationMode: "standard", + isPending: false, + hasError: true, + ErrorView: ( +
+ + + +

Error loading data

+

Something went wrong while fetching the data

+ +
+ ), + }, +}; + +export const InfinitePagination: Story = { + args: { + paginationMode: "infinite", + hasNextPage: true, + fetchNextPage: () => console.log("Fetching next page..."), + isFetching: false, + isPending: false, + }, +}; + +export const WithTotalRowCount: Story = { + args: { + paginationMode: "standard", + totalRowCount: 127, + isPending: false, + }, +}; + +export const WithCustomClassName: Story = { + args: { + paginationMode: "standard", + isPending: false, + className: "border-2 border-blue-500", + containerClassName: "bg-gray-50", + headerClassName: "bg-blue-100", + }, +}; + +export const WithRowClickHandler: Story = { + args: { + paginationMode: "standard", + isPending: false, + onRowMouseclick: (row) => { + alert(`Clicked on ${row.original.name}`); + }, + }, +}; + +export const WithCustomRowClassName: Story = { + args: { + paginationMode: "standard", + isPending: false, + rowClassName: (row) => { + return row.original.status === "inactive" ? "opacity-50" : ""; + }, + }, +}; + +export const ComplexExample: Story = { + args: { + paginationMode: "standard", + isPending: false, + variant: "default", + totalRowCount: 127, + ToolbarLeft: ( +
+

User Management

+ 127 users +
+ ), + ToolbarRight: ( +
+ + + +
+ ), + onRowMouseclick: (row) => { + console.log("Selected user:", row.original.name); + }, + }, +}; + +export const LargeDataset: Story = { + args: { + paginationMode: "standard", + isPending: false, + data: Array.from({ length: 50 }, (_, i) => ({ + id: i + 1, + name: `User ${i + 1}`, + email: `user${i + 1}@example.com`, + role: ["Admin", "User", "Editor"][i % 3], + status: i % 3 === 0 ? "inactive" : "active", + })) as SampleData[], + totalRowCount: 500, + }, +}; + +export const WithChildren: Story = { + args: { + paginationMode: "standard", + isPending: false, + ToolbarLeft:
Users
, + children: ( +
+

+ This is additional content rendered between the toolbar and the table. You can use this for + filters, notifications, or other contextual information. +

+
+ ), + }, +}; diff --git a/packages/features/ee/video/MeetingSessionDetailsDialog.stories.tsx b/packages/features/ee/video/MeetingSessionDetailsDialog.stories.tsx new file mode 100644 index 00000000000000..72680c750a10f7 --- /dev/null +++ b/packages/features/ee/video/MeetingSessionDetailsDialog.stories.tsx @@ -0,0 +1,352 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; +import { fn } from "storybook/test"; + +import { Button } from "@calcom/ui/components/button"; + +import { MeetingSessionDetailsDialog } from "./MeetingSessionDetailsDialog"; + +const meta = { + component: MeetingSessionDetailsDialog, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + isOpenDialog: { + description: "Controls whether the dialog is open or closed", + control: "boolean", + }, + timeFormat: { + description: "Time format (12 or 24 hour)", + control: "select", + options: [12, 24, null], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const mockBookingBase = { + id: 12345, + uid: "abc123xyz", + title: "30 Min Meeting", + description: "Quick sync meeting", + startTime: new Date("2024-03-15T14:00:00Z"), + endTime: new Date("2024-03-15T14:30:00Z"), + attendees: [ + { + id: 1, + email: "john@example.com", + name: "John Doe", + timeZone: "America/New_York", + locale: "en", + }, + ], + user: { + id: 1, + email: "host@example.com", + name: "Meeting Host", + timeZone: "America/Los_Angeles", + }, + metadata: {}, + status: "ACCEPTED" as const, + paid: false, + payment: [], + references: [ + { + id: 1, + type: "daily_video", + uid: "daily-meeting-room-123", + meetingId: "daily-meeting-room-123", + meetingPassword: "secret123", + meetingUrl: "https://example.daily.co/daily-meeting-room-123", + }, + ], +}; + +const mockBookingWithoutVideo = { + ...mockBookingBase, + references: [ + { + id: 2, + type: "google_calendar", + uid: "gcal-event-456", + }, + ], +}; + +export const Default: Story = { + render: function DefaultStory() { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + +
+ ); + }, +}; + +export const Open: Story = { + args: { + booking: mockBookingBase, + isOpenDialog: true, + setIsOpenDialog: fn(), + timeFormat: 12, + }, +}; + +export const With24HourFormat: Story = { + render: function With24HourFormatStory() { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + +
+ ); + }, +}; + +export const WithoutVideoReference: Story = { + render: function WithoutVideoReferenceStory() { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + +
+ ); + }, +}; + +export const TeamMeeting: Story = { + render: function TeamMeetingStory() { + const [isOpen, setIsOpen] = useState(false); + + const teamBooking = { + ...mockBookingBase, + title: "Team Standup - Engineering", + startTime: new Date("2024-03-20T10:00:00Z"), + endTime: new Date("2024-03-20T10:30:00Z"), + attendees: [ + { + id: 1, + email: "alice@example.com", + name: "Alice Smith", + timeZone: "America/New_York", + locale: "en", + }, + { + id: 2, + email: "bob@example.com", + name: "Bob Johnson", + timeZone: "America/Chicago", + locale: "en", + }, + { + id: 3, + email: "charlie@example.com", + name: "Charlie Brown", + timeZone: "Europe/London", + locale: "en", + }, + ], + }; + + return ( +
+ + +
+ ); + }, +}; + +export const LongMeeting: Story = { + render: function LongMeetingStory() { + const [isOpen, setIsOpen] = useState(false); + + const longBooking = { + ...mockBookingBase, + title: "Quarterly Business Review & Strategic Planning Session", + startTime: new Date("2024-04-01T09:00:00Z"), + endTime: new Date("2024-04-01T11:00:00Z"), + }; + + return ( +
+ + +
+ ); + }, +}; + +export const RecurringMeeting: Story = { + render: function RecurringMeetingStory() { + const [isOpen, setIsOpen] = useState(false); + + const recurringBooking = { + ...mockBookingBase, + title: "Weekly 1:1 Check-in", + startTime: new Date("2024-03-18T15:00:00Z"), + endTime: new Date("2024-03-18T15:30:00Z"), + recurringEventId: "weekly-checkin-789", + }; + + return ( +
+ + +
+ ); + }, +}; + +export const PastMeeting: Story = { + render: function PastMeetingStory() { + const [isOpen, setIsOpen] = useState(false); + + const pastBooking = { + ...mockBookingBase, + title: "Product Demo", + startTime: new Date("2024-01-15T14:00:00Z"), + endTime: new Date("2024-01-15T14:45:00Z"), + }; + + return ( +
+ + +
+ ); + }, +}; + +export const UndefinedBooking: Story = { + render: function UndefinedBookingStory() { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + +
+ ); + }, +}; + +export const InteractiveExample: Story = { + render: function InteractiveExampleStory() { + const [isOpen, setIsOpen] = useState(false); + const [selectedMeeting, setSelectedMeeting] = useState(mockBookingBase); + + const meetings = [ + { + label: "Standard Meeting", + booking: mockBookingBase, + }, + { + label: "Team Meeting", + booking: { + ...mockBookingBase, + title: "Team Standup", + attendees: [ + ...mockBookingBase.attendees, + { + id: 2, + email: "jane@example.com", + name: "Jane Smith", + timeZone: "Europe/London", + locale: "en", + }, + ], + }, + }, + { + label: "No Video Reference", + booking: mockBookingWithoutVideo, + }, + ]; + + return ( +
+
+ {meetings.map((meeting) => ( + + ))} +
+ +
+ ); + }, + parameters: { + layout: "padded", + }, +}; diff --git a/packages/features/embed/Embed.stories.tsx b/packages/features/embed/Embed.stories.tsx new file mode 100644 index 00000000000000..ec22dcaf5fe8bd --- /dev/null +++ b/packages/features/embed/Embed.stories.tsx @@ -0,0 +1,352 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { BookerLayouts } from "@calcom/prisma/zod-utils"; + +import { EmbedButton, EmbedDialog } from "./Embed"; +import { EmbedTabName } from "./lib/EmbedTabs"; +import { EmbedType } from "./types"; + +// Mock tabs configuration +const mockTabs = [ + { + name: "HTML", + href: "embedTabName=embed-code", + type: "code" as const, + Component: () => null, + }, + { + name: "React", + href: "embedTabName=embed-react", + type: "code" as const, + Component: () => null, + }, + { + name: "Preview", + href: "embedTabName=embed-preview", + type: "preview" as const, + Component: () => null, + }, +]; + +// Mock embed types +const mockTypes = [ + { + type: "inline" as EmbedType, + title: "Inline Embed", + subtitle: "Embed the booking page directly into your website", + illustration:
, + }, + { + type: "floating-popup" as EmbedType, + title: "Floating Button", + subtitle: "Add a floating button to your website", + illustration:
, + }, + { + type: "element-click" as EmbedType, + title: "Pop-up on Element Click", + subtitle: "Show booking page when clicking an element", + illustration:
, + }, + { + type: "email" as EmbedType, + title: "Email Embed", + subtitle: "Share booking slots via email", + illustration:
, + }, +]; + +const meta = { + component: EmbedDialog, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + noQueryParamMode: { + control: "boolean", + description: "Whether to use query params or internal state for dialog management", + }, + eventTypeHideOptionDisabled: { + control: "boolean", + description: "Disable the option to hide event type details", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default story showing the EmbedDialog in no-query-param mode. + * This allows the dialog to be controlled via internal state. + */ +export const Default: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + {isOpen && ( + + )} +
+ ); + }, +}; + +/** + * EmbedButton component that triggers the embed dialog. + * This is the typical way users would interact with the embed functionality. + */ +export const WithEmbedButton: Story = { + render: () => ( +
+

Click the button below to open the embed dialog:

+ + Embed This Event + +
+ ), +}; + +/** + * Inline embed type variant. + * Shows the dialog configured for inline embedding. + */ +export const InlineEmbedType: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + {isOpen && ( + t.type === "inline")} + tabs={mockTabs} + eventTypeHideOptionDisabled={false} + defaultBrandColor={{ brandColor: "#292929", darkBrandColor: "#fafafa" }} + noQueryParamMode={true} + /> + )} +
+ ); + }, +}; + +/** + * Floating popup embed type variant. + * Shows the dialog configured for floating button embedding. + */ +export const FloatingPopupEmbedType: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + {isOpen && ( + t.type === "floating-popup")} + tabs={mockTabs} + eventTypeHideOptionDisabled={false} + defaultBrandColor={{ brandColor: "#000000", darkBrandColor: "#ffffff" }} + noQueryParamMode={true} + /> + )} +
+ ); + }, +}; + +/** + * Element click embed type variant. + * Shows the dialog configured for element click embedding. + */ +export const ElementClickEmbedType: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + {isOpen && ( + t.type === "element-click")} + tabs={mockTabs} + eventTypeHideOptionDisabled={false} + defaultBrandColor={{ brandColor: "#000000", darkBrandColor: "#ffffff" }} + noQueryParamMode={true} + /> + )} +
+ ); + }, +}; + +/** + * Custom brand colors variant. + * Shows the dialog with custom brand colors configured. + */ +export const WithCustomBrandColors: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + {isOpen && ( + + )} +
+ ); + }, +}; + +/** + * Event type hide option disabled variant. + * Shows the dialog with the event type hide option disabled. + */ +export const EventTypeHideDisabled: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+

+ This variant has the event type hide option disabled. +

+ + {isOpen && ( + + )} +
+ ); + }, +}; + +/** + * Multiple embed buttons variant. + * Shows multiple embed buttons for different event types. + */ +export const MultipleEmbedButtons: Story = { + render: () => ( +
+

Multiple event types with embed buttons:

+
+ + Embed 15 Min Meeting + + + Embed 30 Min Meeting + + + Embed 60 Min Meeting + +
+
+ ), +}; + +/** + * All embed types variant. + * Shows the dialog with all available embed types. + */ +export const AllEmbedTypes: Story = { + render: (args) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+

+ This shows all available embed types: Inline, Floating Button, Element Click, and Email. +

+ + {isOpen && ( + + )} +
+ ); + }, +}; diff --git a/packages/features/embed/EventTypeEmbed.stories.tsx b/packages/features/embed/EventTypeEmbed.stories.tsx new file mode 100644 index 00000000000000..b3db843a658d02 --- /dev/null +++ b/packages/features/embed/EventTypeEmbed.stories.tsx @@ -0,0 +1,202 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import { EventTypeEmbedButton } from "./EventTypeEmbed"; + +const meta = { + component: EventTypeEmbedButton, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + embedUrl: "john-doe/30min", + namespace: "default", + }, + argTypes: { + embedUrl: { + control: "text", + description: "The URL path for the event type to embed (e.g., 'username/event-slug')", + }, + namespace: { + control: "text", + description: "Namespace identifier for the embed instance", + }, + eventId: { + control: "number", + description: "Optional event type ID", + }, + noQueryParamMode: { + control: "boolean", + description: "Whether to use query parameters or internal state for embed dialog", + }, + className: { + control: "text", + description: "Additional CSS classes to apply to the button", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + embedUrl: "john-doe/30min", + namespace: "default", + children: "Embed", + }, +}; + +export const WithCustomText: Story = { + args: { + embedUrl: "team/consultation", + namespace: "team-embed", + children: "Add to your site", + }, +}; + +export const WithEventId: Story = { + args: { + embedUrl: "jane-smith/discovery-call", + namespace: "discovery", + eventId: 123, + children: "Embed Event", + }, +}; + +export const NoQueryParamMode: Story = { + args: { + embedUrl: "sales/demo", + namespace: "sales-demo", + noQueryParamMode: true, + children: "Embed (No Query Params)", + }, +}; + +export const WithCustomClassName: Story = { + args: { + embedUrl: "support/meeting", + namespace: "support", + className: "bg-blue-500 text-white hover:bg-blue-600", + children: "Book a Meeting", + }, +}; + +export const TeamEvent: Story = { + args: { + embedUrl: "team/engineering/standup", + namespace: "team-standup", + eventId: 456, + children: "Embed Team Event", + }, +}; + +export const LongEventName: Story = { + args: { + embedUrl: "consultant/in-depth-strategic-planning-session", + namespace: "strategic-planning", + children: "Embed Strategic Planning", + }, +}; + +export const MultipleButtons: Story = { + render: () => ( +
+
+ + 30 Min Meeting + +
+
+ + 60 Min Meeting + +
+
+ + Free Consultation + +
+
+ ), + parameters: { + layout: "padded", + }, +}; + +export const InCardLayout: Story = { + render: () => ( +
+

Schedule a Meeting

+

+ Click the button below to embed our booking calendar on your website or share it with your audience. +

+ + Get Embed Code + +
+ ), + parameters: { + layout: "centered", + }, +}; + +export const WithDifferentColorSchemes: Story = { + render: () => ( +
+ + Primary Style + + + Secondary Style + + + Minimal Style + +
+ ), + parameters: { + layout: "padded", + }, +}; + +export const AsCustomElement: Story = { + args: { + embedUrl: "designer/portfolio-review", + namespace: "custom-element", + as: "div", + children: "Click to Embed", + }, +}; + +export const Interactive: Story = { + args: { + embedUrl: "interactive/demo", + namespace: "interactive-demo", + children: "Open Embed Dialog", + }, + play: async ({ args }) => { + // This story demonstrates the interactive behavior + // When clicked, it should open the embed dialog + console.log("Embed button clicked with args:", args); + }, +}; diff --git a/packages/features/embed/RoutingFormEmbed.stories.tsx b/packages/features/embed/RoutingFormEmbed.stories.tsx new file mode 100644 index 00000000000000..64158ce31bbc6c --- /dev/null +++ b/packages/features/embed/RoutingFormEmbed.stories.tsx @@ -0,0 +1,368 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { RoutingFormEmbedDialog, RoutingFormEmbedButton } from "./RoutingFormEmbed"; + +const meta = { + title: "Features/Embed/RoutingFormEmbed", + component: RoutingFormEmbedDialog, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock data for stories +const mockUserData = { + id: 1, + username: "testuser", + email: "test@example.com", + brandColor: "#292929", + darkBrandColor: "#fafafa", + name: "Test User", + timeZone: "America/New_York", + locale: "en", +}; + +/** + * Default RoutingFormEmbedDialog story + * + * This shows the routing form embed dialog with all standard embed types. + * The dialog allows users to choose how to embed their routing form: + * - Inline: Embedded directly in a page + * - Floating Popup: A floating button that opens the form + * - On Element Click: Triggered when clicking specific elements + */ +export const Default: Story = { + render: function DefaultStory() { + return ( +
+

+ Click the button below to open the Routing Form Embed Dialog +

+ + Open Routing Form Embed + + +
+ ); + }, + parameters: { + docs: { + description: { + story: + "The default routing form embed dialog. Users can select different embed types for their routing forms, excluding email embeds which are not supported for routing forms.", + }, + }, + }, +}; + +/** + * RoutingFormEmbedButton story + * + * Shows the embed button component in isolation with different variants. + */ +export const EmbedButton: Story = { + render: function EmbedButtonStory() { + return ( +
+
+

Default Button

+ + Embed Routing Form + +
+ +
+

Custom Styled Button

+ + Custom Embed Button + +
+ +
+

Different Event ID

+ + Sales Form Embed + +
+
+ ); + }, + parameters: { + docs: { + description: { + story: + "The RoutingFormEmbedButton component triggers the embed dialog. It supports custom styling and can be used with different event IDs and namespaces.", + }, + }, + }, +}; + +/** + * Dialog with Custom Brand Colors + * + * Shows how the embed dialog adapts to different brand colors. + */ +export const WithCustomBrandColors: Story = { + render: function CustomBrandColorsStory() { + return ( +
+

+ This example shows the embed dialog with custom brand colors configured. + The dialog will use these colors in the embed code generation. +

+ + Open Branded Embed Dialog + + +
+ ); + }, + parameters: { + docs: { + description: { + story: + "When a user has custom brand colors configured, the embed dialog will include these in the generated embed code.", + }, + }, + }, +}; + +/** + * Multiple Buttons Example + * + * Shows how multiple embed buttons can be used on the same page. + */ +export const MultipleEmbedButtons: Story = { + render: function MultipleButtonsStory() { + return ( +
+
+

Marketing Form

+

+ Embed button for marketing routing form +

+ + Embed Marketing Form + +
+ +
+

Sales Form

+

+ Embed button for sales routing form +

+ + Embed Sales Form + +
+ +
+

Support Form

+

+ Embed button for support routing form +

+ + Embed Support Form + +
+ + +
+ ); + }, + parameters: { + layout: "padded", + docs: { + description: { + story: + "Multiple routing form embed buttons can be used on the same page, each with different configurations and event IDs.", + }, + }, + }, +}; + +/** + * Button as Custom Element + * + * Shows how the embed button can be rendered as a custom element type. + */ +export const ButtonAsCustomElement: Story = { + render: function CustomElementStory() { + return ( +
+

+ The embed button can be rendered as different HTML elements using the `as` prop. +

+ +
+
+

As default button:

+ + Default Button Element + +
+ +
+

With custom className:

+ + Custom Styled Element + +
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: + "The RoutingFormEmbedButton component accepts an `as` prop to render as different element types, and supports custom className for styling.", + }, + }, + }, +}; + +/** + * Compact Button Variant + * + * Shows a more compact version of the embed button. + */ +export const CompactButton: Story = { + render: function CompactButtonStory() { + return ( +
+

+ Compact embed button suitable for limited spaces +

+ + Embed + +
+ ); + }, + parameters: { + docs: { + description: { + story: + "A compact version of the embed button, useful when space is limited or when you want a more subtle call-to-action.", + }, + }, + }, +}; + +/** + * Button with Different Namespaces + * + * Demonstrates how different namespaces affect the embed configuration. + */ +export const DifferentNamespaces: Story = { + render: function DifferentNamespacesStory() { + return ( +
+

+ Different namespaces help organize and differentiate multiple embeds on the same page. +

+ +
+
+

Namespace: "onboarding"

+ + Onboarding Form + +
+ +
+

Namespace: "feedback"

+ + Feedback Form + +
+ +
+

Namespace: "contact"

+ + Contact Form + +
+ +
+

Namespace: "application"

+ + Application Form + +
+
+ + +
+ ); + }, + parameters: { + layout: "padded", + docs: { + description: { + story: + "Namespaces are used to differentiate multiple routing form embeds on the same page. Each namespace creates an isolated embed instance.", + }, + }, + }, +}; diff --git a/packages/features/eventtypes/components/AddMembersWithSwitch.stories.tsx b/packages/features/eventtypes/components/AddMembersWithSwitch.stories.tsx new file mode 100644 index 00000000000000..8f6d54d8ad7b25 --- /dev/null +++ b/packages/features/eventtypes/components/AddMembersWithSwitch.stories.tsx @@ -0,0 +1,432 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; + +import type { FormValues, Host, TeamMember } from "@calcom/features/eventtypes/lib/types"; + +import { AddMembersWithSwitch } from "./AddMembersWithSwitch"; + +// Mock team members data +const mockTeamMembers: TeamMember[] = [ + { + value: "1", + label: "Alice Johnson", + avatar: "https://i.pravatar.cc/150?img=1", + email: "alice@example.com", + defaultScheduleId: 1, + }, + { + value: "2", + label: "Bob Smith", + avatar: "https://i.pravatar.cc/150?img=2", + email: "bob@example.com", + defaultScheduleId: 2, + }, + { + value: "3", + label: "Charlie Davis", + avatar: "https://i.pravatar.cc/150?img=3", + email: "charlie@example.com", + defaultScheduleId: 3, + }, + { + value: "4", + label: "Diana Prince", + avatar: "https://i.pravatar.cc/150?img=4", + email: "diana@example.com", + defaultScheduleId: 4, + }, + { + value: "5", + label: "Eve Martinez", + avatar: "https://i.pravatar.cc/150?img=5", + email: "eve@example.com", + defaultScheduleId: 5, + }, +]; + +// Wrapper component to provide form context +const FormWrapper = ({ children, defaultValues }: { children: React.ReactNode; defaultValues?: any }) => { + const methods = useForm({ + defaultValues: { + assignRRMembersUsingSegment: defaultValues?.assignRRMembersUsingSegment || false, + rrSegmentQueryValue: defaultValues?.rrSegmentQueryValue || null, + ...defaultValues, + }, + }); + + return {children}; +}; + +const meta = { + component: AddMembersWithSwitch, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [hosts, setHosts] = useState([]); + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(false); + + return ( + + console.log("Active")} + isFixed={false} + placeholder="Add team members" + teamId={1} + groupId={null} + /> + + ); + }, +}; + +export const WithSelectedMembers: Story = { + render: () => { + const [hosts, setHosts] = useState([ + { + isFixed: false, + userId: 1, + priority: 2, + weight: 100, + scheduleId: 1, + groupId: null, + }, + { + isFixed: false, + userId: 2, + priority: 2, + weight: 100, + scheduleId: 2, + groupId: null, + }, + ]); + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(false); + + return ( + + console.log("Active")} + isFixed={false} + placeholder="Add team members" + teamId={1} + groupId={null} + /> + + ); + }, +}; + +export const WithAssignAllEnabled: Story = { + render: () => { + const [hosts, setHosts] = useState([]); + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(false); + + return ( + + console.log("Active")} + isFixed={false} + placeholder="Add team members" + teamId={1} + groupId={null} + /> + + ); + }, +}; + +export const WithAssignAllActive: Story = { + render: () => { + const [hosts, setHosts] = useState([]); + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(true); + + return ( + + console.log("Active")} + isFixed={false} + placeholder="Add team members" + teamId={1} + groupId={null} + /> + + ); + }, +}; + +export const WithSegmentApplicable: Story = { + render: () => { + const [hosts, setHosts] = useState([]); + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(true); + + return ( + + console.log("Active")} + isFixed={false} + placeholder="Add team members" + teamId={1} + isSegmentApplicable={true} + groupId={null} + /> + + ); + }, +}; + +export const WithRoundRobinWeights: Story = { + render: () => { + const [hosts, setHosts] = useState([ + { + isFixed: false, + userId: 1, + priority: 2, + weight: 80, + scheduleId: 1, + groupId: null, + }, + { + isFixed: false, + userId: 2, + priority: 1, + weight: 120, + scheduleId: 2, + groupId: null, + }, + { + isFixed: false, + userId: 3, + priority: 2, + weight: 100, + scheduleId: 3, + groupId: null, + }, + ]); + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(false); + + return ( + + console.log("Active")} + isFixed={false} + placeholder="Add team members" + isRRWeightsEnabled={true} + teamId={1} + groupId={null} + /> + + ); + }, +}; + +export const FixedHosts: Story = { + render: () => { + const [hosts, setHosts] = useState([ + { + isFixed: true, + userId: 1, + priority: 2, + weight: 100, + scheduleId: 1, + groupId: null, + }, + { + isFixed: true, + userId: 3, + priority: 2, + weight: 100, + scheduleId: 3, + groupId: null, + }, + ]); + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(false); + + return ( + + console.log("Active")} + isFixed={true} + placeholder="Add fixed hosts" + teamId={1} + groupId={null} + /> + + ); + }, +}; + +export const WithCustomClassNames: Story = { + render: () => { + const [hosts, setHosts] = useState([]); + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(false); + + return ( + + console.log("Active")} + isFixed={false} + placeholder="Add team members" + teamId={1} + groupId={null} + customClassNames={{ + assingAllTeamMembers: { + container: "border-2 border-blue-200 p-4 rounded-lg", + }, + }} + /> + + ); + }, +}; + +export const WithGroupId: Story = { + render: () => { + const [hosts, setHosts] = useState([ + { + isFixed: false, + userId: 1, + priority: 2, + weight: 100, + scheduleId: 1, + groupId: "group-123", + }, + ]); + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(false); + + return ( + + console.log("Active")} + isFixed={false} + placeholder="Add team members" + teamId={1} + groupId="group-123" + /> + + ); + }, +}; + +export const Interactive: Story = { + render: () => { + const [hosts, setHosts] = useState([]); + const [assignAllTeamMembers, setAssignAllTeamMembers] = useState(false); + + return ( + +
+
+

State:

+
+

Assign all: {assignAllTeamMembers ? "Yes" : "No"}

+

Selected hosts: {hosts.length}

+ {hosts.length > 0 && ( +
+

Host details:

+ {hosts.map((host) => { + const member = mockTeamMembers.find((m) => m.value === host.userId.toString()); + return ( +
+ {member?.label} (Priority: {host.priority}, Weight: {host.weight}) +
+ ); + })} +
+ )} +
+
+ + { + console.log("Hosts changed:", newHosts); + setHosts(newHosts); + }} + assignAllTeamMembers={assignAllTeamMembers} + setAssignAllTeamMembers={(value) => { + console.log("Assign all changed:", value); + setAssignAllTeamMembers(value); + }} + automaticAddAllEnabled={true} + onActive={() => console.log("Activated")} + isFixed={false} + placeholder="Select team members" + isRRWeightsEnabled={true} + teamId={1} + groupId={null} + /> +
+
+ ); + }, +}; diff --git a/packages/features/eventtypes/components/CheckedTeamSelect.stories.tsx b/packages/features/eventtypes/components/CheckedTeamSelect.stories.tsx new file mode 100644 index 00000000000000..5d491f07674075 --- /dev/null +++ b/packages/features/eventtypes/components/CheckedTeamSelect.stories.tsx @@ -0,0 +1,276 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import { CheckedTeamSelect } from "./CheckedTeamSelect"; +import type { CheckedSelectOption } from "./CheckedTeamSelect"; + +const mockOptions: CheckedSelectOption[] = [ + { + avatar: "https://cal.com/stakeholder/peer.jpg", + label: "John Doe", + value: "user-1", + priority: 2, + weight: 100, + isFixed: false, + disabled: false, + defaultScheduleId: 1, + groupId: null, + }, + { + avatar: "https://cal.com/stakeholder/bailey.jpg", + label: "Jane Smith", + value: "user-2", + priority: 3, + weight: 80, + isFixed: false, + disabled: false, + defaultScheduleId: 2, + groupId: null, + }, + { + avatar: "https://cal.com/stakeholder/alex.jpg", + label: "Alex Johnson", + value: "user-3", + priority: 1, + weight: 60, + isFixed: false, + disabled: false, + defaultScheduleId: 3, + groupId: null, + }, + { + avatar: "https://cal.com/stakeholder/chris.jpg", + label: "Chris Brown", + value: "user-4", + priority: 4, + weight: 120, + isFixed: false, + disabled: false, + defaultScheduleId: 4, + groupId: null, + }, + { + avatar: "https://cal.com/stakeholder/sam.jpg", + label: "Sam Wilson", + value: "user-5", + priority: 0, + weight: 50, + isFixed: false, + disabled: false, + defaultScheduleId: 5, + groupId: null, + }, +]; + +const mockOptionsWithFixed: CheckedSelectOption[] = [ + ...mockOptions.slice(0, 2), + { + avatar: "https://cal.com/stakeholder/admin.jpg", + label: "Admin User (Fixed)", + value: "user-admin", + priority: 2, + weight: 100, + isFixed: true, + disabled: false, + defaultScheduleId: 6, + groupId: null, + }, + ...mockOptions.slice(2), +]; + +const meta = { + component: CheckedTeamSelect, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + onChange: fn(), + groupId: null, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + options: mockOptions, + value: [], + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: false, + }, +}; + +export const WithSelectedMembers: Story = { + args: { + options: mockOptions, + value: [mockOptions[0], mockOptions[1]], + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: false, + }, +}; + +export const WithWeightsEnabled: Story = { + args: { + options: mockOptions, + value: [mockOptions[0], mockOptions[1], mockOptions[2]], + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: true, + }, +}; + +export const WithFixedMember: Story = { + args: { + options: mockOptionsWithFixed, + value: [mockOptionsWithFixed[0], mockOptionsWithFixed[2]], + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: false, + }, +}; + +export const WithFixedMemberAndWeights: Story = { + args: { + options: mockOptionsWithFixed, + value: [mockOptionsWithFixed[0], mockOptionsWithFixed[2], mockOptionsWithFixed[3]], + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: true, + }, +}; + +export const AllPriorityLevels: Story = { + args: { + options: mockOptions, + value: mockOptions, + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: false, + }, +}; + +export const AllPriorityLevelsWithWeights: Story = { + args: { + options: mockOptions, + value: mockOptions, + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: true, + }, +}; + +export const WithDisabledOptions: Story = { + args: { + options: mockOptions.map((opt, index) => ({ + ...opt, + disabled: index % 2 === 0, + })), + value: [mockOptions[1]], + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: false, + }, +}; + +export const WithGroupId: Story = { + args: { + options: mockOptions.map((opt) => ({ ...opt, groupId: "group-1" })), + value: [ + { ...mockOptions[0], groupId: "group-1" }, + { ...mockOptions[1], groupId: "group-1" }, + ], + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: false, + groupId: "group-1", + }, +}; + +export const MixedGroups: Story = { + args: { + options: mockOptions.map((opt, index) => ({ + ...opt, + groupId: index < 3 ? "group-1" : "group-2", + })), + value: [ + { ...mockOptions[0], groupId: "group-1" }, + { ...mockOptions[1], groupId: "group-1" }, + { ...mockOptions[3], groupId: "group-2" }, + ], + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: true, + groupId: "group-1", + }, +}; + +export const SingleSelection: Story = { + args: { + options: mockOptions, + value: [mockOptions[0]], + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: false, + }, +}; + +export const CustomPlaceholder: Story = { + args: { + options: mockOptions, + value: [], + name: "team-members", + placeholder: "Add hosts to your event type...", + isRRWeightsEnabled: false, + }, +}; + +export const EmptyState: Story = { + args: { + options: [], + value: [], + name: "team-members", + placeholder: "No team members available", + isRRWeightsEnabled: false, + }, +}; + +export const LargeTeam: Story = { + args: { + options: Array.from({ length: 20 }, (_, i) => ({ + avatar: `https://cal.com/stakeholder/peer.jpg`, + label: `Team Member ${i + 1}`, + value: `user-${i + 1}`, + priority: i % 5, + weight: 50 + i * 5, + isFixed: false, + disabled: false, + defaultScheduleId: i + 1, + groupId: null, + })), + value: Array.from({ length: 5 }, (_, i) => ({ + avatar: `https://cal.com/stakeholder/peer.jpg`, + label: `Team Member ${i + 1}`, + value: `user-${i + 1}`, + priority: i % 5, + weight: 50 + i * 5, + isFixed: false, + disabled: false, + defaultScheduleId: i + 1, + groupId: null, + })), + name: "team-members", + placeholder: "Select team members...", + isRRWeightsEnabled: true, + }, +}; diff --git a/packages/features/eventtypes/components/CreateEventTypeForm.stories.tsx b/packages/features/eventtypes/components/CreateEventTypeForm.stories.tsx new file mode 100644 index 00000000000000..317c8673756f8f --- /dev/null +++ b/packages/features/eventtypes/components/CreateEventTypeForm.stories.tsx @@ -0,0 +1,282 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { createEventTypeInput } from "@calcom/features/eventtypes/lib/types"; +import type { CreateEventTypeFormValues } from "@calcom/features/eventtypes/hooks/useCreateEventType"; +import { Button } from "@calcom/ui/components/button"; + +import CreateEventTypeForm from "./CreateEventTypeForm"; + +const meta = { + component: CreateEventTypeForm, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const FormWrapper = ({ + isManagedEventType = false, + isPending = false, + pageSlug = "johndoe", + urlPrefix = "https://cal.com", + defaultValues, +}: { + isManagedEventType?: boolean; + isPending?: boolean; + pageSlug?: string; + urlPrefix?: string; + defaultValues?: Partial; +}) => { + const form = useForm({ + defaultValues: { + length: 15, + title: "", + slug: "", + description: "", + ...defaultValues, + }, + resolver: zodResolver(createEventTypeInput), + }); + + const handleSubmit = (values: CreateEventTypeFormValues) => { + console.log("Form submitted:", values); + alert(`Event Type Created:\nTitle: ${values.title}\nSlug: ${values.slug}\nDuration: ${values.length} minutes`); + }; + + const SubmitButton = (isPending: boolean) => ( +
+ +
+ ); + + return ( + + ); +}; + +export const Default: Story = { + render: () => , +}; + +export const WithDefaultValues: Story = { + render: () => ( + + ), +}; + +export const LongerDuration: Story = { + render: () => ( + + ), +}; + +export const WithLongUrlPrefix: Story = { + render: () => ( + + ), +}; + +export const ManagedEventType: Story = { + render: () => ( + + ), +}; + +export const LoadingState: Story = { + render: () => ( + + ), +}; + +export const ShortUrlPrefix: Story = { + render: () => ( + + ), +}; + +export const CustomPageSlug: Story = { + render: () => ( + + ), +}; + +export const EmptyForm: Story = { + render: () => , +}; + +export const WithMarkdownDescription: Story = { + render: () => ( + + ), +}; + +export const AllDurations: Story = { + render: () => ( +
+
+

15 Minutes

+ +
+
+

30 Minutes

+ +
+
+

60 Minutes

+ +
+
+

120 Minutes

+ +
+
+ ), + parameters: { + layout: "padded", + }, +}; + +export const DifferentEventTypes: Story = { + render: () => ( +
+
+

Personal Event

+ +
+
+

Managed Event

+ +
+
+ ), + parameters: { + layout: "padded", + }, +}; diff --git a/packages/features/eventtypes/components/EventTypeDescription.stories.tsx b/packages/features/eventtypes/components/EventTypeDescription.stories.tsx new file mode 100644 index 00000000000000..224b8fb3b09472 --- /dev/null +++ b/packages/features/eventtypes/components/EventTypeDescription.stories.tsx @@ -0,0 +1,354 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { SchedulingType } from "@calcom/prisma/enums"; + +import { EventTypeDescription } from "./EventTypeDescription"; + +const meta = { + component: EventTypeDescription, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + shortenDescription: { + description: "Whether to shorten the description to 4 lines", + control: "boolean", + }, + isPublic: { + description: "Whether this is a public event type", + control: "boolean", + }, + className: { + description: "Additional CSS classes", + control: "text", + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const baseEventType = { + id: 1, + title: "30 Minute Meeting", + slug: "30min", + length: 30, + descriptionAsSafeHTML: "A quick 30 minute meeting to discuss your needs and goals.", + position: 0, + userId: 1, + teamId: null, + eventName: null, + parentId: null, + timeZone: null, + periodType: "UNLIMITED" as const, + periodStartDate: null, + periodEndDate: null, + periodDays: null, + periodCountCalendarDays: false, + requiresConfirmation: false, + recurringEvent: null, + disableGuests: false, + hideCalendarNotes: false, + minimumBookingNotice: 120, + beforeEventBuffer: 0, + afterEventBuffer: 0, + schedulingType: null, + price: 0, + currency: "usd", + slotInterval: null, + metadata: {}, + successRedirectUrl: null, + seatsPerTimeSlot: null, + seatsShowAttendees: null, + seatsShowAvailabilityCount: null, + forwardParamsSuccessRedirect: null, + offsetStart: 0, + hidden: false, + locations: null, +}; + +export const Default: Story = { + args: { + eventType: baseEventType, + }, +}; + +export const WithLongDescription: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: `# Welcome to our consultation + +This is a comprehensive meeting where we will: + +- Discuss your project requirements in detail +- Review your current setup and challenges +- Provide recommendations and next steps +- Answer any questions you may have + +We look forward to speaking with you!`, + }, + }, +}; + +export const ShortenedDescription: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: `# Welcome to our consultation + +This is a comprehensive meeting where we will: + +- Discuss your project requirements in detail +- Review your current setup and challenges +- Provide recommendations and next steps +- Answer any questions you may have + +We look forward to speaking with you!`, + }, + shortenDescription: true, + }, +}; + +export const RoundRobinScheduling: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: "Meet with one of our team members in a round-robin fashion.", + schedulingType: SchedulingType.ROUND_ROBIN, + }, + }, +}; + +export const CollectiveScheduling: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: "Meet with our entire team together.", + schedulingType: SchedulingType.COLLECTIVE, + }, + }, +}; + +export const RecurringEvent: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: "Weekly check-in meeting that repeats.", + recurringEvent: { + freq: 2, + count: 12, + interval: 1, + }, + }, + }, +}; + +export const WithPayment: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: "One-on-one coaching session.", + metadata: { + apps: { + stripe: { + enabled: true, + price: 5000, + currency: "usd", + }, + }, + }, + }, + }, +}; + +export const RequiresConfirmation: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: "This meeting requires manual confirmation before being scheduled.", + requiresConfirmation: true, + }, + }, +}; + +export const RequiresConfirmationWithThreshold: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: "May require confirmation based on availability.", + requiresConfirmation: true, + metadata: { + requiresConfirmationThreshold: { + time: 30, + unit: "minutes", + }, + }, + }, + }, +}; + +export const WithSeats: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: "Group event with limited seats available.", + seatsPerTimeSlot: 10, + }, + }, +}; + +export const MultipleDurations: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: "Flexible meeting with multiple duration options.", + metadata: { + multipleDuration: [15, 30, 60], + }, + }, + }, +}; + +export const CompleteExample: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: `# Premium Consultation + +A comprehensive consultation session with our expert team. + +**What you'll get:** +- Personalized recommendations +- Action plan +- Follow-up resources + +[Learn more](https://example.com)`, + schedulingType: SchedulingType.ROUND_ROBIN, + requiresConfirmation: true, + recurringEvent: { + freq: 2, + count: 4, + interval: 1, + }, + metadata: { + apps: { + stripe: { + enabled: true, + price: 10000, + currency: "usd", + }, + }, + }, + seatsPerTimeSlot: 5, + }, + }, +}; + +export const NoDescription: Story = { + args: { + eventType: { + ...baseEventType, + descriptionAsSafeHTML: null, + }, + }, +}; + +export const AllFeatures: Story = { + render: () => ( +
+
+

Basic Event

+ +
+
+

Round Robin

+ +
+
+

Collective

+ +
+
+

With Payment

+ +
+
+

Recurring

+ +
+
+

Requires Confirmation

+ +
+
+

With Seats

+ +
+
+

Multiple Durations

+ +
+
+ ), + parameters: { + layout: "padded", + }, +}; diff --git a/packages/features/eventtypes/components/HostEditDialogs.stories.tsx b/packages/features/eventtypes/components/HostEditDialogs.stories.tsx new file mode 100644 index 00000000000000..e8121d96f95350 --- /dev/null +++ b/packages/features/eventtypes/components/HostEditDialogs.stories.tsx @@ -0,0 +1,367 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; + +import type { FormValues } from "@calcom/features/eventtypes/lib/types"; +import { DEFAULT_GROUP_ID } from "@calcom/lib/constants"; + +import type { CheckedSelectOption } from "./CheckedTeamSelect"; +import { PriorityDialog, WeightDialog } from "./HostEditDialogs"; + +// Mock form data for the stories +const mockHosts = [ + { + userId: 1, + priority: 2, + weight: 100, + isFixed: false, + groupId: DEFAULT_GROUP_ID, + }, + { + userId: 2, + priority: 3, + weight: 80, + isFixed: false, + groupId: DEFAULT_GROUP_ID, + }, + { + userId: 3, + priority: 1, + weight: 60, + isFixed: false, + groupId: DEFAULT_GROUP_ID, + }, +]; + +const mockOptions: CheckedSelectOption[] = [ + { + avatar: "https://i.pravatar.cc/150?img=1", + label: "John Doe", + value: "1", + priority: 2, + weight: 100, + isFixed: false, + groupId: DEFAULT_GROUP_ID, + }, + { + avatar: "https://i.pravatar.cc/150?img=2", + label: "Jane Smith", + value: "2", + priority: 3, + weight: 80, + isFixed: false, + groupId: DEFAULT_GROUP_ID, + }, + { + avatar: "https://i.pravatar.cc/150?img=3", + label: "Bob Johnson", + value: "3", + priority: 1, + weight: 60, + isFixed: false, + groupId: DEFAULT_GROUP_ID, + }, +]; + +// Wrapper component to provide form context +const FormWrapper = ({ children }: { children: React.ReactNode }) => { + const methods = useForm({ + defaultValues: { + hosts: mockHosts, + isRRWeightsEnabled: true, + hostGroups: [], + }, + }); + + return {children}; +}; + +// Priority Dialog Stories +const metaPriority = { + component: PriorityDialog, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + isOpenDialog: { + description: "Controls whether the dialog is open", + control: "boolean", + }, + option: { + description: "The selected host option to edit", + control: "object", + }, + customClassNames: { + description: "Custom CSS class names for styling", + control: "object", + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default metaPriority; +type PriorityStory = StoryObj; + +export const PriorityDialogDefault: PriorityStory = { + args: { + isOpenDialog: true, + option: mockOptions[0], + options: mockOptions, + onChange: (value) => console.log("Priority changed:", value), + setIsOpenDialog: () => {}, + }, +}; + +export const PriorityDialogHighPriority: PriorityStory = { + args: { + isOpenDialog: true, + option: { + ...mockOptions[1], + priority: 3, + }, + options: mockOptions, + onChange: (value) => console.log("Priority changed:", value), + setIsOpenDialog: () => {}, + }, +}; + +export const PriorityDialogLowestPriority: PriorityStory = { + args: { + isOpenDialog: true, + option: { + ...mockOptions[2], + priority: 0, + }, + options: mockOptions, + onChange: (value) => console.log("Priority changed:", value), + setIsOpenDialog: () => {}, + }, +}; + +export const PriorityDialogInteractive: PriorityStory = { + render: (args) => { + const [isOpen, setIsOpen] = useState(true); + const [selectedOptions, setSelectedOptions] = useState(mockOptions); + + return ( + +
+ + { + console.log("Priority changed:", value); + setSelectedOptions(value as CheckedSelectOption[]); + }} + /> +
+
+ ); + }, +}; + +// Weight Dialog Stories +const metaWeight = { + component: WeightDialog, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + isOpenDialog: { + description: "Controls whether the dialog is open", + control: "boolean", + }, + option: { + description: "The selected host option to edit", + control: "object", + }, + customClassNames: { + description: "Custom CSS class names for styling", + control: "object", + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export { metaWeight as WeightDialogMeta }; +type WeightStory = StoryObj; + +export const WeightDialogDefault: WeightStory = { + args: { + isOpenDialog: true, + option: mockOptions[0], + options: mockOptions, + onChange: (value) => console.log("Weight changed:", value), + setIsOpenDialog: () => {}, + }, +}; + +export const WeightDialogHighWeight: WeightStory = { + args: { + isOpenDialog: true, + option: { + ...mockOptions[1], + weight: 150, + }, + options: mockOptions, + onChange: (value) => console.log("Weight changed:", value), + setIsOpenDialog: () => {}, + }, +}; + +export const WeightDialogLowWeight: WeightStory = { + args: { + isOpenDialog: true, + option: { + ...mockOptions[2], + weight: 50, + }, + options: mockOptions, + onChange: (value) => console.log("Weight changed:", value), + setIsOpenDialog: () => {}, + }, +}; + +export const WeightDialogInteractive: WeightStory = { + render: (args) => { + const [isOpen, setIsOpen] = useState(true); + const [selectedOptions, setSelectedOptions] = useState(mockOptions); + + return ( + +
+ + { + console.log("Weight changed:", value); + setSelectedOptions(value as CheckedSelectOption[]); + }} + /> +
+
+ ); + }, +}; + +export const WeightDialogWithCustomClassNames: WeightStory = { + args: { + isOpenDialog: true, + option: mockOptions[0], + options: mockOptions, + onChange: (value) => console.log("Weight changed:", value), + setIsOpenDialog: () => {}, + customClassNames: { + container: "bg-gray-50 p-4 rounded-lg", + label: "text-blue-600 font-bold", + confirmButton: "bg-green-600 hover:bg-green-700", + }, + }, +}; + +export const PriorityDialogWithCustomClassNames: PriorityStory = { + args: { + isOpenDialog: true, + option: mockOptions[0], + options: mockOptions, + onChange: (value) => console.log("Priority changed:", value), + setIsOpenDialog: () => {}, + customClassNames: { + container: "bg-gray-50 p-4 rounded-lg", + label: "text-blue-600 font-bold", + confirmButton: "bg-green-600 hover:bg-green-700", + }, + }, +}; + +// Combined story showing both dialogs +export const AllDialogs: PriorityStory = { + render: () => { + const [priorityOpen, setPriorityOpen] = useState(false); + const [weightOpen, setWeightOpen] = useState(false); + const [selectedOptions, setSelectedOptions] = useState(mockOptions); + + return ( + +
+

Host Edit Dialogs

+
+ + +
+ +
+

Current Hosts:

+
    + {selectedOptions.map((opt) => ( +
  • +
    {opt.label}
    +
    + Priority: {opt.priority ?? 2} | Weight: {opt.weight ?? 100}% +
    +
  • + ))} +
+
+ + setSelectedOptions(value as CheckedSelectOption[])} + /> + + setSelectedOptions(value as CheckedSelectOption[])} + /> +
+
+ ); + }, + parameters: { + layout: "padded", + }, +}; diff --git a/packages/features/eventtypes/components/MultiplePrivateLinksController.stories.tsx b/packages/features/eventtypes/components/MultiplePrivateLinksController.stories.tsx new file mode 100644 index 00000000000000..adfca20cc6d5b0 --- /dev/null +++ b/packages/features/eventtypes/components/MultiplePrivateLinksController.stories.tsx @@ -0,0 +1,316 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { FormProvider, useForm } from "react-hook-form"; + +import type { FormValues } from "@calcom/features/eventtypes/lib/types"; + +import { MultiplePrivateLinksController } from "./MultiplePrivateLinksController"; + +// Mock wrapper component to provide form context +const FormWrapper = ({ + children, + defaultValues, +}: { + children: React.ReactNode; + defaultValues?: Partial; +}) => { + const methods = useForm({ + defaultValues: { + slug: "30min", + users: [{ id: 1 }], + multiplePrivateLinks: [], + ...defaultValues, + } as FormValues, + }); + + return {children}; +}; + +const meta = { + component: MultiplePrivateLinksController, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + bookerUrl: { + description: "The base URL for booking links", + control: "text", + }, + userTimeZone: { + description: "User's timezone for link expiration calculations", + control: "text", + }, + team: { + description: "Team information if this is a team event type", + control: "object", + }, + }, + decorators: [ + (Story, context) => ( +
+ + + +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + bookerUrl: "https://cal.com", + userTimeZone: "America/New_York", + team: undefined, + defaultValues: { + slug: "30min", + users: [{ id: 1 }], + multiplePrivateLinks: [], + } as Partial, + }, +}; + +export const WithExistingLinks: Story = { + args: { + bookerUrl: "https://cal.com", + userTimeZone: "America/New_York", + team: undefined, + defaultValues: { + slug: "30min", + users: [{ id: 1 }], + multiplePrivateLinks: [ + { + link: "abc123def456", + expiresAt: null, + maxUsageCount: 1, + usageCount: 0, + }, + { + link: "xyz789uvw012", + expiresAt: null, + maxUsageCount: 5, + usageCount: 2, + }, + ], + } as Partial, + }, +}; + +export const WithTimeBasedExpiration: Story = { + args: { + bookerUrl: "https://cal.com", + userTimeZone: "America/New_York", + team: undefined, + defaultValues: { + slug: "30min", + users: [{ id: 1 }], + multiplePrivateLinks: [ + { + link: "time123exp456", + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now + maxUsageCount: null, + usageCount: 0, + }, + { + link: "time789exp012", + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + maxUsageCount: null, + usageCount: 0, + }, + ], + } as Partial, + }, +}; + +export const WithExpiredLinks: Story = { + args: { + bookerUrl: "https://cal.com", + userTimeZone: "America/New_York", + team: undefined, + defaultValues: { + slug: "30min", + users: [{ id: 1 }], + multiplePrivateLinks: [ + { + link: "expired123old456", + expiresAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days ago + maxUsageCount: null, + usageCount: 0, + }, + { + link: "maxed789out012", + expiresAt: null, + maxUsageCount: 3, + usageCount: 3, + }, + ], + } as Partial, + }, +}; + +export const WithMixedLinks: Story = { + args: { + bookerUrl: "https://cal.com", + userTimeZone: "America/New_York", + team: undefined, + defaultValues: { + slug: "30min", + users: [{ id: 1 }], + multiplePrivateLinks: [ + { + link: "active123link456", + expiresAt: null, + maxUsageCount: 1, + usageCount: 0, + }, + { + link: "time789based012", + expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days from now + maxUsageCount: null, + usageCount: 0, + }, + { + link: "multiple345uses678", + expiresAt: null, + maxUsageCount: 10, + usageCount: 4, + }, + { + link: "expired901link234", + expiresAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // 2 days ago + maxUsageCount: null, + usageCount: 0, + }, + ], + } as Partial, + }, +}; + +export const WithTeamEvent: Story = { + args: { + bookerUrl: "https://cal.com", + userTimeZone: "Europe/London", + team: { + id: 100, + name: "Engineering Team", + slug: "engineering", + } as any, + defaultValues: { + slug: "team-meeting", + users: [{ id: 1 }], + multiplePrivateLinks: [ + { + link: "team123link456", + expiresAt: null, + maxUsageCount: 5, + usageCount: 1, + }, + ], + } as Partial, + }, +}; + +export const WithMultipleUsageCounts: Story = { + args: { + bookerUrl: "https://cal.com", + userTimeZone: "America/Los_Angeles", + team: undefined, + defaultValues: { + slug: "consultation", + users: [{ id: 1 }], + multiplePrivateLinks: [ + { + link: "single123use456", + expiresAt: null, + maxUsageCount: 1, + usageCount: 0, + }, + { + link: "five789uses012", + expiresAt: null, + maxUsageCount: 5, + usageCount: 0, + }, + { + link: "ten345uses678", + expiresAt: null, + maxUsageCount: 10, + usageCount: 3, + }, + { + link: "hundred901uses234", + expiresAt: null, + maxUsageCount: 100, + usageCount: 42, + }, + ], + } as Partial, + }, +}; + +export const WithPartiallyUsedLinks: Story = { + args: { + bookerUrl: "https://cal.com", + userTimeZone: "Asia/Tokyo", + team: undefined, + defaultValues: { + slug: "webinar", + users: [{ id: 1 }], + multiplePrivateLinks: [ + { + link: "almost123full456", + expiresAt: null, + maxUsageCount: 5, + usageCount: 4, + }, + { + link: "half789used012", + expiresAt: null, + maxUsageCount: 10, + usageCount: 5, + }, + { + link: "barely345used678", + expiresAt: null, + maxUsageCount: 20, + usageCount: 2, + }, + ], + } as Partial, + }, +}; + +export const WithCustomBookerUrl: Story = { + args: { + bookerUrl: "https://custom-domain.example.com", + userTimeZone: "America/New_York", + team: undefined, + defaultValues: { + slug: "meeting", + users: [{ id: 1 }], + multiplePrivateLinks: [ + { + link: "custom123domain456", + expiresAt: null, + maxUsageCount: 3, + usageCount: 0, + }, + ], + } as Partial, + }, +}; + +export const Empty: Story = { + args: { + bookerUrl: "https://cal.com", + userTimeZone: "America/New_York", + team: undefined, + defaultValues: { + slug: "30min", + users: [{ id: 1 }], + multiplePrivateLinks: [], + } as Partial, + }, +}; diff --git a/packages/features/filters/components/TeamsFilter.stories.tsx b/packages/features/filters/components/TeamsFilter.stories.tsx new file mode 100644 index 00000000000000..9cdd2837f298d2 --- /dev/null +++ b/packages/features/filters/components/TeamsFilter.stories.tsx @@ -0,0 +1,421 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createTRPCReact } from "@trpc/react-query"; +import { SessionProvider } from "next-auth/react"; +import { fn } from "storybook/test"; + +import { TeamsFilter } from "./TeamsFilter"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +// Mock teams data +const mockTeams = [ + { + id: 1, + name: "Engineering Team", + slug: "engineering", + logoUrl: null, + isOrganization: false, + }, + { + id: 2, + name: "Design Team", + slug: "design", + logoUrl: null, + isOrganization: false, + }, + { + id: 3, + name: "Marketing Team", + slug: "marketing", + logoUrl: null, + isOrganization: false, + }, + { + id: 4, + name: "Sales Team", + slug: "sales", + logoUrl: null, + isOrganization: false, + }, + { + id: 5, + name: "Product Team", + slug: "product", + logoUrl: null, + isOrganization: false, + }, +]; + +// Mock session data +const mockSession = { + expires: new Date(Date.now() + 2 * 86400).toISOString(), + user: { + id: 1, + name: "John Doe", + email: "john@example.com", + username: "johndoe", + }, + upId: "user-profile-1", +}; + +// Mock tRPC client +const mockTrpc = createTRPCReact(); + +const createMockTrpcClient = (teams = mockTeams) => + mockTrpc.createClient({ + links: [ + () => + ({ op }) => { + return { + subscribe: (observer: any) => { + // Mock the teams.list query + if (op.path === "viewer.teams.list") { + observer.next({ + result: { + data: teams, + }, + }); + } else { + observer.next({ + result: { + data: {}, + }, + }); + } + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + }, + ], + }); + +const meta = { + component: TeamsFilter, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story, context) => { + const mockTrpcClient = createMockTrpcClient(context.args.mockTeams || mockTeams); + return ( + + + +
+ +
+
+
+
+ ); + }, + ], + argTypes: { + popoverTriggerClassNames: { + description: "Custom CSS classes for the popover trigger button", + control: "text", + }, + useProfileFilter: { + description: "Whether to use profile filter (upIds) instead of user filter (userIds)", + control: "boolean", + }, + showVerticalDivider: { + description: "Whether to show a vertical divider after the filter", + control: "boolean", + }, + mockTeams: { + description: "Mock teams data for Storybook", + control: "object", + table: { + disable: true, + }, + }, + mockSession: { + description: "Mock session data for Storybook", + control: "object", + table: { + disable: true, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + popoverTriggerClassNames: undefined, + useProfileFilter: false, + showVerticalDivider: false, + mockTeams, + mockSession, + }, +}; + +export const WithVerticalDivider: Story = { + args: { + popoverTriggerClassNames: undefined, + useProfileFilter: false, + showVerticalDivider: true, + mockTeams, + mockSession, + }, +}; + +export const WithProfileFilter: Story = { + args: { + popoverTriggerClassNames: undefined, + useProfileFilter: true, + showVerticalDivider: false, + mockTeams, + mockSession, + }, + parameters: { + docs: { + description: { + story: "Uses profile filter (upIds) instead of user filter (userIds) for filtering.", + }, + }, + }, +}; + +export const CustomTriggerClassNames: Story = { + args: { + popoverTriggerClassNames: "bg-blue-500 text-white hover:bg-blue-600", + useProfileFilter: false, + showVerticalDivider: false, + mockTeams, + mockSession, + }, + parameters: { + docs: { + description: { + story: "Custom styling applied to the popover trigger button.", + }, + }, + }, +}; + +export const FewTeams: Story = { + args: { + popoverTriggerClassNames: undefined, + useProfileFilter: false, + showVerticalDivider: false, + mockTeams: [ + { + id: 1, + name: "Engineering Team", + slug: "engineering", + logoUrl: null, + isOrganization: false, + }, + { + id: 2, + name: "Design Team", + slug: "design", + logoUrl: null, + isOrganization: false, + }, + ], + mockSession, + }, + parameters: { + docs: { + description: { + story: "Filter with only a few teams available.", + }, + }, + }, +}; + +export const ManyTeams: Story = { + args: { + popoverTriggerClassNames: undefined, + useProfileFilter: false, + showVerticalDivider: false, + mockTeams: [ + { id: 1, name: "Engineering Team", slug: "engineering", logoUrl: null, isOrganization: false }, + { id: 2, name: "Design Team", slug: "design", logoUrl: null, isOrganization: false }, + { id: 3, name: "Marketing Team", slug: "marketing", logoUrl: null, isOrganization: false }, + { id: 4, name: "Sales Team", slug: "sales", logoUrl: null, isOrganization: false }, + { id: 5, name: "Product Team", slug: "product", logoUrl: null, isOrganization: false }, + { id: 6, name: "Customer Success Team", slug: "customer-success", logoUrl: null, isOrganization: false }, + { id: 7, name: "Operations Team", slug: "operations", logoUrl: null, isOrganization: false }, + { id: 8, name: "Finance Team", slug: "finance", logoUrl: null, isOrganization: false }, + { id: 9, name: "HR Team", slug: "hr", logoUrl: null, isOrganization: false }, + { id: 10, name: "Legal Team", slug: "legal", logoUrl: null, isOrganization: false }, + ], + mockSession, + }, + parameters: { + docs: { + description: { + story: "Filter with many teams showing scrollable list and search functionality.", + }, + }, + }, +}; + +export const TeamsWithLogos: Story = { + args: { + popoverTriggerClassNames: undefined, + useProfileFilter: false, + showVerticalDivider: false, + mockTeams: [ + { + id: 1, + name: "Engineering Team", + slug: "engineering", + logoUrl: "https://avatars.githubusercontent.com/u/1?v=4", + isOrganization: false, + }, + { + id: 2, + name: "Design Team", + slug: "design", + logoUrl: "https://avatars.githubusercontent.com/u/2?v=4", + isOrganization: false, + }, + { + id: 3, + name: "Marketing Team", + slug: "marketing", + logoUrl: "https://avatars.githubusercontent.com/u/3?v=4", + isOrganization: false, + }, + ], + mockSession, + }, + parameters: { + docs: { + description: { + story: "Teams with custom avatar logos displayed in the filter.", + }, + }, + }, +}; + +export const NoTeams: Story = { + args: { + popoverTriggerClassNames: undefined, + useProfileFilter: false, + showVerticalDivider: false, + mockTeams: [], + mockSession, + }, + parameters: { + docs: { + description: { + story: "When no teams are available, the component returns null and renders nothing.", + }, + }, + }, +}; + +export const WithOrganizationFiltered: Story = { + args: { + popoverTriggerClassNames: undefined, + useProfileFilter: false, + showVerticalDivider: false, + mockTeams: [ + { + id: 1, + name: "Acme Organization", + slug: "acme", + logoUrl: null, + isOrganization: true, + }, + { + id: 2, + name: "Engineering Team", + slug: "engineering", + logoUrl: null, + isOrganization: false, + }, + { + id: 3, + name: "Design Team", + slug: "design", + logoUrl: null, + isOrganization: false, + }, + ], + mockSession, + }, + parameters: { + docs: { + description: { + story: + "Organizations are automatically filtered out from the teams list, only showing actual teams.", + }, + }, + }, +}; + +export const LongTeamNames: Story = { + args: { + popoverTriggerClassNames: undefined, + useProfileFilter: false, + showVerticalDivider: false, + mockTeams: [ + { + id: 1, + name: "Engineering and Development Team for Product Infrastructure", + slug: "engineering-long", + logoUrl: null, + isOrganization: false, + }, + { + id: 2, + name: "User Experience and Interface Design Department", + slug: "design-long", + logoUrl: null, + isOrganization: false, + }, + { + id: 3, + name: "Marketing and Brand Communications Division", + slug: "marketing-long", + logoUrl: null, + isOrganization: false, + }, + ], + mockSession, + }, + parameters: { + docs: { + description: { + story: "Teams with long names demonstrating text truncation and tooltip functionality.", + }, + }, + }, +}; + +export const CompleteConfiguration: Story = { + args: { + popoverTriggerClassNames: "border-2 border-blue-300", + useProfileFilter: true, + showVerticalDivider: true, + mockTeams, + mockSession, + }, + parameters: { + docs: { + description: { + story: "All options enabled: custom classes, profile filter, and vertical divider.", + }, + }, + }, +}; diff --git a/packages/features/flags/components/AssignFeatureSheet.stories.tsx b/packages/features/flags/components/AssignFeatureSheet.stories.tsx new file mode 100644 index 00000000000000..c22c278d9f9a6d --- /dev/null +++ b/packages/features/flags/components/AssignFeatureSheet.stories.tsx @@ -0,0 +1,482 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import type { RouterOutputs } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; + +import { AssignFeatureSheet } from "./AssignFeatureSheet"; + +// Mock flag type +type Flag = RouterOutputs["viewer"]["features"]["list"][number]; + +// Mock team type +type Team = { + id: number; + name: string | null; + slug: string | null; + logoUrl: string | null; + hasFeature: boolean; + isOrganization: boolean; + parent: { + name: string | null; + logoUrl: string | null; + } | null; + parentId: number | null; +}; + +// Mock TRPC module +jest.mock("@calcom/trpc/react", () => ({ + trpc: { + useUtils: jest.fn(), + viewer: { + admin: { + getTeamsForFeature: { + useInfiniteQuery: jest.fn(), + }, + assignFeatureToTeam: { + useMutation: jest.fn(), + }, + unassignFeatureFromTeam: { + useMutation: jest.fn(), + }, + }, + }, + }, +})); + +const { trpc } = require("@calcom/trpc/react"); + +// Mock utils +const mockUtils = { + viewer: { + admin: { + getTeamsForFeature: { + invalidate: jest.fn(), + }, + }, + }, +}; + +// Mock flag data helper +const createMockFlag = (overrides?: Partial): Flag => ({ + slug: "example-feature", + description: "An example feature flag for testing", + enabled: true, + type: "EXPERIMENT", + lastUpdated: new Date().toISOString(), + ...overrides, +}); + +// Mock team data helper +const createMockTeam = (overrides?: Partial): Team => ({ + id: 1, + name: "Engineering", + slug: "engineering", + logoUrl: "https://cal.com/api/avatar/team-1.png", + hasFeature: false, + isOrganization: false, + parent: null, + parentId: null, + ...overrides, +}); + +// Wrapper component for interactive stories +function AssignFeatureSheetWrapper({ + flag, + teams, + hasNextPage = false, + isPending = false, +}: { + flag: Flag; + teams: Team[]; + hasNextPage?: boolean; + isPending?: boolean; +}) { + const [open, setOpen] = useState(true); + const [teamStates, setTeamStates] = useState>( + teams.reduce((acc, team) => ({ ...acc, [team.id]: team.hasFeature }), {}) + ); + + // Mock useUtils + trpc.useUtils.mockReturnValue(mockUtils); + + // Mock useInfiniteQuery + trpc.viewer.admin.getTeamsForFeature.useInfiniteQuery.mockReturnValue({ + data: { + pages: [ + { + teams: teams.map((team) => ({ + ...team, + hasFeature: teamStates[team.id] ?? team.hasFeature, + })), + nextCursor: hasNextPage ? "next-cursor" : undefined, + }, + ], + }, + fetchNextPage: jest.fn(), + hasNextPage, + isFetchingNextPage: false, + isPending, + }); + + // Mock assignFeatureToTeam mutation + trpc.viewer.admin.assignFeatureToTeam.useMutation.mockReturnValue({ + mutate: jest.fn(({ teamId }) => { + console.log("Assigning feature to team:", teamId); + setTeamStates((prev) => ({ ...prev, [teamId]: true })); + }), + isPending: false, + }); + + // Mock unassignFeatureFromTeam mutation + trpc.viewer.admin.unassignFeatureFromTeam.useMutation.mockReturnValue({ + mutate: jest.fn(({ teamId }) => { + console.log("Unassigning feature from team:", teamId); + setTeamStates((prev) => ({ ...prev, [teamId]: false })); + }), + isPending: false, + }); + + return ( + <> + + + + ); +} + +const meta = { + component: AssignFeatureSheet, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const WithOrganizations: Story = { + render: () => ( + + ), +}; + +export const WithoutAvatars: Story = { + render: () => ( + + ), +}; + +export const AllTeamsAssigned: Story = { + render: () => ( + + ), +}; + +export const NoTeamsAssigned: Story = { + render: () => ( + + ), +}; + +export const ManyTeams: Story = { + render: () => { + const teams = Array.from({ length: 20 }, (_, i) => { + const isOrg = i === 0; + return createMockTeam({ + id: i + 1, + name: isOrg ? "Acme Corporation" : `Team ${i}`, + slug: isOrg ? "acme-corp" : `team-${i}`, + logoUrl: i % 3 === 0 ? null : `https://cal.com/api/avatar/team-${i}.png`, + hasFeature: i % 4 === 0, + isOrganization: isOrg, + parent: !isOrg && i <= 10 ? { name: "Acme Corporation", logoUrl: null } : null, + parentId: !isOrg && i <= 10 ? 1 : null, + }); + }); + + return ( + + ); + }, +}; + +export const LongTeamNames: Story = { + render: () => ( + + ), +}; + +export const EmptyState: Story = { + render: () => ( + + ), +}; + +export const LoadingState: Story = { + render: () => ( + + ), +}; + +export const MixedOrganizationStructure: Story = { + render: () => ( + + ), +}; diff --git a/packages/features/flags/components/FlagAdminList.stories.tsx b/packages/features/flags/components/FlagAdminList.stories.tsx new file mode 100644 index 00000000000000..d8b7c1018a3c1b --- /dev/null +++ b/packages/features/flags/components/FlagAdminList.stories.tsx @@ -0,0 +1,393 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import type { RouterOutputs } from "@calcom/trpc/react"; +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { PanelCard } from "@calcom/ui/components/card"; +import { Switch } from "@calcom/ui/components/form"; +import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/components/list"; +import { List } from "@calcom/ui/components/list"; + +import { AssignFeatureSheet } from "./AssignFeatureSheet"; + +// Mock flag type +type Flag = RouterOutputs["viewer"]["features"]["list"][number]; + +// Mock FlagAdminList component without TRPC dependencies +const FlagAdminListStory = ({ flags }: { flags: Flag[] }) => { + const [selectedFlag, setSelectedFlag] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [flagStates, setFlagStates] = useState>( + flags.reduce((acc, flag) => ({ ...acc, [flag.slug]: flag.enabled }), {}) + ); + + const groupedFlags = flags.reduce((acc, flag) => { + const type = flag.type || "OTHER"; + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(flag); + return acc; + }, {} as Record); + + const sortedTypes = Object.keys(groupedFlags).sort(); + + const handleAssignClick = (flag: Flag) => { + setSelectedFlag(flag); + setSheetOpen(true); + }; + + const handleToggle = (slug: string, checked: boolean) => { + setFlagStates((prev) => ({ ...prev, [slug]: checked })); + }; + + return ( + <> +
+ {sortedTypes.map((type) => ( + + + {groupedFlags[type].map((flag: Flag, index: number) => ( + +
+ {flag.slug} + {flag.description} +
+
+ handleToggle(flag.slug, checked)} + /> + +
+
+ ))} +
+
+ ))} +
+ {selectedFlag && ( + + )} + + ); +}; + +const meta = { + component: FlagAdminListStory, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock flag data helper +const createMockFlag = (overrides?: Partial): Flag => ({ + slug: "example-flag", + description: "An example feature flag", + enabled: false, + type: "EXPERIMENT", + lastUpdated: new Date().toISOString(), + ...overrides, +}); + +export const Default: Story = { + args: { + flags: [ + createMockFlag({ + slug: "new-booking-flow", + description: "Enable the new streamlined booking flow with improved UX", + enabled: true, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "advanced-analytics", + description: "Advanced analytics dashboard with detailed insights", + enabled: false, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "team-collaboration", + description: "Enhanced team collaboration features", + enabled: true, + type: "FEATURE", + }), + ], + }, +}; + +export const SingleType: Story = { + args: { + flags: [ + createMockFlag({ + slug: "experiment-a", + description: "First experimental feature for testing", + enabled: true, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "experiment-b", + description: "Second experimental feature for testing", + enabled: false, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "experiment-c", + description: "Third experimental feature for testing", + enabled: false, + type: "EXPERIMENT", + }), + ], + }, +}; + +export const MultipleTypes: Story = { + args: { + flags: [ + createMockFlag({ + slug: "ai-scheduling", + description: "AI-powered scheduling suggestions", + enabled: true, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "smart-routing", + description: "Smart meeting routing based on availability", + enabled: false, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "calendar-sync", + description: "Enhanced calendar synchronization", + enabled: true, + type: "FEATURE", + }), + createMockFlag({ + slug: "video-integration", + description: "Native video conferencing integration", + enabled: true, + type: "FEATURE", + }), + createMockFlag({ + slug: "payment-processing", + description: "Integrated payment processing", + enabled: false, + type: "INTEGRATION", + }), + createMockFlag({ + slug: "crm-sync", + description: "CRM synchronization and data export", + enabled: false, + type: "INTEGRATION", + }), + ], + }, +}; + +export const AllEnabled: Story = { + args: { + flags: [ + createMockFlag({ + slug: "feature-one", + description: "First enabled feature", + enabled: true, + type: "FEATURE", + }), + createMockFlag({ + slug: "feature-two", + description: "Second enabled feature", + enabled: true, + type: "FEATURE", + }), + createMockFlag({ + slug: "feature-three", + description: "Third enabled feature", + enabled: true, + type: "FEATURE", + }), + ], + }, +}; + +export const AllDisabled: Story = { + args: { + flags: [ + createMockFlag({ + slug: "disabled-feature-one", + description: "First disabled feature", + enabled: false, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "disabled-feature-two", + description: "Second disabled feature", + enabled: false, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "disabled-feature-three", + description: "Third disabled feature", + enabled: false, + type: "EXPERIMENT", + }), + ], + }, +}; + +export const LongDescriptions: Story = { + args: { + flags: [ + createMockFlag({ + slug: "comprehensive-analytics", + description: + "A comprehensive analytics suite that provides deep insights into booking patterns, user behavior, conversion rates, and team performance metrics. Includes customizable dashboards, exportable reports, and real-time data visualization.", + enabled: true, + type: "FEATURE", + }), + createMockFlag({ + slug: "enterprise-sso", + description: + "Enterprise-grade Single Sign-On (SSO) integration with support for SAML 2.0, OAuth 2.0, and OpenID Connect. Enables seamless authentication across your organization's identity provider with advanced security features and audit logging.", + enabled: false, + type: "FEATURE", + }), + ], + }, +}; + +export const ManyFlags: Story = { + args: { + flags: [ + createMockFlag({ + slug: "experiment-alpha", + description: "Alpha experiment for new UI patterns", + enabled: true, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "experiment-beta", + description: "Beta experiment for performance optimization", + enabled: false, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "experiment-gamma", + description: "Gamma experiment for user onboarding", + enabled: true, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "feature-notifications", + description: "Enhanced notification system", + enabled: true, + type: "FEATURE", + }), + createMockFlag({ + slug: "feature-mobile-app", + description: "Native mobile application support", + enabled: false, + type: "FEATURE", + }), + createMockFlag({ + slug: "feature-webhooks", + description: "Advanced webhook configuration", + enabled: true, + type: "FEATURE", + }), + createMockFlag({ + slug: "integration-slack", + description: "Slack workspace integration", + enabled: true, + type: "INTEGRATION", + }), + createMockFlag({ + slug: "integration-teams", + description: "Microsoft Teams integration", + enabled: false, + type: "INTEGRATION", + }), + createMockFlag({ + slug: "integration-salesforce", + description: "Salesforce CRM integration", + enabled: false, + type: "INTEGRATION", + }), + createMockFlag({ + slug: "beta-ai-assistant", + description: "AI-powered scheduling assistant", + enabled: true, + type: "BETA", + }), + createMockFlag({ + slug: "beta-voice-booking", + description: "Voice-activated booking", + enabled: false, + type: "BETA", + }), + ], + }, +}; + +export const EmptyState: Story = { + args: { + flags: [], + }, + render: () => ( +
+

No feature flags configured

+
+ ), +}; + +export const MixedStates: Story = { + args: { + flags: [ + createMockFlag({ + slug: "booking-buffer-time", + description: "Add buffer time between bookings", + enabled: true, + type: "FEATURE", + }), + createMockFlag({ + slug: "custom-branding", + description: "Custom branding and white-labeling options", + enabled: false, + type: "FEATURE", + }), + createMockFlag({ + slug: "round-robin-v2", + description: "Improved round-robin scheduling algorithm", + enabled: true, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "advanced-routing", + description: "Advanced meeting routing rules", + enabled: false, + type: "EXPERIMENT", + }), + createMockFlag({ + slug: "zapier-integration", + description: "Zapier automation integration", + enabled: true, + type: "INTEGRATION", + }), + ], + }, +}; diff --git a/packages/features/flags/pages/flag-listing-view.stories.tsx b/packages/features/flags/pages/flag-listing-view.stories.tsx new file mode 100644 index 00000000000000..26f04e3bceca02 --- /dev/null +++ b/packages/features/flags/pages/flag-listing-view.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { Suspense } from "react"; + +import NoSSR from "@calcom/lib/components/NoSSR"; +import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton"; + +const SkeletonLoader = () => { + return ( + +
+ + +
+
+ ); +}; + +// Story component that mimics FlagListingView structure +const FlagListingViewStory = ({ showLoading = false }: { showLoading?: boolean }) => { + return ( + + }> + {showLoading ? ( + + ) : ( +
+

Flag listing content would be rendered here.

+

+ This story demonstrates the FlagListingView wrapper component with NoSSR and Suspense. +

+
+ )} +
+
+ ); +}; + +const meta = { + title: "Features/Flags/FlagListingView", + component: FlagListingViewStory, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + showLoading: false, + }, +}; + +export const LoadingState: Story = { + args: { + showLoading: true, + }, +}; + +export const WithSkeleton: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/packages/features/form-builder/Components.stories.tsx b/packages/features/form-builder/Components.stories.tsx new file mode 100644 index 00000000000000..14bfe657f61be0 --- /dev/null +++ b/packages/features/form-builder/Components.stories.tsx @@ -0,0 +1,630 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useState } from "react"; + +import { ComponentForField } from "./FormBuilderField"; + +const meta = { + component: ComponentForField, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Text Component +export const TextComponent: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( + + ); + }, +}; + +// Textarea Component +export const TextareaComponent: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( + + ); + }, +}; + +// Number Component +export const NumberComponent: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( + + ); + }, +}; + +// Name Component - Full Name Variant +export const NameComponentFullName: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( + + ); + }, +}; + +// Name Component - First and Last Name Variant +export const NameComponentFirstAndLastName: Story = { + render: () => { + const [value, setValue] = useState>({}); + return ( + + ); + }, +}; + +// Email Component +export const EmailComponent: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( + + ); + }, +}; + +// Phone Component +export const PhoneComponent: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( + + ); + }, +}; + +// Address Component +export const AddressComponent: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( + + ); + }, +}; + +// Multi-Email Component +export const MultiEmailComponent: Story = { + render: () => { + const [value, setValue] = useState([""]); + return ( + + ); + }, +}; + +// Select Component +export const SelectComponent: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( + + ); + }, +}; + +// Multiselect Component +export const MultiselectComponent: Story = { + render: () => { + const [value, setValue] = useState([]); + return ( + + ); + }, +}; + +// Checkbox Component +export const CheckboxComponent: Story = { + render: () => { + const [value, setValue] = useState([]); + return ( + + ); + }, +}; + +// Radio Component +export const RadioComponent: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( + + ); + }, +}; + +// RadioInput Component +export const RadioInputComponent: Story = { + render: () => { + const [value, setValue] = useState<{ value: string; optionValue: string }>({ + value: "phone", + optionValue: "", + }); + return ( + + ); + }, +}; + +// Boolean Component +export const BooleanComponent: Story = { + render: () => { + const [value, setValue] = useState(false); + return ( + + ); + }, +}; + +// URL Component +export const URLComponent: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( + + ); + }, +}; + +// Read-only States +export const ReadOnlyText: Story = { + render: () => { + const [value, setValue] = useState("John Doe"); + return ( + + ); + }, +}; + +export const ReadOnlySelect: Story = { + render: () => { + const [value, setValue] = useState("us"); + return ( + + ); + }, +}; + +export const ReadOnlyCheckbox: Story = { + render: () => { + const [value, setValue] = useState(["email", "sms"]); + return ( + + ); + }, +}; + +// Default story showcasing all field types +export const Default: Story = { + render: () => { + const [textValue, setTextValue] = useState(""); + const [emailValue, setEmailValue] = useState(""); + const [selectValue, setSelectValue] = useState(""); + const [booleanValue, setBooleanValue] = useState(false); + + return ( +
+ + + + +
+ ); + }, + parameters: { + layout: "padded", + }, +}; + +// Form with validation constraints +export const WithValidationConstraints: Story = { + render: () => { + const [value, setValue] = useState(""); + return ( + + ); + }, +}; + +// Pre-filled form fields +export const PrefilledForm: Story = { + render: () => { + const [nameValue, setNameValue] = useState("Jane Smith"); + const [emailValue, setEmailValue] = useState("jane@example.com"); + const [countryValue, setCountryValue] = useState("us"); + + return ( +
+ + + +
+ ); + }, + parameters: { + layout: "padded", + }, +}; diff --git a/packages/features/form-builder/FormBuilder.stories.tsx b/packages/features/form-builder/FormBuilder.stories.tsx new file mode 100644 index 00000000000000..7c1199405d0805 --- /dev/null +++ b/packages/features/form-builder/FormBuilder.stories.tsx @@ -0,0 +1,421 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useForm, FormProvider } from "react-hook-form"; + +import { FormBuilder } from "./FormBuilder"; + +// Wrapper component to provide form context +function FormBuilderWrapper(props: React.ComponentProps) { + const methods = useForm({ + defaultValues: { + fields: [ + { + name: "name", + type: "name" as const, + label: "Your Name", + required: true, + editable: "system" as const, + defaultLabel: "your_name", + variant: "fullName", + variantsConfig: { + variants: { + fullName: { + fields: [ + { + name: "fullName", + type: "text" as const, + label: "your_name", + required: true, + }, + ], + }, + }, + }, + }, + { + name: "email", + type: "email" as const, + label: "Email Address", + required: true, + editable: "system" as const, + defaultLabel: "email_address", + }, + { + name: "location", + type: "radioInput" as const, + label: "Location", + required: false, + editable: "system-but-optional" as const, + defaultLabel: "location", + }, + ], + }, + }); + + return ( + +
+ +
+
+ ); +} + +const meta = { + component: FormBuilder, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + title: { + description: "Title for the form builder section", + control: "text", + }, + description: { + description: "Description text for the form builder", + control: "text", + }, + addFieldLabel: { + description: "Label for the add field button", + control: "text", + }, + disabled: { + description: "Whether the form builder is disabled", + control: "boolean", + }, + showPhoneAndEmailToggle: { + description: "Show toggle between phone and email fields", + control: "boolean", + }, + showPriceField: { + description: "Show price field for applicable field types", + control: "boolean", + }, + paymentCurrency: { + description: "Currency for pricing fields", + control: "text", + }, + }, + render: (args) => , +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Booking Questions", + description: "Customize the information you collect from people booking with you", + addFieldLabel: "Add question", + formProp: "fields", + disabled: false, + LockedIcon: false, + dataStore: { + options: {}, + }, + paymentCurrency: "USD", + }, +}; + +export const Disabled: Story = { + args: { + title: "Booking Questions (View Only)", + description: "You don't have permission to edit these questions", + addFieldLabel: "Add question", + formProp: "fields", + disabled: true, + LockedIcon: false, + dataStore: { + options: {}, + }, + paymentCurrency: "USD", + }, +}; + +export const WithPhoneEmailToggle: Story = { + args: { + title: "Booking Questions", + description: "Customize the information you collect from people booking with you", + addFieldLabel: "Add question", + formProp: "fields", + disabled: false, + LockedIcon: false, + dataStore: { + options: {}, + }, + showPhoneAndEmailToggle: true, + paymentCurrency: "USD", + }, +}; + +export const WithPricing: Story = { + args: { + title: "Booking Questions with Pricing", + description: "Collect payment information with custom questions", + addFieldLabel: "Add paid question", + formProp: "fields", + disabled: false, + LockedIcon: false, + dataStore: { + options: {}, + }, + showPriceField: true, + paymentCurrency: "USD", + }, +}; + +export const WithEuroCurrency: Story = { + args: { + title: "Booking Questions (EUR)", + description: "Pricing in Euros", + addFieldLabel: "Add question", + formProp: "fields", + disabled: false, + LockedIcon: false, + dataStore: { + options: {}, + }, + showPriceField: true, + paymentCurrency: "EUR", + }, +}; + +// Story with custom fields using a wrapper that provides different default values +function CustomFieldsWrapper(props: React.ComponentProps) { + const methods = useForm({ + defaultValues: { + fields: [ + { + name: "name", + type: "name" as const, + label: "Your Name", + required: true, + editable: "system" as const, + defaultLabel: "your_name", + variant: "fullName", + variantsConfig: { + variants: { + fullName: { + fields: [ + { + name: "fullName", + type: "text" as const, + label: "your_name", + required: true, + }, + ], + }, + }, + }, + }, + { + name: "email", + type: "email" as const, + label: "Email Address", + required: true, + editable: "system" as const, + defaultLabel: "email_address", + }, + { + name: "company", + type: "text" as const, + label: "Company Name", + required: false, + editable: "user" as const, + sources: [ + { + label: "User", + type: "user" as const, + id: "user", + fieldRequired: false, + }, + ], + }, + { + name: "guests", + type: "multiemail" as const, + label: "Additional Guests", + required: false, + hidden: true, + editable: "system-but-optional" as const, + defaultLabel: "additional_guests", + }, + { + name: "meetingPurpose", + type: "select" as const, + label: "Meeting Purpose", + required: true, + editable: "user" as const, + options: [ + { label: "Sales Demo", value: "sales" }, + { label: "Technical Support", value: "support" }, + { label: "General Inquiry", value: "inquiry" }, + ], + sources: [ + { + label: "User", + type: "user" as const, + id: "user", + fieldRequired: true, + }, + ], + }, + { + name: "specialRequirements", + type: "textarea" as const, + label: "Special Requirements", + placeholder: "Any special requests or requirements...", + required: false, + editable: "user" as const, + sources: [ + { + label: "User", + type: "user" as const, + id: "user", + fieldRequired: false, + }, + ], + }, + ], + }, + }); + + return ( + +
+ +
+
+ ); +} + +export const WithMixedFieldTypes: Story = { + render: (args) => , + args: { + title: "Event Registration Form", + description: "Gather comprehensive information from attendees", + addFieldLabel: "Add custom field", + formProp: "fields", + disabled: false, + LockedIcon: false, + dataStore: { + options: {}, + }, + paymentCurrency: "USD", + }, +}; + +// Story demonstrating the locked state with icon +function LockedFormWrapper(props: React.ComponentProps) { + const methods = useForm({ + defaultValues: { + fields: [ + { + name: "name", + type: "name" as const, + label: "Your Name", + required: true, + editable: "system" as const, + defaultLabel: "your_name", + variant: "fullName", + variantsConfig: { + variants: { + fullName: { + fields: [ + { + name: "fullName", + type: "text" as const, + label: "your_name", + required: true, + }, + ], + }, + }, + }, + }, + { + name: "email", + type: "email" as const, + label: "Email Address", + required: true, + editable: "system" as const, + defaultLabel: "email_address", + }, + ], + }, + }); + + return ( + +
+ +
+
+ ); +} + +export const WithLockedIcon: Story = { + render: (args) => , + args: { + title: "Booking Questions", + description: "Premium feature - upgrade to customize", + addFieldLabel: "Add question", + formProp: "fields", + disabled: false, + LockedIcon: ( + + + + + + + ), + dataStore: { + options: {}, + }, + paymentCurrency: "USD", + }, +}; + +// Empty state story +function EmptyFormWrapper(props: React.ComponentProps) { + const methods = useForm({ + defaultValues: { + fields: [], + }, + }); + + return ( + +
+ +
+
+ ); +} + +export const EmptyState: Story = { + render: (args) => , + args: { + title: "Booking Questions", + description: "Start building your custom booking form", + addFieldLabel: "Add your first question", + formProp: "fields", + disabled: false, + LockedIcon: false, + dataStore: { + options: {}, + }, + paymentCurrency: "USD", + }, +}; diff --git a/packages/features/form/components/Select.stories.tsx b/packages/features/form/components/Select.stories.tsx new file mode 100644 index 00000000000000..17f0967016d8a1 --- /dev/null +++ b/packages/features/form/components/Select.stories.tsx @@ -0,0 +1,234 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import Select, { SelectWithValidation } from "./Select"; + +type Option = { label: string; value: string }; + +const options: Option[] = [ + { label: "15 minutes", value: "15" }, + { label: "30 minutes", value: "30" }, + { label: "45 minutes", value: "45" }, + { label: "60 minutes", value: "60" }, +]; + +const meta = { + component: Select, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + onChange: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + options, + placeholder: "Select duration...", + }, +}; + +export const WithValue: Story = { + args: { + options, + value: options[1], + }, +}; + +export const Disabled: Story = { + args: { + options, + placeholder: "Disabled select", + isDisabled: true, + }, +}; + +export const Searchable: Story = { + args: { + options, + placeholder: "Search options...", + isSearchable: true, + }, +}; + +export const Clearable: Story = { + args: { + options, + value: options[0], + isClearable: true, + }, +}; + +export const Loading: Story = { + args: { + options, + placeholder: "Loading...", + isLoading: true, + }, +}; + +export const MultiSelect: Story = { + args: { + options, + placeholder: "Select multiple...", + isMulti: true, + }, +}; + +export const MultiSelectWithValues: Story = { + args: { + options, + value: [options[0], options[2]], + isMulti: true, + }, +}; + +export const WithGroups: Story = { + args: { + options: [ + { + label: "Short meetings", + options: [ + { label: "15 minutes", value: "15" }, + { label: "30 minutes", value: "30" }, + ], + }, + { + label: "Long meetings", + options: [ + { label: "45 minutes", value: "45" }, + { label: "60 minutes", value: "60" }, + { label: "90 minutes", value: "90" }, + ], + }, + ], + placeholder: "Select duration...", + }, +}; + +export const CustomClassNames: Story = { + args: { + options, + placeholder: "Select with custom class", + className: "custom-select-class", + }, +}; + +export const WithValidation: Story = { + render: () => ( + + ), +}; + +export const WithValidationAndValue: Story = { + render: () => ( + + ), +}; + +export const WithValidationMultiSelect: Story = { + render: () => ( + + ), +}; + +export const TimezoneExample: Story = { + args: { + options: [ + { label: "America/New_York (EST)", value: "America/New_York" }, + { label: "America/Los_Angeles (PST)", value: "America/Los_Angeles" }, + { label: "America/Chicago (CST)", value: "America/Chicago" }, + { label: "Europe/London (GMT)", value: "Europe/London" }, + { label: "Europe/Paris (CET)", value: "Europe/Paris" }, + { label: "Asia/Tokyo (JST)", value: "Asia/Tokyo" }, + { label: "Asia/Shanghai (CST)", value: "Asia/Shanghai" }, + { label: "Australia/Sydney (AEST)", value: "Australia/Sydney" }, + ], + placeholder: "Select timezone...", + isSearchable: true, + }, +}; + +export const FormExample: Story = { + render: () => ( +
+
+ + +
+
+ + +
+ ), + parameters: { + layout: "padded", + }, +}; diff --git a/packages/features/insights/components/BookedByCell.stories.tsx b/packages/features/insights/components/BookedByCell.stories.tsx new file mode 100644 index 00000000000000..ae956264838bf6 --- /dev/null +++ b/packages/features/insights/components/BookedByCell.stories.tsx @@ -0,0 +1,141 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { BookedByCell } from "./BookedByCell"; + +const meta = { + component: BookedByCell, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + attendees: [ + { + name: "John Doe", + email: "john.doe@example.com", + phoneNumber: "+1 (555) 123-4567", + }, + ], + rowId: 1, + }, +}; + +export const MultipleAttendees: Story = { + args: { + attendees: [ + { + name: "John Doe", + email: "john.doe@example.com", + phoneNumber: "+1 (555) 123-4567", + }, + { + name: "Jane Smith", + email: "jane.smith@example.com", + phoneNumber: "+1 (555) 987-6543", + }, + { + name: "Bob Johnson", + email: "bob.johnson@example.com", + phoneNumber: "+1 (555) 246-8135", + }, + ], + rowId: 2, + }, +}; + +export const WithoutPhoneNumbers: Story = { + args: { + attendees: [ + { + name: "Alice Cooper", + email: "alice.cooper@example.com", + phoneNumber: null, + }, + { + name: "Charlie Brown", + email: "charlie.brown@example.com", + phoneNumber: null, + }, + ], + rowId: 3, + }, +}; + +export const SingleAttendeeWithoutPhone: Story = { + args: { + attendees: [ + { + name: "Emily Davis", + email: "emily.davis@example.com", + phoneNumber: null, + }, + ], + rowId: 4, + }, +}; + +export const LongNameAttendee: Story = { + args: { + attendees: [ + { + name: "Alexander Christopher Montgomery Wellington III", + email: "alexander.wellington@example.com", + phoneNumber: "+1 (555) 111-2222", + }, + ], + rowId: 5, + }, +}; + +export const ManyAttendees: Story = { + args: { + attendees: [ + { + name: "Attendee 1", + email: "attendee1@example.com", + phoneNumber: "+1 (555) 111-1111", + }, + { + name: "Attendee 2", + email: "attendee2@example.com", + phoneNumber: "+1 (555) 222-2222", + }, + { + name: "Attendee 3", + email: "attendee3@example.com", + phoneNumber: "+1 (555) 333-3333", + }, + { + name: "Attendee 4", + email: "attendee4@example.com", + phoneNumber: "+1 (555) 444-4444", + }, + { + name: "Attendee 5", + email: "attendee5@example.com", + phoneNumber: "+1 (555) 555-5555", + }, + ], + rowId: 6, + }, +}; + +export const Empty: Story = { + args: { + attendees: [], + rowId: 7, + }, +}; + +export const NullAttendees: Story = { + args: { + attendees: null, + rowId: 8, + }, +}; diff --git a/packages/features/insights/components/BookingAtCell.stories.tsx b/packages/features/insights/components/BookingAtCell.stories.tsx new file mode 100644 index 00000000000000..81ce647c1806ba --- /dev/null +++ b/packages/features/insights/components/BookingAtCell.stories.tsx @@ -0,0 +1,237 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { BookingStatus } from "@calcom/prisma/enums"; + +import type { RoutingFormTableRow } from "../lib/types"; +import { BookingAtCell } from "./BookingAtCell"; + +const meta = { + component: BookingAtCell, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + row: { + description: "The routing form table row data containing booking information", + control: { type: "object" }, + }, + rowId: { + description: "Unique identifier for the row", + control: { type: "number" }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Helper function to create mock booking data +const createMockRow = (overrides?: Partial): RoutingFormTableRow => ({ + id: "row-1", + formId: "form-1", + formFillerId: "filler-1", + response: {}, + createdAt: new Date("2024-01-15T10:30:00Z"), + bookingUid: "booking-abc123", + bookingUserId: 1, + bookingUserName: "John Doe", + bookingUserEmail: "john.doe@example.com", + bookingUserAvatarUrl: "https://cal.com/avatar/john-doe.jpg", + bookingCreatedAt: new Date("2024-01-15T14:00:00Z"), + bookingStatus: BookingStatus.ACCEPTED, + ...overrides, +}); + +export const Default: Story = { + args: { + row: createMockRow(), + rowId: 1, + }, +}; + +export const WithoutBooking: Story = { + args: { + row: createMockRow({ + bookingUserId: null, + bookingCreatedAt: null, + bookingUid: null, + bookingUserName: null, + bookingUserEmail: null, + bookingUserAvatarUrl: null, + bookingStatus: null, + }), + rowId: 1, + }, +}; + +export const AcceptedBooking: Story = { + args: { + row: createMockRow({ + bookingUserName: "Alice Johnson", + bookingUserEmail: "alice.johnson@example.com", + bookingUserAvatarUrl: "https://cal.com/avatar/alice.jpg", + bookingStatus: BookingStatus.ACCEPTED, + bookingCreatedAt: new Date("2024-03-20T09:00:00Z"), + }), + rowId: 1, + }, +}; + +export const PendingBooking: Story = { + args: { + row: createMockRow({ + bookingUserName: "Bob Smith", + bookingUserEmail: "bob.smith@example.com", + bookingUserAvatarUrl: "https://cal.com/avatar/bob.jpg", + bookingStatus: BookingStatus.PENDING, + bookingCreatedAt: new Date("2024-03-21T10:30:00Z"), + }), + rowId: 1, + }, +}; + +export const CancelledBooking: Story = { + args: { + row: createMockRow({ + bookingUserName: "Charlie Brown", + bookingUserEmail: "charlie.brown@example.com", + bookingUserAvatarUrl: "https://cal.com/avatar/charlie.jpg", + bookingStatus: BookingStatus.CANCELLED, + bookingCreatedAt: new Date("2024-03-19T15:45:00Z"), + }), + rowId: 1, + }, +}; + +export const RejectedBooking: Story = { + args: { + row: createMockRow({ + bookingUserName: "Diana Prince", + bookingUserEmail: "diana.prince@example.com", + bookingUserAvatarUrl: "https://cal.com/avatar/diana.jpg", + bookingStatus: BookingStatus.REJECTED, + bookingCreatedAt: new Date("2024-03-18T11:00:00Z"), + }), + rowId: 1, + }, +}; + +export const AwaitingHostBooking: Story = { + args: { + row: createMockRow({ + bookingUserName: "Eve Adams", + bookingUserEmail: "eve.adams@example.com", + bookingUserAvatarUrl: "https://cal.com/avatar/eve.jpg", + bookingStatus: BookingStatus.AWAITING_HOST, + bookingCreatedAt: new Date("2024-03-22T08:15:00Z"), + }), + rowId: 1, + }, +}; + +export const WithoutAvatar: Story = { + args: { + row: createMockRow({ + bookingUserName: "Frank Wilson", + bookingUserEmail: "frank.wilson@example.com", + bookingUserAvatarUrl: null, + bookingStatus: BookingStatus.ACCEPTED, + bookingCreatedAt: new Date("2024-03-17T16:30:00Z"), + }), + rowId: 1, + }, +}; + +export const LongEmailAddress: Story = { + args: { + row: createMockRow({ + bookingUserName: "Grace Hopper", + bookingUserEmail: "grace.hopper.with.a.very.long.email@example-company.com", + bookingUserAvatarUrl: "https://cal.com/avatar/grace.jpg", + bookingStatus: BookingStatus.ACCEPTED, + bookingCreatedAt: new Date("2024-03-16T13:20:00Z"), + }), + rowId: 1, + }, +}; + +export const AllStatuses: Story = { + render: () => ( +
+
+

Accepted

+ +
+
+

Pending

+ +
+
+

Cancelled

+ +
+
+

Rejected

+ +
+
+

Awaiting Host

+ +
+
+

No Booking

+ +
+
+ ), + parameters: { + layout: "padded", + }, +}; diff --git a/packages/features/insights/components/ChartCard.stories.tsx b/packages/features/insights/components/ChartCard.stories.tsx new file mode 100644 index 00000000000000..cb796515738b6f --- /dev/null +++ b/packages/features/insights/components/ChartCard.stories.tsx @@ -0,0 +1,330 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; + +import { ChartCard, ChartCardItem } from "./ChartCard"; + +const meta = { + component: ChartCard, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + title: { + description: "Title of the chart card", + control: { type: "text" }, + }, + legend: { + description: "Array of legend items with label and color", + control: { type: "object" }, + }, + legendSize: { + description: "Size of the legend items", + control: { type: "select" }, + options: ["sm", "default"], + }, + enabledLegend: { + description: "Array of enabled legend items for toggling", + control: { type: "object" }, + }, + onSeriesToggle: { + description: "Callback when a legend item is toggled", + action: "series toggled", + }, + isPending: { + description: "Loading state", + control: { type: "boolean" }, + }, + isError: { + description: "Error state", + control: { type: "boolean" }, + }, + children: { + description: "Chart content or other child components", + control: false, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Sample data for charts +const sampleChartData = [ + { month: "Jan", created: 65, completed: 45, cancelled: 10 }, + { month: "Feb", created: 72, completed: 58, cancelled: 8 }, + { month: "Mar", created: 88, completed: 70, cancelled: 12 }, + { month: "Apr", created: 95, completed: 82, cancelled: 9 }, + { month: "May", created: 105, completed: 88, cancelled: 11 }, + { month: "Jun", created: 118, completed: 95, cancelled: 15 }, +]; + +const sampleLegend = [ + { label: "Created", color: "#a855f7" }, + { label: "Completed", color: "#22c55e" }, + { label: "Cancelled", color: "#ef4444" }, +]; + +const extendedLegend = [ + { label: "Created", color: "#a855f7" }, + { label: "Completed", color: "#22c55e" }, + { label: "Rescheduled", color: "#3b82f6" }, + { label: "Cancelled", color: "#ef4444" }, + { label: "No-Show (Host)", color: "#64748b" }, + { label: "No-Show (Guest)", color: "#f97316" }, +]; + +// Simple chart component for stories +const SimpleLineChart = ({ data }: { data: typeof sampleChartData }) => ( +
+ + + + + + + + + + + +
+); + +export const Default: Story = { + args: { + title: "Event Trends", + legend: sampleLegend, + children: , + }, +}; + +export const WithLegend: Story = { + args: { + title: "Bookings Overview", + legend: sampleLegend, + children: , + }, +}; + +export const WithExtendedLegend: Story = { + args: { + title: "Detailed Event Trends", + legend: extendedLegend, + children: ( +
+ + + + + + + + + + + +
+ ), + }, +}; + +export const WithSmallLegend: Story = { + args: { + title: "Event Trends (Compact Legend)", + legend: extendedLegend, + legendSize: "sm", + children: , + }, +}; + +export const WithHeaderContent: Story = { + args: { + title: "Event Trends", + legend: sampleLegend, + headerContent: ( +
+ Last 30 days +
+ ), + children: , + }, +}; + +export const LoadingState: Story = { + args: { + title: "Event Trends", + legend: sampleLegend, + isPending: true, + }, +}; + +export const LoadingWithContent: Story = { + args: { + title: "Event Trends", + legend: sampleLegend, + isPending: true, + children: , + }, +}; + +export const ErrorState: Story = { + args: { + title: "Event Trends", + legend: sampleLegend, + isError: true, + children: ( +
+

Failed to load chart data

+
+ ), + }, +}; + +export const WithListItems: Story = { + args: { + title: "Top Event Types", + children: ( +
+ 30 Minute Meeting + Discovery Call + Team Sync + Sales Demo + Technical Interview +
+ ), + }, +}; + +export const WithListItemsNoCount: Story = { + args: { + title: "Recent Activities", + children: ( +
+ Meeting with John Doe scheduled + Discovery Call with Jane Smith completed + Team Sync with Bob Wilson cancelled + Sales Demo with Alice Johnson rescheduled +
+ ), + }, +}; + +export const MixedContent: Story = { + args: { + title: "Top Performers", + legend: sampleLegend, + headerContent: This Month, + children: ( +
+ Sarah Johnson + Michael Chen + Emily Rodriguez + David Kim + Lisa Anderson +
+ ), + }, +}; + +export const InteractiveLegend: Story = { + args: { + title: "Event Trends (Click to toggle)", + legend: sampleLegend, + enabledLegend: sampleLegend, + onSeriesToggle: (label: string) => { + console.log("Toggled series:", label); + }, + children: , + }, +}; + +export const CompactCard: Story = { + args: { + title: "Quick Stats", + children: ( +
+
+
+
142
+
Total
+
+
+
98
+
Completed
+
+
+
12
+
Cancelled
+
+
+
+ ), + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+

Default with Chart

+ + + +
+
+

Loading State

+ +
+
+

With List Items

+ + 30 Minute Meeting + Discovery Call + Team Sync + +
+
+

With Header Content

+ Last 30 days}> + + +
+
+ ), + parameters: { + layout: "padded", + }, + decorators: [], +}; diff --git a/packages/features/insights/components/ResponseValueCell.stories.tsx b/packages/features/insights/components/ResponseValueCell.stories.tsx new file mode 100644 index 00000000000000..070d7bcf7ef356 --- /dev/null +++ b/packages/features/insights/components/ResponseValueCell.stories.tsx @@ -0,0 +1,122 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { ResponseValueCell } from "./ResponseValueCell"; + +const meta = { + component: ResponseValueCell, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + optionMap: { + "opt1": "Option 1", + "opt2": "Option 2", + }, + values: ["opt1"], + rowId: 1, + }, +}; + +export const TwoValues: Story = { + args: { + optionMap: { + "opt1": "Option 1", + "opt2": "Option 2", + "opt3": "Option 3", + }, + values: ["opt1", "opt2"], + rowId: 2, + }, +}; + +export const ThreeValues: Story = { + args: { + optionMap: { + "opt1": "Option 1", + "opt2": "Option 2", + "opt3": "Option 3", + }, + values: ["opt1", "opt2", "opt3"], + rowId: 3, + }, +}; + +export const ManyValues: Story = { + args: { + optionMap: { + "opt1": "Option 1", + "opt2": "Option 2", + "opt3": "Option 3", + "opt4": "Option 4", + "opt5": "Option 5", + "opt6": "Option 6", + }, + values: ["opt1", "opt2", "opt3", "opt4", "opt5", "opt6"], + rowId: 4, + }, +}; + +export const LongOptionLabels: Story = { + args: { + optionMap: { + "opt1": "This is a very long option label that might overflow", + "opt2": "Another extremely long label for testing purposes", + "opt3": "Yet another long label to see how it behaves", + }, + values: ["opt1", "opt2", "opt3"], + rowId: 5, + }, +}; + +export const UnmappedValues: Story = { + args: { + optionMap: { + "opt1": "Option 1", + }, + values: ["opt1", "opt2", "opt3"], + rowId: 6, + }, +}; + +export const Empty: Story = { + args: { + optionMap: { + "opt1": "Option 1", + "opt2": "Option 2", + }, + values: [], + rowId: 7, + }, +}; + +export const WithSpecialCharacters: Story = { + args: { + optionMap: { + "opt1": "Option with emoji 🎉", + "opt2": "Option & Special < > Characters", + "opt3": "Option with 'quotes'", + }, + values: ["opt1", "opt2", "opt3"], + rowId: 8, + }, +}; + +export const NumericKeys: Story = { + args: { + optionMap: { + "1": "First Choice", + "2": "Second Choice", + "3": "Third Choice", + "4": "Fourth Choice", + }, + values: ["1", "2", "3", "4"], + rowId: 9, + }, +}; diff --git a/packages/features/insights/filters/DateTargetSelector.stories.tsx b/packages/features/insights/filters/DateTargetSelector.stories.tsx new file mode 100644 index 00000000000000..d0aa40a9a09992 --- /dev/null +++ b/packages/features/insights/filters/DateTargetSelector.stories.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { DateTargetSelector, type DateTarget } from "./DateTargetSelector"; + +// Mock the useLocale hook +jest.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string) => { + const translations: Record = { + booking_time_option: "Booking Time", + booking_time_option_description: "Filter by when the booking is scheduled to occur", + created_at_option: "Created At", + created_at_option_description: "Filter by when the booking was created", + }; + return translations[key] || key; + }, + }), +})); + +const meta = { + component: DateTargetSelector, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + value: { + control: "select", + options: ["startTime", "createdAt"], + description: "The currently selected date target", + }, + onChange: { + action: "changed", + description: "Callback function called when the selection changes", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [value, setValue] = useState("startTime"); + return ; + }, +}; + +export const BookingTimeSelected: Story = { + render: () => { + const [value, setValue] = useState("startTime"); + return ; + }, +}; + +export const CreatedAtSelected: Story = { + render: () => { + const [value, setValue] = useState("createdAt"); + return ; + }, +}; + +export const Interactive: Story = { + render: () => { + const [value, setValue] = useState("startTime"); + return ( +
+ +
+ Current selection: {value} +
+
+ ); + }, +}; + +export const WithContextDisplay: Story = { + render: () => { + const [value, setValue] = useState("startTime"); + const displayText = value === "startTime" ? "Booking Time" : "Created At"; + return ( +
+
+ Filter by: + +
+
+ Filtering insights by: {displayText} +
+
+ ); + }, +}; + +export const MultipleInstances: Story = { + render: () => { + const [value1, setValue1] = useState("startTime"); + const [value2, setValue2] = useState("createdAt"); + return ( +
+
+ Filter 1: + + ({value1}) +
+
+ Filter 2: + + ({value2}) +
+
+ ); + }, +}; + +export const InFilterBar: Story = { + render: () => { + const [value, setValue] = useState("startTime"); + return ( +
+ Insights Filters: +
+ +
+ ); + }, +}; diff --git a/packages/features/insights/filters/OrgTeamsFilter.stories.tsx b/packages/features/insights/filters/OrgTeamsFilter.stories.tsx new file mode 100644 index 00000000000000..fc561c56e8e68c --- /dev/null +++ b/packages/features/insights/filters/OrgTeamsFilter.stories.tsx @@ -0,0 +1,303 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { SessionProvider } from "next-auth/react"; +import { useState } from "react"; + +import { InsightsOrgTeamsContext } from "../context/InsightsOrgTeamsProvider"; +import type { OrgTeamsType } from "./OrgTeamsFilter"; +import { OrgTeamsFilter } from "./OrgTeamsFilter"; + +// Mock TRPC hook +const mockTRPCHook = (data: any) => ({ + data, + isLoading: false, + isError: false, + error: null, + refetch: () => Promise.resolve({ data }), +}); + +// Mock teams data +const mockTeamsData = [ + { + id: 1, + name: "Engineering", + isOrg: false, + logoUrl: "https://cal.com/api/avatar/team-1.png", + }, + { + id: 2, + name: "Marketing", + isOrg: false, + logoUrl: "https://cal.com/api/avatar/team-2.png", + }, + { + id: 3, + name: "Sales", + isOrg: false, + logoUrl: "https://cal.com/api/avatar/team-3.png", + }, + { + id: 4, + name: "Design", + isOrg: false, + logoUrl: null, + }, +]; + +const mockOrgData = [ + { + id: 100, + name: "Acme Corp", + isOrg: true, + logoUrl: "https://cal.com/api/avatar/org.png", + }, + ...mockTeamsData, +]; + +// Mock session data +const mockSession = { + user: { + id: 1, + name: "John Doe", + email: "john.doe@example.com", + avatarUrl: "https://cal.com/api/avatar/user.png", + org: { + id: 100, + name: "Acme Corp", + role: "OWNER", + }, + }, + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), +}; + +const mockSessionWithoutOrg = { + user: { + id: 1, + name: "John Doe", + email: "john.doe@example.com", + avatarUrl: "https://cal.com/api/avatar/user.png", + }, + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), +}; + +// Mock TRPC module +jest.mock("@calcom/trpc/react", () => ({ + trpc: { + viewer: { + insights: { + teamListForUser: { + useQuery: jest.fn(), + }, + }, + }, + }, +})); + +const { trpc } = require("@calcom/trpc/react"); + +// Wrapper component with context providers +function OrgTeamsFilterWrapper({ + teamsData, + session, + initialOrgTeamsType = "org", + initialSelectedTeamId, +}: { + teamsData: any[]; + session: any; + initialOrgTeamsType?: OrgTeamsType; + initialSelectedTeamId?: number; +}) { + const [orgTeamsType, setOrgTeamsType] = useState(initialOrgTeamsType); + const [selectedTeamId, setSelectedTeamId] = useState(initialSelectedTeamId); + + // Mock TRPC response + trpc.viewer.insights.teamListForUser.useQuery.mockReturnValue(mockTRPCHook(teamsData)); + + return ( + + + + + + ); +} + +const meta = { + component: OrgTeamsFilter, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const SelectedYours: Story = { + render: () => ( + + ), +}; + +export const SelectedTeam: Story = { + render: () => ( + + ), +}; + +export const WithoutOrganization: Story = { + render: () => ( + + ), +}; + +export const NoTeams: Story = { + render: () => ( + + ), +}; + +export const ManyTeams: Story = { + render: () => { + const manyTeams = [ + { + id: 100, + name: "Acme Corp", + isOrg: true, + logoUrl: "https://cal.com/api/avatar/org.png", + }, + ...Array.from({ length: 15 }, (_, i) => ({ + id: i + 1, + name: `Team ${i + 1}`, + isOrg: false, + logoUrl: i % 3 === 0 ? null : `https://cal.com/api/avatar/team-${i + 1}.png`, + })), + ]; + return ( + + ); + }, +}; + +export const LongTeamNames: Story = { + render: () => { + const teamsWithLongNames = [ + { + id: 100, + name: "Acme Corp", + isOrg: true, + logoUrl: "https://cal.com/api/avatar/org.png", + }, + { + id: 1, + name: "Engineering and Product Development Team", + isOrg: false, + logoUrl: "https://cal.com/api/avatar/team-1.png", + }, + { + id: 2, + name: "Marketing, Communications, and Brand Strategy", + isOrg: false, + logoUrl: "https://cal.com/api/avatar/team-2.png", + }, + { + id: 3, + name: "Sales and Customer Success Division", + isOrg: false, + logoUrl: null, + }, + ]; + return ( + + ); + }, +}; + +export const WithoutAvatars: Story = { + render: () => { + const teamsWithoutAvatars = [ + { + id: 100, + name: "Acme Corp", + isOrg: true, + logoUrl: null, + }, + { + id: 1, + name: "Engineering", + isOrg: false, + logoUrl: null, + }, + { + id: 2, + name: "Marketing", + isOrg: false, + logoUrl: null, + }, + { + id: 3, + name: "Sales", + isOrg: false, + logoUrl: null, + }, + ]; + const sessionWithoutAvatar = { + ...mockSession, + user: { + ...mockSession.user, + avatarUrl: null, + }, + }; + return ( + + ); + }, +}; diff --git a/packages/features/mintlify-chat/MintlifyChat.stories.tsx b/packages/features/mintlify-chat/MintlifyChat.stories.tsx new file mode 100644 index 00000000000000..c6b75da870ca03 --- /dev/null +++ b/packages/features/mintlify-chat/MintlifyChat.stories.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { MintlifyChat } from "./MintlifyChat"; + +const meta = { + component: MintlifyChat, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + searchText: { + description: "The search query text that the AI will answer", + control: "text", + }, + aiResponse: { + description: "The AI-generated response (can include markdown and citations)", + control: "text", + }, + setAiResponse: { + description: "Function to update the AI response state", + table: { + disable: true, + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Wrapper component to handle state for interactive stories +const MintlifyChatWrapper = ({ searchText, initialResponse }: { searchText: string; initialResponse: string }) => { + const [aiResponse, setAiResponse] = useState(initialResponse); + + return ; +}; + +export const Default: Story = { + render: () => { + return ; + }, +}; + +export const WithSearchQuery: Story = { + render: () => { + return ; + }, +}; + +export const WithResponse: Story = { + render: () => { + const response = `Event types are the foundation of your scheduling setup in Cal.com. They define the type of meetings you want to offer, including duration, availability, and booking conditions. + +To create an event type: +1. Go to your dashboard +2. Click on "Event Types" +3. Click "New Event Type" +4. Configure your settings +5. Save and share your booking link||[{"id":"1","link":"/event-types","chunk_html":"","metadata":{"title":"Event Types Documentation"}},{"id":"2","link":"/getting-started","chunk_html":"","metadata":{"title":"Getting Started Guide"}}]`; + + return ; + }, +}; + +export const WithMarkdownResponse: Story = { + render: () => { + const response = `You can customize your booking page in several ways: + +## Appearance +- **Colors**: Choose your brand colors +- **Logo**: Upload your company logo +- **Background**: Set a custom background image + +## Settings +- Add custom questions +- Set buffer times +- Configure notifications||[{"id":"1","link":"/appearance","chunk_html":"","metadata":{"title":"Appearance Settings"}},{"id":"2","link":"/booking-questions","chunk_html":"","metadata":{"title":"Custom Questions"}}]`; + + return ; + }, +}; + +export const WithLongResponse: Story = { + render: () => { + const response = `Cal.com offers extensive integration capabilities with various tools and platforms: + +## Calendar Integrations +Connect with Google Calendar, Outlook, Apple Calendar, and CalDAV to sync your availability automatically. This ensures you never get double-booked. + +## Video Conferencing +Integrate with Zoom, Google Meet, Microsoft Teams, or any custom video conferencing solution. Each meeting can automatically include video conferencing links. + +## Payment Processing +Accept payments through Stripe, PayPal, or other payment processors. Set up paid event types for consultations, workshops, or any service. + +## CRM Integration +Sync your bookings with Salesforce, HubSpot, and other CRM platforms to keep your customer data up-to-date. + +## Automation +Use webhooks and API integrations to automate workflows with tools like Zapier, Make, or custom solutions.||[{"id":"1","link":"/integrations/calendar","chunk_html":"","metadata":{"title":"Calendar Integrations"}},{"id":"2","link":"/integrations/video","chunk_html":"","metadata":{"title":"Video Conferencing"}},{"id":"3","link":"/integrations/payment","chunk_html":"","metadata":{"title":"Payment Processing"}},{"id":"4","link":"/integrations/crm","chunk_html":"","metadata":{"title":"CRM Integration"}}]`; + + return ; + }, +}; + +export const ShortQuery: Story = { + render: () => { + return ; + }, +}; + +export const ComplexQuery: Story = { + render: () => { + return ; + }, +}; + +export const ResponseWithoutCitations: Story = { + render: () => { + const response = "Round-robin scheduling allows you to distribute bookings evenly across team members. This is perfect for sales teams, support staff, or any scenario where you want to balance the load.||"; + + return ; + }, +}; + +export const ResponseWithManyCitations: Story = { + render: () => { + const response = `Cal.com provides comprehensive team scheduling features for organizations of all sizes.||[{"id":"1","link":"/teams/getting-started","chunk_html":"","metadata":{"title":"Getting Started with Teams"}},{"id":"2","link":"/teams/round-robin","chunk_html":"","metadata":{"title":"Round-Robin Scheduling"}},{"id":"3","link":"/teams/collective","chunk_html":"","metadata":{"title":"Collective Events"}},{"id":"4","link":"/teams/permissions","chunk_html":"","metadata":{"title":"Team Permissions"}},{"id":"5","link":"/teams/billing","chunk_html":"","metadata":{"title":"Team Billing"}},{"id":"6","link":"/teams/branding","chunk_html":"","metadata":{"title":"Team Branding"}}]`; + + return ; + }, +}; + +export const AllStates: Story = { + render: () => ( +
+
+

Initial State (No Response)

+
+ +
+
+ +
+

With Response and Citations

+
+ +
+
+
+ ), +}; diff --git a/packages/features/schedules/components/ScheduleListItem.stories.tsx b/packages/features/schedules/components/ScheduleListItem.stories.tsx new file mode 100644 index 00000000000000..26c1e6855c2874 --- /dev/null +++ b/packages/features/schedules/components/ScheduleListItem.stories.tsx @@ -0,0 +1,318 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import { ScheduleListItem } from "./ScheduleListItem"; + +const meta = { + component: ScheduleListItem, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+
    + +
+
+ ), + ], + argTypes: { + schedule: { + description: "Schedule data including name, availability times, and timezone", + }, + deleteFunction: { + description: "Function called when deleting a schedule", + }, + displayOptions: { + description: "Display options for timezone and time formatting", + }, + isDeletable: { + description: "Whether the schedule can be deleted", + control: "boolean", + }, + updateDefault: { + description: "Function called when setting a schedule as default", + }, + duplicateFunction: { + description: "Function called when duplicating a schedule", + }, + redirectUrl: { + description: "URL to navigate to when clicking the schedule", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const mockAvailability = [ + { + id: 1, + userId: 1, + eventTypeId: null, + days: [1, 2, 3, 4, 5], + startTime: new Date(Date.UTC(1970, 0, 1, 9, 0, 0, 0)), + endTime: new Date(Date.UTC(1970, 0, 1, 17, 0, 0, 0)), + date: null, + scheduleId: 1, + profileId: null, + }, +]; + +const mockSchedule = { + id: 1, + userId: 1, + name: "Working Hours", + availability: mockAvailability, + timeZone: "America/New_York", + dateOverrides: [], + isDefault: false, + isManaged: false, + workingHours: [], + profileId: null, +}; + +export const Default: Story = { + args: { + schedule: mockSchedule, + deleteFunction: fn(), + updateDefault: fn(), + duplicateFunction: fn(), + isDeletable: true, + redirectUrl: "/availability/1", + displayOptions: { + hour12: true, + weekStart: "Sunday", + }, + }, +}; + +export const DefaultSchedule: Story = { + args: { + schedule: { + ...mockSchedule, + isDefault: true, + }, + deleteFunction: fn(), + updateDefault: fn(), + duplicateFunction: fn(), + isDeletable: true, + redirectUrl: "/availability/1", + displayOptions: { + hour12: true, + weekStart: "Sunday", + }, + }, +}; + +export const NotDeletable: Story = { + args: { + schedule: { + ...mockSchedule, + name: "Last Schedule", + }, + deleteFunction: fn(), + updateDefault: fn(), + duplicateFunction: fn(), + isDeletable: false, + redirectUrl: "/availability/1", + displayOptions: { + hour12: true, + weekStart: "Sunday", + }, + }, +}; + +export const TwentyFourHourFormat: Story = { + args: { + schedule: mockSchedule, + deleteFunction: fn(), + updateDefault: fn(), + duplicateFunction: fn(), + isDeletable: true, + redirectUrl: "/availability/1", + displayOptions: { + hour12: false, + weekStart: "Sunday", + }, + }, +}; + +export const WeekendSchedule: Story = { + args: { + schedule: { + ...mockSchedule, + name: "Weekend Availability", + availability: [ + { + id: 2, + userId: 1, + eventTypeId: null, + days: [6, 0], + startTime: new Date(Date.UTC(1970, 0, 1, 10, 0, 0, 0)), + endTime: new Date(Date.UTC(1970, 0, 1, 14, 0, 0, 0)), + date: null, + scheduleId: 2, + profileId: null, + }, + ], + timeZone: "America/Los_Angeles", + }, + deleteFunction: fn(), + updateDefault: fn(), + duplicateFunction: fn(), + isDeletable: true, + redirectUrl: "/availability/2", + displayOptions: { + hour12: true, + weekStart: "Sunday", + }, + }, +}; + +export const MultipleTimeRanges: Story = { + args: { + schedule: { + ...mockSchedule, + name: "Split Schedule", + availability: [ + { + id: 3, + userId: 1, + eventTypeId: null, + days: [1, 2, 3, 4, 5], + startTime: new Date(Date.UTC(1970, 0, 1, 9, 0, 0, 0)), + endTime: new Date(Date.UTC(1970, 0, 1, 12, 0, 0, 0)), + date: null, + scheduleId: 3, + profileId: null, + }, + { + id: 4, + userId: 1, + eventTypeId: null, + days: [1, 2, 3, 4, 5], + startTime: new Date(Date.UTC(1970, 0, 1, 13, 0, 0, 0)), + endTime: new Date(Date.UTC(1970, 0, 1, 17, 0, 0, 0)), + date: null, + scheduleId: 3, + profileId: null, + }, + ], + timeZone: "Europe/London", + }, + deleteFunction: fn(), + updateDefault: fn(), + duplicateFunction: fn(), + isDeletable: true, + redirectUrl: "/availability/3", + displayOptions: { + hour12: false, + weekStart: "Monday", + }, + }, +}; + +export const StaggeredDays: Story = { + args: { + schedule: { + ...mockSchedule, + name: "Custom Days", + availability: [ + { + id: 5, + userId: 1, + eventTypeId: null, + days: [1, 3, 5], + startTime: new Date(Date.UTC(1970, 0, 1, 8, 0, 0, 0)), + endTime: new Date(Date.UTC(1970, 0, 1, 16, 30, 0, 0)), + date: null, + scheduleId: 4, + profileId: null, + }, + ], + timeZone: "Asia/Tokyo", + }, + deleteFunction: fn(), + updateDefault: fn(), + duplicateFunction: fn(), + isDeletable: true, + redirectUrl: "/availability/4", + displayOptions: { + hour12: true, + weekStart: "Sunday", + }, + }, +}; + +export const AllDaysAvailable: Story = { + args: { + schedule: { + ...mockSchedule, + name: "24/7 Support", + availability: [ + { + id: 6, + userId: 1, + eventTypeId: null, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date(Date.UTC(1970, 0, 1, 0, 0, 0, 0)), + endTime: new Date(Date.UTC(1970, 0, 1, 23, 59, 0, 0)), + date: null, + scheduleId: 5, + profileId: null, + }, + ], + timeZone: "UTC", + }, + deleteFunction: fn(), + updateDefault: fn(), + duplicateFunction: fn(), + isDeletable: true, + redirectUrl: "/availability/5", + displayOptions: { + hour12: false, + weekStart: "Sunday", + }, + }, +}; + +export const NoTimezone: Story = { + args: { + schedule: { + ...mockSchedule, + name: "No Timezone Schedule", + timeZone: null, + availability: mockAvailability, + }, + deleteFunction: fn(), + updateDefault: fn(), + duplicateFunction: fn(), + isDeletable: true, + redirectUrl: "/availability/6", + displayOptions: { + hour12: true, + weekStart: "Sunday", + }, + }, +}; + +export const LongScheduleName: Story = { + args: { + schedule: { + ...mockSchedule, + name: "This is a very long schedule name that should be truncated in the UI to prevent layout issues", + }, + deleteFunction: fn(), + updateDefault: fn(), + duplicateFunction: fn(), + isDeletable: true, + redirectUrl: "/availability/7", + displayOptions: { + hour12: true, + weekStart: "Sunday", + }, + }, +}; diff --git a/packages/features/settings/BookerLayoutSelector.stories.tsx b/packages/features/settings/BookerLayoutSelector.stories.tsx new file mode 100644 index 00000000000000..c12cd2e48d19aa --- /dev/null +++ b/packages/features/settings/BookerLayoutSelector.stories.tsx @@ -0,0 +1,303 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { FormProvider, useForm } from "react-hook-form"; + +import { BookerLayouts } from "@calcom/prisma/zod-utils"; +import type { BookerLayoutSettings } from "@calcom/prisma/zod-utils"; + +import { BookerLayoutSelector } from "./BookerLayoutSelector"; + +// Wrapper component to provide form context +const FormWrapper = ({ + children, + defaultValues, +}: { + children: React.ReactNode; + defaultValues?: any; +}) => { + const methods = useForm({ + defaultValues: { + metadata: { + bookerLayouts: defaultValues?.bookerLayouts || { + enabledLayouts: [BookerLayouts.MONTH_VIEW, BookerLayouts.WEEK_VIEW, BookerLayouts.COLUMN_VIEW], + defaultLayout: BookerLayouts.MONTH_VIEW, + }, + }, + ...defaultValues, + }, + }); + + return {children}; +}; + +const meta = { + component: BookerLayoutSelector, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + ), +}; + +export const WithCustomTitle: Story = { + render: () => ( + + + + ), +}; + +export const DarkMode: Story = { + render: () => ( + + + + ), +}; + +export const WithOuterBorder: Story = { + render: () => ( + + + + ), +}; + +export const Loading: Story = { + render: () => ( + + + + ), +}; + +export const Disabled: Story = { + render: () => ( + + + + ), +}; + +export const MonthViewOnly: Story = { + render: () => ( + + + + ), +}; + +export const WeekViewDefault: Story = { + render: () => ( + + + + ), +}; + +export const ColumnViewDefault: Story = { + render: () => ( + + + + ), +}; + +export const TwoLayoutsEnabled: Story = { + render: () => ( + + + + ), +}; + +export const WithUserFallback: Story = { + render: () => ( + + + + ), +}; + +export const WithUserFallbackLoading: Story = { + render: () => ( + + + + ), +}; + +export const DarkModeWithOuterBorder: Story = { + render: () => ( + + + + ), +}; + +export const CustomFieldName: Story = { + render: () => ( + + + + ), +}; + +export const Interactive: Story = { + render: () => { + const FormContent = () => { + const methods = useForm({ + defaultValues: { + metadata: { + bookerLayouts: { + enabledLayouts: [BookerLayouts.MONTH_VIEW, BookerLayouts.WEEK_VIEW, BookerLayouts.COLUMN_VIEW], + defaultLayout: BookerLayouts.MONTH_VIEW, + } as BookerLayoutSettings, + }, + }, + }); + + const bookerLayouts = methods.watch("metadata.bookerLayouts"); + + return ( + +
+
+

Current State:

+
+

+ Enabled layouts:{" "} + {bookerLayouts?.enabledLayouts?.join(", ") || "None"} +

+

Default layout: {bookerLayouts?.defaultLayout || "None"}

+
+
+ +
+
+ ); + }; + + return ; + }, +}; + +export const InteractiveDarkMode: Story = { + render: () => { + const FormContent = () => { + const methods = useForm({ + defaultValues: { + metadata: { + bookerLayouts: { + enabledLayouts: [BookerLayouts.MONTH_VIEW, BookerLayouts.WEEK_VIEW, BookerLayouts.COLUMN_VIEW], + defaultLayout: BookerLayouts.MONTH_VIEW, + } as BookerLayoutSettings, + }, + }, + }); + + const bookerLayouts = methods.watch("metadata.bookerLayouts"); + + return ( + +
+
+

Current State:

+
+

+ Enabled layouts:{" "} + {bookerLayouts?.enabledLayouts?.join(", ") || "None"} +

+

Default layout: {bookerLayouts?.defaultLayout || "None"}

+
+
+ +
+
+ ); + }; + + return ; + }, +}; diff --git a/packages/features/settings/TimezoneChangeDialog.stories.tsx b/packages/features/settings/TimezoneChangeDialog.stories.tsx new file mode 100644 index 00000000000000..f9d2fee33daba9 --- /dev/null +++ b/packages/features/settings/TimezoneChangeDialog.stories.tsx @@ -0,0 +1,365 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createTRPCReact } from "@trpc/react-query"; +import { SessionProvider } from "next-auth/react"; +import { useState } from "react"; + +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog"; +import { Button } from "@calcom/ui/components/button"; + +// Mock the useLocale hook +jest.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string, options?: any) => { + const translations: Record = { + update_timezone_question: "Update timezone?", + update_timezone_description: `Your current timezone is ${options?.formattedCurrentTz || "America/New_York"}. Do you want to update your timezone?`, + dont_update: "Don't update", + update_timezone: "Update timezone", + we_wont_show_again: "We won't show this again", + updated_timezone_to: `Timezone updated to ${options?.formattedCurrentTz || "America/New_York"}`, + couldnt_update_timezone: "Couldn't update timezone", + }; + return translations[key] || key; + }, + isLocaleReady: true, + i18n: { + language: "en", + }, + }), +})); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +// Mock tRPC client +const mockTrpc = createTRPCReact(); + +const createMockTrpcClient = (overrides?: any) => { + return mockTrpc.createClient({ + links: [ + () => + ({ op }) => { + return { + subscribe: (observer: any) => { + if (op.path === "viewer.me.get") { + observer.next({ + result: { + data: { + timeZone: "America/Los_Angeles", + id: 1, + name: "Test User", + email: "test@example.com", + ...overrides?.user, + }, + }, + }); + } else if (op.path === "viewer.me.updateProfile") { + observer.next({ + result: { + data: { + success: true, + }, + }, + }); + } else { + observer.next({ + result: { + data: {}, + }, + }); + } + observer.complete(); + return { + unsubscribe: () => {}, + }; + }, + } as any; + }, + ], + }); +}; + +const mockSession = { + user: { + id: 1, + name: "Test User", + email: "test@example.com", + }, + expires: "2099-12-31", +}; + +// Internal component for rendering the dialog content +const TimezoneChangeDialogContent = () => { + const t = (key: string, options?: any) => { + const translations: Record = { + update_timezone_question: "Update timezone?", + update_timezone_description: `Your current timezone is ${options?.formattedCurrentTz || "America/New_York"}. Do you want to update your timezone?`, + dont_update: "Don't update", + update_timezone: "Update timezone", + we_wont_show_again: "We won't show this again", + }; + return translations[key] || key; + }; + + const formattedCurrentTz = "America/New_York"; + + return ( + <> + +
+ + {}} color="secondary"> + {t("dont_update")} + + {}} color="primary"> + {t("update_timezone")} + + + + ); +}; + +const meta = { + component: TimezoneChangeDialogContent, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => { + const mockTrpcClient = createMockTrpcClient(); + return ( + + + + + + + + ); + }, + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [open, setOpen] = useState(true); + + return ( +
+ + + + + + +
+ ); + }, +}; + +export const Closed: Story = { + render: () => { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + + +
+ ); + }, +}; + +export const WithDifferentTimezone: Story = { + render: () => { + const [open, setOpen] = useState(true); + + const CustomDialogContent = () => { + const t = (key: string, options?: any) => { + const translations: Record = { + update_timezone_question: "Update timezone?", + update_timezone_description: `Your current timezone is ${options?.formattedCurrentTz || "Europe/London"}. Do you want to update your timezone?`, + dont_update: "Don't update", + update_timezone: "Update timezone", + }; + return translations[key] || key; + }; + + const formattedCurrentTz = "Europe/London"; + + return ( + <> + +
+ + {}} color="secondary"> + {t("dont_update")} + + {}} color="primary"> + {t("update_timezone")} + + + + ); + }; + + return ( +
+ + + + + + +
+ ); + }, +}; + +export const WithAsianTimezone: Story = { + render: () => { + const [open, setOpen] = useState(true); + + const CustomDialogContent = () => { + const t = (key: string, options?: any) => { + const translations: Record = { + update_timezone_question: "Update timezone?", + update_timezone_description: `Your current timezone is ${options?.formattedCurrentTz || "Asia/Tokyo"}. Do you want to update your timezone?`, + dont_update: "Don't update", + update_timezone: "Update timezone", + }; + return translations[key] || key; + }; + + const formattedCurrentTz = "Asia/Tokyo"; + + return ( + <> + +
+ + {}} color="secondary"> + {t("dont_update")} + + {}} color="primary"> + {t("update_timezone")} + + + + ); + }; + + return ( +
+ + + + + + +
+ ); + }, +}; + +export const InteractionExample: Story = { + render: () => { + const [open, setOpen] = useState(true); + const [timezoneUpdated, setTimezoneUpdated] = useState(false); + + const InteractiveDialogContent = () => { + const t = (key: string, options?: any) => { + const translations: Record = { + update_timezone_question: "Update timezone?", + update_timezone_description: `Your current timezone is ${options?.formattedCurrentTz || "America/Chicago"}. Do you want to update your timezone?`, + dont_update: "Don't update", + update_timezone: "Update timezone", + }; + return translations[key] || key; + }; + + const formattedCurrentTz = "America/Chicago"; + + return ( + <> + +
+ + { + setOpen(false); + }} + color="secondary"> + {t("dont_update")} + + { + setTimezoneUpdated(true); + setOpen(false); + }} + color="primary"> + {t("update_timezone")} + + + + ); + }; + + return ( +
+
+ {timezoneUpdated ? ( +

Timezone updated successfully!

+ ) : ( +

Timezone not updated yet

+ )} +
+ + + + + + +
+ ); + }, +}; diff --git a/packages/features/settings/appDir/SettingsHeader.stories.tsx b/packages/features/settings/appDir/SettingsHeader.stories.tsx new file mode 100644 index 00000000000000..db606aeef1f4b9 --- /dev/null +++ b/packages/features/settings/appDir/SettingsHeader.stories.tsx @@ -0,0 +1,175 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { Button } from "@calcom/ui/components/button"; + +import Header from "./SettingsHeader"; + +const meta = { + component: Header, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Profile Settings", + description: "Manage your profile information and preferences", + children:
Content goes here
, + }, +}; + +export const WithBackButton: Story = { + args: { + title: "Edit Profile", + description: "Update your personal information", + backButton: true, + onBackButtonClick: () => console.log("Back button clicked"), + children:
Edit form content
, + }, +}; + +export const WithCTA: Story = { + args: { + title: "Billing Settings", + description: "Manage your subscription and payment methods", + CTA: ( + + ), + children:
Billing details
, + }, +}; + +export const WithBackButtonAndCTA: Story = { + args: { + title: "Team Settings", + description: "Configure team members and permissions", + backButton: true, + onBackButtonClick: () => console.log("Back button clicked"), + CTA: ( + + ), + children:
Team management content
, + }, +}; + +export const WithBorderInShellHeader: Story = { + args: { + title: "General Settings", + description: "Configure your general preferences", + borderInShellHeader: true, + children:
General settings content
, + }, +}; + +export const WithBorderAndCTA: Story = { + args: { + title: "API Keys", + description: "Manage your API keys and integrations", + borderInShellHeader: true, + CTA: ( + + ), + children:
API keys list
, + }, +}; + +export const WithCustomCTAClassName: Story = { + args: { + title: "Notification Settings", + description: "Control how you receive notifications", + CTA: ( +
+ + +
+ ), + ctaClassName: "flex gap-2", + children:
Notification preferences
, + }, +}; + +export const Loading: Story = { + args: { + children:
Content that loads after suspension
, + }, +}; + +export const LongDescription: Story = { + args: { + title: "Security Settings", + description: + "Configure your security preferences including two-factor authentication, password requirements, and session management to keep your account secure", + CTA: , + children:
Security settings form
, + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+

Default

+
+
Content area
+
+
+ +
+

With Back Button

+
console.log("Back")}> +
Content area
+
+
+ +
+

With CTA Button

+
Upgrade}> +
Content area
+
+
+ +
+

With Border

+
Save}> +
Content area
+
+
+
+ ), + parameters: { + layout: "padded", + }, +}; diff --git a/packages/features/settings/appDir/SettingsHeaderWithBackButton.stories.tsx b/packages/features/settings/appDir/SettingsHeaderWithBackButton.stories.tsx new file mode 100644 index 00000000000000..3a2bebcefb250a --- /dev/null +++ b/packages/features/settings/appDir/SettingsHeaderWithBackButton.stories.tsx @@ -0,0 +1,190 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { Button } from "@calcom/ui/components/button"; + +import SettingsHeaderWithBackButton from "./SettingsHeaderWithBackButton"; + +const meta = { + component: SettingsHeaderWithBackButton, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Edit Profile", + description: "Update your personal information", + children:
Content goes here
, + }, +}; + +export const WithCTA: Story = { + args: { + title: "Edit Team Settings", + description: "Configure team members and permissions", + CTA: ( + + ), + children:
Team settings form
, + }, +}; + +export const WithMultipleCTAButtons: Story = { + args: { + title: "Billing Configuration", + description: "Update your payment methods and subscription", + CTA: ( +
+ + +
+ ), + ctaClassName: "flex gap-2", + children:
Billing form content
, + }, +}; + +export const WithBorderInShellHeader: Story = { + args: { + title: "General Settings", + description: "Configure your general preferences", + borderInShellHeader: true, + children:
General settings content
, + }, +}; + +export const WithBorderAndCTA: Story = { + args: { + title: "API Configuration", + description: "Manage your API keys and integrations", + borderInShellHeader: true, + CTA: ( + + ), + children:
API keys list
, + }, +}; + +export const WithIconCTA: Story = { + args: { + title: "Add Team Member", + description: "Invite a new member to your team", + CTA: ( + + ), + children:
Add member form
, + }, +}; + +export const WithCustomCTAClassName: Story = { + args: { + title: "Notification Preferences", + description: "Control how you receive notifications", + CTA: ( +
+ + +
+ ), + ctaClassName: "flex gap-2", + children:
Notification settings content
, + }, +}; + +export const LongDescription: Story = { + args: { + title: "Security Settings", + description: + "Configure your security preferences including two-factor authentication, password requirements, and session management to keep your account secure", + CTA: , + children:
Security settings form
, + }, +}; + +export const MinimalContent: Story = { + args: { + title: "Quick Edit", + description: "Make quick changes", + children:
Minimal content area
, + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+

Default

+ +
Content area
+
+
+ +
+

With CTA Button

+ Save}> +
Content area
+
+
+ +
+

With Border

+ Generate}> +
Content area
+
+
+ +
+

With Multiple CTAs

+ + + +
+ }> +
Content area
+ +
+
+ ), + parameters: { + layout: "padded", + }, +}; diff --git a/packages/features/troubleshooter/components/EventTypeSelect.stories.tsx b/packages/features/troubleshooter/components/EventTypeSelect.stories.tsx new file mode 100644 index 00000000000000..7ce985a25209ce --- /dev/null +++ b/packages/features/troubleshooter/components/EventTypeSelect.stories.tsx @@ -0,0 +1,321 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import { EventTypeSelect } from "./EventTypeSelect"; + +/** + * Mock event types data for stories + */ +const mockEventTypes = [ + { + id: 1, + title: "15 Min Meeting", + slug: "15min", + length: 15, + team: null, + }, + { + id: 2, + title: "30 Min Meeting", + slug: "30min", + length: 30, + team: null, + }, + { + id: 3, + title: "60 Min Meeting", + slug: "60min", + length: 60, + team: null, + }, + { + id: 4, + title: "Discovery Call", + slug: "discovery-call", + length: 45, + team: null, + }, + { + id: 5, + title: "Team Standup", + slug: "team-standup", + length: 15, + team: { id: 1, name: "Engineering Team" }, + }, + { + id: 6, + title: "Team Planning Session", + slug: "team-planning", + length: 90, + team: { id: 1, name: "Engineering Team" }, + }, +]; + +/** + * EventTypeSelect allows users to select from their available event types. + * It integrates with the troubleshooter store and displays event types with their durations. + * + * This component: + * - Fetches event types via tRPC + * - Syncs selection with the troubleshooter store + * - Supports initialization from URL query parameters + * - Displays both personal and team event types + */ +const meta = { + component: EventTypeSelect, + parameters: { + layout: "centered", + mockData: [ + { + url: "/api/trpc/viewer.eventTypes.listWithTeam", + method: "GET", + status: 200, + response: { + result: { + data: mockEventTypes, + }, + }, + }, + ], + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default state showing the event type selector with multiple options. + */ +export const Default: Story = {}; + +/** + * Shows the component in a loading state while fetching event types. + */ +export const Loading: Story = { + parameters: { + mockData: [ + { + url: "/api/trpc/viewer.eventTypes.listWithTeam", + method: "GET", + delay: 999999, // Simulate infinite loading + }, + ], + }, +}; + +/** + * Shows the component when there are no event types available. + * The select is disabled in this state. + */ +export const NoEventTypes: Story = { + parameters: { + mockData: [ + { + url: "/api/trpc/viewer.eventTypes.listWithTeam", + method: "GET", + status: 200, + response: { + result: { + data: [], + }, + }, + }, + ], + }, +}; + +/** + * Shows a user with only one event type. + */ +export const SingleEventType: Story = { + parameters: { + mockData: [ + { + url: "/api/trpc/viewer.eventTypes.listWithTeam", + method: "GET", + status: 200, + response: { + result: { + data: [mockEventTypes[0]], + }, + }, + }, + ], + }, +}; + +/** + * Shows only personal event types (no team events). + */ +export const PersonalEventTypesOnly: Story = { + parameters: { + mockData: [ + { + url: "/api/trpc/viewer.eventTypes.listWithTeam", + method: "GET", + status: 200, + response: { + result: { + data: mockEventTypes.filter((e) => !e.team), + }, + }, + }, + ], + }, +}; + +/** + * Shows only team event types. + */ +export const TeamEventTypesOnly: Story = { + parameters: { + mockData: [ + { + url: "/api/trpc/viewer.eventTypes.listWithTeam", + method: "GET", + status: 200, + response: { + result: { + data: mockEventTypes.filter((e) => e.team), + }, + }, + }, + ], + }, +}; + +/** + * Shows a large number of event types to test scrolling behavior. + */ +export const ManyEventTypes: Story = { + parameters: { + mockData: [ + { + url: "/api/trpc/viewer.eventTypes.listWithTeam", + method: "GET", + status: 200, + response: { + result: { + data: [ + ...mockEventTypes, + { + id: 7, + title: "Coffee Chat", + slug: "coffee-chat", + length: 30, + team: null, + }, + { + id: 8, + title: "Quick Sync", + slug: "quick-sync", + length: 15, + team: null, + }, + { + id: 9, + title: "Code Review", + slug: "code-review", + length: 45, + team: { id: 1, name: "Engineering Team" }, + }, + { + id: 10, + title: "Product Demo", + slug: "product-demo", + length: 60, + team: { id: 2, name: "Product Team" }, + }, + { + id: 11, + title: "Strategy Meeting", + slug: "strategy-meeting", + length: 120, + team: { id: 3, name: "Leadership Team" }, + }, + { + id: 12, + title: "1-on-1", + slug: "one-on-one", + length: 30, + team: null, + }, + ], + }, + }, + }, + ], + }, +}; + +/** + * Shows event types with varying durations from short to long. + */ +export const VariedDurations: Story = { + parameters: { + mockData: [ + { + url: "/api/trpc/viewer.eventTypes.listWithTeam", + method: "GET", + status: 200, + response: { + result: { + data: [ + { + id: 1, + title: "Quick Question", + slug: "quick-question", + length: 10, + team: null, + }, + { + id: 2, + title: "Standard Meeting", + slug: "standard-meeting", + length: 30, + team: null, + }, + { + id: 3, + title: "Deep Dive", + slug: "deep-dive", + length: 90, + team: null, + }, + { + id: 4, + title: "Workshop", + slug: "workshop", + length: 180, + team: null, + }, + ], + }, + }, + }, + ], + }, +}; + +/** + * Example showing the component in a form context with other fields. + */ +export const InFormContext: Story = { + render: () => ( +
+
Schedule a Troubleshooting Session
+ +
+ Select an event type to begin troubleshooting availability issues. +
+
+ ), +}; diff --git a/packages/features/troubleshooter/components/LargeCalendar.stories.tsx b/packages/features/troubleshooter/components/LargeCalendar.stories.tsx new file mode 100644 index 00000000000000..731bb403cebbff --- /dev/null +++ b/packages/features/troubleshooter/components/LargeCalendar.stories.tsx @@ -0,0 +1,620 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import dayjs from "@calcom/dayjs"; +import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state"; +import { BookingStatus } from "@calcom/prisma/enums"; + +import { LargeCalendar } from "./LargeCalendar"; + +// Mock next-auth session +jest.mock("next-auth/react", () => ({ + useSession: () => ({ + data: { + user: { + username: "testuser", + org: { + slug: "test-org", + }, + }, + }, + status: "authenticated", + }), +})); + +// Mock troubleshooter store +const mockTroubleshooterStore = { + selectedDate: dayjs().format("YYYY-MM-DD"), + event: { + id: 1, + slug: "30min", + duration: 30, + teamId: null, + }, + calendarToColorMap: { + "calendar-1": "#FF5733", + "calendar-2": "#33C1FF", + "calendar-3": "#7CFC00", + }, +}; + +jest.mock("../store", () => ({ + useTroubleshooterStore: (selector: any) => { + if (typeof selector === "function") { + return selector(mockTroubleshooterStore); + } + return mockTroubleshooterStore; + }, +})); + +// Mock time preferences +jest.mock("../../bookings/lib/timePreferences", () => ({ + useTimePreferences: () => ({ + timezone: "America/New_York", + }), +})); + +// Mock tRPC +const mockBusyEvents = [ + { + title: "Team Standup", + start: dayjs().hour(9).minute(0).toISOString(), + end: dayjs().hour(9).minute(30).toISOString(), + source: "calendar-1", + }, + { + title: "Client Meeting", + start: dayjs().hour(14).minute(0).toISOString(), + end: dayjs().hour(15).minute(0).toISOString(), + source: "calendar-2", + }, + { + title: "Code Review", + start: dayjs().add(1, "day").hour(10).minute(0).toISOString(), + end: dayjs().add(1, "day").hour(11).minute(0).toISOString(), + source: "calendar-3", + }, +]; + +jest.mock("@calcom/trpc/react", () => ({ + trpc: { + viewer: { + availability: { + user: { + useQuery: () => ({ + data: { + busy: mockBusyEvents, + dateOverrides: [], + workingHours: [ + { + days: [0, 1, 2, 3, 4, 5, 6], + startTime: 540, // 9:00 AM + endTime: 1020, // 5:00 PM + }, + ], + }, + isLoading: false, + }), + }, + }, + }, + }, +})); + +// Mock useSchedule +jest.mock("../../schedules/lib/use-schedule/useSchedule", () => ({ + useSchedule: () => ({ + data: { + slots: {}, + }, + isLoading: false, + }), +})); + +// Helper function to generate available timeslots +const generateAvailableTimeslots = (days: number, startDate: Date): CalendarAvailableTimeslots => { + const slots: CalendarAvailableTimeslots = {}; + + for (let i = 0; i < days; i++) { + const date = dayjs(startDate).add(i, "day"); + const dateKey = date.format("YYYY-MM-DD"); + + // Generate morning slots (9 AM - 12 PM) + const morningSlots = []; + for (let hour = 9; hour < 12; hour++) { + morningSlots.push({ + start: date.hour(hour).minute(0).toDate(), + end: date.hour(hour).minute(30).toDate(), + }); + morningSlots.push({ + start: date.hour(hour).minute(30).toDate(), + end: date.hour(hour + 1).minute(0).toDate(), + }); + } + + // Generate afternoon slots (1 PM - 5 PM) + const afternoonSlots = []; + for (let hour = 13; hour < 17; hour++) { + afternoonSlots.push({ + start: date.hour(hour).minute(0).toDate(), + end: date.hour(hour).minute(30).toDate(), + }); + afternoonSlots.push({ + start: date.hour(hour).minute(30).toDate(), + end: date.hour(hour + 1).minute(0).toDate(), + }); + } + + slots[dateKey] = [...morningSlots, ...afternoonSlots]; + } + + return slots; +}; + +// Mock useAvailableTimeSlots +let mockAvailableSlots: CalendarAvailableTimeslots = generateAvailableTimeslots(7, new Date()); + +jest.mock("@calcom/features/bookings/Booker/components/hooks/useAvailableTimeSlots", () => ({ + useAvailableTimeSlots: () => mockAvailableSlots, +})); + +/** + * LargeCalendar displays a weekly calendar view with busy times, available slots, + * and date overrides for troubleshooting availability issues. + * + * This component: + * - Shows busy events from connected calendars + * - Displays available time slots for booking + * - Highlights date overrides and working hours + * - Supports multiple days view (configurable via extraDays prop) + * - Color-codes events by calendar source + */ +const meta = { + component: LargeCalendar, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + extraDays: { + description: "Number of days to display in the calendar view", + control: { type: "number", min: 1, max: 30 }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default 7-day calendar view showing busy events and available slots + */ +export const Default: Story = { + args: { + extraDays: 7, + }, +}; + +/** + * Single day view - useful for detailed day analysis + */ +export const SingleDay: Story = { + args: { + extraDays: 1, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * Three-day view - compact view for shorter time range + */ +export const ThreeDays: Story = { + args: { + extraDays: 3, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * Two-week view - extended view for longer range planning + */ +export const TwoWeeks: Story = { + args: { + extraDays: 14, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * Full month view (30 days) + */ +export const FullMonth: Story = { + args: { + extraDays: 30, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * Calendar with no busy events - shows only available slots + */ +export const NoBusyEvents: Story = { + args: { + extraDays: 7, + }, + parameters: { + mockData: [ + { + url: "/api/trpc/viewer.availability.user", + method: "GET", + status: 200, + response: { + result: { + data: { + busy: [], + dateOverrides: [], + workingHours: [ + { + days: [0, 1, 2, 3, 4, 5, 6], + startTime: 540, + endTime: 1020, + }, + ], + }, + }, + }, + }, + ], + }, +}; + +/** + * Heavily booked calendar - many busy time slots + */ +export const HeavilyBooked: Story = { + args: { + extraDays: 7, + }, + parameters: { + mockData: [ + { + url: "/api/trpc/viewer.availability.user", + method: "GET", + status: 200, + response: { + result: { + data: { + busy: [ + ...mockBusyEvents, + { + title: "Morning Sync", + start: dayjs().hour(8).minute(0).toISOString(), + end: dayjs().hour(8).minute(30).toISOString(), + source: "calendar-1", + }, + { + title: "Lunch Meeting", + start: dayjs().hour(12).minute(0).toISOString(), + end: dayjs().hour(13).minute(0).toISOString(), + source: "calendar-2", + }, + { + title: "Planning Session", + start: dayjs().hour(16).minute(0).toISOString(), + end: dayjs().hour(17).minute(30).toISOString(), + source: "calendar-3", + }, + { + title: "Weekly Review", + start: dayjs().add(2, "day").hour(9).minute(0).toISOString(), + end: dayjs().add(2, "day").hour(10).minute(30).toISOString(), + source: "calendar-1", + }, + { + title: "Product Demo", + start: dayjs().add(2, "day").hour(14).minute(0).toISOString(), + end: dayjs().add(2, "day").hour(15).minute(30).toISOString(), + source: "calendar-2", + }, + ], + dateOverrides: [], + workingHours: [ + { + days: [0, 1, 2, 3, 4, 5, 6], + startTime: 540, + endTime: 1020, + }, + ], + }, + }, + }, + }, + ], + }, +}; + +/** + * Calendar with limited working hours (10 AM - 2 PM only) + */ +export const LimitedWorkingHours: Story = { + args: { + extraDays: 7, + }, + decorators: [ + (Story) => { + // Mock limited availability slots + mockAvailableSlots = (() => { + const slots: CalendarAvailableTimeslots = {}; + const startDate = new Date(); + + for (let i = 0; i < 7; i++) { + const date = dayjs(startDate).add(i, "day"); + const dateKey = date.format("YYYY-MM-DD"); + const limitedSlots = []; + + // Only 10 AM - 2 PM + for (let hour = 10; hour < 14; hour++) { + limitedSlots.push({ + start: date.hour(hour).minute(0).toDate(), + end: date.hour(hour).minute(30).toDate(), + }); + limitedSlots.push({ + start: date.hour(hour).minute(30).toDate(), + end: date.hour(hour + 1).minute(0).toDate(), + }); + } + + slots[dateKey] = limitedSlots; + } + + return slots; + })(); + + return ( +
+ +
+ ); + }, + ], +}; + +/** + * Weekdays only availability (Monday - Friday) + */ +export const WeekdaysOnly: Story = { + args: { + extraDays: 7, + }, + decorators: [ + (Story) => { + mockAvailableSlots = (() => { + const slots: CalendarAvailableTimeslots = {}; + const startDate = new Date(); + + for (let i = 0; i < 7; i++) { + const date = dayjs(startDate).add(i, "day"); + const dayOfWeek = date.day(); + + // Only weekdays (1-5, Mon-Fri) + if (dayOfWeek >= 1 && dayOfWeek <= 5) { + const dateKey = date.format("YYYY-MM-DD"); + const weekdaySlots = []; + + for (let hour = 9; hour < 17; hour++) { + weekdaySlots.push({ + start: date.hour(hour).minute(0).toDate(), + end: date.hour(hour).minute(30).toDate(), + }); + weekdaySlots.push({ + start: date.hour(hour).minute(30).toDate(), + end: date.hour(hour + 1).minute(0).toDate(), + }); + } + + slots[dateKey] = weekdaySlots; + } + } + + return slots; + })(); + + return ( +
+ +
+ ); + }, + ], +}; + +/** + * Calendar with date overrides + */ +export const WithDateOverrides: Story = { + args: { + extraDays: 7, + }, + parameters: { + mockData: [ + { + url: "/api/trpc/viewer.availability.user", + method: "GET", + status: 200, + response: { + result: { + data: { + busy: mockBusyEvents, + dateOverrides: [ + { + start: dayjs().add(3, "day").toISOString(), + end: dayjs().add(3, "day").toISOString(), + }, + ], + workingHours: [ + { + days: [0, 1, 2, 3, 4, 5, 6], + startTime: 540, + endTime: 1020, + }, + ], + }, + }, + }, + }, + ], + }, +}; + +/** + * Early morning availability (6 AM - 12 PM) + */ +export const EarlyMorning: Story = { + args: { + extraDays: 7, + }, + decorators: [ + (Story) => { + mockAvailableSlots = (() => { + const slots: CalendarAvailableTimeslots = {}; + const startDate = new Date(); + + for (let i = 0; i < 7; i++) { + const date = dayjs(startDate).add(i, "day"); + const dateKey = date.format("YYYY-MM-DD"); + const morningSlots = []; + + for (let hour = 6; hour < 12; hour++) { + morningSlots.push({ + start: date.hour(hour).minute(0).toDate(), + end: date.hour(hour).minute(30).toDate(), + }); + morningSlots.push({ + start: date.hour(hour).minute(30).toDate(), + end: date.hour(hour + 1).minute(0).toDate(), + }); + } + + slots[dateKey] = morningSlots; + } + + return slots; + })(); + + return ( +
+ +
+ ); + }, + ], +}; + +/** + * Evening availability (2 PM - 10 PM) + */ +export const Evening: Story = { + args: { + extraDays: 7, + }, + decorators: [ + (Story) => { + mockAvailableSlots = (() => { + const slots: CalendarAvailableTimeslots = {}; + const startDate = new Date(); + + for (let i = 0; i < 7; i++) { + const date = dayjs(startDate).add(i, "day"); + const dateKey = date.format("YYYY-MM-DD"); + const eveningSlots = []; + + for (let hour = 14; hour < 22; hour++) { + eveningSlots.push({ + start: date.hour(hour).minute(0).toDate(), + end: date.hour(hour).minute(30).toDate(), + }); + eveningSlots.push({ + start: date.hour(hour).minute(30).toDate(), + end: date.hour(hour + 1).minute(0).toDate(), + }); + } + + slots[dateKey] = eveningSlots; + } + + return slots; + })(); + + return ( +
+ +
+ ); + }, + ], +}; + +/** + * Comparison view showing multiple calendar scenarios + */ +export const AllVariants: Story = { + render: () => ( +
+
+

Default - 7 Days

+
+ +
+
+ +
+

Single Day View

+
+ +
+
+ +
+

Two Week View

+
+ +
+
+
+ ), + parameters: { + layout: "fullscreen", + }, + decorators: [], +}; diff --git a/packages/features/troubleshooter/components/TroubleshooterHeader.stories.tsx b/packages/features/troubleshooter/components/TroubleshooterHeader.stories.tsx new file mode 100644 index 00000000000000..e868701e08a2e7 --- /dev/null +++ b/packages/features/troubleshooter/components/TroubleshooterHeader.stories.tsx @@ -0,0 +1,219 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { fn } from "storybook/test"; + +import dayjs from "@calcom/dayjs"; + +import { TroubleshooterHeader } from "./TroubleshooterHeader"; + +// Mock the useLocale hook +const mockUseLocale = () => ({ + t: (key: string) => { + const translations: Record = { + today: "Today", + }; + return translations[key] || key; + }, + i18n: { + language: "en", + }, +}); + +// Mock the store with default values +const createMockStore = (selectedDate: string) => (selector: any) => { + const state = { + selectedDate, + setSelectedDate: fn((date: string) => console.log("setSelectedDate", date)), + addToSelectedDate: fn((days: number) => console.log("addToSelectedDate", days)), + }; + return selector(state); +}; + +// Mock modules +jest.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: mockUseLocale, +})); + +jest.mock("../store", () => ({ + useTroubleshooterStore: createMockStore(dayjs().format("YYYY-MM-DD")), +})); + +const meta = { + component: TroubleshooterHeader, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + extraDays: { + control: "number", + description: "Number of days to show in the date range", + }, + isMobile: { + control: "boolean", + description: "Whether the component is displayed on mobile (returns null when true)", + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + extraDays: 7, + isMobile: false, + }, +}; + +export const SingleDay: Story = { + args: { + extraDays: 1, + isMobile: false, + }, +}; + +export const ThreeDays: Story = { + args: { + extraDays: 3, + isMobile: false, + }, +}; + +export const TwoWeeks: Story = { + args: { + extraDays: 14, + isMobile: false, + }, +}; + +export const Month: Story = { + args: { + extraDays: 30, + isMobile: false, + }, +}; + +export const Mobile: Story = { + args: { + extraDays: 7, + isMobile: true, + }, + parameters: { + docs: { + description: { + story: "On mobile, the component returns null and doesn't render anything.", + }, + }, + }, +}; + +export const CrossMonthRange: Story = { + args: { + extraDays: 14, + isMobile: false, + }, + parameters: { + docs: { + description: { + story: + "When the date range spans across different months, both month names are displayed.", + }, + }, + }, + render: (args) => { + // Temporarily override the mock for this story + const originalMock = require("../store").useTroubleshooterStore; + const endOfMonthDate = dayjs().endOf("month").subtract(5, "days").format("YYYY-MM-DD"); + + jest.mock("../store", () => ({ + useTroubleshooterStore: createMockStore(endOfMonthDate), + })); + + return ; + }, +}; + +export const WithTodayButton: Story = { + args: { + extraDays: 7, + isMobile: false, + }, + parameters: { + docs: { + description: { + story: + "When the selected date is more than 3 days away from today, a 'Today' button appears to quickly jump back to the current date.", + }, + }, + }, + render: (args) => { + // Temporarily override the mock for this story + const pastDate = dayjs().subtract(10, "days").format("YYYY-MM-DD"); + + jest.mock("../store", () => ({ + useTroubleshooterStore: createMockStore(pastDate), + })); + + return ; + }, +}; + +export const InteractiveNavigation: Story = { + args: { + extraDays: 7, + isMobile: false, + }, + parameters: { + docs: { + description: { + story: + "Use the chevron buttons to navigate between date ranges. The navigation buttons will add or subtract the number of extraDays.", + }, + }, + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+

Default - 7 Days

+
+ +
+
+ +
+

Single Day

+
+ +
+
+ +
+

Two Weeks

+
+ +
+
+ +
+

Month

+
+ +
+
+
+ ), + parameters: { + layout: "fullscreen", + }, + decorators: [], +}; diff --git a/packages/features/troubleshooter/components/TroubleshooterListItemContainer.stories.tsx b/packages/features/troubleshooter/components/TroubleshooterListItemContainer.stories.tsx new file mode 100644 index 00000000000000..45a336c89ca8ac --- /dev/null +++ b/packages/features/troubleshooter/components/TroubleshooterListItemContainer.stories.tsx @@ -0,0 +1,299 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; + +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { Icon } from "@calcom/ui/components/icon"; + +import { TroubleshooterListItemContainer, TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer"; + +const meta = { + component: TroubleshooterListItemContainer, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + title: { + description: "The main title text", + control: { type: "text" }, + }, + subtitle: { + description: "Optional subtitle text", + control: { type: "text" }, + }, + prefixSlot: { + description: "Content to display before the title", + control: false, + }, + suffixSlot: { + description: "Content to display after the title", + control: false, + }, + className: { + description: "Additional CSS classes", + control: { type: "text" }, + }, + children: { + description: "Content to display in the container body", + control: false, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Default Item", + children:

This is the content area of the troubleshooter item.

, + }, +}; + +export const WithSubtitle: Story = { + args: { + title: "Item with Subtitle", + subtitle: "Additional information here", + children: ( +
+

Main content with subtitle example.

+

This shows how subtitles work in the header.

+
+ ), + }, +}; + +export const WithPrefixIcon: Story = { + args: { + title: "Calendar Integration", + subtitle: "Connected", + prefixSlot:
, + children: ( +
+

Calendar integration settings and options.

+

Sync your events across all calendars.

+
+ ), + }, +}; + +export const WithBadge: Story = { + args: { + title: "Google Calendar", + subtitle: "google@calendar.com", + prefixSlot:
, + suffixSlot: ( + + Connected + + ), + children: ( +
+

Your Google Calendar is successfully connected.

+

Last synced: 2 minutes ago

+
+ ), + }, +}; + +export const WithActionButton: Story = { + args: { + title: "Availability Schedule", + subtitle: "Working Hours", + prefixSlot:
, + suffixSlot: ( + + ), + children: ( +
+

Monday - Friday: 9:00 AM - 5:00 PM

+

Configure your availability settings

+
+ ), + }, +}; + +export const WithEditBadge: Story = { + args: { + title: "Event Schedule", + prefixSlot:
, + suffixSlot: ( + + Edit + + ), + className: "group", + children: ( +
+

Customize your event scheduling options.

+

Click edit to modify settings

+
+ ), + }, +}; + +export const DisconnectedState: Story = { + args: { + title: "Outlook Calendar", + subtitle: "outlook@email.com", + prefixSlot:
, + suffixSlot: ( + + Disconnected + + ), + children: ( +
+

Your Outlook Calendar connection has been lost.

+ +
+ ), + }, +}; + +export const WithIconPrefix: Story = { + args: { + title: "Notifications", + subtitle: "Email & SMS alerts", + prefixSlot: , + suffixSlot: ( + + Enabled + + ), + children: ( +
+

Manage your notification preferences.

+
+ +
+
+ ), + }, +}; + +export const MultipleItems: Story = { + render: () => ( +
+ } + suffixSlot={ + + Connected + + }> +

Calendar synced successfully.

+
+ + } + suffixSlot={ + + Connected + + }> +

Calendar synced successfully.

+
+ + } + suffixSlot={ + + Disconnected + + }> +

Connection failed. Please reconnect.

+
+
+ ), +}; + +export const HeaderOnly: Story = { + render: () => ( +
+ } + suffixSlot={ + + Connected + + } + /> + } + suffixSlot={ + + Connected + + } + /> + } + suffixSlot={ + + Disconnected + + } + className="border-b" + /> +
+ ), +}; + +export const RichContent: Story = { + args: { + title: "Advanced Configuration", + subtitle: "System settings", + prefixSlot: , + suffixSlot: ( + + Active + + ), + children: ( +
+
+

Current Settings

+
    +
  • - Timezone: UTC-8
  • +
  • - Buffer time: 15 minutes
  • +
  • - Max bookings per day: 8
  • +
+
+
+ + +
+
+ ), + }, +}; diff --git a/packages/ui/components/address/Fields.stories.tsx b/packages/ui/components/address/Fields.stories.tsx new file mode 100644 index 00000000000000..c4a708396128d6 --- /dev/null +++ b/packages/ui/components/address/Fields.stories.tsx @@ -0,0 +1,315 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { useForm } from "react-hook-form"; + +import { + Input, + Label, + InputLeading, + TextField, + PasswordField, + EmailInput, + EmailField, + TextArea, + TextAreaField, + Form, + FieldsetLegend, + InputGroupBox, +} from "./Fields"; + +const meta = { + title: "Address/Fields", + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; + +// Input Component Stories +export const BasicInput: StoryObj = { + name: "Basic Input", + render: () => , +}; + +export const InputWithValue: StoryObj = { + name: "Input with Value", + render: () => , +}; + +export const DisabledInput: StoryObj = { + name: "Disabled Input", + render: () => , +}; + +// Label Component Stories +export const BasicLabel: StoryObj = { + name: "Basic Label", + render: () => , +}; + +export const LabelWithInput: StoryObj = { + name: "Label with Input", + render: () => ( +
+ + +
+ ), +}; + +// InputLeading Component Stories +export const InputLeadingExample: StoryObj = { + name: "Input Leading", + render: () => ( +
+ https:// + +
+ ), +}; + +// TextField Component Stories +export const BasicTextField: StoryObj = { + name: "Basic TextField", + render: () => { + const methods = useForm(); + return ( +
{}}> + + + ); + }, +}; + +export const TextFieldWithAddOnLeading: StoryObj = { + name: "TextField with Leading Add-on", + render: () => { + const methods = useForm(); + return ( +
{}}> + https://} + placeholder="example.com" + /> + + ); + }, +}; + +export const TextFieldWithHint: StoryObj = { + name: "TextField with Hint", + render: () => { + const methods = useForm(); + return ( +
{}}> + This will be part of your event URL

} + /> + + ); + }, +}; + +export const TextFieldWithError: StoryObj = { + name: "TextField with Error", + render: () => { + const methods = useForm(); + methods.setError("errorField", { + type: "manual", + message: "This field is required", + }); + return ( +
{}}> + + + ); + }, +}; + +// PasswordField Component Stories +export const BasicPasswordField: StoryObj = { + name: "Basic Password Field", + render: () => { + const methods = useForm(); + return ( +
{}}> + + + ); + }, +}; + +// EmailInput Component Stories +export const BasicEmailInput: StoryObj = { + name: "Basic Email Input", + render: () => , +}; + +// EmailField Component Stories +export const BasicEmailField: StoryObj = { + name: "Basic Email Field", + render: () => { + const methods = useForm(); + return ( +
{}}> + + + ); + }, +}; + +// TextArea Component Stories +export const BasicTextArea: StoryObj = { + name: "Basic TextArea", + render: () =>