mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-02 01:15:31 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ebdeae5a5 | ||
|
|
f1678d7ac2 | ||
|
|
eff97e7404 | ||
|
|
b736ac320f | ||
|
|
c41e7e735b | ||
|
|
137fb6388f | ||
|
|
4648bb29d3 | ||
|
|
44e36421b1 |
No files matched your search
@@ -12,135 +12,139 @@ Use these skills for detailed patterns on-demand:
|
||||
|
||||
### Generic Skills (Any Project)
|
||||
|
||||
| Skill | Description | URL |
|
||||
|-------|-------------|-----|
|
||||
| `typescript` | Const types, flat interfaces, utility types | [SKILL.md](skills/typescript/SKILL.md) |
|
||||
| `react-19` | No useMemo/useCallback, React Compiler | [SKILL.md](skills/react-19/SKILL.md) |
|
||||
| `nextjs-16` | App Router, Server Actions, proxy.ts, streaming | [SKILL.md](skills/nextjs-16/SKILL.md) |
|
||||
| `tailwind-4` | cn() utility, no var() in className | [SKILL.md](skills/tailwind-4/SKILL.md) |
|
||||
| `playwright` | Page Object Model, MCP workflow, selectors | [SKILL.md](skills/playwright/SKILL.md) |
|
||||
| `pytest` | Fixtures, mocking, markers, parametrize | [SKILL.md](skills/pytest/SKILL.md) |
|
||||
| `django-drf` | ViewSets, Serializers, Filters | [SKILL.md](skills/django-drf/SKILL.md) |
|
||||
| `jsonapi` | Strict JSON:API v1.1 spec compliance | [SKILL.md](skills/jsonapi/SKILL.md) |
|
||||
| `zod-4` | New API (z.email(), z.uuid()) | [SKILL.md](skills/zod-4/SKILL.md) |
|
||||
| `zustand-5` | Persist, selectors, slices | [SKILL.md](skills/zustand-5/SKILL.md) |
|
||||
| `ai-sdk-5` | UIMessage, streaming, LangChain | [SKILL.md](skills/ai-sdk-5/SKILL.md) |
|
||||
| `vitest` | Unit testing, React Testing Library | [SKILL.md](skills/vitest/SKILL.md) |
|
||||
| `tdd` | Test-Driven Development workflow | [SKILL.md](skills/tdd/SKILL.md) |
|
||||
| Skill | Description | URL |
|
||||
| ------------ | ----------------------------------------------- | -------------------------------------- |
|
||||
| `typescript` | Const types, flat interfaces, utility types | [SKILL.md](skills/typescript/SKILL.md) |
|
||||
| `react-19` | No useMemo/useCallback, React Compiler | [SKILL.md](skills/react-19/SKILL.md) |
|
||||
| `nextjs-16` | App Router, Server Actions, proxy.ts, streaming | [SKILL.md](skills/nextjs-16/SKILL.md) |
|
||||
| `tailwind-4` | cn() utility, no var() in className | [SKILL.md](skills/tailwind-4/SKILL.md) |
|
||||
| `playwright` | Page Object Model, MCP workflow, selectors | [SKILL.md](skills/playwright/SKILL.md) |
|
||||
| `pytest` | Fixtures, mocking, markers, parametrize | [SKILL.md](skills/pytest/SKILL.md) |
|
||||
| `django-drf` | ViewSets, Serializers, Filters | [SKILL.md](skills/django-drf/SKILL.md) |
|
||||
| `jsonapi` | Strict JSON:API v1.1 spec compliance | [SKILL.md](skills/jsonapi/SKILL.md) |
|
||||
| `zod-4` | New API (z.email(), z.uuid()) | [SKILL.md](skills/zod-4/SKILL.md) |
|
||||
| `zustand-5` | Persist, selectors, slices | [SKILL.md](skills/zustand-5/SKILL.md) |
|
||||
| `ai-sdk-5` | UIMessage, streaming, LangChain | [SKILL.md](skills/ai-sdk-5/SKILL.md) |
|
||||
| `vitest` | Unit testing, React Testing Library | [SKILL.md](skills/vitest/SKILL.md) |
|
||||
| `tdd` | Test-Driven Development workflow | [SKILL.md](skills/tdd/SKILL.md) |
|
||||
|
||||
### Prowler-Specific Skills
|
||||
|
||||
| Skill | Description | URL |
|
||||
|-------|-------------|-----|
|
||||
| `prowler` | Project overview, component navigation | [SKILL.md](skills/prowler/SKILL.md) |
|
||||
| `prowler-api` | Django + RLS + JSON:API patterns | [SKILL.md](skills/prowler-api/SKILL.md) |
|
||||
| `prowler-ui` | Next.js + shadcn conventions | [SKILL.md](skills/prowler-ui/SKILL.md) |
|
||||
| `prowler-sdk-check` | Create new security checks | [SKILL.md](skills/prowler-sdk-check/SKILL.md) |
|
||||
| `prowler-mcp` | MCP server tools and models | [SKILL.md](skills/prowler-mcp/SKILL.md) |
|
||||
| `prowler-test-sdk` | SDK testing (pytest + moto) | [SKILL.md](skills/prowler-test-sdk/SKILL.md) |
|
||||
| `prowler-test-api` | API testing (pytest-django + RLS) | [SKILL.md](skills/prowler-test-api/SKILL.md) |
|
||||
| `prowler-test-ui` | E2E testing (Playwright) | [SKILL.md](skills/prowler-test-ui/SKILL.md) |
|
||||
| `prowler-compliance` | Compliance framework structure | [SKILL.md](skills/prowler-compliance/SKILL.md) |
|
||||
| `prowler-compliance-review` | Review compliance framework PRs | [SKILL.md](skills/prowler-compliance-review/SKILL.md) |
|
||||
| `prowler-provider` | Add new cloud providers | [SKILL.md](skills/prowler-provider/SKILL.md) |
|
||||
| `prowler-changelog` | Changelog entries (keepachangelog.com) | [SKILL.md](skills/prowler-changelog/SKILL.md) |
|
||||
| `prowler-ci` | CI checks and PR gates (GitHub Actions) | [SKILL.md](skills/prowler-ci/SKILL.md) |
|
||||
| `prowler-commit` | Professional commits (conventional-commits) | [SKILL.md](skills/prowler-commit/SKILL.md) |
|
||||
| `prowler-pr` | Pull request conventions | [SKILL.md](skills/prowler-pr/SKILL.md) |
|
||||
| `prowler-docs` | Documentation style guide | [SKILL.md](skills/prowler-docs/SKILL.md) |
|
||||
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
|
||||
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
|
||||
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
|
||||
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
|
||||
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
|
||||
| Skill | Description | URL |
|
||||
| ---------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| `prowler` | Project overview, component navigation | [SKILL.md](skills/prowler/SKILL.md) |
|
||||
| `prowler-api` | Django + RLS + JSON:API patterns | [SKILL.md](skills/prowler-api/SKILL.md) |
|
||||
| `prowler-ui` | Next.js + shadcn conventions | [SKILL.md](skills/prowler-ui/SKILL.md) |
|
||||
| `prowler-ui-motion` | shadcn/Radix visible microinteraction conventions | [SKILL.md](skills/prowler-ui-motion/SKILL.md) |
|
||||
| `prowler-ui-skeletons` | shadcn skeleton loading and content reveal conventions | [SKILL.md](skills/prowler-ui-skeletons/SKILL.md) |
|
||||
| `prowler-sdk-check` | Create new security checks | [SKILL.md](skills/prowler-sdk-check/SKILL.md) |
|
||||
| `prowler-mcp` | MCP server tools and models | [SKILL.md](skills/prowler-mcp/SKILL.md) |
|
||||
| `prowler-test-sdk` | SDK testing (pytest + moto) | [SKILL.md](skills/prowler-test-sdk/SKILL.md) |
|
||||
| `prowler-test-api` | API testing (pytest-django + RLS) | [SKILL.md](skills/prowler-test-api/SKILL.md) |
|
||||
| `prowler-test-ui` | E2E testing (Playwright) | [SKILL.md](skills/prowler-test-ui/SKILL.md) |
|
||||
| `prowler-compliance` | Compliance framework structure | [SKILL.md](skills/prowler-compliance/SKILL.md) |
|
||||
| `prowler-compliance-review` | Review compliance framework PRs | [SKILL.md](skills/prowler-compliance-review/SKILL.md) |
|
||||
| `prowler-provider` | Add new cloud providers | [SKILL.md](skills/prowler-provider/SKILL.md) |
|
||||
| `prowler-changelog` | Changelog entries (keepachangelog.com) | [SKILL.md](skills/prowler-changelog/SKILL.md) |
|
||||
| `prowler-ci` | CI checks and PR gates (GitHub Actions) | [SKILL.md](skills/prowler-ci/SKILL.md) |
|
||||
| `prowler-commit` | Professional commits (conventional-commits) | [SKILL.md](skills/prowler-commit/SKILL.md) |
|
||||
| `prowler-pr` | Pull request conventions | [SKILL.md](skills/prowler-pr/SKILL.md) |
|
||||
| `prowler-docs` | Documentation style guide | [SKILL.md](skills/prowler-docs/SKILL.md) |
|
||||
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
|
||||
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
|
||||
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
|
||||
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
|
||||
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
|
||||
|
||||
### Auto-invoke Skills
|
||||
|
||||
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|
||||
| Action | Skill |
|
||||
|--------|-------|
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
|
||||
| Adding indexes or constraints to database tables | `django-migration-psql` |
|
||||
| Adding new providers | `prowler-provider` |
|
||||
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
|
||||
| Adding services to existing providers | `prowler-provider` |
|
||||
| After creating/modifying a skill | `skill-sync` |
|
||||
| App Router / Server Actions | `nextjs-16` |
|
||||
| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Configuring MCP servers in agentic workflows | `gh-aw` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
| Create a PR with gh pr create | `prowler-pr` |
|
||||
| Creating API endpoints | `jsonapi` |
|
||||
| Creating Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Creating GitHub Agentic Workflows | `gh-aw` |
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating new checks | `prowler-sdk-check` |
|
||||
| Creating new skills | `skill-creator` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
| Creating/modifying Prowler UI components | `prowler-ui` |
|
||||
| Creating/modifying models, views, serializers | `prowler-api` |
|
||||
| Creating/updating compliance frameworks | `prowler-compliance` |
|
||||
| Debug why a GitHub Actions job is failing | `prowler-ci` |
|
||||
| Debugging gh-aw compilation errors | `gh-aw` |
|
||||
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` |
|
||||
| General Prowler development questions | `prowler` |
|
||||
| Implementing JSON:API endpoints | `django-drf` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Importing Copilot Custom Agents into workflows | `gh-aw` |
|
||||
| Inspect PR CI checks and gates (.github/workflows/*) | `prowler-ci` |
|
||||
| Inspect PR CI workflows (.github/workflows/*): conventional-commit, pr-check-changelog, pr-conflict-checker, labeler | `prowler-pr` |
|
||||
| Mapping checks to compliance controls | `prowler-compliance` |
|
||||
| Mocking AWS with moto in tests | `prowler-test-sdk` |
|
||||
| Modifying API responses | `jsonapi` |
|
||||
| Modifying component | `tdd` |
|
||||
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
|
||||
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Reviewing JSON:API compliance | `jsonapi` |
|
||||
| Reviewing compliance framework PRs | `prowler-compliance-review` |
|
||||
| Running makemigrations or pgmakemigrations | `django-migration-psql` |
|
||||
| Syncing compliance framework with upstream catalog | `prowler-compliance` |
|
||||
| Testing RLS tenant isolation | `prowler-test-api` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Troubleshoot why a skill is missing from AGENTS.md auto-invoke | `skill-sync` |
|
||||
| Understand CODEOWNERS/labeler-based automation | `prowler-ci` |
|
||||
| Understand PR title conventional-commit validation | `prowler-ci` |
|
||||
| Understand changelog gate and no-changelog label behavior | `prowler-ci` |
|
||||
| Understand review ownership with CODEOWNERS | `prowler-pr` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Updating README.md provider statistics table | `prowler-readme-table` |
|
||||
| Updating checks, services, compliance, or categories count in README.md | `prowler-readme-table` |
|
||||
| Updating existing Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Updating existing checks and metadata | `prowler-sdk-check` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
| Working on MCP server tools | `prowler-mcp` |
|
||||
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
|
||||
| Working on task | `tdd` |
|
||||
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
|
||||
| Working with Tailwind classes | `tailwind-4` |
|
||||
| Writing Playwright E2E tests | `playwright` |
|
||||
| Writing Prowler API tests | `prowler-test-api` |
|
||||
| Writing Prowler SDK tests | `prowler-test-sdk` |
|
||||
| Writing Prowler UI E2E tests | `prowler-test-ui` |
|
||||
| Writing Python tests with pytest | `pytest` |
|
||||
| Writing React component tests | `vitest` |
|
||||
| Writing React components | `react-19` |
|
||||
| Writing TypeScript types/interfaces | `typescript` |
|
||||
| Writing Vitest tests | `vitest` |
|
||||
| Writing data backfill or data migration | `django-migration-psql` |
|
||||
| Writing documentation | `prowler-docs` |
|
||||
| Writing unit tests for UI | `vitest` |
|
||||
| Action | Skill |
|
||||
| --------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
|
||||
| Adding indexes or constraints to database tables | `django-migration-psql` |
|
||||
| Adding new providers | `prowler-provider` |
|
||||
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
|
||||
| Adding services to existing providers | `prowler-provider` |
|
||||
| After creating/modifying a skill | `skill-sync` |
|
||||
| App Router / Server Actions | `nextjs-16` |
|
||||
| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Configuring MCP servers in agentic workflows | `gh-aw` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
| Create a PR with gh pr create | `prowler-pr` |
|
||||
| Creating API endpoints | `jsonapi` |
|
||||
| Creating Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Creating GitHub Agentic Workflows | `gh-aw` |
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating new checks | `prowler-sdk-check` |
|
||||
| Creating new skills | `skill-creator` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
| Creating/modifying Prowler UI components | `prowler-ui` |
|
||||
| Creating/modifying UI motion, transitions, or microinteractions | `prowler-ui-motion` |
|
||||
| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` |
|
||||
| Creating/modifying models, views, serializers | `prowler-api` |
|
||||
| Creating/updating compliance frameworks | `prowler-compliance` |
|
||||
| Debug why a GitHub Actions job is failing | `prowler-ci` |
|
||||
| Debugging gh-aw compilation errors | `gh-aw` |
|
||||
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` |
|
||||
| General Prowler development questions | `prowler` |
|
||||
| Implementing JSON:API endpoints | `django-drf` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Importing Copilot Custom Agents into workflows | `gh-aw` |
|
||||
| Inspect PR CI checks and gates (.github/workflows/\*) | `prowler-ci` |
|
||||
| Inspect PR CI workflows (.github/workflows/\*): conventional-commit, pr-check-changelog, pr-conflict-checker, labeler | `prowler-pr` |
|
||||
| Mapping checks to compliance controls | `prowler-compliance` |
|
||||
| Mocking AWS with moto in tests | `prowler-test-sdk` |
|
||||
| Modifying API responses | `jsonapi` |
|
||||
| Modifying component | `tdd` |
|
||||
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
|
||||
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Reviewing JSON:API compliance | `jsonapi` |
|
||||
| Reviewing compliance framework PRs | `prowler-compliance-review` |
|
||||
| Running makemigrations or pgmakemigrations | `django-migration-psql` |
|
||||
| Syncing compliance framework with upstream catalog | `prowler-compliance` |
|
||||
| Testing RLS tenant isolation | `prowler-test-api` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Troubleshoot why a skill is missing from AGENTS.md auto-invoke | `skill-sync` |
|
||||
| Understand CODEOWNERS/labeler-based automation | `prowler-ci` |
|
||||
| Understand PR title conventional-commit validation | `prowler-ci` |
|
||||
| Understand changelog gate and no-changelog label behavior | `prowler-ci` |
|
||||
| Understand review ownership with CODEOWNERS | `prowler-pr` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Updating README.md provider statistics table | `prowler-readme-table` |
|
||||
| Updating checks, services, compliance, or categories count in README.md | `prowler-readme-table` |
|
||||
| Updating existing Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Updating existing checks and metadata | `prowler-sdk-check` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
| Working on MCP server tools | `prowler-mcp` |
|
||||
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
|
||||
| Working on task | `tdd` |
|
||||
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
|
||||
| Working with Tailwind classes | `tailwind-4` |
|
||||
| Writing Playwright E2E tests | `playwright` |
|
||||
| Writing Prowler API tests | `prowler-test-api` |
|
||||
| Writing Prowler SDK tests | `prowler-test-sdk` |
|
||||
| Writing Prowler UI E2E tests | `prowler-test-ui` |
|
||||
| Writing Python tests with pytest | `pytest` |
|
||||
| Writing React component tests | `vitest` |
|
||||
| Writing React components | `react-19` |
|
||||
| Writing TypeScript types/interfaces | `typescript` |
|
||||
| Writing Vitest tests | `vitest` |
|
||||
| Writing data backfill or data migration | `django-migration-psql` |
|
||||
| Writing documentation | `prowler-docs` |
|
||||
| Writing unit tests for UI | `vitest` |
|
||||
|
||||
---
|
||||
|
||||
@@ -148,13 +152,13 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|
||||
Prowler is an open-source cloud security assessment tool supporting AWS, Azure, GCP, Kubernetes, GitHub, M365, and more.
|
||||
|
||||
| Component | Location | Tech Stack |
|
||||
|-----------|----------|------------|
|
||||
| SDK | `prowler/` | Python 3.10+, uv |
|
||||
| API | `api/` | Django 5.1, DRF, Celery |
|
||||
| UI | `ui/` | Next.js 16, React 19, Tailwind 4 |
|
||||
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
|
||||
| Dashboard | `dashboard/` | Dash, Plotly |
|
||||
| Component | Location | Tech Stack |
|
||||
| ---------- | ------------- | -------------------------------- |
|
||||
| SDK | `prowler/` | Python 3.10+, uv |
|
||||
| API | `api/` | Django 5.1, DRF, Celery |
|
||||
| UI | `ui/` | Next.js 16, React 19, Tailwind 4 |
|
||||
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
|
||||
| Dashboard | `dashboard/` | Dash, Plotly |
|
||||
|
||||
---
|
||||
|
||||
@@ -180,6 +184,7 @@ Follow conventional-commit style: `<type>[scope]: <description>`
|
||||
**Types:** `feat`, `fix`, `docs`, `chore`, `perf`, `refactor`, `style`, `test`
|
||||
|
||||
Before creating a PR:
|
||||
|
||||
1. Complete checklist in `.github/pull_request_template.md`
|
||||
2. Run all relevant tests and linters
|
||||
3. Link screenshots for UI changes
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: prowler-ui-motion
|
||||
description: >
|
||||
Prowler UI visible microinteraction rules for shadcn primitives, forms, tabs, expandable rows, status states, and motion QA.
|
||||
Trigger: Creating/modifying UI motion, transitions, animations, microinteractions, Radix/shadcn primitives, or interactive table rows.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
version: "1.0"
|
||||
scope: [root, ui]
|
||||
auto_invoke:
|
||||
- "Creating/modifying UI motion"
|
||||
- "Creating/modifying microinteractions"
|
||||
- "Creating/modifying shadcn primitives"
|
||||
- "Creating/modifying expandable rows"
|
||||
---
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill before adding or changing visible UI motion in Prowler: shadcn/Radix primitives, form controls, overlays, tabs, expandable rows, table affordances, status badges, progress, spinners, skeleton handoffs, and icon-only actions.
|
||||
|
||||
## Critical Patterns
|
||||
|
||||
- Prefer shadcn/Tailwind motion; do not add new HeroUI motion surfaces.
|
||||
- Motion must be visible, not theoretical: use durations humans can perceive (`200ms–700ms` depending on scope).
|
||||
- Preserve Radix state semantics; animate via classes, force-mounted indicators, `data-state`, or `asChild` wrappers without breaking accessibility.
|
||||
- Always include `motion-reduce` behavior for transform/animation-heavy changes.
|
||||
- Opening and closing must both animate when the component supports unmount/exit behavior.
|
||||
- Keep row/background transitions separate from control/checkmark transitions; do not couple table selection backgrounds to checkbox internals.
|
||||
- Avoid feature-local motion copies when a shared primitive owns the interaction.
|
||||
- For skeleton/loading handoffs, load `prowler-ui-skeletons` and follow its boundary/reveal rules.
|
||||
|
||||
## Decision Gates
|
||||
|
||||
| Surface | Required motion contract |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| Dialog, Drawer, Popover, Dropdown, Select, Combobox | Enter and exit motion; preserve focus/portal behavior. |
|
||||
| Tabs | Content switch should fade/slide; inactive panels must not flash. |
|
||||
| Checkbox, Radio, pills, badges | Background/icon/content state changes should transition together. |
|
||||
| Input, SearchInput, Textarea, Dropzone | Focus border, clear button, placeholder/selection affordances need visible timing. |
|
||||
| Collapsible, Tree, expandable table row | Expand and collapse must both animate height/opacity/chevron. |
|
||||
| StatusBadge, Progress, Spinner | State/color/value changes should feel smooth and respect reduced motion. |
|
||||
| Data table affordances | Row hover/selection may animate, but do not break table layout semantics. |
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. Identify whether the interaction belongs to a shared primitive or a feature-local surface.
|
||||
2. Add the smallest shared motion primitive that covers the behavior.
|
||||
3. Verify enter and exit paths, including closed/unmounted states.
|
||||
4. Add focused unit tests for shared primitives or reusable motion contracts.
|
||||
5. Provide at least one real UI route/flow where the user can visually test the motion.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cd ui && pnpm run typecheck
|
||||
cd ui && pnpm test:unit <focused-test-files>
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- `skills/prowler-ui-skeletons/SKILL.md` — skeleton scanner and content reveal rules.
|
||||
- `skills/prowler-ui/SKILL.md` — component placement and shadcn vs HeroUI rules.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: prowler-ui-skeletons
|
||||
description: "Trigger: skeleton, loading state, Suspense fallback, content reveal, shimmer. Use Prowler shadcn skeletons correctly."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
version: "1.0"
|
||||
scope: [root, ui]
|
||||
auto_invoke:
|
||||
- "Creating/modifying skeletons"
|
||||
- "Creating/modifying loading states"
|
||||
- "Adding Suspense fallbacks"
|
||||
---
|
||||
|
||||
## Activation Contract
|
||||
|
||||
Use this skill before creating or modifying any Prowler UI skeleton, loading placeholder, Suspense fallback, or loading-to-content transition.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Prefer shadcn `Skeleton` from `@/components/shadcn`; do not add new HeroUI skeletons.
|
||||
- Do not mix HeroUI and shadcn inside the same new loading surface.
|
||||
- Keep scanner/shimmer behavior centralized in shadcn `Skeleton`; never duplicate scanner CSS in feature files.
|
||||
- For Suspense data loading, wrap the boundary with `SkeletonBoundary` so fallback removal and real content reveal are paired.
|
||||
- For client-state loading (`isLoading`, drawers, modals, expanded rows), add a reveal wrapper around the resolved content, not around the skeleton.
|
||||
- Respect `motion-reduce`; every animation must degrade to no transform/transition.
|
||||
- Preserve layout stability: skeleton dimensions must match the final content as closely as practical.
|
||||
- Do not migrate legacy/HeroUI skeletons unless the task explicitly includes that migration.
|
||||
|
||||
## Decision Gates
|
||||
|
||||
| Situation | Action |
|
||||
| --- | --- |
|
||||
| Page/server data with `Suspense` fallback | Use `SkeletonBoundary` with the skeleton fallback. |
|
||||
| Nested Suspense inside tab/chart content | Use `SkeletonBoundary` unless the fallback is legacy/HeroUI. |
|
||||
| Client state swaps skeleton to content | Keep shadcn `Skeleton`; wrap resolved content with `SkeletonContentReveal` or an equivalent shared reveal. |
|
||||
| Existing HeroUI skeleton | Leave unchanged unless migration is explicitly requested. |
|
||||
| Text-only `Loading...` fallback | Replace only if the requested scope includes that surface. |
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. Identify whether the skeleton is shadcn, HeroUI legacy, or text-only fallback.
|
||||
2. If shadcn + Suspense, use `SkeletonBoundary` instead of raw `Suspense`.
|
||||
3. If shadcn + client state, keep the skeleton fallback and reveal only the loaded content.
|
||||
4. Verify reduced-motion classes remain present.
|
||||
5. Add or update focused tests when changing shared skeleton primitives or reusable boundaries.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report:
|
||||
- Which loading surfaces changed.
|
||||
- Whether each surface is Suspense-boundary or client-state loading.
|
||||
- Which legacy/HeroUI skeletons were intentionally left untouched.
|
||||
- Test/typecheck evidence when implementation changes are made.
|
||||
|
||||
## References
|
||||
|
||||
- `ui/components/shadcn/skeleton/skeleton.tsx`
|
||||
- `ui/components/shadcn/skeleton/skeleton-boundary.tsx`
|
||||
- `ui/components/shadcn/skeleton/skeleton-content-reveal.tsx`
|
||||
+32
-29
@@ -3,6 +3,8 @@
|
||||
> **Skills Reference**: For detailed patterns, use these skills:
|
||||
>
|
||||
> - [`prowler-ui`](../skills/prowler-ui/SKILL.md) - Prowler-specific UI patterns
|
||||
> - [`prowler-ui-motion`](../skills/prowler-ui-motion/SKILL.md) - shadcn/Radix visible microinteraction conventions
|
||||
> - [`prowler-ui-skeletons`](../skills/prowler-ui-skeletons/SKILL.md) - shadcn skeleton loading and content reveal conventions
|
||||
> - [`prowler-test-ui`](../skills/prowler-test-ui/SKILL.md) - Playwright E2E testing (comprehensive)
|
||||
> - [`typescript`](../skills/typescript/SKILL.md) - Const types, flat interfaces
|
||||
> - [`react-19`](../skills/react-19/SKILL.md) - No useMemo/useCallback, compiler
|
||||
@@ -19,35 +21,36 @@
|
||||
|
||||
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|
||||
| Action | Skill |
|
||||
| -------------------------------------------------------------- | ------------------- |
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| App Router / Server Actions | `nextjs-16` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating/modifying Prowler UI components | `prowler-ui` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Modifying component | `tdd` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
|
||||
| Working on task | `tdd` |
|
||||
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
|
||||
| Working with Tailwind classes | `tailwind-4` |
|
||||
| Writing Playwright E2E tests | `playwright` |
|
||||
| Writing Prowler UI E2E tests | `prowler-test-ui` |
|
||||
| Writing React component tests | `vitest` |
|
||||
| Writing React components | `react-19` |
|
||||
| Writing TypeScript types/interfaces | `typescript` |
|
||||
| Writing Vitest tests | `vitest` |
|
||||
| Writing unit tests for UI | `vitest` |
|
||||
| Action | Skill |
|
||||
| ------------------------------------------------------------------- | ---------------------- |
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| App Router / Server Actions | `nextjs-16` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating/modifying Prowler UI components | `prowler-ui` |
|
||||
| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Modifying component | `tdd` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
|
||||
| Working on task | `tdd` |
|
||||
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
|
||||
| Working with Tailwind classes | `tailwind-4` |
|
||||
| Writing Playwright E2E tests | `playwright` |
|
||||
| Writing Prowler UI E2E tests | `prowler-test-ui` |
|
||||
| Writing React component tests | `vitest` |
|
||||
| Writing React components | `react-19` |
|
||||
| Writing TypeScript types/interfaces | `typescript` |
|
||||
| Writing Vitest tests | `vitest` |
|
||||
| Writing unit tests for UI | `vitest` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Skeleton } from "@heroui/skeleton";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table";
|
||||
import { Skeleton, SkeletonBoundary } from "@/components/shadcn";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { GraphsTabsClient } from "./_components/graphs-tabs-client";
|
||||
@@ -14,8 +12,8 @@ import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
|
||||
|
||||
const LoadingFallback = () => (
|
||||
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex w-full flex-col space-y-4 rounded-lg border p-4">
|
||||
<Skeleton className="bg-bg-neutral-tertiary h-6 w-1/3 rounded" />
|
||||
<Skeleton className="bg-bg-neutral-tertiary h-[457px] w-full rounded" />
|
||||
<Skeleton className="h-6 w-1/3 rounded" />
|
||||
<Skeleton className="h-[457px] w-full rounded" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -44,11 +42,13 @@ export const GraphsTabsWrapper = async ({
|
||||
GRAPH_TABS.map((tab) => {
|
||||
const Component = GRAPH_COMPONENTS[tab.id];
|
||||
const fallback = TAB_FALLBACKS[tab.id] ?? <LoadingFallback />;
|
||||
const content = <Component searchParams={searchParams} />;
|
||||
|
||||
return [
|
||||
tab.id,
|
||||
<Suspense key={tab.id} fallback={fallback}>
|
||||
<Component searchParams={searchParams} />
|
||||
</Suspense>,
|
||||
<SkeletonBoundary key={tab.id} fallback={fallback}>
|
||||
{content}
|
||||
</SkeletonBoundary>,
|
||||
];
|
||||
}),
|
||||
) as Record<TabId, React.ReactNode>;
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import type { ScanEntity } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
@@ -175,17 +176,19 @@ const PreviewSummarySkeleton = () => (
|
||||
const PreviewSummary = ({ preview }: { preview: PreviewState }) => {
|
||||
if (preview.status === "error") {
|
||||
return (
|
||||
<Card variant="danger" padding="sm">
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
Test result
|
||||
</span>
|
||||
<Badge variant="tag">Error</Badge>
|
||||
</div>
|
||||
<p className="text-text-error-primary text-sm">{preview.error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SkeletonContentReveal>
|
||||
<Card variant="danger" padding="sm">
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
Test result
|
||||
</span>
|
||||
<Badge variant="tag">Error</Badge>
|
||||
</div>
|
||||
<p className="text-text-error-primary text-sm">{preview.error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SkeletonContentReveal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,16 +196,18 @@ const PreviewSummary = ({ preview }: { preview: PreviewState }) => {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<Card variant="inner" padding="sm">
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
Test result
|
||||
</span>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{getPreviewMessage(data)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SkeletonContentReveal>
|
||||
<Card variant="inner" padding="sm">
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
Test result
|
||||
</span>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{getPreviewMessage(data)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SkeletonContentReveal>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Info } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
getComplianceOverviewMetadataInfo,
|
||||
@@ -16,6 +15,7 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com
|
||||
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { SkeletonBoundary } from "@/components/shadcn/skeleton/skeleton-boundary";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
|
||||
import {
|
||||
@@ -156,7 +156,7 @@ export default async function Compliance({
|
||||
)}
|
||||
|
||||
{/* Row 3: Compliance grid with client-side search */}
|
||||
<Suspense
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={
|
||||
<ComplianceOverviewPanel>
|
||||
@@ -169,7 +169,7 @@ export default async function Compliance({
|
||||
scanId={selectedScanId}
|
||||
selectedScan={selectedScanData}
|
||||
/>
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</>
|
||||
) : (
|
||||
<NoScansAvailable />
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
adaptFindingGroupsResponse,
|
||||
getFindingGroups,
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
FindingsGroupTable,
|
||||
SkeletonTableFindings,
|
||||
} from "@/components/findings/table";
|
||||
import { SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import {
|
||||
@@ -111,12 +110,12 @@ export default async function Findings({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTableFindings />}>
|
||||
<SkeletonBoundary fallback={<SkeletonTableFindings />}>
|
||||
<SSRDataTable
|
||||
searchParams={resolvedSearchParams}
|
||||
filters={resolvedFilters}
|
||||
/>
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</FilterTransitionWrapper>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getInvitations } from "@/actions/invitations/invitation";
|
||||
import { getRoles } from "@/actions/roles";
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
ColumnsInvitation,
|
||||
SkeletonTableInvitation,
|
||||
} from "@/components/invitations/table";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Button, SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { InvitationProps, Role, SearchParamsProps } from "@/types";
|
||||
@@ -39,9 +38,12 @@ export default async function Invitations({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableInvitation />}>
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={<SkeletonTableInvitation />}
|
||||
>
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/actions/processors";
|
||||
import { Button, Card, Skeleton } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { fontMono } from "@/config/fonts";
|
||||
@@ -187,111 +188,114 @@ export function AdvancedMutelistForm() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Card variant="base" className="p-6">
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
{config && <input type="hidden" name="id" value={config.id} />}
|
||||
<SkeletonContentReveal>
|
||||
<Card variant="base" className="p-6">
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
{config && <input type="hidden" name="id" value={config.id} />}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-default-700 mb-2 text-lg font-semibold">
|
||||
Advanced Mutelist Configuration
|
||||
</h3>
|
||||
<ul className="text-default-600 mb-4 list-disc pl-5 text-sm">
|
||||
<li>
|
||||
<strong>
|
||||
This Mutelist configuration will take effect on the next
|
||||
scan.
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Use this for pattern-based muting with wildcards, regions, and
|
||||
tags.
|
||||
</li>
|
||||
<li>
|
||||
Learn more about configuring the Mutelist{" "}
|
||||
<CustomLink
|
||||
size="sm"
|
||||
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-mute-findings"
|
||||
>
|
||||
here
|
||||
</CustomLink>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
A default Mutelist is used to exclude certain predefined
|
||||
resources if no Mutelist is provided.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="configuration"
|
||||
className="text-default-700 text-sm font-medium"
|
||||
>
|
||||
Mutelist Configuration (YAML)
|
||||
</label>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Textarea
|
||||
id="configuration"
|
||||
name="configuration"
|
||||
placeholder={defaultMutedFindingsConfig}
|
||||
variant="bordered"
|
||||
value={configText}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
minRows={20}
|
||||
maxRows={20}
|
||||
isInvalid={
|
||||
(!hasUserStartedTyping && !!state?.errors?.configuration) ||
|
||||
!yamlValidation.isValid
|
||||
}
|
||||
errorMessage={
|
||||
(!hasUserStartedTyping && state?.errors?.configuration) ||
|
||||
(!yamlValidation.isValid ? yamlValidation.error : "")
|
||||
}
|
||||
classNames={{
|
||||
input: fontMono.className + " text-sm",
|
||||
base: "min-h-[400px]",
|
||||
errorMessage: "whitespace-pre-wrap",
|
||||
}}
|
||||
/>
|
||||
{yamlValidation.isValid &&
|
||||
configText &&
|
||||
hasUserStartedTyping && (
|
||||
<div className="text-tiny text-success my-1 flex items-center px-1">
|
||||
<span>Valid YAML format</span>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-default-700 mb-2 text-lg font-semibold">
|
||||
Advanced Mutelist Configuration
|
||||
</h3>
|
||||
<ul className="text-default-600 mb-4 list-disc pl-5 text-sm">
|
||||
<li>
|
||||
<strong>
|
||||
This Mutelist configuration will take effect on the next
|
||||
scan.
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Use this for pattern-based muting with wildcards, regions,
|
||||
and tags.
|
||||
</li>
|
||||
<li>
|
||||
Learn more about configuring the Mutelist{" "}
|
||||
<CustomLink
|
||||
size="sm"
|
||||
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-mute-findings"
|
||||
>
|
||||
here
|
||||
</CustomLink>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
A default Mutelist is used to exclude certain predefined
|
||||
resources if no Mutelist is provided.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="configuration"
|
||||
className="text-default-700 text-sm font-medium"
|
||||
>
|
||||
Mutelist Configuration (YAML)
|
||||
</label>
|
||||
<div>
|
||||
<Textarea
|
||||
id="configuration"
|
||||
name="configuration"
|
||||
placeholder={defaultMutedFindingsConfig}
|
||||
variant="bordered"
|
||||
value={configText}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
minRows={20}
|
||||
maxRows={20}
|
||||
isInvalid={
|
||||
(!hasUserStartedTyping &&
|
||||
!!state?.errors?.configuration) ||
|
||||
!yamlValidation.isValid
|
||||
}
|
||||
errorMessage={
|
||||
(!hasUserStartedTyping && state?.errors?.configuration) ||
|
||||
(!yamlValidation.isValid ? yamlValidation.error : "")
|
||||
}
|
||||
classNames={{
|
||||
input: fontMono.className + " text-sm",
|
||||
base: "min-h-[400px]",
|
||||
errorMessage: "whitespace-pre-wrap",
|
||||
}}
|
||||
/>
|
||||
{yamlValidation.isValid &&
|
||||
configText &&
|
||||
hasUserStartedTyping && (
|
||||
<div className="text-tiny text-success my-1 flex items-center px-1">
|
||||
<span>Valid YAML format</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
{config && (
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
{config && (
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Delete Configuration"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setShowDeleteConfirmation(true)}
|
||||
disabled={isPending || isDeleting}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Delete Configuration"
|
||||
variant="outline"
|
||||
type="submit"
|
||||
size="lg"
|
||||
onClick={() => setShowDeleteConfirmation(true)}
|
||||
disabled={isPending || isDeleting}
|
||||
disabled={
|
||||
isPending || !yamlValidation.isValid || !configText.trim()
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
{isPending ? "Saving..." : config ? "Update" : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={
|
||||
isPending || !yamlValidation.isValid || !configText.trim()
|
||||
}
|
||||
>
|
||||
{isPending ? "Saving..." : config ? "Update" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</SkeletonContentReveal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
|
||||
@@ -18,9 +17,12 @@ export default async function MutelistPage({
|
||||
<ContentLayout title="Mutelist" icon="lucide:volume-x">
|
||||
<MutelistTabs
|
||||
simpleContent={
|
||||
<Suspense key={searchParamsKey} fallback={<MuteRulesTableSkeleton />}>
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={<MuteRulesTableSkeleton />}
|
||||
>
|
||||
<MuteRulesTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
}
|
||||
/>
|
||||
</ContentLayout>
|
||||
|
||||
+28
-20
@@ -1,7 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
@@ -47,55 +46,64 @@ export default async function Home({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<SkeletonBoundary
|
||||
fallback={<ThreatScoreSkeleton />}
|
||||
className="w-full lg:max-w-[312px]"
|
||||
>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<SkeletonBoundary
|
||||
fallback={<StatusChartSkeleton />}
|
||||
className="min-w-[312px] flex-1 md:min-w-[380px]"
|
||||
>
|
||||
<CheckFindingsSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<SkeletonBoundary
|
||||
fallback={<RiskSeverityChartSkeleton />}
|
||||
className="min-w-[312px] flex-1 md:min-w-[380px]"
|
||||
>
|
||||
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<ResourcesInventorySkeleton />}>
|
||||
<SkeletonBoundary fallback={<ResourcesInventorySkeleton />}>
|
||||
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
|
||||
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:flex-wrap sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<SkeletonBoundary fallback={<WatchlistCardSkeleton />}>
|
||||
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<SkeletonBoundary fallback={<WatchlistCardSkeleton />}>
|
||||
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts column: Attack Surface on top, Findings Over Time below */}
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
<Suspense fallback={<AttackSurfaceSkeleton />}>
|
||||
<SkeletonBoundary fallback={<AttackSurfaceSkeleton />}>
|
||||
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
|
||||
</SkeletonBoundary>
|
||||
<SkeletonBoundary fallback={<FindingSeverityOverTimeSkeleton />}>
|
||||
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<RiskPipelineViewSkeleton />}>
|
||||
<SkeletonBoundary fallback={<RiskPipelineViewSkeleton />}>
|
||||
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { ProvidersAccountsView } from "@/components/providers";
|
||||
import { SkeletonTableProviders } from "@/components/providers/table";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { Skeleton, SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
@@ -30,20 +28,20 @@ export default async function Providers({
|
||||
<ProviderPageTabs
|
||||
activeTab={activeTab}
|
||||
providersContent={
|
||||
<Suspense
|
||||
<SkeletonBoundary
|
||||
key={`providers-${searchParamsKey}`}
|
||||
fallback={<ProvidersTableFallback />}
|
||||
>
|
||||
<ProvidersTabContent searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
}
|
||||
providerGroupsContent={
|
||||
<Suspense
|
||||
<SkeletonBoundary
|
||||
key={`groups-${searchParamsKey}`}
|
||||
fallback={<ProviderGroupsFallback />}
|
||||
>
|
||||
<ProviderGroupsContent searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
}
|
||||
/>
|
||||
</FilterTransitionWrapper>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import {
|
||||
getLatestMetadataInfo,
|
||||
@@ -11,6 +9,7 @@ import {
|
||||
import { ResourcesFilters } from "@/components/resources/resources-filters";
|
||||
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
|
||||
import { ResourcesTableWithSelection } from "@/components/resources/table";
|
||||
import { SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import {
|
||||
@@ -86,12 +85,12 @@ export default async function Resources({
|
||||
uniqueGroups={uniqueGroups}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTableResources />}>
|
||||
<SkeletonBoundary fallback={<SkeletonTableResources />}>
|
||||
<SSRDataTable
|
||||
searchParams={resolvedSearchParams}
|
||||
initialResource={processedResource}
|
||||
/>
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</FilterTransitionWrapper>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getRoles } from "@/actions/roles";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { filterRoles } from "@/components/filters/data-filters";
|
||||
import { AddIcon } from "@/components/icons";
|
||||
import { ColumnsRoles, SkeletonTableRoles } from "@/components/roles/table";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Button, SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
@@ -34,9 +33,12 @@ export default async function Roles({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableRoles />}>
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={<SkeletonTableRoles />}
|
||||
>
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { auth } from "@/auth.config";
|
||||
@@ -12,6 +10,7 @@ import { ScansPageShell } from "@/components/scans/scans-page-shell";
|
||||
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
|
||||
import { SkeletonTableScans } from "@/components/scans/table";
|
||||
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
|
||||
import { SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import {
|
||||
ProviderProps,
|
||||
@@ -88,7 +87,7 @@ export default async function Scans({
|
||||
hasManageScansPermission={hasManageScansPermission}
|
||||
activeScanCount={activeScanCount}
|
||||
>
|
||||
<Suspense
|
||||
<SkeletonBoundary
|
||||
fallback={
|
||||
<SkeletonTableScans
|
||||
tab={getScanJobsTab(resolvedSearchParams.tab)}
|
||||
@@ -96,7 +95,7 @@ export default async function Scans({
|
||||
}
|
||||
>
|
||||
<SSRDataTableScans searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</ScansPageShell>
|
||||
)}
|
||||
</ContentLayout>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getRoles } from "@/actions/roles/roles";
|
||||
import { getCurrentUserTenantRole, getUsers } from "@/actions/users/users";
|
||||
import { auth } from "@/auth.config";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { AddIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Button, SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
|
||||
@@ -35,9 +34,12 @@ export default async function Users({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableUser />}>
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={<SkeletonTableUser />}
|
||||
>
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getStandaloneFindingColumns,
|
||||
SkeletonTableFindings,
|
||||
} from "@/components/findings/table";
|
||||
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
|
||||
import { Accordion } from "@/components/ui/accordion/Accordion";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
|
||||
@@ -177,7 +178,7 @@ export const ClientAccordionContent = ({
|
||||
|
||||
if (findings?.data?.length && findings.data.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<SkeletonContentReveal>
|
||||
<h4 className="mb-2 text-sm font-medium">Findings</h4>
|
||||
|
||||
<DataTable
|
||||
@@ -186,14 +187,14 @@ export const ClientAccordionContent = ({
|
||||
metadata={findings?.meta}
|
||||
disableScroll={true}
|
||||
/>
|
||||
</>
|
||||
</SkeletonContentReveal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 mb-1 text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
<SkeletonContentReveal className="mt-3 mb-1 text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
⚠️ There are no findings for these regions
|
||||
</div>
|
||||
</SkeletonContentReveal>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MuteRuleActionState } from "@/actions/mute-rules/types";
|
||||
import { Button, Input, Textarea } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
|
||||
import { FormButtons } from "@/components/ui/form";
|
||||
import { Label } from "@/components/ui/form/Label";
|
||||
import { useMuteRuleAction } from "@/hooks/use-mute-rule-action";
|
||||
@@ -170,7 +171,7 @@ export function MuteFindingsModal({
|
||||
</div>
|
||||
</>
|
||||
) : preparationError ? (
|
||||
<>
|
||||
<SkeletonContentReveal>
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
|
||||
<p className="text-text-neutral-primary text-sm font-medium">
|
||||
We couldn't prepare this mute action.
|
||||
@@ -190,9 +191,9 @@ export function MuteFindingsModal({
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</SkeletonContentReveal>
|
||||
) : (
|
||||
<>
|
||||
<SkeletonContentReveal>
|
||||
<div className="space-y-4">
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
|
||||
<p className="text-text-neutral-tertiary text-xs font-medium tracking-[0.08em] uppercase">
|
||||
@@ -315,7 +316,7 @@ export function MuteFindingsModal({
|
||||
submitText="Mute Findings"
|
||||
isDisabled={isPending}
|
||||
/>
|
||||
</>
|
||||
</SkeletonContentReveal>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomBanner } from "@/components/ui/custom/custom-banner";
|
||||
import { Form, FormField, FormMessage } from "@/components/ui/form";
|
||||
@@ -270,151 +271,152 @@ export const SendToJiraModal = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration Selection */}
|
||||
{!isFetchingIntegrations && integrations.length > 1 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="integration"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-integration-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Jira Integration
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-integration-select"
|
||||
options={integrationOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset dependent fields
|
||||
form.setValue("project", "");
|
||||
form.setValue("issueType", "");
|
||||
setFetchedIssueTypes({});
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira integration"
|
||||
searchable={true}
|
||||
emptyIndicator="No integrations found."
|
||||
disabled={isFetchingIntegrations}
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
{!isFetchingIntegrations && (
|
||||
<SkeletonContentReveal className="flex flex-col gap-4">
|
||||
{/* Integration Selection */}
|
||||
{integrations.length > 1 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="integration"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-integration-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Jira Integration
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-integration-select"
|
||||
options={integrationOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset dependent fields
|
||||
form.setValue("project", "");
|
||||
form.setValue("issueType", "");
|
||||
setFetchedIssueTypes({});
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira integration"
|
||||
searchable={true}
|
||||
emptyIndicator="No integrations found."
|
||||
disabled={isFetchingIntegrations}
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Project Selection */}
|
||||
{!isFetchingIntegrations &&
|
||||
selectedIntegration &&
|
||||
projectEntries.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="project"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-project-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Project
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-project-select"
|
||||
options={projectOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset issue type when project changes
|
||||
form.setValue("issueType", "");
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira project"
|
||||
searchable={true}
|
||||
emptyIndicator="No projects found."
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Issue Type Selection */}
|
||||
{selectedProject && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issueType"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-issue-type-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Issue Type
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-issue-type-select"
|
||||
options={issueTypeOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder={
|
||||
isFetchingIssueTypes
|
||||
? "Loading issue types..."
|
||||
: "Select an issue type"
|
||||
}
|
||||
searchable={true}
|
||||
emptyIndicator="No issue types found."
|
||||
disabled={isFetchingIssueTypes}
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
{/* Project Selection */}
|
||||
{selectedIntegration && projectEntries.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="project"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-project-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Project
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-project-select"
|
||||
options={projectOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset issue type when project changes
|
||||
form.setValue("issueType", "");
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira project"
|
||||
searchable={true}
|
||||
emptyIndicator="No projects found."
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No integrations or none connected message */}
|
||||
{!isFetchingIntegrations &&
|
||||
(integrations.length === 0 || !hasConnectedIntegration) ? (
|
||||
<CustomBanner
|
||||
title="Jira integration is not available"
|
||||
message="Please add or connect an integration first"
|
||||
buttonLabel="Configure"
|
||||
buttonLink="/integrations/jira"
|
||||
/>
|
||||
) : (
|
||||
<FormButtons
|
||||
setIsOpen={setOpenForFormButtons}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
submitText="Send to Jira"
|
||||
cancelText="Cancel"
|
||||
loadingText="Sending..."
|
||||
isDisabled={
|
||||
!form.formState.isValid ||
|
||||
form.formState.isSubmitting ||
|
||||
isFetchingIntegrations ||
|
||||
isFetchingIssueTypes ||
|
||||
integrations.length === 0 ||
|
||||
!hasConnectedIntegration
|
||||
}
|
||||
rightIcon={<Send size={20} />}
|
||||
/>
|
||||
{/* Issue Type Selection */}
|
||||
{selectedProject && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issueType"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-issue-type-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Issue Type
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-issue-type-select"
|
||||
options={issueTypeOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder={
|
||||
isFetchingIssueTypes
|
||||
? "Loading issue types..."
|
||||
: "Select an issue type"
|
||||
}
|
||||
searchable={true}
|
||||
emptyIndicator="No issue types found."
|
||||
disabled={isFetchingIssueTypes}
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No integrations or none connected message */}
|
||||
{integrations.length === 0 || !hasConnectedIntegration ? (
|
||||
<CustomBanner
|
||||
title="Jira integration is not available"
|
||||
message="Please add or connect an integration first"
|
||||
buttonLabel="Configure"
|
||||
buttonLink="/integrations/jira"
|
||||
/>
|
||||
) : (
|
||||
<FormButtons
|
||||
setIsOpen={setOpenForFormButtons}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
submitText="Send to Jira"
|
||||
cancelText="Cancel"
|
||||
loadingText="Sending..."
|
||||
isDisabled={
|
||||
!form.formState.isValid ||
|
||||
form.formState.isSubmitting ||
|
||||
isFetchingIntegrations ||
|
||||
isFetchingIssueTypes ||
|
||||
integrations.length === 0 ||
|
||||
!hasConnectedIntegration
|
||||
}
|
||||
rightIcon={<Send size={20} />}
|
||||
/>
|
||||
)}
|
||||
</SkeletonContentReveal>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ChevronsDown } from "lucide-react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
|
||||
@@ -233,56 +234,64 @@ export function InlineResourceContainer({
|
||||
className="max-h-[440px] overflow-y-auto pl-6"
|
||||
>
|
||||
{/* Resource rows or skeleton placeholder */}
|
||||
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
|
||||
<tbody>
|
||||
{isLoading && rows.length === 0 ? (
|
||||
Array.from({ length: skeletonRowCount }).map((_, i) => (
|
||||
{isLoading && rows.length === 0 ? (
|
||||
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
|
||||
<tbody>
|
||||
{Array.from({ length: skeletonRowCount }).map((_, i) => (
|
||||
<ResourceSkeletonRow
|
||||
key={i}
|
||||
isEmptyStateSized={filteredResourceCount === 0}
|
||||
/>
|
||||
))
|
||||
) : rows.length > 0 ? (
|
||||
rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
// Don't open drawer if clicking interactive elements
|
||||
// (links, buttons, checkboxes, dropdown items)
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest(
|
||||
"a, button, input, [role=menuitem]",
|
||||
)
|
||||
)
|
||||
return;
|
||||
drawer.openDrawer(row.index);
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<SkeletonContentReveal>
|
||||
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
|
||||
<tbody>
|
||||
{rows.length > 0 ? (
|
||||
rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
// Don't open drawer if clicking interactive elements
|
||||
// (links, buttons, checkboxes, dropdown items)
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest(
|
||||
"a, button, input, [role=menuitem]",
|
||||
)
|
||||
)
|
||||
return;
|
||||
drawer.openDrawer(row.index);
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{getFindingGroupEmptyStateMessage(group, filters)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{getFindingGroupEmptyStateMessage(group, filters)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</SkeletonContentReveal>
|
||||
)}
|
||||
|
||||
{/* Loading state for infinite scroll (subsequent pages only) */}
|
||||
{isLoading && rows.length > 0 && (
|
||||
@@ -308,7 +317,7 @@ export function InlineResourceContainer({
|
||||
{showScrollHint && (
|
||||
<div className="pointer-events-none absolute right-0 bottom-0 left-6 z-30">
|
||||
<div className="absolute inset-x-0 bottom-2 flex justify-center">
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary animate-bounce rounded-full px-3 py-1 text-xs shadow-md">
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary animate-bounce rounded-full px-3 py-1 text-xs shadow-md motion-reduce:animate-none">
|
||||
<ChevronsDown className="inline size-3.5" /> Scroll for
|
||||
more
|
||||
</div>
|
||||
|
||||
+3
-2
@@ -37,6 +37,7 @@ import {
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -494,7 +495,7 @@ export function ResourceDetailDrawerContent({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
<SkeletonContentReveal className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
{/* Mute modal — rendered outside drawer content to avoid overlay conflicts */}
|
||||
{f && !f.isMuted && (
|
||||
<MuteFindingsModal
|
||||
@@ -1339,7 +1340,7 @@ export function ResourceDetailDrawerContent({
|
||||
Analyze This Finding With Lighthouse AI
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</SkeletonContentReveal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[background-color,border-color,color,box-shadow] duration-200 ease-out motion-reduce:transition-none overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "./collapsible";
|
||||
|
||||
describe("Collapsible", () => {
|
||||
it("uses an intentional open and close motion contract", () => {
|
||||
// Given
|
||||
render(
|
||||
<Collapsible open>
|
||||
<CollapsibleTrigger>Toggle details</CollapsibleTrigger>
|
||||
<CollapsibleContent>Expandable content</CollapsibleContent>
|
||||
</Collapsible>,
|
||||
);
|
||||
|
||||
// When
|
||||
const content = screen.getByText("Expandable content");
|
||||
|
||||
// Then
|
||||
expect(content).toHaveAttribute("data-slot", "collapsible-content");
|
||||
expect(content).toHaveClass(
|
||||
"overflow-hidden",
|
||||
"data-[state=open]:animate-collapsible-down",
|
||||
"data-[state=closed]:animate-collapsible-up",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes transform-heavy motion for reduced-motion users", () => {
|
||||
// Given
|
||||
render(
|
||||
<Collapsible open>
|
||||
<CollapsibleTrigger>Toggle details</CollapsibleTrigger>
|
||||
<CollapsibleContent>Expandable content</CollapsibleContent>
|
||||
</Collapsible>,
|
||||
);
|
||||
|
||||
// When
|
||||
const content = screen.getByText("Expandable content");
|
||||
|
||||
// Then
|
||||
expect(content).toHaveClass(
|
||||
"motion-reduce:animate-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
@@ -20,11 +22,19 @@ function CollapsibleTrigger({
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
"data-[state=open]:animate-collapsible-down",
|
||||
"data-[state=closed]:animate-collapsible-up",
|
||||
"motion-reduce:animate-none motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FileUploadDropzone } from "./file-upload-dropzone";
|
||||
|
||||
describe("FileUploadDropzone", () => {
|
||||
it("animates drag feedback and selected file content", async () => {
|
||||
// Given - A dropzone without a selected file
|
||||
const user = userEvent.setup();
|
||||
const onFileSelect = vi.fn();
|
||||
render(<FileUploadDropzone onFileSelect={onFileSelect} />);
|
||||
|
||||
// When - The dropzone renders
|
||||
const dropzone = screen.getByText(/drag and drop/i).closest("label");
|
||||
const input = screen.getByLabelText(/drag and drop/i, {
|
||||
selector: "input",
|
||||
});
|
||||
|
||||
// Then - Drag feedback and internal content have visible motion contracts
|
||||
expect(dropzone).toHaveClass(
|
||||
"transition-[background-color,border-color,box-shadow,transform]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(dropzone?.querySelector("svg")).toHaveClass(
|
||||
"transition-transform",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"group-hover:-translate-y-0.5",
|
||||
"motion-reduce:transform-none",
|
||||
);
|
||||
|
||||
await user.upload(
|
||||
input,
|
||||
new File(["prowler"], "evidence.json", { type: "application/json" }),
|
||||
);
|
||||
|
||||
expect(onFileSelect).toHaveBeenCalledWith(expect.any(File));
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,9 @@ export function FileUploadDropzone({
|
||||
title = "Drag and drop your file here",
|
||||
emptyDescription = "or",
|
||||
selectText = "Select File",
|
||||
icon = <FileUp className="text-text-neutral-secondary size-6" />,
|
||||
icon = (
|
||||
<FileUp className="text-text-neutral-secondary size-6 transition-transform duration-150 ease-out group-hover:-translate-y-0.5 motion-reduce:transform-none motion-reduce:transition-none" />
|
||||
),
|
||||
}: FileUploadDropzoneProps) {
|
||||
const inputId = useId();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -45,23 +47,23 @@ export function FileUploadDropzone({
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"border-border-neutral-tertiary bg-bg-neutral-primary hover:bg-bg-neutral-tertiary flex min-h-[132px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-colors",
|
||||
"border-border-neutral-tertiary bg-bg-neutral-primary hover:bg-bg-neutral-tertiary group flex min-h-[132px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-[background-color,border-color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none",
|
||||
isDragging &&
|
||||
"border-border-input-primary-press bg-bg-neutral-tertiary",
|
||||
"border-border-input-primary-press bg-bg-neutral-tertiary scale-[1.01] shadow-sm motion-reduce:scale-100",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
<span className="text-text-neutral-primary text-sm font-medium transition-colors duration-150 ease-out motion-reduce:transition-none">
|
||||
{file ? file.name : title}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
<span className="text-text-neutral-secondary text-xs transition-colors duration-150 ease-out motion-reduce:transition-none">
|
||||
{file
|
||||
? `${Math.ceil(file.size / 1024).toLocaleString()} KB`
|
||||
: emptyDescription}
|
||||
</span>
|
||||
{!file && (
|
||||
<span className="text-button-tertiary text-sm font-medium">
|
||||
<span className="text-button-tertiary text-sm font-medium transition-colors duration-150 ease-out motion-reduce:transition-none">
|
||||
{selectText}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,8 @@ export * from "./select/multiselect";
|
||||
export * from "./select/select";
|
||||
export * from "./separator/separator";
|
||||
export * from "./skeleton/skeleton";
|
||||
export * from "./skeleton/skeleton-boundary";
|
||||
export * from "./skeleton/skeleton-content-reveal";
|
||||
export * from "./tabs/generic-tabs";
|
||||
export * from "./tabs/tabs";
|
||||
export * from "./textarea/textarea";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Input } from "./input";
|
||||
|
||||
describe("Input", () => {
|
||||
it("uses visible hover and focus microinteraction timing", () => {
|
||||
// Given - A standard text input
|
||||
render(<Input aria-label="Alias" />);
|
||||
|
||||
// When - The input renders
|
||||
const input = screen.getByRole("textbox", { name: /alias/i });
|
||||
|
||||
// Then - The focus/hover state changes are intentionally timed
|
||||
expect(input).toHaveClass(
|
||||
"transition-[background-color,border-color,box-shadow,color]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { ComponentProps, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const inputVariants = cva(
|
||||
"flex w-full rounded-lg border text-sm transition-all outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex w-full rounded-lg border text-sm transition-[background-color,border-color,box-shadow,color] duration-150 ease-out outline-none motion-reduce:transition-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Progress } from "./progress";
|
||||
|
||||
describe("Progress", () => {
|
||||
it("animates progress value changes with a transform-only transition", () => {
|
||||
// Given
|
||||
render(<Progress aria-label="Scan progress" value={40} />);
|
||||
|
||||
// When
|
||||
const root = screen.getByRole("progressbar", { name: /scan progress/i });
|
||||
const indicator = root.querySelector("[data-slot='progress-indicator']");
|
||||
|
||||
// Then
|
||||
expect(root).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(indicator).toHaveClass(
|
||||
"transition-transform",
|
||||
"duration-300",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(indicator).toHaveStyle({ transform: "translateX(-60%)" });
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ function Progress({
|
||||
data-slot="progress"
|
||||
value={normalizedValue}
|
||||
className={cn(
|
||||
"border-border-neutral-secondary bg-bg-neutral-secondary relative h-2 w-full overflow-hidden rounded-full border",
|
||||
"border-border-neutral-secondary bg-bg-neutral-secondary relative h-2 w-full overflow-hidden rounded-full border transition-colors duration-200 ease-out motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -30,7 +30,7 @@ function Progress({
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn(
|
||||
"bg-button-primary h-full w-full flex-1 transition-all",
|
||||
"bg-button-primary h-full w-full flex-1 transition-transform duration-300 ease-out motion-reduce:transition-none",
|
||||
indicatorClassName,
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - normalizedValue}%)` }}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { RadioGroup, RadioGroupItem } from "./radio-group";
|
||||
|
||||
describe("RadioGroup", () => {
|
||||
it("animates item state and indicator entry", async () => {
|
||||
// Given - A controlled radio group
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
render(
|
||||
<RadioGroup value="aws" onValueChange={onValueChange}>
|
||||
<RadioGroupItem value="aws" aria-label="AWS" />
|
||||
<RadioGroupItem value="azure" aria-label="Azure" />
|
||||
</RadioGroup>,
|
||||
);
|
||||
|
||||
// When - The user selects another radio option
|
||||
const azure = screen.getByRole("radio", { name: /azure/i });
|
||||
await user.click(azure);
|
||||
const indicator = azure.querySelector(
|
||||
"[data-slot='radio-group-indicator']",
|
||||
);
|
||||
|
||||
// Then - The item and dot use synchronized visual feedback
|
||||
expect(azure).toHaveClass(
|
||||
"transition-[background-color,border-color,box-shadow]",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(indicator).toHaveClass(
|
||||
"transition-[opacity,transform]",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"data-[state=checked]:scale-100",
|
||||
"data-[state=checked]:opacity-100",
|
||||
"data-[state=unchecked]:scale-75",
|
||||
"data-[state=unchecked]:opacity-0",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(onValueChange).toHaveBeenCalledWith("azure");
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ function RadioGroupItem({
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-border-input-primary aspect-square size-4 shrink-0 rounded-full border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)] transition-all outline-none",
|
||||
"border-border-input-primary aspect-square size-4 shrink-0 rounded-full border shadow-[0_1px_2px_0_rgba(0,0,0,0.1)] transition-[background-color,border-color,box-shadow] duration-200 ease-out outline-none motion-reduce:transition-none",
|
||||
"focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press/50 focus-visible:ring-2",
|
||||
"data-[state=checked]:border-button-primary",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
@@ -34,8 +34,9 @@ function RadioGroupItem({
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
forceMount
|
||||
data-slot="radio-group-indicator"
|
||||
className="grid place-content-center"
|
||||
className="grid place-content-center transition-[opacity,transform] duration-200 ease-out data-[state=checked]:scale-100 data-[state=checked]:opacity-100 data-[state=unchecked]:scale-75 data-[state=unchecked]:opacity-0 motion-reduce:scale-100 motion-reduce:transition-none"
|
||||
>
|
||||
<span className="bg-button-primary size-2 rounded-full" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getClearButtonMotion, SearchInput } from "./search-input";
|
||||
|
||||
describe("getClearButtonMotion", () => {
|
||||
it("animates with scale when motion is allowed", () => {
|
||||
// Given / When
|
||||
const motion = getClearButtonMotion(false);
|
||||
|
||||
// Then
|
||||
expect(motion.animate).toHaveProperty("scale", 1);
|
||||
expect(motion.initial).toHaveProperty("scale");
|
||||
expect(motion.exit).toHaveProperty("scale");
|
||||
expect(motion.transition.duration).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("degrades to opacity-only with no scale under reduced motion", () => {
|
||||
// Given / When
|
||||
const motion = getClearButtonMotion(true);
|
||||
|
||||
// Then
|
||||
expect(motion.initial).not.toHaveProperty("scale");
|
||||
expect(motion.animate).not.toHaveProperty("scale");
|
||||
expect(motion.exit).not.toHaveProperty("scale");
|
||||
expect(motion.transition.duration).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchInput", () => {
|
||||
it("animates input focus, icon color, and clear button entry", () => {
|
||||
// Given - A search input with a clear action
|
||||
render(
|
||||
<SearchInput
|
||||
aria-label="Search findings"
|
||||
value="cloudflare"
|
||||
readOnly
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When - The search field has a value
|
||||
const input = screen.getByRole("textbox", { name: /search findings/i });
|
||||
const clearButton = screen.getByRole("button", { name: /clear search/i });
|
||||
const searchIcon = input.parentElement?.querySelector("svg");
|
||||
|
||||
// Then - Search-specific affordances have visible motion
|
||||
expect(input).toHaveClass(
|
||||
"transition-[background-color,border-color,box-shadow,color]",
|
||||
"duration-250",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(searchIcon).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-250",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(clearButton).toHaveAttribute("data-slot", "search-input-clear");
|
||||
expect(clearButton).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-250",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { SearchIcon, XCircle } from "lucide-react";
|
||||
import { ComponentProps, forwardRef } from "react";
|
||||
|
||||
@@ -20,7 +21,7 @@ const searchInputWrapperVariants = cva("relative flex items-center w-full", {
|
||||
});
|
||||
|
||||
const searchInputVariants = cva(
|
||||
"flex w-full rounded-lg border text-sm transition-all outline-none placeholder:text-text-neutral-tertiary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex w-full rounded-lg border text-sm transition-[background-color,border-color,box-shadow,color] duration-250 ease-out outline-none motion-reduce:transition-none placeholder:text-text-neutral-tertiary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -66,6 +67,24 @@ export interface SearchInputProps
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
export function getClearButtonMotion(shouldReduceMotion: boolean) {
|
||||
if (shouldReduceMotion) {
|
||||
return {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
transition: { duration: 0, ease: "easeOut" as const },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.95 },
|
||||
transition: { duration: 0.25, ease: "easeOut" as const },
|
||||
};
|
||||
}
|
||||
|
||||
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
(
|
||||
{
|
||||
@@ -83,13 +102,15 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
const iconPosition = iconPositionMap[size || "default"];
|
||||
const clearButtonPosition = clearButtonPositionMap[size || "default"];
|
||||
const hasValue = value && String(value).length > 0;
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const clearButtonMotion = getClearButtonMotion(!!shouldReduceMotion);
|
||||
|
||||
return (
|
||||
<div className={cn(searchInputWrapperVariants({ size }))}>
|
||||
<SearchIcon
|
||||
size={iconSize}
|
||||
className={cn(
|
||||
"text-text-neutral-tertiary pointer-events-none absolute",
|
||||
"text-text-neutral-tertiary pointer-events-none absolute transition-colors duration-250 ease-out motion-reduce:transition-none",
|
||||
iconPosition,
|
||||
)}
|
||||
/>
|
||||
@@ -102,19 +123,27 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
className={cn(searchInputVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
{hasValue && onClear && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={onClear}
|
||||
className={cn(
|
||||
"text-text-neutral-tertiary hover:text-text-neutral-primary absolute transition-colors focus:outline-none",
|
||||
clearButtonPosition,
|
||||
)}
|
||||
>
|
||||
<XCircle size={iconSize} />
|
||||
</button>
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
{hasValue && onClear && (
|
||||
<motion.button
|
||||
key="clear-search"
|
||||
type="button"
|
||||
data-slot="search-input-clear"
|
||||
aria-label="Clear search"
|
||||
initial={clearButtonMotion.initial}
|
||||
animate={clearButtonMotion.animate}
|
||||
exit={clearButtonMotion.exit}
|
||||
transition={clearButtonMotion.transition}
|
||||
onClick={onClear}
|
||||
className={cn(
|
||||
"text-text-neutral-tertiary hover:text-text-neutral-primary absolute transition-colors duration-250 ease-out focus:outline-none motion-reduce:transition-none",
|
||||
clearButtonPosition,
|
||||
)}
|
||||
>
|
||||
<XCircle size={iconSize} />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Skeleton } from "./skeleton";
|
||||
import { SkeletonBoundary } from "./skeleton-boundary";
|
||||
|
||||
describe("SkeletonBoundary", () => {
|
||||
it("wraps resolved content with the shared skeleton reveal", () => {
|
||||
// Given
|
||||
render(
|
||||
<SkeletonBoundary fallback={<Skeleton aria-label="Loading content" />}>
|
||||
<section aria-label="Resolved content">Ready</section>
|
||||
</SkeletonBoundary>,
|
||||
);
|
||||
|
||||
// When
|
||||
const reveal = screen.getByTestId("skeleton-content-reveal");
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Resolved content")).toBeInTheDocument();
|
||||
expect(reveal).toHaveAttribute("data-motion", "skeleton-content-handoff");
|
||||
});
|
||||
|
||||
it("forwards className to the reveal wrapper", () => {
|
||||
// Given
|
||||
render(
|
||||
<SkeletonBoundary
|
||||
fallback={<Skeleton aria-label="Loading content" />}
|
||||
className="custom-boundary"
|
||||
>
|
||||
Ready
|
||||
</SkeletonBoundary>,
|
||||
);
|
||||
|
||||
// When
|
||||
const reveal = screen.getByTestId("skeleton-content-reveal");
|
||||
|
||||
// Then
|
||||
expect(reveal).toHaveClass("custom-boundary");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ReactNode, Suspense } from "react";
|
||||
|
||||
import { SkeletonContentReveal } from "./skeleton-content-reveal";
|
||||
|
||||
interface SkeletonBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SkeletonBoundary({
|
||||
children,
|
||||
fallback,
|
||||
className,
|
||||
}: SkeletonBoundaryProps) {
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<SkeletonContentReveal className={className}>
|
||||
{children}
|
||||
</SkeletonContentReveal>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export { SkeletonBoundary };
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SkeletonContentReveal } from "./skeleton-content-reveal";
|
||||
|
||||
describe("SkeletonContentReveal", () => {
|
||||
it("reveals streamed content with insertion-time CSS motion", () => {
|
||||
// Given
|
||||
render(
|
||||
<SkeletonContentReveal>
|
||||
<section aria-label="Loaded content">Ready</section>
|
||||
</SkeletonContentReveal>,
|
||||
);
|
||||
|
||||
// When
|
||||
const wrapper = screen.getByTestId("skeleton-content-reveal");
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("Loaded content")).toBeInTheDocument();
|
||||
expect(wrapper).toHaveAttribute("data-motion", "skeleton-content-handoff");
|
||||
expect(wrapper).toHaveClass(
|
||||
"transition-[opacity,transform]",
|
||||
"duration-700",
|
||||
"starting:opacity-0",
|
||||
"starting:translate-y-3",
|
||||
"opacity-100",
|
||||
"translate-y-0",
|
||||
"motion-reduce:transform-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("merges caller classes without dropping the motion contract", () => {
|
||||
// Given
|
||||
render(
|
||||
<SkeletonContentReveal className="custom-reveal">
|
||||
Ready
|
||||
</SkeletonContentReveal>,
|
||||
);
|
||||
|
||||
// When
|
||||
const wrapper = screen.getByTestId("skeleton-content-reveal");
|
||||
|
||||
// Then
|
||||
expect(wrapper).toHaveClass(
|
||||
"custom-reveal",
|
||||
"transition-[opacity,transform]",
|
||||
"starting:opacity-0",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SkeletonContentRevealProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SkeletonContentReveal({
|
||||
children,
|
||||
className,
|
||||
}: SkeletonContentRevealProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="skeleton-content-reveal"
|
||||
data-motion="skeleton-content-handoff"
|
||||
className={cn(
|
||||
"translate-y-0 opacity-100 transition-[opacity,transform] duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transform-none motion-reduce:transition-none starting:translate-y-3 starting:opacity-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SkeletonContentReveal };
|
||||
@@ -0,0 +1,34 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Skeleton } from "./skeleton";
|
||||
|
||||
describe("Skeleton", () => {
|
||||
it("uses a subtle scanner animation that respects reduced motion", () => {
|
||||
// Given
|
||||
render(<Skeleton aria-label="Loading providers" />);
|
||||
|
||||
// When
|
||||
const skeleton = screen.getByLabelText("Loading providers");
|
||||
|
||||
// Then
|
||||
expect(skeleton).toHaveClass(
|
||||
"relative",
|
||||
"overflow-hidden",
|
||||
"bg-border-neutral-tertiary",
|
||||
"transition-colors",
|
||||
"duration-500",
|
||||
"ease-out",
|
||||
);
|
||||
|
||||
const scanner = skeleton.querySelector("[data-slot='skeleton-scanner']");
|
||||
expect(scanner).toHaveClass(
|
||||
"animate-skeleton-scan",
|
||||
"bg-gradient-to-r",
|
||||
"from-transparent",
|
||||
"via-white/10",
|
||||
"to-transparent",
|
||||
"motion-reduce:hidden",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,25 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function Skeleton({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn(
|
||||
"bg-border-neutral-tertiary animate-pulse rounded-md",
|
||||
"bg-border-neutral-tertiary relative overflow-hidden rounded-md transition-colors duration-500 ease-out motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<span
|
||||
data-slot="skeleton-scanner"
|
||||
className="animate-skeleton-scan pointer-events-none absolute inset-y-0 -left-1/2 w-1/2 bg-gradient-to-r from-transparent via-white/10 to-transparent motion-reduce:hidden"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,4 +33,22 @@ describe("LoadingState", () => {
|
||||
const { container } = render(<LoadingState className="custom-wrapper" />);
|
||||
expect(container.firstChild).toHaveClass("custom-wrapper");
|
||||
});
|
||||
|
||||
it("animates the loading state entry and label color subtly", () => {
|
||||
const { container } = render(<LoadingState label="Loading findings..." />);
|
||||
expect(container.firstChild).toHaveClass(
|
||||
"animate-in",
|
||||
"fade-in-0",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"motion-reduce:animate-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(screen.getByText("Loading findings...")).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,16 @@ export function LoadingState({
|
||||
}: LoadingStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-center gap-2 py-8", className)}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 flex items-center justify-center gap-2 py-8 duration-200 ease-out motion-reduce:animate-none motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Spinner className={cn("size-6", spinnerClassName)} />
|
||||
{label && (
|
||||
<span className="text-text-neutral-tertiary text-sm">{label}</span>
|
||||
<span className="text-text-neutral-tertiary text-sm transition-colors duration-200 ease-out motion-reduce:transition-none">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,10 @@ interface SpinnerProps {
|
||||
export function Spinner({ className }: SpinnerProps) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("size-5 shrink-0 animate-spin", className)}
|
||||
className={cn(
|
||||
"size-5 shrink-0 animate-spin motion-reduce:animate-none",
|
||||
className,
|
||||
)}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
describe("Textarea", () => {
|
||||
it("uses visible hover and focus microinteraction timing", () => {
|
||||
// Given - A standard textarea
|
||||
render(<Textarea aria-label="Reason" />);
|
||||
|
||||
// When - The textarea renders
|
||||
const textarea = screen.getByRole("textbox", { name: /reason/i });
|
||||
|
||||
// Then - The focus/hover state changes are intentionally timed
|
||||
expect(textarea).toHaveClass(
|
||||
"transition-[background-color,border-color,box-shadow,color]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { ComponentProps, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const textareaVariants = cva(
|
||||
"flex w-full rounded-lg border text-sm transition-all outline-none resize-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex w-full rounded-lg border text-sm transition-[background-color,border-color,box-shadow,color] duration-150 ease-out outline-none motion-reduce:transition-none resize-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -58,7 +58,9 @@ export function TreeLeaf({
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5",
|
||||
"hover:bg-prowler-white/5 cursor-pointer",
|
||||
"transition-[background-color,box-shadow,color] duration-150 ease-out motion-reduce:transition-none",
|
||||
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
|
||||
isSelected && "bg-prowler-white/5",
|
||||
item.disabled && "cursor-not-allowed opacity-50",
|
||||
item.className,
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { KeyboardEvent } from "react";
|
||||
|
||||
@@ -14,6 +14,24 @@ import { TreeSpinner } from "./tree-spinner";
|
||||
import { TreeStatusIndicator } from "./tree-status-indicator";
|
||||
import { getAllDescendantIds, getTreeNodePadding } from "./utils";
|
||||
|
||||
export function getTreeChildrenMotion(shouldReduceMotion: boolean) {
|
||||
if (shouldReduceMotion) {
|
||||
return {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
transition: { duration: 0, ease: "easeInOut" as const },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
initial: { opacity: 0, height: 0 },
|
||||
animate: { opacity: 1, height: "auto" as const },
|
||||
exit: { opacity: 0, height: 0 },
|
||||
transition: { duration: 0.2, ease: "easeInOut" as const },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TreeNode component for rendering expandable nodes with children.
|
||||
*
|
||||
@@ -36,6 +54,8 @@ export function TreeNode({
|
||||
renderItem,
|
||||
enableSelectChildren,
|
||||
}: TreeNodeProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const childrenMotion = getTreeChildrenMotion(!!shouldReduceMotion);
|
||||
const isExpanded = expandedIds.includes(item.id);
|
||||
const isSelected = selectedIds.includes(item.id);
|
||||
const statusIcon =
|
||||
@@ -96,7 +116,9 @@ export function TreeNode({
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5",
|
||||
"hover:bg-prowler-white/5 cursor-pointer",
|
||||
"transition-[background-color,box-shadow,color] duration-150 ease-out motion-reduce:transition-none",
|
||||
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
|
||||
isSelected && "bg-prowler-white/5",
|
||||
item.disabled && "cursor-not-allowed opacity-50",
|
||||
item.className,
|
||||
)}
|
||||
@@ -110,7 +132,7 @@ export function TreeNode({
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<button
|
||||
className="hover:bg-prowler-white/10 shrink-0 rounded p-0.5"
|
||||
className="hover:bg-prowler-white/10 shrink-0 rounded p-0.5 transition-colors duration-150 ease-out motion-reduce:transition-none"
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -123,7 +145,7 @@ export function TreeNode({
|
||||
) : (
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
"h-4 w-4 transition-transform duration-200 ease-out motion-reduce:transition-none",
|
||||
isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
@@ -164,10 +186,10 @@ export function TreeNode({
|
||||
{isExpanded && (
|
||||
<motion.ul
|
||||
key={`children-${item.id}`}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
initial={childrenMotion.initial}
|
||||
animate={childrenMotion.animate}
|
||||
exit={childrenMotion.exit}
|
||||
transition={childrenMotion.transition}
|
||||
className="mt-1 space-y-1 overflow-hidden"
|
||||
role="group"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getTreeChildrenMotion } from "./tree-node";
|
||||
import { TreeView } from "./tree-view";
|
||||
|
||||
describe("getTreeChildrenMotion", () => {
|
||||
it("animates height when motion is allowed", () => {
|
||||
// Given / When
|
||||
const motion = getTreeChildrenMotion(false);
|
||||
|
||||
// Then
|
||||
expect(motion.initial).toHaveProperty("height", 0);
|
||||
expect(motion.animate).toHaveProperty("height", "auto");
|
||||
expect(motion.transition.duration).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("degrades to opacity-only with no height under reduced motion", () => {
|
||||
// Given / When
|
||||
const motion = getTreeChildrenMotion(true);
|
||||
|
||||
// Then
|
||||
expect(motion.initial).not.toHaveProperty("height");
|
||||
expect(motion.animate).not.toHaveProperty("height");
|
||||
expect(motion.exit).not.toHaveProperty("height");
|
||||
expect(motion.transition.duration).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const treeData = [
|
||||
{
|
||||
id: "org-1",
|
||||
name: "Organization",
|
||||
children: [
|
||||
{ id: "account-1", name: "Production" },
|
||||
{ id: "account-2", name: "Development" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe("TreeView", () => {
|
||||
it("animates node affordances and expanded content", () => {
|
||||
// Given
|
||||
render(<TreeView data={treeData} expandedIds={["org-1"]} showCheckboxes />);
|
||||
|
||||
// When
|
||||
const node = screen.getByRole("treeitem", { name: /organization/i });
|
||||
const expandButton = screen.getByRole("button", { name: /collapse/i });
|
||||
const chevron = expandButton.querySelector("svg");
|
||||
const group = screen.getByRole("group");
|
||||
|
||||
// Then
|
||||
expect(node).toHaveClass(
|
||||
"transition-[background-color,box-shadow,color]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(expandButton).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(chevron).toHaveClass(
|
||||
"transition-transform",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
"rotate-90",
|
||||
);
|
||||
expect(group).toHaveClass("overflow-hidden");
|
||||
});
|
||||
|
||||
it("animates selected leaf row feedback", () => {
|
||||
// Given
|
||||
render(
|
||||
<TreeView
|
||||
data={treeData}
|
||||
expandedIds={["org-1"]}
|
||||
selectedIds={["account-1"]}
|
||||
onSelectionChange={vi.fn()}
|
||||
showCheckboxes
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const selectedLeaf = screen.getByRole("treeitem", { name: /production/i });
|
||||
|
||||
// Then
|
||||
expect(selectedLeaf).toHaveAttribute("aria-selected", "true");
|
||||
expect(selectedLeaf).toHaveClass(
|
||||
"bg-prowler-white/5",
|
||||
"transition-[background-color,box-shadow,color]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DataTableSearch } from "./data-table-search";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/findings",
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-url-filters", () => ({
|
||||
useUrlFilters: () => ({ updateFilter: vi.fn() }),
|
||||
}));
|
||||
|
||||
describe("DataTableSearch", () => {
|
||||
it("uses visible focus and icon microinteraction timing", async () => {
|
||||
// Given - A table search field
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableSearch placeholder="Search findings" />);
|
||||
|
||||
// When - The user focuses the table search
|
||||
const input = screen.getByRole("searchbox", { name: /search findings/i });
|
||||
await user.click(input);
|
||||
|
||||
const control = screen.getByTestId("data-table-search-control");
|
||||
const icon = screen.getByTestId("data-table-search-icon");
|
||||
|
||||
// Then - The table search control has visible focus/highlight timing
|
||||
expect(control).toHaveClass(
|
||||
"transition-[background-color,border-color,box-shadow,color]",
|
||||
"duration-250",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
"focus-within:ring-1",
|
||||
);
|
||||
expect(icon).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-250",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -142,13 +142,17 @@ export const DataTableSearch = ({
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
data-testid="data-table-search-control"
|
||||
className={cn(
|
||||
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary flex items-center gap-1.5 rounded-md border transition-colors",
|
||||
isFocused && "border-border-input-primary-pressed",
|
||||
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary focus-within:ring-border-input-primary-press flex items-center gap-1.5 rounded-md border transition-[background-color,border-color,box-shadow,color] duration-250 ease-out focus-within:ring-1 focus-within:ring-inset motion-reduce:transition-none",
|
||||
isFocused && "border-border-input-primary-press",
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center pl-3">
|
||||
<SearchIcon className="text-text-neutral-tertiary size-4" />
|
||||
<SearchIcon
|
||||
data-testid="data-table-search-icon"
|
||||
className="text-text-neutral-tertiary size-4 transition-colors duration-250 ease-out motion-reduce:transition-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasBadge && (
|
||||
@@ -180,6 +184,7 @@ export const DataTableSearch = ({
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="search"
|
||||
aria-label={placeholder}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
|
||||
@@ -22,9 +22,26 @@ describe("StatusBadge", () => {
|
||||
);
|
||||
|
||||
it("renders the executing state with spinner and progress percentage", () => {
|
||||
render(<StatusBadge status="executing" loadingProgress={42} />);
|
||||
const { container } = render(
|
||||
<StatusBadge status="executing" loadingProgress={42} />,
|
||||
);
|
||||
expect(screen.getByText("executing")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toHaveClass(
|
||||
"animate-spin",
|
||||
"motion-reduce:animate-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("animates status color changes without layout motion", () => {
|
||||
const { container } = render(<StatusBadge status="completed" />);
|
||||
const badge = container.querySelector("[data-slot='badge']");
|
||||
expect(badge).toHaveClass(
|
||||
"transition-[background-color,border-color,color,box-shadow]",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits progress when loadingProgress is not provided", () => {
|
||||
|
||||
@@ -68,7 +68,10 @@ export const StatusBadge = ({
|
||||
>
|
||||
{status === "executing" ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<SpinnerIcon size={12} className="animate-spin" />
|
||||
<SpinnerIcon
|
||||
size={12}
|
||||
className="animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
{loadingProgress !== undefined && (
|
||||
<span className="text-[0.6rem]">{loadingProgress}%</span>
|
||||
)}
|
||||
|
||||
@@ -409,6 +409,21 @@
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes skeletonScan {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
55%,
|
||||
100% {
|
||||
transform: translateX(300%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-skeleton-scan {
|
||||
animation: skeletonScan 2.6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== BASE LAYER ===== */
|
||||
|
||||
Reference in new issue
Block a user