mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-24 07:00:34 -06:00
Compare commits
12 Commits
release/v3
...
chore/tail
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d31095eef6 | ||
|
|
91f26d9974 | ||
|
|
09ff2ee25e | ||
|
|
264b3cf9e3 | ||
|
|
e28cdc0696 | ||
|
|
99c1e434df | ||
|
|
b13699801b | ||
|
|
ceb2e85d96 | ||
|
|
c5f8b5ec32 | ||
|
|
bdbd57c2fc | ||
|
|
d44aa17814 | ||
|
|
23d38b4c5b |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description:
|
||||
description: Migrate deprecated UI components to a unified component
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
177
.cursor/rules/storybook-create-new-story.mdc
Normal file
177
.cursor/rules/storybook-create-new-story.mdc
Normal file
@@ -0,0 +1,177 @@
|
||||
---
|
||||
description: Create a story in Storybook for a given component
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Formbricks Storybook Stories
|
||||
|
||||
## When generating Storybook stories for Formbricks components:
|
||||
|
||||
### 1. **File Structure**
|
||||
- Create `stories.tsx` (not `.stories.tsx`) in component directory
|
||||
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
|
||||
- Import component from `"./index"`
|
||||
|
||||
### 2. **Story Structure Template**
|
||||
```tsx
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ComponentName } from "./index";
|
||||
|
||||
// For complex components with configurable options
|
||||
// consider this as an example the options need to reflect the props types
|
||||
interface StoryOptions {
|
||||
showIcon: boolean;
|
||||
numberOfElements: number;
|
||||
customLabels: string[];
|
||||
}
|
||||
|
||||
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/ComponentName",
|
||||
component: ComponentName,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component: "The **ComponentName** component provides [description].",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
// Organize in exactly these categories: Behavior, Appearance, Content
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
|
||||
```
|
||||
|
||||
### 3. **ArgTypes Organization**
|
||||
Organize ALL argTypes into exactly three categories:
|
||||
- **Behavior**: disabled, variant, onChange, etc.
|
||||
- **Appearance**: size, color, layout, styling, etc.
|
||||
- **Content**: text, icons, numberOfElements, etc.
|
||||
|
||||
Format:
|
||||
```tsx
|
||||
argTypes: {
|
||||
propName: {
|
||||
control: "select" | "boolean" | "text" | "number",
|
||||
options: ["option1", "option2"], // for select
|
||||
description: "Clear description",
|
||||
table: {
|
||||
category: "Behavior" | "Appearance" | "Content",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **Required Stories**
|
||||
Every component must include:
|
||||
- `Default`: Most common use case
|
||||
- `Disabled`: If component supports disabled state
|
||||
- `WithIcon`: If component supports icons
|
||||
- Variant stories for each variant (Primary, Secondary, Error, etc.)
|
||||
- Edge case stories (ManyElements, LongText, CustomStyling)
|
||||
|
||||
### 5. **Story Format**
|
||||
```tsx
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
// Props with realistic values
|
||||
},
|
||||
};
|
||||
|
||||
export const EdgeCase: Story = {
|
||||
args: { /* ... */ },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this when [specific scenario].",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 6. **Dynamic Content Pattern**
|
||||
For components with dynamic content, create render function:
|
||||
```tsx
|
||||
const renderComponent = (args: StoryProps) => {
|
||||
const { numberOfElements, showIcon, customLabels } = args;
|
||||
|
||||
// Generate dynamic content
|
||||
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
|
||||
id: `element-${i}`,
|
||||
label: customLabels[i] || `Element ${i + 1}`,
|
||||
icon: showIcon ? <IconComponent /> : undefined,
|
||||
}));
|
||||
|
||||
return <ComponentName {...args} elements={elements} />;
|
||||
};
|
||||
|
||||
export const Dynamic: Story = {
|
||||
render: renderComponent,
|
||||
args: {
|
||||
numberOfElements: 3,
|
||||
showIcon: true,
|
||||
customLabels: ["First", "Second", "Third"],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 7. **State Management**
|
||||
For interactive components:
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
|
||||
const ComponentWithState = (args: any) => {
|
||||
const [value, setValue] = useState(args.defaultValue);
|
||||
|
||||
return (
|
||||
<ComponentName
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue);
|
||||
args.onChange?.(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: ComponentWithState,
|
||||
args: { defaultValue: "initial" },
|
||||
};
|
||||
```
|
||||
|
||||
### 8. **Quality Requirements**
|
||||
- Include component description in parameters.docs
|
||||
- Add story documentation for non-obvious use cases
|
||||
- Test edge cases (overflow, empty states, many elements)
|
||||
- Ensure no TypeScript errors
|
||||
- Use realistic prop values
|
||||
- Include at least 3-5 story variants
|
||||
- Example values need to be in the context of survey application
|
||||
|
||||
### 9. **Naming Conventions**
|
||||
- **Story titles**: "UI/ComponentName"
|
||||
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
|
||||
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
|
||||
- **Props**: camelCase matching component props
|
||||
|
||||
### 10. **Special Cases**
|
||||
- **Generic components**: Remove `component` from meta if type conflicts
|
||||
- **Form components**: Include Invalid, WithValue stories
|
||||
- **Navigation**: Include ManyItems stories
|
||||
- **Modals, Dropdowns and Popups **: Include trigger and content structure
|
||||
|
||||
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.
|
||||
@@ -11,6 +11,10 @@ inputs:
|
||||
sentry_auth_token:
|
||||
description: 'Sentry authentication token'
|
||||
required: true
|
||||
environment:
|
||||
description: 'Sentry environment (e.g., production, staging)'
|
||||
required: false
|
||||
default: 'staging'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
@@ -107,7 +111,7 @@ runs:
|
||||
SENTRY_ORG: formbricks
|
||||
SENTRY_PROJECT: formbricks-cloud
|
||||
with:
|
||||
environment: production
|
||||
environment: ${{ inputs.environment }}
|
||||
version: ${{ inputs.release_version }}
|
||||
sourcemaps: './extracted-next/'
|
||||
|
||||
|
||||
17
.github/workflows/deploy-formbricks-cloud.yml
vendored
17
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -17,8 +17,8 @@ on:
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- stage
|
||||
- prod
|
||||
- staging
|
||||
- production
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:github
|
||||
args: --exit-node=100.75.161.87
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
@@ -66,8 +67,8 @@ jobs:
|
||||
AWS_REGION: eu-central-1
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Prod
|
||||
if: inputs.ENVIRONMENT == 'prod'
|
||||
name: Deploy Formbricks Cloud Production
|
||||
if: inputs.ENVIRONMENT == 'production'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -84,8 +85,8 @@ jobs:
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Stage
|
||||
if: inputs.ENVIRONMENT == 'stage'
|
||||
name: Deploy Formbricks Cloud Staging
|
||||
if: inputs.ENVIRONMENT == 'staging'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -101,13 +102,13 @@ jobs:
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- name: Purge Cloudflare Cache
|
||||
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
|
||||
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
|
||||
env:
|
||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
# Set hostname based on environment
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
|
||||
PURGE_HOST="app.formbricks.com"
|
||||
else
|
||||
PURGE_HOST="stage.app.formbricks.com"
|
||||
|
||||
20
.github/workflows/formbricks-release.yml
vendored
20
.github/workflows/formbricks-release.yml
vendored
@@ -1,17 +1,22 @@
|
||||
name: Build, release & deploy Formbricks images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
name: Build & release stable docker image
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
name: Build & release docker image
|
||||
uses: ./.github/workflows/release-docker-github.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
|
||||
helm-chart-release:
|
||||
name: Release Helm Chart
|
||||
@@ -31,7 +36,7 @@ jobs:
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
ENVIRONMENT: ${{ env.ENVIRONMENT }}
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
@@ -54,3 +59,4 @@ jobs:
|
||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
environment: ${{ env.ENVIRONMENT }}
|
||||
|
||||
@@ -29,6 +29,10 @@ jobs:
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
outputs:
|
||||
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
|
||||
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
@@ -38,6 +42,56 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Generate SemVer version from branch or tag
|
||||
id: generate_version
|
||||
run: |
|
||||
# Get reference name and type
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
REF_TYPE="${{ github.ref_type }}"
|
||||
|
||||
echo "Reference type: $REF_TYPE"
|
||||
echo "Reference name: $REF_NAME"
|
||||
|
||||
if [[ "$REF_TYPE" == "tag" ]]; then
|
||||
# If running from a tag, use the tag name
|
||||
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
||||
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
|
||||
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
||||
echo "Using SemVer tag: $VERSION"
|
||||
else
|
||||
# Tag is not SemVer, treat as prerelease
|
||||
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-$SANITIZED_TAG"
|
||||
echo "Using tag as prerelease: $VERSION"
|
||||
fi
|
||||
else
|
||||
# Running from branch, use branch name as prerelease
|
||||
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-$SANITIZED_BRANCH"
|
||||
echo "Using branch as prerelease: $VERSION"
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Generated SemVer version: $VERSION"
|
||||
|
||||
- name: Update package.json version
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
cd ./apps/web
|
||||
npm version $VERSION --no-git-tag-version
|
||||
echo "Updated version to: $(npm pkg get version)"
|
||||
|
||||
- name: Set Sentry environment in .env
|
||||
run: |
|
||||
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
|
||||
echo "SENTRY_ENVIRONMENT=staging" >> .env
|
||||
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
|
||||
else
|
||||
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
|
||||
fi
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
@@ -83,6 +137,21 @@ jobs:
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Extract image info for sourcemap upload
|
||||
id: extract_image_info
|
||||
run: |
|
||||
# Use the first readable tag from metadata action output
|
||||
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
|
||||
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
|
||||
|
||||
# Use the generated version for Sentry release
|
||||
RELEASE_VERSION="$VERSION"
|
||||
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "Docker image: $DOCKER_IMAGE"
|
||||
echo "Release version: $RELEASE_VERSION"
|
||||
echo "Available tags: ${{ steps.meta.outputs.tags }}"
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
@@ -97,3 +166,25 @@ jobs:
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Upload Sentry Sourcemaps
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }}
|
||||
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
environment: staging
|
||||
|
||||
15
.github/workflows/release-docker-github.yml
vendored
15
.github/workflows/release-docker-github.yml
vendored
@@ -7,6 +7,12 @@ name: Docker Release to Github
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
IS_PRERELEASE:
|
||||
description: 'Whether this is a prerelease (affects latest tag)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
VERSION:
|
||||
description: release version
|
||||
@@ -45,10 +51,12 @@ jobs:
|
||||
- name: Get Release Tag
|
||||
id: extract_release_tag
|
||||
run: |
|
||||
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
||||
TAG=${{ github.ref }}
|
||||
TAG=${TAG#refs/tags/v}
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using tag-based version: $TAG"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
@@ -81,6 +89,13 @@ jobs:
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# Default semver tags (version, major.minor, major)
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
# Only tag as 'latest' for stable releases (not prereleases)
|
||||
type=raw,value=latest,enable=${{ inputs.IS_PRERELEASE != 'true' }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
|
||||
@@ -40,7 +40,7 @@ vi.mock("@tolgee/react", () => ({
|
||||
|
||||
// Mock Next.js hooks
|
||||
const mockPush = vi.fn();
|
||||
const mockPathname = "/environments/env-id/surveys/survey-id/summary";
|
||||
const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary";
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -69,6 +69,14 @@ vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the useSingleUseId hook
|
||||
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
||||
useSingleUseId: vi.fn(() => ({
|
||||
singleUseId: "test-single-use-id",
|
||||
refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage",
|
||||
@@ -434,4 +442,328 @@ describe("SurveyAnalysisCTA", () => {
|
||||
expect(screen.getByTestId("success-message")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("duplicates survey when primary button is clicked in edit dialog", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
...mockSurvey,
|
||||
id: "new-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
expect(mockCopySurveyAction).toHaveBeenCalledWith({
|
||||
environmentId: "test-env-id",
|
||||
surveyId: "test-survey-id",
|
||||
targetEnvironmentId: "test-env-id",
|
||||
});
|
||||
expect(toast.default.success).toHaveBeenCalledWith("Survey duplicated successfully");
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/new-survey-id/edit");
|
||||
});
|
||||
|
||||
test("handles error when duplicating survey fails", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: undefined,
|
||||
serverError: "Duplication failed",
|
||||
validationErrors: undefined,
|
||||
bindArgsValidationErrors: [],
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
expect(toast.default.error).toHaveBeenCalledWith("Error message");
|
||||
});
|
||||
|
||||
test("navigates to edit when secondary button is clicked in edit dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click secondary button (edit)
|
||||
await user.click(screen.getByTestId("secondary-button"));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/test-survey-id/edit");
|
||||
});
|
||||
|
||||
test("shows loading state during duplication", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
|
||||
// Mock a delayed response
|
||||
mockCopySurveyAction.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
data: {
|
||||
...mockSurvey,
|
||||
id: "new-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
// Check loading state
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
|
||||
test("closes dialog after successful duplication", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
...mockSurvey,
|
||||
id: "new-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
// Dialog should be closed
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("opens preview with single use ID when enabled", async () => {
|
||||
const mockUseSingleUseId = vi.mocked(
|
||||
await import("@/modules/survey/hooks/useSingleUseId")
|
||||
).useSingleUseId;
|
||||
mockUseSingleUseId.mockReturnValue({
|
||||
singleUseId: "test-single-use-id",
|
||||
refreshSingleUseId: vi.fn().mockResolvedValue("new-single-use-id"),
|
||||
});
|
||||
|
||||
const surveyWithSingleUse = {
|
||||
...mockSurvey,
|
||||
type: "link" as const,
|
||||
singleUse: { enabled: true, isEncrypted: false },
|
||||
};
|
||||
|
||||
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
"https://example.com/s/test-survey-id?suId=new-single-use-id&preview=true",
|
||||
"_blank"
|
||||
);
|
||||
windowOpenSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("handles single use ID generation failure", async () => {
|
||||
const mockUseSingleUseId = vi.mocked(
|
||||
await import("@/modules/survey/hooks/useSingleUseId")
|
||||
).useSingleUseId;
|
||||
mockUseSingleUseId.mockReturnValue({
|
||||
singleUseId: "test-single-use-id",
|
||||
refreshSingleUseId: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const surveyWithSingleUse = {
|
||||
...mockSurvey,
|
||||
type: "link" as const,
|
||||
singleUse: { enabled: true, isEncrypted: false },
|
||||
};
|
||||
|
||||
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith("https://example.com/s/test-survey-id?preview=true", "_blank");
|
||||
windowOpenSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("opens share modal with correct modal view when share button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share");
|
||||
});
|
||||
|
||||
test("handles different survey statuses correctly", () => {
|
||||
const completedSurvey = { ...mockSurvey, status: "completed" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={completedSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles paused survey status", () => {
|
||||
const pausedSurvey = { ...mockSurvey, status: "paused" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={pausedSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render share modal when user is null", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} user={null as any} />);
|
||||
|
||||
expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isFormbricksCloud values", () => {
|
||||
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={true} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={false} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isContactsEnabled values", () => {
|
||||
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={true} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={false} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles app survey type", () => {
|
||||
const appSurvey = { ...mockSurvey, type: "app" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={appSurvey} />);
|
||||
|
||||
// Should not show preview icon for app surveys
|
||||
expect(screen.queryByTestId("icon-bar-action-1")).toBeInTheDocument(); // This should be edit button
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit");
|
||||
});
|
||||
|
||||
test("handles modal state changes correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
// Open modal via share button
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Close modal
|
||||
await user.click(screen.getByText("Close Modal"));
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("opens share modal via share button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
// Should open the modal with share view
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share");
|
||||
});
|
||||
|
||||
test("closes share modal and updates modal state", async () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
// Modal should be open initially due to share param
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
await user.click(screen.getByText("Close Modal"));
|
||||
|
||||
// Should close the modal
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("handles empty segments array", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} segments={[]} />);
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles zero response count", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} />);
|
||||
|
||||
expect(screen.queryByTestId("edit-public-survey-alert-dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows all icon actions for non-readonly app survey", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
// Should show bell (notifications) and edit actions
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit");
|
||||
});
|
||||
|
||||
test("shows all icon actions for non-readonly link survey", () => {
|
||||
const linkSurvey = { ...mockSurvey, type: "link" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={linkSurvey} />);
|
||||
|
||||
// Should show bell (notifications), preview, and edit actions
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
|
||||
expect(screen.getByTestId("icon-bar-action-2")).toHaveAttribute("title", "Edit");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -378,4 +378,56 @@ describe("AnonymousLinksTab", () => {
|
||||
screen.getByText("environments.surveys.share.anonymous_links.data_prefilling")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows read-only input with copy button when encryption is disabled", async () => {
|
||||
// surveyWithSingleUse has encryption disabled
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
// Check if single-use link is enabled
|
||||
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Check if encryption is disabled
|
||||
expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute(
|
||||
"data-checked",
|
||||
"false"
|
||||
);
|
||||
|
||||
// Check for the custom URL display
|
||||
const surveyUrlWithCustomSuid = `${defaultProps.surveyUrl}?suId=CUSTOM-ID`;
|
||||
expect(screen.getByText(surveyUrlWithCustomSuid)).toBeInTheDocument();
|
||||
|
||||
// Check for the copy button and try to click it
|
||||
const copyButton = screen.getByText("common.copy");
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
// check if toast is called
|
||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
|
||||
// Check for the alert
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.custom_single_use_id_title")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Ensure the number of links input is not visible
|
||||
expect(
|
||||
screen.queryByText("environments.surveys.share.anonymous_links.number_of_links_label")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides read-only input with copy button when encryption is enabled", async () => {
|
||||
// surveyWithEncryption has encryption enabled
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
|
||||
|
||||
// Check if single-use link is enabled
|
||||
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Check if encryption is enabled
|
||||
expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Ensure the number of links input is visible
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.number_of_links_label")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,7 +261,6 @@ export const AnonymousLinksTab = ({
|
||||
<AlertTitle>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
|
||||
</AlertDescription>
|
||||
@@ -276,12 +275,10 @@ export const AnonymousLinksTab = ({
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
|
||||
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
}}
|
||||
className="col-span-1 gap-1 text-sm">
|
||||
{t("common.copy")}
|
||||
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CRON_SECRET, SMTP_HOST } from "@/lib/constants";
|
||||
import { CRON_SECRET } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail } from "@/modules/email";
|
||||
import { headers } from "next/headers";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getNotificationResponse } from "./lib/notificationResponse";
|
||||
import { getOrganizationIds } from "./lib/organization";
|
||||
import { getProjectsByOrganizationId } from "./lib/project";
|
||||
@@ -17,11 +16,6 @@ export const POST = async (): Promise<Response> => {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
if (!SMTP_HOST) {
|
||||
logger.info("SMTP_HOST is not configured, skipping weekly summary email");
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
const emailSendingPromises: Promise<void>[] = [];
|
||||
|
||||
// Fetch all organization IDs
|
||||
|
||||
@@ -9,13 +9,34 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/error-component", () => ({
|
||||
ErrorComponent: () => <div data-testid="ErrorComponent">ErrorComponent</div>,
|
||||
ErrorComponent: ({ title, description }: { title: string; description: string }) => (
|
||||
<div data-testid="ErrorComponent">
|
||||
<div data-testid="error-title">{title}</div>
|
||||
<div data-testid="error-description">{description}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"common.error_rate_limit_title": "Too Many Requests",
|
||||
"common.error_rate_limit_description": "You're making too many requests. Please slow down.",
|
||||
"common.error_component_title": "Something went wrong",
|
||||
"common.error_component_description": "An unexpected error occurred. Please try again.",
|
||||
"common.try_again": "Try Again",
|
||||
"common.go_to_dashboard": "Go to Dashboard",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/types/errors", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@formbricks/types/errors")>();
|
||||
return {
|
||||
@@ -40,8 +61,7 @@ describe("ErrorBoundary", () => {
|
||||
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "Something went wrong",
|
||||
description: "An unexpected error occurred. Please try again.",
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
@@ -60,8 +80,7 @@ describe("ErrorBoundary", () => {
|
||||
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "Something went wrong",
|
||||
description: "An unexpected error occurred. Please try again.",
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
@@ -76,13 +95,12 @@ describe("ErrorBoundary", () => {
|
||||
test("calls reset when try again button is clicked for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "Something went wrong",
|
||||
description: "An unexpected error occurred. Please try again.",
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
const tryAgainBtn = screen.getByRole("button", { name: "common.try_again" });
|
||||
const tryAgainBtn = screen.getByRole("button", { name: "Try Again" });
|
||||
userEvent.click(tryAgainBtn);
|
||||
await waitFor(() => expect(resetMock).toHaveBeenCalled());
|
||||
});
|
||||
@@ -90,16 +108,15 @@ describe("ErrorBoundary", () => {
|
||||
test("sets window.location.href to '/' when dashboard button is clicked for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "Something went wrong",
|
||||
description: "An unexpected error occurred. Please try again.",
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
const originalLocation = window.location;
|
||||
delete (window as any).location;
|
||||
(window as any).location = undefined;
|
||||
(window as any).location = { href: "" };
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
const dashBtn = screen.getByRole("button", { name: "common.go_to_dashboard" });
|
||||
const dashBtn = screen.getByRole("button", { name: "Go to Dashboard" });
|
||||
userEvent.click(dashBtn);
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/");
|
||||
@@ -110,28 +127,60 @@ describe("ErrorBoundary", () => {
|
||||
test("does not show buttons for rate limit errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "common.error_rate_limit_title",
|
||||
description: "common.error_rate_limit_description",
|
||||
type: "rate_limit",
|
||||
showButtons: false,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "common.try_again" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "common.go_to_dashboard" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Try Again" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Go to Dashboard" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error component with custom title and description for rate limit errors", async () => {
|
||||
test("shows error component with rate limit messages for rate limit errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "common.error_rate_limit_title",
|
||||
description: "common.error_rate_limit_description",
|
||||
type: "rate_limit",
|
||||
showButtons: false,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
|
||||
|
||||
expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Too Many Requests");
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"You're making too many requests. Please slow down."
|
||||
);
|
||||
expect(getClientErrorData).toHaveBeenCalledWith(dummyError);
|
||||
});
|
||||
|
||||
test("shows error component with general messages for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
|
||||
|
||||
expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"An unexpected error occurred. Please try again."
|
||||
);
|
||||
expect(getClientErrorData).toHaveBeenCalledWith(dummyError);
|
||||
});
|
||||
|
||||
test("shows buttons for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Try Again" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Go to Dashboard" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,30 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { ErrorComponent } from "@/modules/ui/components/error-component";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { getClientErrorData } from "@formbricks/types/errors";
|
||||
import { type ClientErrorType, getClientErrorData } from "@formbricks/types/errors";
|
||||
|
||||
/**
|
||||
* Get translated error messages based on error type
|
||||
* All translation keys are directly visible to Tolgee's static analysis
|
||||
*/
|
||||
const getErrorMessages = (type: ClientErrorType, t: (key: string) => string) => {
|
||||
if (type === "rate_limit") {
|
||||
return {
|
||||
title: t("common.error_rate_limit_title"),
|
||||
description: t("common.error_rate_limit_description"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t("common.error_component_title"),
|
||||
description: t("common.error_component_description"),
|
||||
};
|
||||
};
|
||||
|
||||
const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) => {
|
||||
const { t } = useTranslate();
|
||||
const errorData = getClientErrorData(error);
|
||||
const { title, description } = getErrorMessages(errorData.type, t);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error(error.message);
|
||||
@@ -19,7 +38,7 @@ const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) =>
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<ErrorComponent title={errorData.title} description={errorData.description} />
|
||||
<ErrorComponent title={title} description={description} />
|
||||
{errorData.showButtons && (
|
||||
<div className="mt-2">
|
||||
<Button variant="secondary" onClick={() => reset()} className="mr-2">
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
"error": "Fehler",
|
||||
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
|
||||
"error_component_title": "Fehler beim Laden der Ressourcen",
|
||||
"expand_rows": "Zeilen erweitern",
|
||||
"finish": "Fertigstellen",
|
||||
"follow_these": "Folge diesen",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Umfrage automatisch zu Beginn des Tages (UTC) freigeben.",
|
||||
"back_button_label": "Zurück\"- Button ",
|
||||
"background_styling": "Hintergründe",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blockiert die Umfrage, wenn bereits eine Antwort mit der Single Use Id (suId) existiert.",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blockiert Umfrage, wenn die Umfrage-URL keine Single-Use-ID (suId) hat.",
|
||||
"brand_color": "Markenfarbe",
|
||||
"brightness": "Helligkeit",
|
||||
"button_label": "Beschriftung",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "Fängt nicht an mit",
|
||||
"edit_recall": "Erinnerung bearbeiten",
|
||||
"edit_translations": "{lang} -Übersetzungen bearbeiten",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Single Use Id (suId) in der Umfrage-URL verschlüsseln.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
|
||||
"enable_spam_protection": "Spamschutz",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Logo in dieser speziellen Umfrage verstecken",
|
||||
"hostname": "Hostname",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
|
||||
"how_it_works": "Wie es funktioniert",
|
||||
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Wenn Du diese Antwort brauchst, frag so lange, bis Du sie bekommst.",
|
||||
"ignore_waiting_time_between_surveys": "Wartezeit zwischen Umfragen ignorieren",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "Maximale Dateigröße begrenzen",
|
||||
"limit_upload_file_size_to": "Maximale Dateigröße für Uploads",
|
||||
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
|
||||
"link_used_message": "Link verwendet",
|
||||
"load_segment": "Segment laden",
|
||||
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
|
||||
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
|
||||
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
|
||||
"simple": "Einfach",
|
||||
"single_use_survey_links": "Einmalige Umfragelinks",
|
||||
"single_use_survey_links_description": "Erlaube nur eine Antwort pro Umfragelink.",
|
||||
"six_points": "6 Punkte",
|
||||
"skip_button_label": "Überspringen-Button-Beschriftung",
|
||||
"smiley": "Smiley",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "Zwischenüberschrift",
|
||||
"subtract": "Subtrahieren -",
|
||||
"suggest_colors": "Farben vorschlagen",
|
||||
"survey_already_answered_heading": "Die Umfrage wurde bereits beantwortet.",
|
||||
"survey_already_answered_subheading": "Du kannst diesen Link nur einmal verwenden.",
|
||||
"survey_completed_heading": "Umfrage abgeschlossen",
|
||||
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
|
||||
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "Hochladen",
|
||||
"upload_at_least_2_images": "Lade mindestens 2 Bilder hoch",
|
||||
"upper_label": "Oberes Label",
|
||||
"url_encryption": "URL-Verschlüsselung",
|
||||
"url_filters": "URL-Filter",
|
||||
"url_not_supported": "URL nicht unterstützt",
|
||||
"use_with_caution": "Mit Vorsicht verwenden",
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You're currently in the {environment} environment.",
|
||||
"error": "Error",
|
||||
"error_component_description": "This resource doesn't exist or you don't have the necessary rights to access it.",
|
||||
"error_component_title": "Error loading resources",
|
||||
"expand_rows": "Expand rows",
|
||||
"finish": "Finish",
|
||||
"follow_these": "Follow these",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Automatically release the survey at the beginning of the day (UTC).",
|
||||
"back_button_label": "\"Back\" Button Label",
|
||||
"background_styling": "Background Styling",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blocks survey if a submission with the Single Use Id (suId) exists already.",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blocks survey if the survey URL has no Single Use Id (suId).",
|
||||
"brand_color": "Brand color",
|
||||
"brightness": "Brightness",
|
||||
"button_label": "Button Label",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "Does not start with",
|
||||
"edit_recall": "Edit Recall",
|
||||
"edit_translations": "Edit {lang} translations",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Enable encryption of Single Use Id (suId) in survey URL.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
|
||||
"enable_spam_protection": "Spam protection",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey",
|
||||
"hostname": "Hostname",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys",
|
||||
"how_it_works": "How it works",
|
||||
"if_you_need_more_please": "If you need more, please",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "If you really want that answer, ask until you get it.",
|
||||
"ignore_waiting_time_between_surveys": "Ignore waiting time between surveys",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "Limit the maximum file size",
|
||||
"limit_upload_file_size_to": "Limit upload file size to",
|
||||
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
|
||||
"link_used_message": "Link Used",
|
||||
"load_segment": "Load segment",
|
||||
"logic_error_warning": "Changing will cause logic errors",
|
||||
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "Show survey to % of users",
|
||||
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
|
||||
"simple": "Simple",
|
||||
"single_use_survey_links": "Single-use survey links",
|
||||
"single_use_survey_links_description": "Allow only 1 response per survey link.",
|
||||
"six_points": "6 points",
|
||||
"skip_button_label": "Skip Button Label",
|
||||
"smiley": "Smiley",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "Subheading",
|
||||
"subtract": "Subtract -",
|
||||
"suggest_colors": "Suggest colors",
|
||||
"survey_already_answered_heading": "The survey has already been answered.",
|
||||
"survey_already_answered_subheading": "You can only use this link once.",
|
||||
"survey_completed_heading": "Survey Completed",
|
||||
"survey_completed_subheading": "This free & open-source survey has been closed",
|
||||
"survey_display_settings": "Survey Display Settings",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "Upload",
|
||||
"upload_at_least_2_images": "Upload at least 2 images",
|
||||
"upper_label": "Upper Label",
|
||||
"url_encryption": "URL Encryption",
|
||||
"url_filters": "URL Filters",
|
||||
"url_not_supported": "URL not supported",
|
||||
"use_with_caution": "Use with caution",
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
"error": "Erreur",
|
||||
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
|
||||
"error_component_title": "Erreur de chargement des ressources",
|
||||
"expand_rows": "Développer les lignes",
|
||||
"finish": "Terminer",
|
||||
"follow_these": "Suivez ceci",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Libérer automatiquement l'enquête au début de la journée (UTC).",
|
||||
"back_button_label": "Label du bouton \"Retour''",
|
||||
"background_styling": "Style de fond",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloque les enquêtes si une soumission avec l'Identifiant à Usage Unique (suId) existe déjà.",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloque les enquêtes si l'URL de l'enquête n'a pas d'Identifiant d'Utilisation Unique (suId).",
|
||||
"brand_color": "Couleur de marque",
|
||||
"brightness": "Luminosité",
|
||||
"button_label": "Label du bouton",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "Ne commence pas par",
|
||||
"edit_recall": "Modifier le rappel",
|
||||
"edit_translations": "Modifier les traductions {lang}",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Activer le chiffrement de l'identifiant à usage unique (suId) dans l'URL de l'enquête.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.",
|
||||
"enable_spam_protection": "Protection contre le spam",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Cacher le logo dans cette enquête spécifique",
|
||||
"hostname": "Nom d'hôte",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
|
||||
"how_it_works": "Comment ça fonctionne",
|
||||
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Si tu veux vraiment cette réponse, demande jusqu'à ce que tu l'obtiennes.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorer le temps d'attente entre les enquêtes",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "Limiter la taille maximale du fichier",
|
||||
"limit_upload_file_size_to": "Limiter la taille des fichiers téléchargés à",
|
||||
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
|
||||
"link_used_message": "Lien utilisé",
|
||||
"load_segment": "Segment de chargement",
|
||||
"logic_error_warning": "Changer causera des erreurs logiques",
|
||||
"logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
|
||||
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
|
||||
"simple": "Simple",
|
||||
"single_use_survey_links": "Liens d'enquête à usage unique",
|
||||
"single_use_survey_links_description": "Autoriser uniquement 1 réponse par lien d'enquête.",
|
||||
"six_points": "6 points",
|
||||
"skip_button_label": "Étiquette du bouton Ignorer",
|
||||
"smiley": "Sourire",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "Sous-titre",
|
||||
"subtract": "Soustraire -",
|
||||
"suggest_colors": "Suggérer des couleurs",
|
||||
"survey_already_answered_heading": "L'enquête a déjà été répondue.",
|
||||
"survey_already_answered_subheading": "Vous ne pouvez utiliser ce lien qu'une seule fois.",
|
||||
"survey_completed_heading": "Enquête terminée",
|
||||
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
|
||||
"survey_display_settings": "Paramètres d'affichage de l'enquête",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "Télécharger",
|
||||
"upload_at_least_2_images": "Téléchargez au moins 2 images",
|
||||
"upper_label": "Étiquette supérieure",
|
||||
"url_encryption": "Chiffrement d'URL",
|
||||
"url_filters": "Filtres d'URL",
|
||||
"url_not_supported": "URL non supportée",
|
||||
"use_with_caution": "À utiliser avec précaution",
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
|
||||
"error_component_title": "Erro ao carregar recursos",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"finish": "Terminar",
|
||||
"follow_these": "Siga esses",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Liberar automaticamente a pesquisa no começo do dia (UTC).",
|
||||
"back_button_label": "Voltar",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia a pesquisa se já existir uma submissão com o Id de Uso Único (suId).",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia a pesquisa se a URL da pesquisa não tiver um Id de Uso Único (suId).",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "brilho",
|
||||
"button_label": "Rótulo do Botão",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "Não começa com",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções de {lang}",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Habilitar criptografia do Id de Uso Único (suId) na URL da pesquisa.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
|
||||
"enable_spam_protection": "Proteção contra spam",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Esconder o logo nessa pesquisa específica",
|
||||
"hostname": "nome do host",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}",
|
||||
"how_it_works": "Como funciona",
|
||||
"if_you_need_more_please": "Se você precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Se você realmente quer essa resposta, pergunte até conseguir.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre pesquisas",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo do arquivo",
|
||||
"limit_upload_file_size_to": "Limitar tamanho do arquivo de upload para",
|
||||
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
|
||||
"link_used_message": "Link Usado",
|
||||
"load_segment": "segmento de carga",
|
||||
"logic_error_warning": "Mudar vai causar erros de lógica",
|
||||
"logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
|
||||
"simple": "Simples",
|
||||
"single_use_survey_links": "Links de pesquisa de uso único",
|
||||
"single_use_survey_links_description": "Permitir apenas 1 resposta por link da pesquisa.",
|
||||
"six_points": "6 pontos",
|
||||
"skip_button_label": "Botão de Pular",
|
||||
"smiley": "Sorridente",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"suggest_colors": "Sugerir cores",
|
||||
"survey_already_answered_heading": "A pesquisa já foi respondida.",
|
||||
"survey_already_answered_subheading": "Você só pode usar esse link uma vez.",
|
||||
"survey_completed_heading": "Pesquisa Concluída",
|
||||
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
|
||||
"survey_display_settings": "Configurações de Exibição da Pesquisa",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "Enviar",
|
||||
"upload_at_least_2_images": "Faz o upload de pelo menos 2 imagens",
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_encryption": "Criptografia de URL",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportada",
|
||||
"use_with_caution": "Use com cuidado",
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
|
||||
"error_component_title": "Erro ao carregar recursos",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"finish": "Concluir",
|
||||
"follow_these": "Siga estes",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Lançar automaticamente o inquérito no início do dia (UTC).",
|
||||
"back_button_label": "Rótulo do botão \"Voltar\"",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia o inquérito se já existir uma submissão com o Id de Uso Único (suId).",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia o inquérito se o URL do inquérito não tiver um Id de Uso Único (suId).",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "Brilho",
|
||||
"button_label": "Rótulo do botão",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "Não começa com",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções {lang}",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Ativar encriptação do Id de Uso Único (suId) no URL do inquérito.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
|
||||
"enable_spam_protection": "Proteção contra spam",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Ocultar o logótipo neste inquérito específico",
|
||||
"hostname": "Nome do host",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}",
|
||||
"how_it_works": "Como funciona",
|
||||
"if_you_need_more_please": "Se precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Se realmente quiser essa resposta, pergunte até obtê-la.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre inquéritos",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo do ficheiro",
|
||||
"limit_upload_file_size_to": "Limitar tamanho do ficheiro carregado a",
|
||||
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
|
||||
"link_used_message": "Link Utilizado",
|
||||
"load_segment": "Carregar segmento",
|
||||
"logic_error_warning": "A alteração causará erros de lógica",
|
||||
"logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
|
||||
"simple": "Simples",
|
||||
"single_use_survey_links": "Links de inquérito de uso único",
|
||||
"single_use_survey_links_description": "Permitir apenas 1 resposta por link de inquérito.",
|
||||
"six_points": "6 pontos",
|
||||
"skip_button_label": "Rótulo do botão Ignorar",
|
||||
"smiley": "Sorridente",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"suggest_colors": "Sugerir cores",
|
||||
"survey_already_answered_heading": "O inquérito já foi respondido.",
|
||||
"survey_already_answered_subheading": "Só pode usar este link uma vez.",
|
||||
"survey_completed_heading": "Inquérito Concluído",
|
||||
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
|
||||
"survey_display_settings": "Configurações de Exibição do Inquérito",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "Carregar",
|
||||
"upload_at_least_2_images": "Carregue pelo menos 2 imagens",
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_encryption": "Encriptação de URL",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportado",
|
||||
"use_with_caution": "Usar com cautela",
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
"error": "錯誤",
|
||||
"error_component_description": "此資源不存在或您沒有存取權限。",
|
||||
"error_component_title": "載入資源錯誤",
|
||||
"expand_rows": "展開列",
|
||||
"finish": "完成",
|
||||
"follow_these": "按照這些步驟",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "在指定日期(UTC時間)自動發佈問卷。",
|
||||
"back_button_label": "「返回」按鈕標籤",
|
||||
"background_styling": "背景樣式設定",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "如果已存在具有單次使用 ID (suId) 的提交,則封鎖問卷。",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "如果問卷網址沒有單次使用 ID (suId),則封鎖問卷。",
|
||||
"brand_color": "品牌顏色",
|
||||
"brightness": "亮度",
|
||||
"button_label": "按鈕標籤",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "不以...開頭",
|
||||
"edit_recall": "編輯回憶",
|
||||
"edit_translations": "編輯 '{'language'}' 翻譯",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "啟用問卷網址中單次使用 ID (suId) 的加密。",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。",
|
||||
"enable_spam_protection": "垃圾郵件保護",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "在此特定問卷中隱藏標誌",
|
||||
"hostname": "主機名稱",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫",
|
||||
"how_it_works": "運作方式",
|
||||
"if_you_need_more_please": "如果您需要更多,請",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "如果您真的想要該答案,請詢問直到您獲得它。",
|
||||
"ignore_waiting_time_between_surveys": "忽略問卷之間的等待時間",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "限制最大檔案大小",
|
||||
"limit_upload_file_size_to": "限制上傳檔案大小為",
|
||||
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
|
||||
"link_used_message": "已使用連結",
|
||||
"load_segment": "載入區隔",
|
||||
"logic_error_warning": "變更將導致邏輯錯誤",
|
||||
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "將問卷顯示給 % 的使用者",
|
||||
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
|
||||
"simple": "簡單",
|
||||
"single_use_survey_links": "單次使用問卷連結",
|
||||
"single_use_survey_links_description": "每個問卷連結只允許 1 個回應。",
|
||||
"six_points": "6 分",
|
||||
"skip_button_label": "「跳過」按鈕標籤",
|
||||
"smiley": "表情符號",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "副標題",
|
||||
"subtract": "減 -",
|
||||
"suggest_colors": "建議顏色",
|
||||
"survey_already_answered_heading": "問卷已回答。",
|
||||
"survey_already_answered_subheading": "您只能使用此連結一次。",
|
||||
"survey_completed_heading": "問卷已完成",
|
||||
"survey_completed_subheading": "此免費且開源的問卷已關閉",
|
||||
"survey_display_settings": "問卷顯示設定",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "上傳",
|
||||
"upload_at_least_2_images": "上傳至少 2 張圖片",
|
||||
"upper_label": "上標籤",
|
||||
"url_encryption": "網址加密",
|
||||
"url_filters": "網址篩選器",
|
||||
"url_not_supported": "不支援網址",
|
||||
"use_with_caution": "謹慎使用",
|
||||
|
||||
@@ -19,9 +19,13 @@ const meta: Meta<StoryProps> = {
|
||||
component: Alert,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
controls: {
|
||||
sort: "requiredFirst",
|
||||
exclude: [],
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **Alert** component displays important messages to users with various styles and optional actions. It supports different variants for different message types and can include icons and buttons.",
|
||||
},
|
||||
},
|
||||
},
|
||||
// These argTypes are for story controls, not component props
|
||||
@@ -61,10 +65,10 @@ const meta: Meta<StoryProps> = {
|
||||
control: "boolean",
|
||||
description: "Whether to show action buttons",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
},
|
||||
order: 4,
|
||||
order: 1,
|
||||
},
|
||||
title: {
|
||||
control: "text",
|
||||
@@ -91,7 +95,7 @@ const meta: Meta<StoryProps> = {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 2,
|
||||
order: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -160,7 +164,7 @@ export const Small: Story = {
|
||||
};
|
||||
|
||||
// With custom icon
|
||||
export const withButtonAndIcon: Story = {
|
||||
export const WithButtonAndIcon: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "default",
|
||||
@@ -170,6 +174,13 @@ export const withButtonAndIcon: Story = {
|
||||
showButton: true,
|
||||
actionButtonText: "Learn more",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you need both visual emphasis and actionable content.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Error variant
|
||||
@@ -186,7 +197,7 @@ export const Destructive: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Only use if the user needs to take immediate action or there is a critical error.",
|
||||
story: "Use for critical errors that need immediate attention or action.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -206,7 +217,7 @@ export const Warning: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to make the user aware of potential issues.",
|
||||
story: "Use to make the user aware of potential issues or risks.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -226,7 +237,7 @@ export const Info: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to give contextual information and support the user.",
|
||||
story: "Use to give contextual information and support the user.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -246,7 +257,46 @@ export const Success: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to give positive feedback.",
|
||||
story: "Use to give positive feedback and confirm successful actions.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "info",
|
||||
title: "Information",
|
||||
description: "This alert has an icon for better visual hierarchy.",
|
||||
showIcon: true,
|
||||
showButton: false,
|
||||
actionButtonText: "",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use icons to improve visual hierarchy and message clarity.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "warning",
|
||||
title: "Long Alert Title That Might Wrap to Multiple Lines",
|
||||
description:
|
||||
"This is a very long alert description that demonstrates how the alert component handles longer content. It should wrap gracefully and maintain proper spacing and readability even with extensive text content.",
|
||||
showIcon: true,
|
||||
showButton: true,
|
||||
actionButtonText: "Acknowledge",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows how the alert handles longer content with proper text wrapping.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Badge } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Badge",
|
||||
component: Badge,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: "select",
|
||||
options: ["warning", "success", "error", "gray"],
|
||||
},
|
||||
size: { control: "select", options: ["small", "normal", "large"] },
|
||||
className: { control: "text" },
|
||||
},
|
||||
} satisfies Meta<typeof Badge>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Gray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeWarning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeSuccess: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeError: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeGray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyWarning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinySuccess: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyError: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyGray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
217
apps/web/modules/ui/components/badge/stories.tsx
Normal file
217
apps/web/modules/ui/components/badge/stories.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Badge } from "./index";
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: "UI/Badge",
|
||||
component: Badge,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **Badge** component displays small status indicators or labels with different colors and sizes. Use it to highlight important information or show status states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: "select",
|
||||
options: ["warning", "success", "error", "gray"],
|
||||
description: "Color variant of the badge",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "gray" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["tiny", "normal", "large"],
|
||||
description: "Size of the badge",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "normal" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
role: {
|
||||
control: "text",
|
||||
description: "Accessibility role attribute",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
text: {
|
||||
control: "text",
|
||||
description: "Badge content text",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
text: "Badge",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for warnings or actions that need attention.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use to indicate successful operations or positive states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for errors or failed operations.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Gray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for neutral information or inactive states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Tiny: Story = {
|
||||
args: {
|
||||
text: "Tiny",
|
||||
type: "gray",
|
||||
size: "tiny",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when space is very limited or for subtle indicators.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
text: "Large",
|
||||
type: "gray",
|
||||
size: "large",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for prominent badges or when more visibility is needed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongText: Story = {
|
||||
args: {
|
||||
text: "Very Long Badge Text",
|
||||
type: "warning",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Badge handles longer text content gracefully.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
text: "Custom",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
className: "bg-purple-100 border-purple-200 text-purple-800",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can override the default styling with custom CSS classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRole: Story = {
|
||||
args: {
|
||||
text: "Status",
|
||||
type: "success",
|
||||
size: "normal",
|
||||
role: "status",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use role attribute for better accessibility, especially for dynamic status updates.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { Button } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["outline", "default", "secondary", "ghost", "destructive", "link"],
|
||||
},
|
||||
size: { control: "select", options: ["sm", "lg", "fab", "icon"] },
|
||||
},
|
||||
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
|
||||
args: { onClick: fn() },
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "secondary",
|
||||
},
|
||||
};
|
||||
|
||||
export const Minimal: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "ghost",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warn: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "destructive",
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "default",
|
||||
loading: true,
|
||||
},
|
||||
};
|
||||
253
apps/web/modules/ui/components/button/stories.tsx
Normal file
253
apps/web/modules/ui/components/button/stories.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { Button } from "./index";
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: "UI/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **Button** component provides clickable actions with multiple variants and sizes. It supports loading states, different visual styles, and can be used as a child component wrapper.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
loading: {
|
||||
control: "boolean",
|
||||
description: "Shows loading spinner and disables interaction",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Disables the button interaction",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
asChild: {
|
||||
control: "boolean",
|
||||
description: "Render as a child component using Slot",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
onClick: {
|
||||
action: "clicked",
|
||||
description: "Click handler function",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "function" },
|
||||
},
|
||||
order: 4,
|
||||
},
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
|
||||
description: "Visual style variant of the button",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["default", "sm", "lg", "icon"],
|
||||
description: "Size of the button",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
children: {
|
||||
control: "text",
|
||||
description: "Button content",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "React.ReactNode" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
args: { onClick: fn() },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: {
|
||||
children: "Delete",
|
||||
variant: "destructive",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for actions that are destructive or potentially harmful, like deleting data.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Outline: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "outline",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for secondary actions or when you need a button with less visual weight.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "secondary",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for secondary actions that are less important than the primary action.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "ghost",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for subtle actions or when you need minimal visual impact.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Link: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "link",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you want button functionality but link appearance.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
children: "Small Button",
|
||||
size: "sm",
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
children: "Large Button",
|
||||
size: "lg",
|
||||
},
|
||||
};
|
||||
|
||||
export const Icon: Story = {
|
||||
args: {
|
||||
children: "×",
|
||||
size: "icon",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for icon-only buttons. The button will be square-shaped.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
children: "Loading...",
|
||||
variant: "default",
|
||||
loading: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Use to show loading state during async operations. The button becomes disabled and shows a spinner.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
children: "Disabled Button",
|
||||
disabled: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when the button action is temporarily unavailable.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
children: "Custom Button",
|
||||
className: "bg-purple-500 hover:bg-purple-600 text-white",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can override the default styling with custom CSS classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,68 +1,132 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { Card } from "./index";
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "../button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./index";
|
||||
|
||||
const meta = {
|
||||
interface CardStoryProps {
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
footerContent: string;
|
||||
showFooter: boolean;
|
||||
className?: string;
|
||||
footerButton?: boolean;
|
||||
}
|
||||
|
||||
const meta: Meta<CardStoryProps> = {
|
||||
title: "UI/Card",
|
||||
component: Card,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha" },
|
||||
docs: {
|
||||
description: {
|
||||
component: `The **card** component is used to display a card with a label, description, and optional icon. It can also display a status and buttons for connecting and viewing documentation.`,
|
||||
component:
|
||||
"The **Card** component is a flexible container for content with consistent styling. It includes subcomponents like CardHeader, CardTitle, CardDescription, CardContent, and CardFooter for structured layouts.",
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
icon: { control: "text" },
|
||||
},
|
||||
argTypes: {
|
||||
title: {
|
||||
control: "text",
|
||||
description: "Card title",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
description: {
|
||||
control: "text",
|
||||
description: "Card description",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
content: {
|
||||
control: "text",
|
||||
description: "Main content of the card",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
footerContent: {
|
||||
control: "text",
|
||||
description: "Content for the card footer",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
showFooter: {
|
||||
control: "boolean",
|
||||
description: "Toggle footer visibility",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
footerButton: {
|
||||
control: "boolean",
|
||||
description: "Show a button in the footer",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: { category: "Appearance" },
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Card>;
|
||||
render: ({ title, description, content, footerContent, showFooter, footerButton, className }) => (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{content}</p>
|
||||
</CardContent>
|
||||
{showFooter && (
|
||||
<CardFooter>{footerButton ? <Button>{footerContent}</Button> : <p>{footerContent}</p>}</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<CardStoryProps>;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: true,
|
||||
statusText: "Connected",
|
||||
title: "Default Card",
|
||||
description: "This is the default card description.",
|
||||
content: "This is the main content area of the card. You can put any React node here.",
|
||||
footerContent: "Footer content",
|
||||
showFooter: true,
|
||||
footerButton: false,
|
||||
className: "w-96",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disconnected: Story = {
|
||||
export const HeaderOnly: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: false,
|
||||
statusText: "Disconnected",
|
||||
...Default.args,
|
||||
title: "Header Only",
|
||||
description: "This card only has a header.",
|
||||
content: "",
|
||||
showFooter: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "A card that only displays a header. Useful for short announcements or titles.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
export const LongContent: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: true,
|
||||
statusText: "Connected",
|
||||
icon: <BellRing />,
|
||||
...Default.args,
|
||||
title: "Card with Long Content",
|
||||
description: "This card demonstrates how longer content is handled.",
|
||||
content:
|
||||
"This is a card with a longer content section to demonstrate how the card component handles extensive text content. The card will expand to accommodate the content while maintaining proper spacing and readability. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
footerContent: "Read More",
|
||||
showFooter: true,
|
||||
footerButton: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows how the card handles longer content with proper spacing and layout.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import { fn } from "storybook/test";
|
||||
import { ColorPicker } from "./index";
|
||||
|
||||
const meta: Meta<typeof ColorPicker> = {
|
||||
title: "ui/ColorPicker",
|
||||
title: "UI/ColorPicker",
|
||||
component: ColorPicker,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "../button";
|
||||
import {
|
||||
@@ -44,14 +44,17 @@ const DefaultBodyContent = (elementCount: number): React.ReactNode => {
|
||||
};
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/Modal",
|
||||
title: "UI/Dialog",
|
||||
component: DialogContent,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: {
|
||||
sort: "requiredFirst",
|
||||
exclude: [],
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **Dialog** component provides modal dialogs for displaying content over the main interface. It supports customizable headers, body content, footers, and various interaction patterns.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
|
||||
@@ -1,20 +1,59 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ErrorComponent } from "./index";
|
||||
|
||||
// Mock the useTranslate hook
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"common.error_component_title": "Something went wrong",
|
||||
"common.error_component_description": "An unexpected error occurred. Please try again.",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ErrorComponent", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders error title", () => {
|
||||
test("renders with default translations when no props provided", () => {
|
||||
render(<ErrorComponent />);
|
||||
expect(screen.getByTestId("error-title")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"An unexpected error occurred. Please try again."
|
||||
);
|
||||
});
|
||||
|
||||
test("renders error description", () => {
|
||||
render(<ErrorComponent />);
|
||||
expect(screen.getByTestId("error-description")).toBeInTheDocument();
|
||||
test("renders with custom title when provided", () => {
|
||||
const customTitle = "Custom Error Title";
|
||||
render(<ErrorComponent title={customTitle} />);
|
||||
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent(customTitle);
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"An unexpected error occurred. Please try again."
|
||||
);
|
||||
});
|
||||
|
||||
test("renders with custom description when provided", () => {
|
||||
const customDescription = "Custom error description";
|
||||
render(<ErrorComponent description={customDescription} />);
|
||||
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(customDescription);
|
||||
});
|
||||
|
||||
test("renders with both custom title and description when provided", () => {
|
||||
const customTitle = "Custom Error Title";
|
||||
const customDescription = "Custom error description";
|
||||
render(<ErrorComponent title={customTitle} description={customDescription} />);
|
||||
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent(customTitle);
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(customDescription);
|
||||
});
|
||||
|
||||
test("renders error icon", () => {
|
||||
@@ -23,4 +62,16 @@ describe("ErrorComponent", () => {
|
||||
const iconElement = document.querySelector("[aria-hidden='true']");
|
||||
expect(iconElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("uses fallback translation when title is empty string", () => {
|
||||
render(<ErrorComponent title="" />);
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
|
||||
});
|
||||
|
||||
test("uses fallback translation when description is empty string", () => {
|
||||
render(<ErrorComponent description="" />);
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"An unexpected error occurred. Please try again."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,17 +4,15 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { XCircleIcon } from "lucide-react";
|
||||
|
||||
interface ErrorComponentProps {
|
||||
/** Pre-translated title text. If not provided, uses default error title */
|
||||
title?: string;
|
||||
/** Pre-translated description text. If not provided, uses default error description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const ErrorComponent: React.FC<ErrorComponentProps> = ({ title, description }) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
// Use custom title/description if provided, otherwise fallback to translations
|
||||
const errorTitle = title || "common.error_component_title";
|
||||
const errorDescription = description || "common.error_component_description";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
@@ -23,10 +21,10 @@ export const ErrorComponent: React.FC<ErrorComponentProps> = ({ title, descripti
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800" data-testid="error-title">
|
||||
{t(errorTitle)}
|
||||
{title || t("common.error_component_title")}
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700" data-testid="error-description">
|
||||
<p>{t(errorDescription)}</p>
|
||||
<p>{description || t("common.error_component_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FileIcon, FolderIcon, ImageIcon } from "lucide-react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { InputCombobox } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/InputCombobox",
|
||||
component: InputCombobox,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The \`InputCombobox\` component is a versatile combination of an input field and a dropdown menu.
|
||||
It supports various features such as:
|
||||
- Searchable options
|
||||
- Grouped options
|
||||
- Multi-select
|
||||
- Icons for options
|
||||
- Clearable selection
|
||||
- Custom input props
|
||||
- Handling both dropdown and input modes
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof InputCombobox>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const commonOptions = [
|
||||
{ label: "File", value: "file", icon: FileIcon },
|
||||
{ label: "Folder", value: "folder", icon: FolderIcon },
|
||||
{ label: "Image", value: "image", icon: ImageIcon },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
id: "input-combobox-default",
|
||||
showSearch: true,
|
||||
searchPlaceholder: "Search...",
|
||||
options: commonOptions,
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
clearable: true,
|
||||
withInput: false,
|
||||
allowMultiSelect: false,
|
||||
showCheckIcon: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInput: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
withInput: true,
|
||||
inputProps: {
|
||||
placeholder: "Type or select an option",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GroupedOptions: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
groupedOptions: [
|
||||
{
|
||||
label: "Common",
|
||||
value: "common",
|
||||
options: commonOptions,
|
||||
},
|
||||
{
|
||||
label: "Advanced",
|
||||
value: "advanced",
|
||||
options: [
|
||||
{ label: "Database", value: "database" },
|
||||
{ label: "Network", value: "network" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiSelect: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
allowMultiSelect: true,
|
||||
value: ["file", "image"],
|
||||
},
|
||||
};
|
||||
|
||||
export const Clearable: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
value: "folder",
|
||||
clearable: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutSearch: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearch: false,
|
||||
},
|
||||
};
|
||||
319
apps/web/modules/ui/components/input-combo-box/stories.tsx
Normal file
319
apps/web/modules/ui/components/input-combo-box/stories.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FileIcon, FolderIcon, ImageIcon } from "lucide-react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { InputCombobox } from "./index";
|
||||
|
||||
interface StoryOptions {
|
||||
numberOfOptions: number;
|
||||
showCustomIcons: boolean;
|
||||
enableMultiSelect: boolean;
|
||||
enableClearable: boolean;
|
||||
}
|
||||
|
||||
type StoryProps = React.ComponentProps<typeof InputCombobox> & StoryOptions;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/InputCombobox",
|
||||
component: InputCombobox,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **InputCombobox** component is a versatile combination of an input field and a dropdown menu. It supports searchable options, grouped options, multi-select, icons, clearable selection, and both dropdown and input modes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
allowMultiSelect: {
|
||||
control: "boolean",
|
||||
description: "Allow selecting multiple options",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
clearable: {
|
||||
control: "boolean",
|
||||
description: "Allow clearing the selection",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
showSearch: {
|
||||
control: "boolean",
|
||||
description: "Show search input in dropdown",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "true" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
withInput: {
|
||||
control: "boolean",
|
||||
description: "Include input field alongside dropdown",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 4,
|
||||
},
|
||||
showCheckIcon: {
|
||||
control: "boolean",
|
||||
description: "Show check icon for selected items",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 5,
|
||||
},
|
||||
onChangeValue: {
|
||||
action: "value changed",
|
||||
description: "Callback when value changes",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "function" },
|
||||
},
|
||||
order: 6,
|
||||
},
|
||||
comboboxClasses: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes for the combobox",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text for search input",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "Search..." },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
emptyDropdownText: {
|
||||
control: "text",
|
||||
description: "Text to show when no options found",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
id: {
|
||||
control: "text",
|
||||
description: "Unique identifier for the component",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InputCombobox>;
|
||||
|
||||
const commonOptions = [
|
||||
{ label: "File", value: "file", icon: FileIcon },
|
||||
{ label: "Folder", value: "folder", icon: FolderIcon },
|
||||
{ label: "Image", value: "image", icon: ImageIcon },
|
||||
];
|
||||
|
||||
const simpleOptions = [
|
||||
{ label: "Option 1", value: "option1" },
|
||||
{ label: "Option 2", value: "option2" },
|
||||
{ label: "Option 3", value: "option3" },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
id: "input-combobox-default",
|
||||
showSearch: true,
|
||||
searchPlaceholder: "Search...",
|
||||
options: commonOptions,
|
||||
value: commonOptions[0].value,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
clearable: false,
|
||||
withInput: false,
|
||||
allowMultiSelect: false,
|
||||
showCheckIcon: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInput: Story = {
|
||||
args: {
|
||||
id: "input-combobox-with-input",
|
||||
showSearch: true,
|
||||
options: commonOptions,
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
withInput: true,
|
||||
inputProps: {
|
||||
placeholder: "Type or select an option",
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you need both dropdown selection and free text input functionality.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiSelect: Story = {
|
||||
args: {
|
||||
id: "input-combobox-multi-select",
|
||||
options: commonOptions,
|
||||
value: ["file", "image"],
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
allowMultiSelect: true,
|
||||
showCheckIcon: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when users need to select multiple options from the dropdown.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Clearable: Story = {
|
||||
args: {
|
||||
id: "input-combobox-clearable",
|
||||
options: commonOptions,
|
||||
value: "folder",
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
clearable: true,
|
||||
showCheckIcon: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when users should be able to clear their selection.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GroupedOptions: Story = {
|
||||
args: {
|
||||
id: "input-combobox-grouped",
|
||||
groupedOptions: [
|
||||
{
|
||||
label: "Common",
|
||||
value: "common",
|
||||
options: commonOptions,
|
||||
},
|
||||
{
|
||||
label: "Advanced",
|
||||
value: "advanced",
|
||||
options: [
|
||||
{ label: "Database", value: "database" },
|
||||
{ label: "Network", value: "network" },
|
||||
{ label: "Security", value: "security" },
|
||||
],
|
||||
},
|
||||
],
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
showCheckIcon: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you need to organize options into logical groups.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutSearch: Story = {
|
||||
args: {
|
||||
id: "input-combobox-no-search",
|
||||
options: simpleOptions,
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
showSearch: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you have a small number of options and don't need search functionality.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyOptions: Story = {
|
||||
args: {
|
||||
id: "input-combobox-many-options",
|
||||
options: Array.from({ length: 50 }, (_, i) => ({
|
||||
label: `Option ${i + 1}`,
|
||||
value: `option${i + 1}`,
|
||||
})),
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
showSearch: true,
|
||||
searchPlaceholder: "Search from 50 options...",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you have many options and need search functionality for better UX.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
id: "input-combobox-custom",
|
||||
options: commonOptions,
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
comboboxClasses: "border-blue-300 hover:border-blue-400",
|
||||
showCheckIcon: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can customize the appearance with custom CSS classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyState: Story = {
|
||||
args: {
|
||||
id: "input-combobox-empty",
|
||||
options: [],
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
emptyDropdownText: "No options available",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows how the component handles empty options list.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,87 +1,277 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Input } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Input",
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: "UI/Input",
|
||||
component: Input,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The \`Input\` component is used to input the form fields.
|
||||
It supports all standard HTML input attributes, along with some additional props to handle specific use cases:
|
||||
- \`isInvalid\`: Adds a visual indicator for invalid input.
|
||||
- \`crossOrigin\`: Specifies how the element handles cross-origin requests.
|
||||
- \`dangerouslySetInnerHTML\`: Allows setting inner HTML content directly, similar to the native \`dangerouslySetInnerHTML\` in React.`,
|
||||
component:
|
||||
"The **Input** component is a versatile form input field that supports all standard HTML input attributes. It includes visual indicators for invalid states and supports various input types with consistent styling.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Input>;
|
||||
argTypes: {
|
||||
isInvalid: {
|
||||
control: "boolean",
|
||||
description: "Shows invalid state with red border",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Disables the input field",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
required: {
|
||||
control: "boolean",
|
||||
description: "Makes the input required",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
readOnly: {
|
||||
control: "boolean",
|
||||
description: "Makes the input read-only",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 4,
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
type: {
|
||||
control: "select",
|
||||
options: ["text", "email", "password", "number", "tel", "url", "search"],
|
||||
description: "Input type",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "text" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
placeholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
value: {
|
||||
control: "text",
|
||||
description: "Input value",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
defaultValue: {
|
||||
control: "text",
|
||||
description: "Default input value",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof Input>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
value: "",
|
||||
className: "",
|
||||
isInvalid: false,
|
||||
disabled: false,
|
||||
placeholder: "Enter text",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
value: "Sample text",
|
||||
placeholder: "Enter text",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you need to display a pre-filled value.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Invalid: Story = {
|
||||
args: {
|
||||
isInvalid: true,
|
||||
disabled: false,
|
||||
placeholder: "Invalid input",
|
||||
value: "Invalid value",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use to show validation errors with red border styling.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
isInvalid: false,
|
||||
disabled: true,
|
||||
placeholder: "Disabled input",
|
||||
value: "Cannot edit this",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when the input should not be editable.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
value: "Prefilled text",
|
||||
isInvalid: false,
|
||||
disabled: false,
|
||||
placeholder: "Enter text",
|
||||
readOnly: true,
|
||||
value: "Read-only value",
|
||||
placeholder: "Read-only input",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you want to display data that cannot be edited but can be selected.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomClass: Story = {
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
className: "rounded-lg bg-slate-50 text-base",
|
||||
isInvalid: false,
|
||||
disabled: false,
|
||||
placeholder: "Input with custom class",
|
||||
required: true,
|
||||
placeholder: "Required field *",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for mandatory form fields.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Password: Story = {
|
||||
args: {
|
||||
type: "password",
|
||||
value: "abcd",
|
||||
isInvalid: false,
|
||||
disabled: false,
|
||||
placeholder: "Enter password",
|
||||
value: "secretpassword",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for password input fields with hidden text.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Email: Story = {
|
||||
args: {
|
||||
type: "email",
|
||||
isInvalid: false,
|
||||
disabled: false,
|
||||
placeholder: "john.doe@email.com",
|
||||
placeholder: "john.doe@example.com",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for email input with built-in validation.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NumberInput: Story = {
|
||||
args: {
|
||||
type: "number",
|
||||
placeholder: "Enter number",
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for numeric input with optional min/max validation.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Search: Story = {
|
||||
args: {
|
||||
type: "search",
|
||||
placeholder: "Search...",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for search input fields with search-specific styling.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomStyling: Story = {
|
||||
args: {
|
||||
className: "rounded-lg bg-slate-50 text-base border-2 border-blue-300 focus:border-blue-500",
|
||||
placeholder: "Custom styled input",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can customize the appearance with additional CSS classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongText: Story = {
|
||||
args: {
|
||||
placeholder:
|
||||
"This is a very long placeholder text that demonstrates how the input handles overflow content gracefully",
|
||||
value: "This is a very long input value that shows how the text scrolls within the input field",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows how the input handles long content with scrolling.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { Card } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/IntegrationCard",
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: "UI/IntegrationCard",
|
||||
component: Card,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
@@ -12,8 +12,60 @@ const meta = {
|
||||
component: `The **card** component is used to display a card with a label, description, and optional icon. It can also display a status and buttons for connecting and viewing documentation.`,
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
icon: { control: "text" },
|
||||
},
|
||||
argTypes: {
|
||||
// Behavior
|
||||
connectHref: {
|
||||
control: "text",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
connectNewTab: {
|
||||
control: "boolean",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
docsHref: {
|
||||
control: "text",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
docsNewTab: {
|
||||
control: "boolean",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
connected: {
|
||||
control: "boolean",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
|
||||
// Content
|
||||
label: {
|
||||
control: "text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
description: {
|
||||
control: "text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
connectText: {
|
||||
control: "text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
docsText: {
|
||||
control: "text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
statusText: {
|
||||
control: "text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
|
||||
// Appearance
|
||||
icon: {
|
||||
control: false,
|
||||
table: { category: "Appearance" },
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Card>;
|
||||
|
||||
@@ -1,29 +1,53 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Label } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Label",
|
||||
const meta: Meta<typeof Label> = {
|
||||
title: "UI/Label",
|
||||
component: Label,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The **Label** component is used to label the form fields.
|
||||
`,
|
||||
component:
|
||||
"The **Label** component is used to label form fields and provide accessible names for interactive elements. It's built on Radix UI Label primitive and supports all standard label attributes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
className: { control: "text" },
|
||||
children: { control: "text" },
|
||||
htmlFor: {
|
||||
control: "text",
|
||||
description: "Associates the label with a form control",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
children: {
|
||||
control: "text",
|
||||
description: "Label text content",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "React.ReactNode" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Label>;
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof Label>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
@@ -31,9 +55,176 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomClass: Story = {
|
||||
args: {
|
||||
children: "Label with Custom Class",
|
||||
className: "text-red-500",
|
||||
export const WithInput: Story = {
|
||||
render: (args) => (
|
||||
<div className="space-y-2">
|
||||
<Label {...args} htmlFor="email">
|
||||
Email Address
|
||||
</Label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
args: {},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Use with form inputs to provide accessible labels. The htmlFor attribute should match the input's id.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
render: (args) => (
|
||||
<div className="space-y-2">
|
||||
<Label {...args} htmlFor="required-field">
|
||||
Required Field <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<input
|
||||
id="required-field"
|
||||
type="text"
|
||||
placeholder="Required input"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
args: {},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use to indicate required form fields with appropriate visual indicators.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCheckbox: Story = {
|
||||
render: (args) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input id="terms" type="checkbox" className="h-4 w-4 rounded border-slate-300 text-blue-600" />
|
||||
<Label {...args} htmlFor="terms">
|
||||
I agree to the terms and conditions
|
||||
</Label>
|
||||
</div>
|
||||
),
|
||||
args: {},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use with checkboxes and radio buttons for proper accessibility.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRadio: Story = {
|
||||
render: (args) => (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input id="option1" type="radio" name="options" className="h-4 w-4 border-slate-300 text-blue-600" />
|
||||
<Label {...args} htmlFor="option1">
|
||||
Option 1
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input id="option2" type="radio" name="options" className="h-4 w-4 border-slate-300 text-blue-600" />
|
||||
<Label {...args} htmlFor="option2">
|
||||
Option 2
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
args: {},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use with radio button groups for proper accessibility and interaction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
children: "Custom Styled Label",
|
||||
className: "text-lg font-bold text-blue-600",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can customize the label appearance with additional CSS classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: (args) => (
|
||||
<div className="space-y-2">
|
||||
<Label {...args} htmlFor="disabled-input" className="opacity-50">
|
||||
Disabled Field
|
||||
</Label>
|
||||
<input
|
||||
id="disabled-input"
|
||||
type="text"
|
||||
placeholder="Disabled input"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 opacity-50"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
args: {},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use with disabled form controls to maintain visual consistency.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongText: Story = {
|
||||
args: {
|
||||
children:
|
||||
"This is a very long label that demonstrates how labels handle extended text content gracefully",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows how the label handles longer text content with proper wrapping.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHelpText: Story = {
|
||||
render: (args) => (
|
||||
<div className="space-y-2">
|
||||
<Label {...args} htmlFor="password">
|
||||
Password
|
||||
</Label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<p className="text-sm text-slate-500">Password must be at least 8 characters long</p>
|
||||
</div>
|
||||
),
|
||||
args: {},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use with additional help text to provide context and guidance.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LoadingSpinner } from "./index";
|
||||
|
||||
const meta: Meta<typeof LoadingSpinner> = {
|
||||
title: "ui/LoadingSpinner",
|
||||
component: LoadingSpinner,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
115
apps/web/modules/ui/components/loading-spinner/stories.tsx
Normal file
115
apps/web/modules/ui/components/loading-spinner/stories.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LoadingSpinner } from "./index";
|
||||
|
||||
const meta: Meta<typeof LoadingSpinner> = {
|
||||
title: "UI/LoadingSpinner",
|
||||
component: LoadingSpinner,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **LoadingSpinner** component displays an animated spinner to indicate loading states. It's centered within its container and can be customized with different sizes and colors.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes for styling the spinner",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "h-6 w-6" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LoadingSpinner>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
className: "h-4 w-4",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for small loading indicators or when space is limited.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
className: "h-10 w-10",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for prominent loading states or when more visibility is needed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: {
|
||||
className: "h-16 w-16",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for full-page loading states or very prominent loading indicators.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ColorVariants: Story = {
|
||||
args: {
|
||||
className: "h-8 w-8 text-blue-500",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can customize the color using text color classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SlowAnimation: Story = {
|
||||
args: {
|
||||
className: "h-8 w-8 animate-spin animate-duration-2000",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can customize the animation speed with Tailwind animation classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomStyles: Story = {
|
||||
args: {
|
||||
className: "h-12 w-12 text-green-600 drop-shadow-lg",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Combine multiple utility classes for custom styling effects.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
const meta = {
|
||||
title: "ui/PageHeader",
|
||||
const meta: Meta<typeof PageHeader> = {
|
||||
title: "UI/PageHeader",
|
||||
component: PageHeader,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
|
||||
62
apps/web/modules/ui/components/tab-nav/index.test.tsx
Normal file
62
apps/web/modules/ui/components/tab-nav/index.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TabNav } from "./index";
|
||||
|
||||
describe("TabNav", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockTabs = [
|
||||
{ id: "tab1", label: "Tab One" },
|
||||
{ id: "tab2", label: "Tab Two" },
|
||||
{ id: "tab3", label: "Tab Three" },
|
||||
];
|
||||
|
||||
test("calls setActiveId when tab is clicked", async () => {
|
||||
const handleSetActiveId = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TabNav tabs={mockTabs} activeId="tab1" setActiveId={handleSetActiveId} />);
|
||||
|
||||
await user.click(screen.getByText("Tab Two"));
|
||||
|
||||
expect(handleSetActiveId).toHaveBeenCalledTimes(1);
|
||||
expect(handleSetActiveId).toHaveBeenCalledWith("tab2");
|
||||
});
|
||||
|
||||
test("renders tabs with icons", () => {
|
||||
const tabsWithIcons = [
|
||||
{ id: "tab1", label: "Tab One", icon: <span data-testid="icon1">🔍</span> },
|
||||
{ id: "tab2", label: "Tab Two", icon: <span data-testid="icon2">📁</span> },
|
||||
];
|
||||
|
||||
render(<TabNav tabs={tabsWithIcons} activeId="tab1" setActiveId={() => {}} />);
|
||||
|
||||
expect(screen.getByTestId("icon1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("icon2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("applies activeTabClassName to active tab", () => {
|
||||
render(
|
||||
<TabNav
|
||||
tabs={mockTabs}
|
||||
activeId="tab1"
|
||||
setActiveId={() => {}}
|
||||
activeTabClassName="custom-active-class"
|
||||
/>
|
||||
);
|
||||
|
||||
const activeTab = screen.getByText("Tab One").closest("button");
|
||||
expect(activeTab).toHaveClass("custom-active-class");
|
||||
});
|
||||
|
||||
test("renders navigation container", () => {
|
||||
render(<TabNav tabs={mockTabs} activeId="tab1" setActiveId={() => {}} />);
|
||||
|
||||
const navContainer = screen.getByRole("navigation");
|
||||
expect(navContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
65
apps/web/modules/ui/components/tab-nav/index.tsx
Normal file
65
apps/web/modules/ui/components/tab-nav/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface TabNavProps {
|
||||
tabs: { id: string; label: string; icon?: React.ReactNode }[];
|
||||
activeId: string;
|
||||
setActiveId: (id: string) => void;
|
||||
activeTabClassName?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
tabs: { id: string; label: string; icon?: React.ReactNode }[];
|
||||
activeId: string;
|
||||
setActiveId: (id: string) => void;
|
||||
activeTabClassName?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Nav: React.FC<NavProps> = ({ tabs, activeId, setActiveId, activeTabClassName, disabled }) => {
|
||||
return (
|
||||
<nav className="flex h-full items-center space-x-3" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-full items-center px-3 text-sm font-medium",
|
||||
disabled
|
||||
? "cursor-not-allowed text-slate-400"
|
||||
: tab.id === activeId
|
||||
? `border-brand-dark text-primary border-b-2 font-semibold ${activeTabClassName}`
|
||||
: "text-slate-500 hover:text-slate-700"
|
||||
)}
|
||||
aria-current={tab.id === activeId ? "page" : undefined}>
|
||||
{tab.icon && <div className="flex h-5 w-5 items-center">{tab.icon}</div>}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export const TabNav: React.FC<TabNavProps> = ({
|
||||
tabs,
|
||||
activeId,
|
||||
setActiveId,
|
||||
activeTabClassName,
|
||||
disabled,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("flex h-14 w-full items-center justify-center rounded-t-md")}>
|
||||
<Nav
|
||||
tabs={tabs}
|
||||
activeId={activeId}
|
||||
setActiveId={setActiveId}
|
||||
activeTabClassName={activeTabClassName}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
143
apps/web/modules/ui/components/tab-nav/stories.tsx
Normal file
143
apps/web/modules/ui/components/tab-nav/stories.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { BarChart, FileText, Home, InfoIcon, KeyRound, Settings, User, UserIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { TabNav } from "./index";
|
||||
|
||||
// Story options separate from component props
|
||||
interface StoryOptions {
|
||||
showIcons: boolean;
|
||||
numberOfTabs: number;
|
||||
tabTexts: string;
|
||||
}
|
||||
|
||||
type StoryProps = React.ComponentProps<typeof TabNav> & StoryOptions;
|
||||
|
||||
// Available icons for tabs
|
||||
const availableIcons = [Home, User, Settings, UserIcon, KeyRound, InfoIcon, FileText, BarChart];
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/TabNav",
|
||||
component: TabNav,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: {
|
||||
sort: "requiredFirst",
|
||||
exclude: [],
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The **TabNav** component provides a navigation interface with tabs. It displays a horizontal bar with underline styling for the active tab. Each tab can include an optional icon.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
// Story Options - Appearance Category
|
||||
showIcons: {
|
||||
control: "boolean",
|
||||
description: "Whether to show icons in tabs",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "true" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
|
||||
// Story Options - Content Category
|
||||
numberOfTabs: {
|
||||
control: { type: "number", min: 2, max: 6, step: 1 },
|
||||
description: "Number of tabs to display",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "number" },
|
||||
defaultValue: { summary: "3" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
|
||||
tabTexts: {
|
||||
control: "text",
|
||||
description: "Comma-separated tab labels (e.g., 'Home,Profile,Settings')",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "Home,Profile,Settings" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof TabNav> & { args: StoryOptions };
|
||||
|
||||
// Create a render function to handle dynamic tab generation
|
||||
const renderTabNav = (args: StoryProps) => {
|
||||
const { showIcons = true, numberOfTabs = 3, tabTexts = "Home,Profile,Settings", activeTabClassName } = args;
|
||||
|
||||
// Parse tab texts from comma-separated string
|
||||
const tabLabels = tabTexts
|
||||
.split(",")
|
||||
.map((text) => text.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Ensure we have enough labels for the number of tabs
|
||||
const finalTabLabels = Array.from({ length: numberOfTabs }, (_, i) => tabLabels[i] || `Tab ${i + 1}`);
|
||||
|
||||
// Generate tabs array
|
||||
const tabs = finalTabLabels.map((label, index) => {
|
||||
const IconComponent = availableIcons[index % availableIcons.length];
|
||||
return {
|
||||
id: `tab-${index + 1}`,
|
||||
label,
|
||||
icon: showIcons ? <IconComponent size={16} /> : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Wrapper component to handle state for stories
|
||||
const TabNavWithState = () => {
|
||||
const [activeId, setActiveId] = useState(tabs[0]?.id || "tab-1");
|
||||
|
||||
return (
|
||||
// <div className="w-[60dvw]">
|
||||
<TabNav
|
||||
tabs={tabs}
|
||||
activeId={activeId}
|
||||
setActiveId={setActiveId}
|
||||
activeTabClassName={activeTabClassName}
|
||||
/>
|
||||
// </div>
|
||||
);
|
||||
};
|
||||
|
||||
return <TabNavWithState />;
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
render: renderTabNav,
|
||||
args: {
|
||||
showIcons: false,
|
||||
numberOfTabs: 3,
|
||||
tabTexts: "Home,Profile,Settings",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render: renderTabNav,
|
||||
args: {
|
||||
showIcons: true,
|
||||
numberOfTabs: 3,
|
||||
tabTexts: "Home,Profile,Settings",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Tab nav with icons alongside text labels.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
389
apps/web/modules/ui/components/tabs/index.test.tsx
Normal file
389
apps/web/modules/ui/components/tabs/index.test.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Home, Settings, User } from "lucide-react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./index";
|
||||
|
||||
describe("Tabs", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders tabs with default variant and size", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
||||
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
<TabsContent value="tab2">Content 2</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Tab 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tab 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Content 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Content 2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches tabs when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
||||
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
<TabsContent value="tab2">Content 2</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Tab 2"));
|
||||
|
||||
expect(screen.getByText("Content 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Content 1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with disabled variant", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList variant="disabled">
|
||||
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
||||
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const tabsList = screen.getByRole("tablist");
|
||||
expect(tabsList).toHaveClass("opacity-50");
|
||||
expect(tabsList).toHaveClass("pointer-events-none");
|
||||
});
|
||||
|
||||
test("renders with big size", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList size="big">
|
||||
<TabsTrigger value="tab1" size="big">
|
||||
Tab 1
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const tabsList = screen.getByRole("tablist");
|
||||
expect(tabsList).toHaveClass("h-auto");
|
||||
|
||||
const trigger = screen.getByRole("tab");
|
||||
expect(trigger).toHaveClass("px-3");
|
||||
expect(trigger).toHaveClass("py-2");
|
||||
});
|
||||
|
||||
test("renders triggers with icons", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1" icon={<Home data-testid="home-icon" />}>
|
||||
Home
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tab2" icon={<User data-testid="user-icon" />}>
|
||||
Profile
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Home Content</TabsContent>
|
||||
<TabsContent value="tab2">Profile Content</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("home-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides icons when showIcon is false", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1" icon={<Home data-testid="home-icon" />} showIcon={false}>
|
||||
Home
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Home Content</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("home-icon")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Home")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows icons when showIcon is true", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1" icon={<Home data-testid="home-icon" />} showIcon={true}>
|
||||
Home
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Home Content</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("home-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with column layout", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1" layout="column">
|
||||
Tab 1
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tab2" layout="column">
|
||||
Tab 2
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const triggers = screen.getAllByRole("tab");
|
||||
triggers.forEach((trigger) => {
|
||||
expect(trigger).toHaveClass("flex-col");
|
||||
expect(trigger).toHaveClass("gap-1");
|
||||
});
|
||||
});
|
||||
|
||||
test("renders with row layout", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1" layout="row">
|
||||
Tab 1
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tab2" layout="row">
|
||||
Tab 2
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const triggers = screen.getAllByRole("tab");
|
||||
triggers.forEach((trigger) => {
|
||||
expect(trigger).toHaveClass("flex-row");
|
||||
expect(trigger).toHaveClass("gap-2");
|
||||
});
|
||||
});
|
||||
|
||||
test("applies custom className to Tabs component", () => {
|
||||
const { container } = render(
|
||||
<Tabs defaultValue="tab1" className="custom-tabs-class">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const tabsContainer = container.firstChild as HTMLElement;
|
||||
expect(tabsContainer).toHaveClass("custom-tabs-class");
|
||||
});
|
||||
|
||||
test("applies custom className to TabsList", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList className="custom-list-class">
|
||||
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const tabsList = screen.getByRole("tablist");
|
||||
expect(tabsList).toHaveClass("custom-list-class");
|
||||
});
|
||||
|
||||
test("applies custom className to TabsTrigger", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1" className="custom-trigger-class">
|
||||
Tab 1
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("tab");
|
||||
expect(trigger).toHaveClass("custom-trigger-class");
|
||||
});
|
||||
|
||||
test("applies custom className to TabsContent", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1" className="custom-content-class">
|
||||
Content 1
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const content = screen.getByText("Content 1");
|
||||
expect(content).toHaveClass("custom-content-class");
|
||||
});
|
||||
|
||||
test("renders with disabled trigger variant", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1" variant="disabled">
|
||||
Tab 1
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("tab");
|
||||
expect(trigger).toHaveClass("opacity-50");
|
||||
expect(trigger).toHaveClass("pointer-events-none");
|
||||
});
|
||||
|
||||
test("handles keyboard navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
||||
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
|
||||
<TabsTrigger value="tab3">Tab 3</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
<TabsContent value="tab2">Content 2</TabsContent>
|
||||
<TabsContent value="tab3">Content 3</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const allTabs = screen.getAllByRole("tab");
|
||||
const firstTab = allTabs[0];
|
||||
const secondTab = allTabs[1];
|
||||
|
||||
await user.tab();
|
||||
expect(firstTab).toHaveFocus();
|
||||
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(secondTab).toHaveFocus();
|
||||
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(firstTab).toHaveFocus();
|
||||
});
|
||||
|
||||
test("renders with big size trigger and correct icon sizing", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList size="big">
|
||||
<TabsTrigger value="tab1" size="big" icon={<Settings data-testid="settings-icon" />}>
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Settings Content</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("tab");
|
||||
expect(trigger).toHaveClass("[&_svg]:size-8");
|
||||
expect(trigger).toHaveClass("[&_svg]:stroke-[1.5]");
|
||||
});
|
||||
|
||||
test("renders with default size trigger and correct icon sizing", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1" icon={<Settings data-testid="settings-icon" />}>
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Settings Content</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("tab");
|
||||
expect(trigger).toHaveClass("[&_svg]:size-4");
|
||||
expect(trigger).toHaveClass("[&_svg]:stroke-2");
|
||||
});
|
||||
|
||||
test("passes through additional props to components", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1" data-testid="tabs-root">
|
||||
<TabsList data-testid="tabs-list">
|
||||
<TabsTrigger value="tab1" data-testid="tabs-trigger">
|
||||
Tab 1
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1" data-testid="tabs-content">
|
||||
Content 1
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("tabs-root")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tabs-list")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tabs-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tabs-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with active state styling", () => {
|
||||
render(
|
||||
<Tabs defaultValue="tab1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
||||
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">Content 1</TabsContent>
|
||||
<TabsContent value="tab2">Content 2</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
const allTabs = screen.getAllByRole("tab");
|
||||
const activeTab = allTabs[0];
|
||||
const inactiveTab = allTabs[1];
|
||||
|
||||
expect(activeTab).toHaveClass("data-[state=active]:bg-white");
|
||||
expect(activeTab).toHaveClass("data-[state=active]:text-slate-900");
|
||||
expect(activeTab).toHaveClass("data-[state=active]:shadow-sm");
|
||||
|
||||
expect(inactiveTab).toHaveClass("data-[state=inactive]:text-slate-600");
|
||||
});
|
||||
|
||||
test("renders multiple tabs with complex layout", () => {
|
||||
render(
|
||||
<Tabs defaultValue="home">
|
||||
<TabsList>
|
||||
<TabsTrigger value="home" icon={<Home />} layout="column" size="big">
|
||||
Home
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="profile" icon={<User />} layout="column" size="big">
|
||||
Profile
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" icon={<Settings />} layout="column" size="big">
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="home">Home Content</TabsContent>
|
||||
<TabsContent value="profile">Profile Content</TabsContent>
|
||||
<TabsContent value="settings">Settings Content</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Home")).toBeInTheDocument();
|
||||
expect(screen.getByText("Profile")).toBeInTheDocument();
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Home Content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
129
apps/web/modules/ui/components/tabs/index.tsx
Normal file
129
apps/web/modules/ui/components/tabs/index.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const tabsVariants = cva(
|
||||
"bg-slate-100 rounded-lg p-1 inline-flex items-center overflow-x-auto [scrollbar-width:none]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
disabled: "opacity-50 pointer-events-none",
|
||||
},
|
||||
size: {
|
||||
default: "h-9",
|
||||
big: "h-auto",
|
||||
},
|
||||
width: {
|
||||
fill: "w-full",
|
||||
fit: "w-fit max-w-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
width: "fit",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const tabsTriggerVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"data-[state=active]:bg-white data-[state=active]:text-slate-900 data-[state=active]:shadow-sm data-[state=inactive]:text-slate-600",
|
||||
disabled: "opacity-50 pointer-events-none",
|
||||
},
|
||||
size: {
|
||||
default: "px-3 py-1 [&_svg]:size-4 [&_svg]:stroke-2",
|
||||
big: "px-3 py-2 [&_svg]:size-8 [&_svg]:stroke-[1.5]",
|
||||
},
|
||||
layout: {
|
||||
row: "flex-row gap-2",
|
||||
column: "flex-col gap-1",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
layout: "row",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface TabsProps extends React.ComponentProps<typeof TabsPrimitive.Root> {}
|
||||
|
||||
function Tabs({ className, ...props }: TabsProps) {
|
||||
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
interface TabsListProps
|
||||
extends React.ComponentProps<typeof TabsPrimitive.List>,
|
||||
VariantProps<typeof tabsVariants> {}
|
||||
|
||||
interface TabsTriggerProps
|
||||
extends React.ComponentProps<typeof TabsPrimitive.Trigger>,
|
||||
VariantProps<typeof tabsTriggerVariants> {
|
||||
readonly icon?: React.ReactNode;
|
||||
readonly showIcon?: boolean;
|
||||
}
|
||||
|
||||
function TabsList({ className, variant, size, width, ...props }: TabsListProps) {
|
||||
const isGridLayout = width === "fill";
|
||||
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
tabsVariants({ variant, size, width }),
|
||||
isGridLayout ? "grid grid-cols-[repeat(var(--tabs-count),1fr)]" : "flex",
|
||||
className
|
||||
)}
|
||||
style={
|
||||
isGridLayout
|
||||
? ({ "--tabs-count": React.Children.count(props.children) } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
layout,
|
||||
icon,
|
||||
showIcon = true,
|
||||
children,
|
||||
...props
|
||||
}: TabsTriggerProps) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(tabsTriggerVariants({ variant, size, layout }), "h-full min-w-max", className)}
|
||||
{...props}>
|
||||
{showIcon && icon}
|
||||
<span className="text-center text-sm font-medium leading-5">{children}</span>
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
export type { TabsProps, TabsListProps, TabsTriggerProps };
|
||||
266
apps/web/modules/ui/components/tabs/stories.tsx
Normal file
266
apps/web/modules/ui/components/tabs/stories.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BarChart, FileText, InfoIcon, KeyRound, Settings, UserIcon } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./index";
|
||||
|
||||
// Story options separate from component props
|
||||
interface StoryOptions {
|
||||
size?: "default" | "big";
|
||||
showIcons?: boolean;
|
||||
numberOfTabs?: number;
|
||||
tabTexts?: string;
|
||||
width?: "fill" | "fit";
|
||||
variant?: "default" | "disabled";
|
||||
}
|
||||
|
||||
type StoryProps = React.ComponentProps<typeof Tabs> & StoryOptions;
|
||||
|
||||
// Available icons for tabs
|
||||
const availableIcons = [UserIcon, KeyRound, InfoIcon, Settings, FileText, BarChart];
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/Tabs",
|
||||
component: Tabs,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: {
|
||||
sort: "requiredFirst",
|
||||
exclude: [],
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
// Story Options - Appearance Category
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["default", "big"],
|
||||
description: "Size of the tabs",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
showIcons: {
|
||||
control: "boolean",
|
||||
description: "Whether to show icons in tabs",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "true" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
width: {
|
||||
control: "select",
|
||||
options: ["fill", "fit"],
|
||||
description: "Width behavior of the tabs component",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "fit" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
|
||||
// Story Options - Content Category
|
||||
numberOfTabs: {
|
||||
control: { type: "number", min: 2, max: 6, step: 1 },
|
||||
description: "Number of tabs to display",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "number" },
|
||||
defaultValue: { summary: "2" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
tabTexts: {
|
||||
control: "text",
|
||||
description: "Comma-separated tab labels (e.g., 'Account,Password,Settings')",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "Account,Password" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
|
||||
// Story Options - Behaviour Category
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "disabled"],
|
||||
description: "Variant of the tabs",
|
||||
table: {
|
||||
category: "Behaviour",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Create a render function to handle dynamic tab generation
|
||||
const renderTabs = (args: StoryProps) => {
|
||||
const {
|
||||
variant = "default",
|
||||
size = "default",
|
||||
width = "fit",
|
||||
showIcons = true,
|
||||
numberOfTabs = 2,
|
||||
tabTexts = "Account,Password",
|
||||
defaultValue,
|
||||
...tabsProps
|
||||
} = args;
|
||||
|
||||
// Parse tab texts from comma-separated string
|
||||
const tabLabels = tabTexts
|
||||
.split(",")
|
||||
.map((text) => text.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Ensure we have enough labels for the number of tabs
|
||||
const finalTabLabels = Array.from({ length: numberOfTabs }, (_, i) => tabLabels[i] || `Tab ${i + 1}`);
|
||||
|
||||
// Generate tab values
|
||||
const tabValues = finalTabLabels.map((_, i) => `tab${i + 1}`);
|
||||
|
||||
const layout = size === "big" ? "column" : "row";
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
<Tabs defaultValue={defaultValue || tabValues[0]} {...tabsProps}>
|
||||
<TabsList variant={variant} size={size} width={width}>
|
||||
{finalTabLabels.map((label, index) => {
|
||||
const IconComponent = availableIcons[index % availableIcons.length];
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={tabValues[index]}
|
||||
value={tabValues[index]}
|
||||
layout={layout}
|
||||
size={size}
|
||||
icon={showIcons ? <IconComponent /> : undefined}
|
||||
showIcon={showIcons}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
{finalTabLabels.map((label, index) => (
|
||||
<TabsContent key={tabValues[index]} value={tabValues[index]} className="mt-4">
|
||||
<div className="rounded-lg border p-6 text-sm">
|
||||
Content for {label} tab. This content can be of varying lengths to demonstrate how the tabs
|
||||
component handles different content widths.
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
render: renderTabs,
|
||||
args: {
|
||||
size: "default",
|
||||
width: "fit",
|
||||
showIcons: false,
|
||||
numberOfTabs: 2,
|
||||
tabTexts: "Account,Password",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render: renderTabs,
|
||||
args: {
|
||||
size: "default",
|
||||
width: "fit",
|
||||
showIcons: true,
|
||||
numberOfTabs: 2,
|
||||
tabTexts: "Account,Password",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Tabs with icons for enhanced visual navigation.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FillWidth: Story = {
|
||||
render: renderTabs,
|
||||
args: {
|
||||
size: "default",
|
||||
width: "fill",
|
||||
showIcons: true,
|
||||
numberOfTabs: 2,
|
||||
tabTexts: "Account,Password",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Tabs that stretch to fill the full width of the parent, with triggers evenly distributed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FitWidth: Story = {
|
||||
render: renderTabs,
|
||||
args: {
|
||||
size: "default",
|
||||
width: "fit",
|
||||
showIcons: true,
|
||||
numberOfTabs: 2,
|
||||
tabTexts: "Account,Password",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Tabs that fit their content and are centered, with all triggers having equal width.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const BigSize: Story = {
|
||||
render: renderTabs,
|
||||
args: {
|
||||
size: "big",
|
||||
width: "fit",
|
||||
showIcons: true,
|
||||
numberOfTabs: 2,
|
||||
tabTexts: "Account,Password",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Larger tabs with column layout, useful for more prominent navigation.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: renderTabs,
|
||||
args: {
|
||||
variant: "disabled",
|
||||
size: "default",
|
||||
width: "fit",
|
||||
showIcons: true,
|
||||
numberOfTabs: 2,
|
||||
tabTexts: "Account,Password",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Disabled tabs that cannot be interacted with.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -147,9 +147,15 @@ export interface ApiErrorResponse {
|
||||
responseMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error types for UI display
|
||||
*/
|
||||
export type ClientErrorType = "rate_limit" | "general";
|
||||
|
||||
export interface ClientErrorData {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Error type to determine which translations to use */
|
||||
type: ClientErrorType;
|
||||
/** Whether to show action buttons */
|
||||
showButtons?: boolean;
|
||||
}
|
||||
|
||||
@@ -160,16 +166,14 @@ export const getClientErrorData = (error: Error): ClientErrorData => {
|
||||
// Check by error name as fallback (in case instanceof fails due to module loading issues)
|
||||
if (error.name === "TooManyRequestsError") {
|
||||
return {
|
||||
title: "common.error_rate_limit_title",
|
||||
description: "common.error_rate_limit_description",
|
||||
type: "rate_limit",
|
||||
showButtons: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Default to general error for any other error
|
||||
return {
|
||||
title: "common.error_component_title",
|
||||
description: "common.error_component_description",
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user