Compare commits

...

17 Commits

Author SHA1 Message Date
Alan Buscaglia 20b93388ec docs(skills): add UI motion guidance
- Document visible microinteraction rules for shadcn primitives
- Register the skill for motion and transition work
- Link skeleton guidance for loading handoff cases
2026-06-05 22:24:10 +02:00
Alan Buscaglia 0fb0807a22 feat(ui): add nested skeleton handoffs
- Reveal loaded content for nested shadcn skeleton states
- Cover finding drawers, modals, and inline resource tables
- Extend handoffs to alert, mutelist, compliance, and overview surfaces
2026-06-05 22:15:16 +02:00
Alan Buscaglia 753a4eda62 feat(ui): add skeleton loading handoffs
- Add reusable shadcn skeleton scanner and reveal primitives
- Wrap page-level loading states with skeleton content handoffs
- Document skeleton usage through a project skill
2026-06-05 22:13:55 +02:00
Alan Buscaglia f4051d52d9 feat(ui): add expandable microinteractions
- Add visible open and close motion to collapsible content
- Animate tree row, chevron, and selection feedback
- Cover expandable motion behavior with focused unit tests
2026-06-05 21:13:15 +02:00
Alan Buscaglia adbe67d2f3 feat(ui): add form control microinteractions
- Add visible focus and clear feedback to search inputs
- Animate radio, text input, textarea, and file dropzone states
- Cover form control motion with focused unit tests
2026-06-05 21:07:12 +02:00
Alan Buscaglia 65c0425729 fix(ui): animate finding group collapse
- Keep after-row content mounted during exit
- Animate inline resource container close state
- Preserve existing finding group table behavior
2026-06-05 14:55:00 +02:00
Alan Buscaglia 5828cce644 feat(ui): add Combobox trigger transition
- Animate combobox trigger and chevron state
- Keep trigger accessible with a stable label
- Cover Combobox motion with focused unit tests
2026-06-05 14:54:52 +02:00
Alan Buscaglia 87bd2e78a1 feat(ui): add Drawer content transition
- Animate drawer overlay and directional content states
- Preserve reduced-motion behavior
- Cover Drawer motion with focused unit tests
2026-06-05 14:54:45 +02:00
Alan Buscaglia ccae4afe68 feat(ui): add Dialog content transition
- Animate dialog overlay and content states
- Preserve reduced-motion behavior
- Cover Dialog motion with focused unit tests
2026-06-05 14:54:37 +02:00
Alan Buscaglia 0e2bb99f02 feat(ui): add Tabs content transition
- Animate tab panels when switching active content
- Preserve reduced-motion behavior
- Cover shared Tabs motion with focused unit tests
2026-06-05 14:49:09 +02:00
Alan Buscaglia 8fb59682d5 feat(ui): add multiselect selection microinteractions
- Animate selected pills and item feedback in multiselect components
- Add checkbox state transitions for provider group selections
- Cover shared selection motion with focused unit tests
2026-06-05 14:44:25 +02:00
Alan Buscaglia 799f062ee0 feat(ui): add Tooltip open close microinteraction
- Add visible Tooltip motion timing and easing

- Preserve reduced-motion fallbacks

- Cover Tooltip motion contract with unit tests
2026-06-05 13:47:11 +02:00
Alan Buscaglia 51945f5cc5 feat(ui): add Dropdown open close microinteraction
- Add visible Dropdown motion timing and easing

- Align submenu motion with dropdown content

- Cover reduced-motion behavior with unit tests
2026-06-05 13:46:53 +02:00
Alan Buscaglia b93e3f9d04 feat(ui): add Select open close microinteraction
- Add visible Select close motion without open-state conflicts
- Preserve reduced-motion behavior
- Cover controlled and uncontrolled close flows
2026-06-05 13:04:16 +02:00
Alan Buscaglia ef4d05a782 feat(ui): add Popover open close microinteraction
- Add explicit timing for Popover state transitions
- Add reduced-motion fallback utilities
- Cover controlled Popover motion behavior
2026-06-05 12:16:27 +02:00
Alan Buscaglia 7185e539c8 feat(ui): add Button press microinteraction
- Add targeted transition recipe for shared Button states
- Add press and reduced-motion behavior
- Cover link and menu motion exceptions
2026-06-05 12:00:35 +02:00
Alejandro Bailo 74251350bc feat(ui): add new scan jobs view (#11258) 2026-05-28 19:20:39 +02:00
178 changed files with 7793 additions and 2501 deletions
+133 -128
View File
@@ -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
+63
View File
@@ -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 (`200ms700ms` 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.
+60
View File
@@ -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
View File
@@ -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` |
---
+4
View File
@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
## [1.29.0] (Prowler UNRELEASED)
### 🚀 Added
- New Scan Jobs view with specific In Progress, Completed, Scheduled tabs [(#11258)](https://github.com/prowler-cloud/prowler/pull/11258)
### 🔄 Changed
- Dark mode: pure-black canvas, pure-white primary text, and brighter border / input tokens for clearer separation between cards, tables, and inputs [(#11073)](https://github.com/prowler-cloud/prowler/pull/11073)
+2
View File
@@ -1,5 +1,6 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib";
@@ -140,6 +141,7 @@ export const scanOnDemand = async (formData: FormData) => {
const result = await handleApiResponse(response, "/scans");
if (result?.data?.id) {
addScanOperation("start", result.data.id);
revalidatePath("/scans");
}
return result;
} catch (error) {
+1
View File
@@ -1,2 +1,3 @@
export * from "./poll";
export * from "./task.adapter";
export * from "./tasks";
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { getScanErrorDetails } from "./task.adapter";
describe("getScanErrorDetails", () => {
it("returns null when response is not a record", () => {
expect(getScanErrorDetails(null)).toBeNull();
expect(getScanErrorDetails("oops")).toBeNull();
expect(getScanErrorDetails(undefined)).toBeNull();
});
it("returns null when data is missing", () => {
expect(getScanErrorDetails({})).toBeNull();
});
it("returns null when attributes.result is missing", () => {
expect(getScanErrorDetails({ data: { attributes: {} } })).toBeNull();
});
it("returns null when result has no recognizable fields", () => {
expect(
getScanErrorDetails({ data: { attributes: { result: {} } } }),
).toBeNull();
});
it("parses an error with only an exc_type", () => {
const details = getScanErrorDetails({
data: { attributes: { result: { exc_type: "BotoCoreError" } } },
});
expect(details).toEqual({
type: "BotoCoreError",
messages: ["-"],
module: undefined,
copyValue: "ErrorType: BotoCoreError\nError: -",
});
});
it("joins multiple exc_message entries in copyValue", () => {
const details = getScanErrorDetails({
data: {
attributes: {
result: {
exc_type: "ScanError",
exc_message: ["Failed to connect", "Retry exhausted"],
exc_module: "scan.runner",
},
},
},
});
expect(details).toEqual({
type: "ScanError",
messages: ["Failed to connect", "Retry exhausted"],
module: "scan.runner",
copyValue:
"ErrorType: ScanError\nError: Failed to connect\nRetry exhausted",
});
});
it("filters non-string entries out of exc_message", () => {
const details = getScanErrorDetails({
data: {
attributes: {
result: {
exc_type: "ScanError",
exc_message: ["valid", 42, null, " ", " trimmed "],
},
},
},
});
expect(details?.messages).toEqual(["valid", "trimmed"]);
});
it("returns null when only whitespace fields are present", () => {
const details = getScanErrorDetails({
data: {
attributes: {
result: {
exc_type: " ",
exc_message: [""],
exc_module: "",
},
},
},
});
expect(details).toBeNull();
});
});
+55
View File
@@ -0,0 +1,55 @@
export interface ScanErrorDetails {
type: string;
messages: string[];
module?: string;
copyValue: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() !== ""
? value.trim()
: undefined;
}
function getStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter((item) => item !== "");
}
export function buildScanErrorDetails(
result: unknown,
): ScanErrorDetails | null {
if (!isRecord(result)) return null;
const type = getString(result.exc_type) ?? "-";
const messages = getStringList(result.exc_message);
const module = getString(result.exc_module);
if (type === "-" && messages.length === 0 && !module) return null;
const errorText = messages.length > 0 ? messages.join("\n") : "-";
return {
type,
messages: messages.length > 0 ? messages : ["-"],
module,
copyValue: `ErrorType: ${type}\nError: ${errorText}`,
};
}
export function getScanErrorDetails(
taskResponse: unknown,
): ScanErrorDetails | null {
if (!isRecord(taskResponse) || !isRecord(taskResponse.data)) return null;
if (!isRecord(taskResponse.data.attributes)) return null;
return buildScanErrorDetails(taskResponse.data.attributes.result);
}
@@ -1,9 +1,11 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { AccountsSelector } from "./accounts-selector";
const multiSelectContentSpy = vi.fn();
const multiSelectSpy = vi.fn();
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
@@ -35,9 +37,25 @@ vi.mock("@/components/icons/providers-badge", () => ({
}));
vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelect: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelect: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) => {
multiSelectSpy({ open });
return (
<div data-open={String(open)}>
<button type="button" onClick={() => onOpenChange?.(true)}>
Open selector
</button>
{children}
</div>
);
},
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
@@ -56,16 +74,26 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
},
MultiSelectItem: ({
children,
disabled,
value,
keywords,
onSelect,
}: {
children: React.ReactNode;
disabled?: boolean;
value: string;
keywords?: string[];
onSelect?: (value: string) => void;
}) => (
<div data-value={value} data-keywords={keywords?.join("|")}>
<button
type="button"
data-value={value}
data-keywords={keywords?.join("|")}
data-disabled={disabled ? "true" : "false"}
onClick={() => onSelect?.(value)}
>
{children}
</div>
</button>
),
}));
@@ -114,8 +142,8 @@ describe("AccountsSelector", () => {
render(<AccountsSelector providers={providers} />);
expect(multiSelectContentSpy).toHaveBeenCalledWith({
placeholder: "Search accounts...",
emptyMessage: "No accounts found.",
placeholder: "Search Providers...",
emptyMessage: "No Providers found.",
});
expect(screen.getByText("Production AWS")).toBeInTheDocument();
});
@@ -140,12 +168,56 @@ describe("AccountsSelector", () => {
).toHaveAttribute("data-keywords", expect.stringContaining("123456789012"));
});
it("can use provider UID values for pages whose API filters by provider_uid__in", () => {
render(
<AccountsSelector providers={providers} filterKey="provider_uid__in" />,
);
expect(
screen.getByText("Production AWS").closest("[data-value]"),
).toHaveAttribute("data-value", "123456789012");
});
it("disables select all when every account is already shown", () => {
render(<AccountsSelector providers={providers} />);
expect(
screen.getByRole("option", { name: /select all accounts/i }),
screen.getByRole("option", { name: /select all Providers/i }),
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
it("marks configured account values as disabled", () => {
render(
<AccountsSelector
providers={providers}
disabledValues={["provider-1"]}
/>,
);
expect(
screen.getByText("Production AWS").closest("[data-value]"),
).toHaveAttribute("data-disabled", "true");
expect(screen.getByText("Disconnected")).toBeInTheDocument();
});
it("can close the dropdown after selecting a launch-scan provider", async () => {
const user = userEvent.setup();
render(
<AccountsSelector
providers={providers}
closeOnSelect
onBatchChange={vi.fn()}
selectedValues={[]}
/>,
);
await user.click(screen.getByRole("button", { name: /open selector/i }));
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: true });
await user.click(screen.getByRole("button", { name: /production aws/i }));
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false });
});
});
@@ -1,7 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import { ReactNode } from "react";
import { ReactNode, useState } from "react";
import {
AlibabaCloudProviderBadge,
@@ -21,6 +21,7 @@ import {
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
import { Badge } from "@/components/shadcn";
import {
MultiSelect,
MultiSelectContent,
@@ -36,6 +37,14 @@ import {
type ProviderType,
} from "@/types/providers";
const ACCOUNT_SELECTOR_FILTER = {
PROVIDER_ID: "provider_id__in",
PROVIDER_UID: "provider_uid__in",
} as const;
type AccountSelectorFilter =
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
aws: <AWSProviderBadge width={18} height={18} />,
azure: <AzureProviderBadge width={18} height={18} />,
@@ -59,12 +68,10 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
interface AccountsSelectorBaseProps {
providers: ProviderProps[];
search?: MultiSelectSearchProp;
/**
* Currently selected provider types (from the pending ProviderTypeSelector state).
* Used only for contextual description/empty-state messaging — does NOT narrow
* the list of available accounts, which remains independent of provider selection.
*/
selectedProviderTypes?: string[];
filterKey?: AccountSelectorFilter;
id?: string;
disabledValues?: string[];
closeOnSelect?: boolean;
}
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
@@ -98,74 +105,79 @@ export function AccountsSelector({
providers,
onBatchChange,
selectedValues,
selectedProviderTypes,
filterKey = ACCOUNT_SELECTOR_FILTER.PROVIDER_ID,
id = "accounts-selector",
disabledValues = [],
search = {
placeholder: "Search accounts...",
emptyMessage: "No accounts found.",
placeholder: "Search Providers...",
emptyMessage: "No Providers found.",
},
closeOnSelect = false,
}: AccountsSelectorProps) {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const [selectorOpen, setSelectorOpen] = useState(false);
const filterKey = "filter[provider_id__in]";
const current = searchParams.get(filterKey) || "";
const labelId = `${id}-label`;
const urlFilterKey = `filter[${filterKey}]`;
const current = searchParams.get(urlFilterKey) || "";
const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
const visibleProviders = providers;
// .filter((p) => p.attributes.connection?.connected)
const getProviderValue = (provider: ProviderProps) =>
filterKey === ACCOUNT_SELECTOR_FILTER.PROVIDER_UID
? provider.attributes.uid
: provider.id;
const disabledValuesSet = new Set(disabledValues);
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
const selectedIds = (onBatchChange ? selectedValues : urlSelectedIds).filter(
(id) => !disabledValuesSet.has(id),
);
const handleMultiValueChange = (ids: string[]) => {
const enabledIds = ids.filter((id) => !disabledValuesSet.has(id));
if (onBatchChange) {
onBatchChange("provider_id__in", ids);
onBatchChange(filterKey, enabledIds);
if (closeOnSelect) setSelectorOpen(false);
return;
}
navigateWithParams((params) => {
params.delete(filterKey);
params.delete(urlFilterKey);
if (ids.length > 0) {
params.set(filterKey, ids.join(","));
if (enabledIds.length > 0) {
params.set(urlFilterKey, enabledIds.join(","));
}
});
if (closeOnSelect) setSelectorOpen(false);
};
const selectedLabel = () => {
if (selectedIds.length === 0) return null;
if (selectedIds.length === 1) {
const p = providers.find((pr) => pr.id === selectedIds[0]);
const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]);
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
return <span className="truncate">{name}</span>;
}
return (
<span className="truncate">{selectedIds.length} accounts selected</span>
<span className="truncate">{selectedIds.length} Providers selected</span>
);
};
// Build a contextual description based on currently selected provider types.
// This is purely for user guidance (aria label + empty state) and does NOT
// narrow the list of available accounts — all providers remain selectable.
const filterDescription =
selectedProviderTypes && selectedProviderTypes.length > 0
? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "All connected provider accounts";
return (
<div className="relative">
<label
htmlFor="accounts-selector"
className="sr-only"
id="accounts-label"
>
Filter by provider account. {filterDescription}. Select one or more
accounts to view findings.
<label htmlFor={id} className="sr-only" id={labelId}>
Filter by Provider. Select one or more Providers to filter results.
</label>
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
<MultiSelectTrigger
id="accounts-selector"
aria-labelledby="accounts-label"
>
{selectedLabel() || <MultiSelectValue placeholder="All accounts" />}
<MultiSelect
values={selectedIds}
onValuesChange={handleMultiValueChange}
open={closeOnSelect ? selectorOpen : undefined}
onOpenChange={closeOnSelect ? setSelectorOpen : undefined}
>
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
{selectedLabel() || <MultiSelectValue placeholder="All Providers" />}
</MultiSelectTrigger>
<MultiSelectContent search={search}>
{visibleProviders.length > 0 ? (
@@ -174,7 +186,7 @@ export function AccountsSelector({
role="option"
aria-selected={selectedIds.length === 0}
aria-disabled={selectedIds.length === 0}
aria-label="Select all accounts (clears current selection to show all)"
aria-label="Select all Providers (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
onClick={() => {
@@ -192,7 +204,8 @@ export function AccountsSelector({
{selectedIds.length === 0 ? "All selected" : "Select All"}
</div>
{visibleProviders.map((p) => {
const id = p.id;
const value = getProviderValue(p);
const isDisabled = disabledValuesSet.has(value);
const displayName = p.attributes.alias || p.attributes.uid;
const providerType = p.attributes.provider as ProviderType;
const icon = PROVIDER_ICON[providerType];
@@ -205,23 +218,28 @@ export function AccountsSelector({
].filter(Boolean);
return (
<MultiSelectItem
key={id}
value={id}
key={p.id}
value={value}
badgeLabel={displayName}
keywords={searchKeywords}
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
disabled={isDisabled}
aria-label={`${displayName} Provider (${providerType.toUpperCase()})`}
onSelect={() => {
if (closeOnSelect) setSelectorOpen(false);
}}
>
<span aria-hidden="true">{icon}</span>
<span className="truncate">{displayName}</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate">{displayName}</span>
{isDisabled && <Badge variant="tag">Disconnected</Badge>}
</span>
</MultiSelectItem>
);
})}
</>
) : (
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
{selectedProviderTypes && selectedProviderTypes.length > 0
? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "No connected accounts available"}
No connected Providers available
</div>
)}
</MultiSelectContent>
@@ -114,8 +114,8 @@ describe("ProviderTypeSelector", () => {
render(<ProviderTypeSelector providers={providers} />);
expect(multiSelectContentSpy).toHaveBeenCalledWith({
placeholder: "Search providers...",
emptyMessage: "No providers found.",
placeholder: "Search Provider Types...",
emptyMessage: "No Provider Types found.",
});
expect(screen.getByText("Amazon Web Services")).toBeInTheDocument();
});
@@ -141,7 +141,7 @@ describe("ProviderTypeSelector", () => {
render(<ProviderTypeSelector providers={providers} />);
expect(
screen.getByRole("option", { name: /select all providers/i }),
screen.getByRole("option", { name: /select all Provider Types/i }),
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
@@ -210,8 +210,8 @@ export const ProviderTypeSelector = ({
onBatchChange,
selectedValues,
search = {
placeholder: "Search providers...",
emptyMessage: "No providers found.",
placeholder: "Search Provider Types...",
emptyMessage: "No Provider Types found.",
},
}: ProviderTypeSelectorProps) => {
const searchParams = useSearchParams();
@@ -274,7 +274,7 @@ export const ProviderTypeSelector = ({
}
return (
<span className="min-w-0 truncate">
{selectedTypes.length} providers selected
{selectedTypes.length} Provider Types selected
</span>
);
};
@@ -286,7 +286,8 @@ export const ProviderTypeSelector = ({
className="sr-only"
id="provider-type-label"
>
Filter by provider type. Select one or more providers to view findings.
Filter by Provider Type. Select one or more Provider Types to view
findings.
</label>
<MultiSelect
values={selectedTypes}
@@ -296,7 +297,9 @@ export const ProviderTypeSelector = ({
id="provider-type-selector"
aria-labelledby="provider-type-label"
>
{selectedLabel() || <MultiSelectValue placeholder="All providers" />}
{selectedLabel() || (
<MultiSelectValue placeholder="All Provider Types" />
)}
</MultiSelectTrigger>
<MultiSelectContent search={search}>
{availableTypes.length > 0 ? (
@@ -305,7 +308,7 @@ export const ProviderTypeSelector = ({
role="option"
aria-selected={selectedTypes.length === 0}
aria-disabled={selectedTypes.length === 0}
aria-label="Select all providers (clears current selection to show all)"
aria-label="Select all Provider Types (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
onClick={() => {
@@ -328,7 +331,7 @@ export const ProviderTypeSelector = ({
value={providerType}
badgeLabel={PROVIDER_DATA[providerType].label}
keywords={[providerType, PROVIDER_DATA[providerType].label]}
aria-label={`${PROVIDER_DATA[providerType].label} provider`}
aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`}
>
<span aria-hidden="true">{renderIcon(providerType)}</span>
<span>{PROVIDER_DATA[providerType].label}</span>
@@ -337,7 +340,7 @@ export const ProviderTypeSelector = ({
</>
) : (
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
No connected providers available
No connected Provider Types available
</div>
)}
</MultiSelectContent>
@@ -2,6 +2,7 @@ import { Skeleton } from "@heroui/skeleton";
import { Suspense } from "react";
import { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table";
import { SkeletonBoundary } from "@/components/shadcn";
import { SearchParamsProps } from "@/types";
import { GraphsTabsClient } from "./_components/graphs-tabs-client";
@@ -44,10 +45,21 @@ 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} />;
if (tab.id === "findings") {
return [
tab.id,
<SkeletonBoundary key={tab.id} fallback={fallback}>
{content}
</SkeletonBoundary>,
];
}
return [
tab.id,
<Suspense key={tab.id} fallback={fallback}>
<Component searchParams={searchParams} />
{content}
</Suspense>,
];
}),
@@ -3,7 +3,7 @@ import {
getFindingsBySeverity,
SeverityByProviderType,
} from "@/actions/overview";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { SankeyChart } from "@/components/graphs/sankey-chart";
import { SearchParamsProps } from "@/types";
@@ -20,7 +20,7 @@ export async function RiskPipelineViewSSR({
const providerIdFilter = filters["filter[provider_id__in]"];
// Fetch providers list to know account types
const providersListResponse = await getProviders({ pageSize: 200 });
const providersListResponse = await getAllProviders();
const allProviders = providersListResponse?.data || [];
// Build severityByProviderType based on filters
@@ -4,7 +4,7 @@ import {
adaptToRiskPlotData,
getProvidersRiskData,
} from "@/actions/overview/risk-plot";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
@@ -21,7 +21,7 @@ export async function RiskPlotSSR({
const providerIdFilter = filters["filter[provider_id__in]"];
// Fetch all providers
const providersListResponse = await getProviders({ pageSize: 200 });
const providersListResponse = await getAllProviders();
const allProviders = providersListResponse?.data || [];
// Filter providers based on search params
@@ -518,7 +518,7 @@ describe("AlertFormModal", () => {
expect(alertEditGrid).toHaveClass("xl:grid-cols-3", "2xl:grid-cols-3");
expect(alertEditGrid).not.toHaveClass("xl:grid-cols-4", "2xl:grid-cols-5");
expect(screen.getAllByText("Amazon Web Services")[0]).toBeVisible();
expect(screen.getByText("All accounts")).toBeVisible();
expect(screen.getByText("All Providers")).toBeVisible();
expect(within(filterControls).getByText("All Delta")).toBeVisible();
expect(within(filterControls).getByText("All Resource Type")).toBeVisible();
expect(
@@ -547,7 +547,9 @@ describe("AlertFormModal", () => {
});
// When
await user.click(screen.getByLabelText(/provider type/i));
await user.click(
screen.getByRole("combobox", { name: /filter by Provider Type/i }),
);
const providerOptions = await screen.findAllByText("Google Cloud Platform");
const visibleProviderOption = providerOptions.at(-1);
expect(visibleProviderOption).toBeDefined();
@@ -597,7 +599,9 @@ describe("AlertFormModal", () => {
});
// When
await user.click(screen.getByLabelText(/provider type/i));
await user.click(
screen.getByRole("combobox", { name: /filter by Provider Type/i }),
);
const providerOptions = await screen.findAllByText("Google Cloud Platform");
const visibleProviderOption = providerOptions.at(-1);
expect(visibleProviderOption).toBeDefined();
@@ -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>
);
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { getLatestMetadataInfo } from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { getAlert, listAlerts } from "@/app/(prowler)/alerts/_actions";
import { AlertsManager } from "@/app/(prowler)/alerts/_components/alerts-manager";
@@ -58,7 +58,7 @@ export default async function AlertsPage({ searchParams }: AlertsPageProps) {
const [result, providersData, scansData, metadataInfoData, editResult] =
await Promise.all([
listAlerts(toAlertsSearchParams(resolvedSearchParams)),
getProviders({ pageSize: 50 }),
getAllProviders(),
getScans({ pageSize: 50 }),
getLatestMetadataInfo({}),
editAlertId ? getAlert(editAlertId) : Promise.resolve(null),
+3 -3
View File
@@ -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 />
+5 -6
View File
@@ -1,12 +1,10 @@
import { Suspense } from "react";
import {
adaptFindingGroupsResponse,
getFindingGroups,
getLatestFindingGroups,
} from "@/actions/finding-groups";
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { getScan, getScans } from "@/actions/scans";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
import { FindingsFilters } from "@/components/findings/findings-filters";
@@ -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 {
@@ -37,7 +36,7 @@ export default async function Findings({
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const [providersData, scansData] = await Promise.all([
getProviders({ pageSize: 50 }),
getAllProviders(),
getScans({ pageSize: 50 }),
]);
@@ -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,7 +1,7 @@
import React from "react";
import { getIntegrations } from "@/actions/integrations";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { S3IntegrationsManager } from "@/components/integrations/s3/s3-integrations-manager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
@@ -47,7 +47,7 @@ export default async function S3Integrations({
const [integrations, providers] = await Promise.all([
getIntegrations(urlSearchParams),
getProviders({ pageSize: 100 }),
getAllProviders(),
]);
const s3Integrations = integrations?.data || [];
@@ -1,7 +1,7 @@
import React from "react";
import { getIntegrations } from "@/actions/integrations";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { SecurityHubIntegrationsManager } from "@/components/integrations/security-hub/security-hub-integrations-manager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
@@ -45,7 +45,7 @@ export default async function SecurityHubIntegrations({
const [integrations, providers] = await Promise.all([
getIntegrations(urlSearchParams),
getProviders({ pageSize: 100 }),
getAllProviders(),
]);
const securityHubIntegrations = integrations?.data || [];
+6 -4
View File
@@ -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>
</>
);
}
+6 -4
View File
@@ -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>
+32 -26
View File
@@ -1,11 +1,9 @@
import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
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";
import { AccountsSelector } from "./_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "./_overview/_components/provider-type-selector";
import {
AttackSurfaceSkeleton,
AttackSurfaceSSR,
@@ -39,65 +37,73 @@ export default async function Home({
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const providersData = await getProviders({ page: 1, pageSize: 200 });
const providersData = await getAllProviders();
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderTypeSelector providers={providersData?.data ?? []} />
<AccountsSelector providers={providersData?.data ?? []} />
<ProviderAccountSelectors providers={providersData?.data ?? []} />
</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>
);
+5 -7
View File
@@ -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>
@@ -2,7 +2,7 @@ import {
getProviderGroupInfoById,
getProviderGroups,
} from "@/actions/manage-groups/manage-groups";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import { getRoles } from "@/actions/roles";
import { AddGroupForm, EditGroupForm } from "@/components/manage-groups/forms";
import { ColumnGroups } from "@/components/manage-groups/table";
@@ -19,7 +19,7 @@ export const ProviderGroupsContent = async ({
// Fetch all data in parallel
const [providersResponse, rolesResponse, providerGroupsData, editGroupData] =
await Promise.all([
getProviders({ pageSize: 50 }),
getAllProviders(),
getRoles({}),
fetchGroupsTableData(searchParams),
providerGroupId && !Array.isArray(providerGroupId)
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
const providersActionsMock = vi.hoisted(() => ({
getProviders: vi.fn(),
getAllProviders: vi.fn(),
}));
const organizationsActionsMock = vi.hoisted(() => ({
@@ -619,6 +620,7 @@ describe("loadProvidersAccountsViewData", () => {
it("does not call organizations endpoints in OSS", async () => {
// Given
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
scansActionsMock.getScans.mockResolvedValue({ data: [] });
// When
@@ -662,6 +664,7 @@ describe("loadProvidersAccountsViewData", () => {
},
})),
});
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({
data: [
{
@@ -724,6 +727,7 @@ describe("loadProvidersAccountsViewData", () => {
it("falls back to empty cloud grouping data when organizations endpoints fail", async () => {
// Given
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({
data: [],
});
@@ -2,7 +2,7 @@ import {
listOrganizationsSafe,
listOrganizationUnitsSafe,
} from "@/actions/organizations/organizations";
import { getProviders } from "@/actions/providers";
import { getAllProviders, getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import {
extractFiltersAndQuery,
@@ -467,7 +467,7 @@ export async function loadProvidersAccountsViewData({
),
// Unfiltered fetch for ProviderTypeSelector — only needs distinct types;
// TODO: Replace with a dedicated lightweight endpoint when available.
resolveActionResult(getProviders({ pageSize: 500 })),
resolveActionResult(getAllProviders()),
// Fetch active scheduled scans to determine daily schedule per provider
resolveActionResult(
getScans({
+5 -6
View File
@@ -1,6 +1,4 @@
import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { getAllProviders } from "@/actions/providers";
import {
getLatestMetadataInfo,
getLatestResources,
@@ -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 {
@@ -44,7 +43,7 @@ export default async function Resources({
filters: outputFilters,
sort: encodedSort,
}),
getProviders({ pageSize: 50 }),
getAllProviders(),
initialResourceId
? getResourceById(initialResourceId, { include: ["provider"] })
: Promise.resolve(undefined),
@@ -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>
);
+6 -4
View File
@@ -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>
);
+91 -110
View File
@@ -1,25 +1,55 @@
import { Suspense } from "react";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { auth } from "@/auth.config";
import { MutedFindingsConfigButton } from "@/components/providers";
import { ScansFilters } from "@/components/scans";
import { ScansLaunchSection } from "@/components/scans/scans-launch-section";
import {
getScanJobsTab,
getScanJobsTabFilters,
getScanJobsUserFilters,
} from "@/components/scans/scans.utils";
import { ScansPageShell } from "@/components/scans/scans-page-shell";
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
import { SkeletonTableScans } from "@/components/scans/table";
import { ScansTableWithPolling } from "@/components/scans/table/scans";
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
import { SkeletonBoundary } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import {
createProviderDetailsMapping,
extractProviderUIDs,
} from "@/lib/provider-helpers";
import {
ExpandedScanData,
ProviderProps,
SCAN_JOBS_TAB,
ScanProps,
SearchParamsProps,
} from "@/types";
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
const getFilterSearchQuery = (
filters: Record<string, string | string[]>,
): string => {
const value = filters["filter[search]"];
if (Array.isArray(value)) return value[0] ?? "";
return value ?? "";
};
const getActiveScanCount = async (
searchParams: SearchParamsProps,
): Promise<number> => {
const userFilters = getScanJobsUserFilters(searchParams);
const filters = {
...userFilters,
...getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE),
};
const scansData = await getScans({
query: getFilterSearchQuery(filters),
page: 1,
pageSize: ACTIVE_SCAN_COUNT_PAGE_SIZE,
filters,
fields: { scans: "state" },
});
return scansData && "meta" in scansData ? scansData.meta.pagination.count : 0;
};
export default async function Scans({
searchParams,
}: {
@@ -27,96 +57,47 @@ export default async function Scans({
}) {
const session = await auth();
const resolvedSearchParams = await searchParams;
const filteredParams = { ...resolvedSearchParams };
delete filteredParams.scanId;
const [providersData, completedScansData] = await Promise.all([
getAllProviders(),
getScans({
filters: { "filter[state]": "completed" },
pageSize: 50,
fields: { scans: "name,completed_at,provider" },
include: "provider",
}),
]);
const providersData = await getAllProviders();
const providers = providersData?.data ?? [];
const completedScans: ExpandedScanData[] = (completedScansData?.data ?? [])
.map((scan: ScanProps) => {
const providerId = scan.relationships?.provider?.data?.id;
const providerData = completedScansData?.included?.find(
(item: { type: string; id: string }) =>
item.type === "providers" && item.id === providerId,
);
if (!providerData) return null;
return {
...scan,
providerInfo: {
provider: providerData.attributes.provider,
uid: providerData.attributes.uid,
alias: providerData.attributes.alias,
},
};
})
.filter(Boolean) as ExpandedScanData[];
const providerInfo =
providersData?.data
?.filter(
(provider: ProviderProps) =>
provider.attributes.connection.connected === true,
)
.map((provider: ProviderProps) => ({
providerId: provider.id,
alias: provider.attributes.alias,
providerType: provider.attributes.provider,
uid: provider.attributes.uid,
connected: provider.attributes.connection.connected,
})) || [];
const thereIsNoProviders =
!providersData?.data || providersData.data.length === 0;
const thereIsNoProvidersConnected = Boolean(
providersData?.data?.every(
(provider: ProviderProps) => !provider.attributes.connection.connected,
),
const connectedProviders = providers.filter(
(provider: ProviderProps) =>
provider.attributes.connection.connected === true,
);
const thereIsNoProviders = providers.length === 0;
const thereIsNoProvidersConnected =
!thereIsNoProviders && connectedProviders.length === 0;
const hasManageScansPermission = Boolean(
session?.user?.permissions?.manage_scans,
);
// Extract provider UIDs and create provider details mapping for filtering
const providerUIDs = providersData ? extractProviderUIDs(providersData) : [];
const providerDetails = providersData
? createProviderDetailsMapping(providerUIDs, providersData)
: [];
const activeScanCount =
thereIsNoProviders || thereIsNoProvidersConnected
? 0
: await getActiveScanCount(resolvedSearchParams);
return (
<ContentLayout title="Scans" icon="lucide:timer">
<>
<ScansLaunchSection
providers={providerInfo}
<ContentLayout title="Scan Jobs" icon="lucide:timer">
{thereIsNoProviders || thereIsNoProvidersConnected ? (
<ScansProvidersEmptyState thereIsNoProviders={thereIsNoProviders} />
) : (
<ScansPageShell
providers={providers}
hasManageScansPermission={hasManageScansPermission}
thereIsNoProviders={thereIsNoProviders}
thereIsNoProvidersConnected={thereIsNoProvidersConnected}
/>
{!thereIsNoProviders && (
<div className="flex flex-col gap-6">
<ScansFilters
providerUIDs={providerUIDs}
providerDetails={providerDetails}
completedScans={completedScans}
/>
<div className="flex items-center justify-end">
<MutedFindingsConfigButton />
</div>
<Suspense fallback={<SkeletonTableScans />}>
<SSRDataTableScans searchParams={resolvedSearchParams} />
</Suspense>
</div>
)}
</>
activeScanCount={activeScanCount}
>
<SkeletonBoundary
fallback={
<SkeletonTableScans
tab={getScanJobsTab(resolvedSearchParams.tab)}
/>
}
>
<SSRDataTableScans searchParams={resolvedSearchParams} />
</SkeletonBoundary>
</ScansPageShell>
)}
</ContentLayout>
);
}
@@ -126,21 +107,27 @@ const SSRDataTableScans = async ({
}: {
searchParams: SearchParamsProps;
}) => {
const tab = getScanJobsTab(searchParams.tab);
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const sort = searchParams.sort?.toString();
// Extract all filter parameters, excluding scanId
const filters = Object.fromEntries(
Object.entries(searchParams).filter(
([key]) => key.startsWith("filter[") && key !== "scanId",
),
const userFilters = Object.entries(searchParams).filter(([key]) =>
key.startsWith("filter["),
);
const hasUserFilters = userFilters.length > 0;
const filters = {
...getScanJobsUserFilters(searchParams),
...getScanJobsTabFilters(
tab,
searchParams["filter[state__in]"] ?? searchParams["filter[state]"],
),
};
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
// Fetch scans data with provider information included
const scansData = await getScans({
query,
page,
@@ -158,19 +145,12 @@ const SSRDataTableScans = async ({
scans?.map((scan: ScanProps) => {
const providerId = scan.relationships?.provider?.data?.id;
if (!providerId) {
return { ...scan, providerInfo: null };
}
// Find the provider data in the included array
const providerData = included?.find(
(item: { type: string; id: string }) =>
item.type === "providers" && item.id === providerId,
);
if (!providerData) {
return { ...scan, providerInfo: null };
}
if (!providerData) return scan;
return {
...scan,
@@ -183,10 +163,11 @@ const SSRDataTableScans = async ({
}) || [];
return (
<ScansTableWithPolling
initialData={expandedScansData}
initialMeta={meta}
searchParams={searchParams}
<ScanJobsTable
data={expandedScansData}
meta={meta}
tab={tab}
hasFilters={hasUserFilters}
/>
);
};
+6 -4
View File
@@ -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>
);
};
@@ -13,7 +13,7 @@ interface ComplianceScanInfoProps {
};
attributes: {
name?: string;
completed_at: string;
completed_at: string | null;
};
};
}
@@ -0,0 +1,247 @@
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderProps } from "@/types/providers";
import { ProviderAccountSelectors } from "./provider-account-selectors";
const { selectorProps, navigateWithParamsMock, currentSearchParams } =
vi.hoisted(() => ({
selectorProps: {
providerType: undefined as
| {
providers: ProviderProps[];
onBatchChange: (filterKey: string, values: string[]) => void;
selectedValues: string[];
}
| undefined,
accounts: undefined as
| {
providers: ProviderProps[];
filterKey?: string;
onBatchChange: (filterKey: string, values: string[]) => void;
selectedValues: string[];
}
| undefined,
},
navigateWithParamsMock: vi.fn(),
currentSearchParams: { value: "" },
}));
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(currentSearchParams.value),
}));
vi.mock("@/hooks/use-url-filters", () => ({
useUrlFilters: () => ({
navigateWithParams: navigateWithParamsMock,
}),
}));
vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({
ProviderTypeSelector: (props: {
providers: ProviderProps[];
onBatchChange: (filterKey: string, values: string[]) => void;
selectedValues: string[];
}) => {
selectorProps.providerType = props;
return <div>Provider type selector</div>;
},
}));
vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
AccountsSelector: (props: {
providers: ProviderProps[];
filterKey?: string;
onBatchChange: (filterKey: string, values: string[]) => void;
selectedValues: string[];
}) => {
selectorProps.accounts = props;
return <div>Accounts selector</div>;
},
}));
const makeProvider = ({
id,
provider,
uid,
alias,
}: {
id: string;
provider: ProviderProps["attributes"]["provider"];
uid: string;
alias: string;
}): ProviderProps => ({
id,
type: "providers",
attributes: {
provider,
uid,
alias,
status: "completed",
resources: 0,
connection: {
connected: true,
last_checked_at: "2026-04-13T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-13T00:00:00Z",
updated_at: "2026-04-13T00:00:00Z",
created_by: {
object: "user",
id: "user-1",
},
},
relationships: {
secret: { data: null },
provider_groups: {
meta: { count: 0 },
data: [],
},
},
});
const providers = [
makeProvider({
id: "aws-provider",
provider: "aws",
uid: "123456789012",
alias: "Production AWS",
}),
makeProvider({
id: "gcp-provider",
provider: "gcp",
uid: "prowler-project",
alias: "Production GCP",
}),
];
const applyLastNavigation = () => {
const modifier = navigateWithParamsMock.mock.calls.at(-1)?.[0] as
| ((params: URLSearchParams) => void)
| undefined;
const params = new URLSearchParams(currentSearchParams.value);
if (!modifier) throw new Error("Expected navigateWithParams to be called");
modifier(params);
return params;
};
describe("ProviderAccountSelectors", () => {
beforeEach(() => {
currentSearchParams.value = "";
selectorProps.providerType = undefined;
selectorProps.accounts = undefined;
navigateWithParamsMock.mockClear();
});
it("filters account options by selected provider types in instant mode", () => {
currentSearchParams.value = "filter%5Bprovider_type__in%5D=aws";
render(<ProviderAccountSelectors providers={providers} />);
expect(selectorProps.accounts?.providers).toEqual([providers[0]]);
});
it("cleans incompatible selected accounts in the same instant navigation", () => {
currentSearchParams.value =
"filter%5Bprovider_type__in%5D=aws&filter%5Bprovider_id__in%5D=aws-provider";
render(<ProviderAccountSelectors providers={providers} />);
selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]);
const params = applyLastNavigation();
expect(params.get("filter[provider_type__in]")).toBe("gcp");
expect(params.get("filter[provider_id__in]")).toBeNull();
});
it("cleans incompatible UID accounts in the same instant navigation", () => {
currentSearchParams.value =
"filter%5Bprovider_type__in%5D=aws&filter%5Bprovider_uid__in%5D=123456789012&page=2&scanId=scan-1";
render(
<ProviderAccountSelectors
providers={providers}
accountFilterKey="provider_uid__in"
accountValue="uid"
paramsToDeleteOnChange={["page", "scanId"]}
/>,
);
selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]);
const params = applyLastNavigation();
expect(selectorProps.accounts?.filterKey).toBe("provider_uid__in");
expect(params.get("filter[provider_type__in]")).toBe("gcp");
expect(params.get("filter[provider_uid__in]")).toBeNull();
expect(params.get("page")).toBeNull();
expect(params.get("scanId")).toBeNull();
});
it("filters account options by selected provider types in batch mode", () => {
render(
<ProviderAccountSelectors
providers={providers}
mode="batch"
selectedProviderTypes={["aws"]}
selectedAccounts={[]}
onBatchChange={vi.fn()}
/>,
);
expect(selectorProps.accounts?.providers).toEqual([providers[0]]);
});
it("cleans incompatible selected accounts in batch mode", () => {
const onBatchChange = vi.fn();
render(
<ProviderAccountSelectors
providers={providers}
mode="batch"
selectedProviderTypes={["aws"]}
selectedAccounts={["aws-provider", "gcp-provider"]}
onBatchChange={onBatchChange}
/>,
);
selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]);
expect(onBatchChange).toHaveBeenCalledWith("provider_type__in", ["gcp"]);
expect(onBatchChange).toHaveBeenCalledWith("provider_id__in", [
"gcp-provider",
]);
});
it("uses provider UID values when accountValue is uid", () => {
const onBatchChange = vi.fn();
render(
<ProviderAccountSelectors
providers={providers}
mode="batch"
accountFilterKey="provider_uid__in"
accountValue="uid"
selectedProviderTypes={["aws"]}
selectedAccounts={["123456789012", "prowler-project"]}
onBatchChange={onBatchChange}
/>,
);
selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]);
expect(selectorProps.accounts?.filterKey).toBe("provider_uid__in");
expect(onBatchChange).toHaveBeenCalledWith("provider_uid__in", [
"prowler-project",
]);
});
});
@@ -0,0 +1,200 @@
"use client";
import { useSearchParams } from "next/navigation";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
import { useUrlFilters } from "@/hooks/use-url-filters";
import type { ProviderProps } from "@/types/providers";
const ACCOUNT_FILTER_KEY = {
PROVIDER_ID: "provider_id__in",
PROVIDER_UID: "provider_uid__in",
} as const;
const ACCOUNT_VALUE = {
ID: "id",
UID: "uid",
} as const;
type AccountFilterKey =
(typeof ACCOUNT_FILTER_KEY)[keyof typeof ACCOUNT_FILTER_KEY];
type AccountValue = (typeof ACCOUNT_VALUE)[keyof typeof ACCOUNT_VALUE];
interface ProviderAccountSelectorsBaseProps {
providers: ProviderProps[];
accountFilterKey?: AccountFilterKey;
accountValue?: AccountValue;
providerSelectorClassName?: string;
accountSelectorClassName?: string;
paramsToDeleteOnChange?: string[];
}
interface ProviderAccountSelectorsInstantProps
extends ProviderAccountSelectorsBaseProps {
mode?: "instant";
selectedProviderTypes?: never;
selectedAccounts?: never;
onBatchChange?: never;
}
interface ProviderAccountSelectorsBatchProps
extends ProviderAccountSelectorsBaseProps {
mode: "batch";
selectedProviderTypes: string[];
selectedAccounts: string[];
onBatchChange: (filterKey: string, values: string[]) => void;
}
type ProviderAccountSelectorsProps =
| ProviderAccountSelectorsInstantProps
| ProviderAccountSelectorsBatchProps;
const toFilterKey = (filterKey: string) => `filter[${filterKey}]`;
const getAccountValue = (
provider: ProviderProps,
accountValue: AccountValue,
): string =>
accountValue === ACCOUNT_VALUE.UID ? provider.attributes.uid : provider.id;
const getCsvValues = (value: string | null): string[] =>
value ? value.split(",").filter(Boolean) : [];
const getFilteredProviders = (
providers: ProviderProps[],
selectedProviderTypes: string[],
): ProviderProps[] => {
if (selectedProviderTypes.length === 0) return providers;
return providers.filter((provider) =>
selectedProviderTypes.includes(provider.attributes.provider),
);
};
const getCompatibleAccounts = ({
providers,
selectedAccounts,
selectedProviderTypes,
accountValue,
}: {
providers: ProviderProps[];
selectedAccounts: string[];
selectedProviderTypes: string[];
accountValue: AccountValue;
}): string[] => {
if (selectedAccounts.length === 0) return [];
if (selectedProviderTypes.length === 0) return selectedAccounts;
const compatibleValues = new Set(
getFilteredProviders(providers, selectedProviderTypes).map((provider) =>
getAccountValue(provider, accountValue),
),
);
return selectedAccounts.filter((account) => compatibleValues.has(account));
};
export function ProviderAccountSelectors({
providers,
accountFilterKey = ACCOUNT_FILTER_KEY.PROVIDER_ID,
accountValue = ACCOUNT_VALUE.ID,
providerSelectorClassName,
accountSelectorClassName,
paramsToDeleteOnChange = [],
...props
}: ProviderAccountSelectorsProps) {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const isBatchMode = props.mode === "batch";
const selectedProviderTypes = isBatchMode
? props.selectedProviderTypes
: getCsvValues(searchParams.get(toFilterKey("provider_type__in")));
const selectedAccounts = isBatchMode
? props.selectedAccounts
: getCsvValues(searchParams.get(toFilterKey(accountFilterKey)));
const filteredProviders = getFilteredProviders(
providers,
selectedProviderTypes,
);
const handleProviderTypeChange = (
filterKey: string,
values: string[],
): void => {
const compatibleAccounts = getCompatibleAccounts({
providers,
selectedAccounts,
selectedProviderTypes: values,
accountValue,
});
if (isBatchMode) {
props.onBatchChange(filterKey, values);
if (compatibleAccounts.length !== selectedAccounts.length) {
props.onBatchChange(accountFilterKey, compatibleAccounts);
}
return;
}
navigateWithParams((params) => {
const providerFilterKey = toFilterKey(filterKey);
const accountUrlFilterKey = toFilterKey(accountFilterKey);
if (values.length > 0) {
params.set(providerFilterKey, values.join(","));
} else {
params.delete(providerFilterKey);
}
if (compatibleAccounts.length > 0) {
params.set(accountUrlFilterKey, compatibleAccounts.join(","));
} else {
params.delete(accountUrlFilterKey);
}
paramsToDeleteOnChange.forEach((key) => params.delete(key));
});
};
const handleAccountChange = (filterKey: string, values: string[]): void => {
if (isBatchMode) {
props.onBatchChange(filterKey, values);
return;
}
navigateWithParams((params) => {
const accountUrlFilterKey = toFilterKey(filterKey);
if (values.length > 0) {
params.set(accountUrlFilterKey, values.join(","));
} else {
params.delete(accountUrlFilterKey);
}
paramsToDeleteOnChange.forEach((key) => params.delete(key));
});
};
return (
<>
<div className={providerSelectorClassName}>
<ProviderTypeSelector
providers={providers}
onBatchChange={handleProviderTypeChange}
selectedValues={selectedProviderTypes}
/>
</div>
<div className={accountSelectorClassName}>
<AccountsSelector
providers={filteredProviders}
filterKey={accountFilterKey}
onBatchChange={handleAccountChange}
selectedValues={selectedAccounts}
/>
</div>
</>
);
}
+15 -31
View File
@@ -4,8 +4,6 @@ import { ChevronDown } from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
@@ -15,6 +13,7 @@ import {
FilterChip,
FilterSummaryStrip,
} from "@/components/filters/filter-summary-strip";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
@@ -30,7 +29,7 @@ import {
} from "./findings-filters.utils";
interface FindingsFiltersProps {
/** Provider data for ProviderTypeSelector and AccountsSelector */
/** Provider data for provider/account filter controls. */
providers: ProviderProps[];
completedScanIds: string[];
scanDetails: { [key: string]: ScanEntity }[];
@@ -96,7 +95,7 @@ export const FindingsFilterBatchControls = ({
const [isExpanded, setIsExpanded] = useState(false);
const isAlertsEdit = variant === "alerts-edit";
// Custom filters for the expandable section (removed Provider - now using AccountsSelector)
// Custom filters for the expandable section.
const customFilters = [
...filterFindings
.filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS)
@@ -203,37 +202,23 @@ export const FindingsFilterBatchControls = ({
? pendingDateValues[0]
: undefined;
const providerTypeControl = (className: string) => (
<div className={className}>
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_type__in]")}
/>
</div>
);
const accountsControl = (className: string) => (
<div className={className}>
<AccountsSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_id__in]")}
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
/>
</div>
const providerAccountControls = (className: string) => (
<ProviderAccountSelectors
providers={providers}
mode="batch"
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
selectedAccounts={getFilterValue("filter[provider_id__in]")}
onBatchChange={setPending}
providerSelectorClassName={className}
accountSelectorClassName={className}
/>
);
const alertEditFilterGrid = hasCustomFilters ? (
<DataTableFilterCustom
gridClassName="w-full gap-3 xl:grid-cols-3 2xl:grid-cols-3"
filters={customFilters}
prependElement={
<>
{providerTypeControl(FILTER_GRID_ITEM_CLASS)}
{accountsControl(FILTER_GRID_ITEM_CLASS)}
</>
}
prependElement={providerAccountControls(FILTER_GRID_ITEM_CLASS)}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
onBatchChange={setPending}
@@ -303,8 +288,7 @@ export const FindingsFilterBatchControls = ({
alertEditFilterGrid
) : (
<>
{providerTypeControl(FILTER_CONTROL_COLUMN_CLASS)}
{accountsControl(FILTER_CONTROL_COLUMN_CLASS)}
{providerAccountControls(FILTER_CONTROL_COLUMN_CLASS)}
{hasCustomFilters && (
<Button
variant="outline"
@@ -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&apos;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>
+143 -141
View File
@@ -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>
@@ -5,11 +5,12 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { AnimatePresence, motion } from "framer-motion";
import { motion } from "framer-motion";
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";
@@ -213,109 +214,120 @@ export function InlineResourceContainer({
onMuteComplete: handleMuteComplete,
}}
>
<tr>
<motion.tr
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<td colSpan={columnCount} className="p-0">
<AnimatePresence initial>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="relative">
<div
ref={combinedScrollRef}
className="max-h-[440px] overflow-y-auto pl-6"
>
{/* Resource rows or skeleton placeholder */}
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="relative">
<div
ref={combinedScrollRef}
className="max-h-[440px] overflow-y-auto pl-6"
>
{/* Resource rows or skeleton placeholder */}
{isLoading && rows.length === 0 ? (
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
<tbody>
{isLoading && rows.length === 0 ? (
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(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{getFindingGroupEmptyStateMessage(group, filters)}
</TableCell>
</TableRow>
)}
{Array.from({ length: skeletonRowCount }).map((_, i) => (
<ResourceSkeletonRow
key={i}
isEmptyStateSized={filteredResourceCount === 0}
/>
))}
</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>
)}
</tbody>
</table>
</SkeletonContentReveal>
)}
{/* Loading state for infinite scroll (subsequent pages only) */}
{isLoading && rows.length > 0 && (
<LoadingState label="Loading resources..." />
)}
{/* Loading state for infinite scroll (subsequent pages only) */}
{isLoading && rows.length > 0 && (
<LoadingState label="Loading resources..." />
)}
{/* Sentinel for scroll hint detection */}
<div
ref={scrollHintSentinelRef}
aria-hidden
className="h-px shrink-0"
/>
{/* Sentinel for scroll hint detection */}
<div
ref={scrollHintSentinelRef}
aria-hidden
className="h-px shrink-0"
/>
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
</div>
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
</div>
{/* Gradients rendered after scroll container so they paint on top */}
<div className="from-bg-neutral-secondary pointer-events-none absolute top-0 right-0 left-6 z-20 h-6 bg-gradient-to-b to-transparent" />
<div className="from-bg-neutral-secondary pointer-events-none absolute right-0 bottom-0 left-6 z-20 h-6 bg-gradient-to-t to-transparent" />
{/* Gradients rendered after scroll container so they paint on top */}
<div className="from-bg-neutral-secondary pointer-events-none absolute top-0 right-0 left-6 z-20 h-6 bg-gradient-to-b to-transparent" />
<div className="from-bg-neutral-secondary pointer-events-none absolute right-0 bottom-0 left-6 z-20 h-6 bg-gradient-to-t to-transparent" />
{/* Scroll hint */}
{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">
<ChevronsDown className="inline size-3.5" /> Scroll for
more
</div>
{/* Scroll hint */}
{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">
<ChevronsDown className="inline size-3.5" /> Scroll for
more
</div>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
)}
</div>
</motion.div>
</td>
</tr>
</motion.tr>
<ResourceDetailDrawer
open={drawer.isOpen}
@@ -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>
);
}
+10 -19
View File
@@ -3,8 +3,6 @@
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
@@ -12,6 +10,7 @@ import {
FilterChip,
FilterSummaryStrip,
} from "@/components/filters/filter-summary-strip";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table";
@@ -170,23 +169,15 @@ export const ResourcesFilters = ({
controlsClassName="gap-3"
controls={
<>
<div className={FILTER_CONTROL_COLUMN_CLASS}>
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_type__in]")}
/>
</div>
<div className={FILTER_CONTROL_COLUMN_CLASS}>
<AccountsSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_id__in]")}
selectedProviderTypes={getFilterValue(
"filter[provider_type__in]",
)}
/>
</div>
<ProviderAccountSelectors
providers={providers}
mode="batch"
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
selectedAccounts={getFilterValue("filter[provider_id__in]")}
onBatchChange={setPending}
providerSelectorClassName={FILTER_CONTROL_COLUMN_CLASS}
accountSelectorClassName={FILTER_CONTROL_COLUMN_CLASS}
/>
{hasCustomFilters && (
<Button
variant="outline"
@@ -0,0 +1,203 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { refreshMock, updateScanMock, toastMock } = vi.hoisted(() => ({
refreshMock: vi.fn(),
updateScanMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: refreshMock }),
}));
vi.mock("@/actions/scans", () => ({
updateScan: updateScanMock,
}));
vi.mock("@/components/ui/toast", () => ({
toast: toastMock,
}));
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
children,
open,
title,
}: {
children: React.ReactNode;
open: boolean;
title: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
{children}
</div>
) : null,
}));
import { EditAliasModal } from "./edit-alias-modal";
describe("EditAliasModal", () => {
beforeEach(() => {
vi.clearAllMocks();
updateScanMock.mockResolvedValue({});
});
it("seeds the input with the current alias", () => {
render(
<EditAliasModal
open
onOpenChange={vi.fn()}
scanId="scan-1"
currentAlias="Production audit"
/>,
);
expect(screen.getByLabelText("Alias")).toHaveValue("Production audit");
});
it("rejects an unchanged alias before calling the action", async () => {
const user = userEvent.setup();
render(
<EditAliasModal
open
onOpenChange={vi.fn()}
scanId="scan-1"
currentAlias="Production audit"
/>,
);
await user.click(screen.getByRole("button", { name: /save/i }));
expect(
await screen.findByText(
/new alias must be different from the current one/i,
),
).toBeInTheDocument();
expect(updateScanMock).not.toHaveBeenCalled();
});
it("rejects a whitespace-only edit of the current alias", async () => {
const user = userEvent.setup();
render(
<EditAliasModal
open
onOpenChange={vi.fn()}
scanId="scan-1"
currentAlias="Production audit"
/>,
);
const input = screen.getByLabelText("Alias");
await user.type(input, " ");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(
await screen.findByText(
/new alias must be different from the current one/i,
),
).toBeInTheDocument();
expect(updateScanMock).not.toHaveBeenCalled();
});
it("submits the new alias as scanName", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(
<EditAliasModal
open
onOpenChange={onOpenChange}
scanId="scan-1"
currentAlias="Old name"
/>,
);
const input = screen.getByLabelText("Alias");
await user.clear(input);
await user.type(input, "Brand new name");
await user.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => expect(updateScanMock).toHaveBeenCalled());
const formData = updateScanMock.mock.calls[0][0] as FormData;
expect(formData.get("scanId")).toBe("scan-1");
expect(formData.get("scanName")).toBe("Brand new name");
expect(toastMock).toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("accepts aliases up to the API limit of 100 characters", async () => {
const user = userEvent.setup();
const alias = "a".repeat(100);
render(
<EditAliasModal
open
onOpenChange={vi.fn()}
scanId="scan-1"
currentAlias="Old name"
/>,
);
const input = screen.getByLabelText("Alias");
await user.clear(input);
await user.type(input, alias);
await user.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => expect(updateScanMock).toHaveBeenCalled());
const formData = updateScanMock.mock.calls[0][0] as FormData;
expect(formData.get("scanName")).toBe(alias);
});
it("rejects aliases over the API limit of 100 characters", async () => {
const user = userEvent.setup();
render(
<EditAliasModal
open
onOpenChange={vi.fn()}
scanId="scan-1"
currentAlias="Old name"
/>,
);
const input = screen.getByLabelText("Alias");
await user.clear(input);
await user.type(input, "a".repeat(101));
await user.click(screen.getByRole("button", { name: /save/i }));
expect(
await screen.findByText(/alias must not exceed 100 characters/i),
).toBeInTheDocument();
expect(updateScanMock).not.toHaveBeenCalled();
});
it("surfaces server-side errors on the alias field", async () => {
const user = userEvent.setup();
updateScanMock.mockResolvedValueOnce({
errors: [{ detail: "Alias already in use" }],
});
render(
<EditAliasModal
open
onOpenChange={vi.fn()}
scanId="scan-1"
currentAlias="Old name"
/>,
);
const input = screen.getByLabelText("Alias");
await user.clear(input);
await user.type(input, "Conflicting");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText("Alias already in use")).toBeInTheDocument();
});
});
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil } from "lucide-react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { updateScan } from "@/actions/scans";
import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { FormButtons } from "@/components/ui/form";
import { toast } from "@/components/ui/toast";
import { scanAliasSchema } from "./scan-alias-validation";
const buildEditAliasSchema = (currentAlias: string) =>
z.object({
alias: scanAliasSchema.refine(
(value) => value.trim() !== currentAlias.trim(),
"The new alias must be different from the current one.",
),
});
type EditAliasFormValues = { alias: string };
interface EditAliasModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
scanId: string;
currentAlias: string;
}
interface EditAliasFormProps {
scanId: string;
currentAlias: string;
onClose: () => void;
}
function EditAliasForm({ scanId, currentAlias, onClose }: EditAliasFormProps) {
const router = useRouter();
const form = useForm<EditAliasFormValues>({
resolver: zodResolver(buildEditAliasSchema(currentAlias)),
defaultValues: { alias: currentAlias },
});
const onSubmit = form.handleSubmit(async ({ alias }) => {
const trimmed = alias.trim();
const formData = new FormData();
formData.set("scanId", scanId);
formData.set("scanName", trimmed);
const result = await updateScan(formData);
if (result?.errors && result.errors.length > 0) {
form.setError("alias", {
message: String(result.errors[0]?.detail ?? "Failed to update alias."),
});
return;
}
toast({
title: "Alias updated",
description: "The scan alias was updated successfully.",
});
onClose();
router.refresh();
});
const aliasError = form.formState.errors.alias?.message;
const isSubmitting = form.formState.isSubmitting;
return (
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<div className="flex items-center gap-2">
<Pencil className="text-text-neutral-secondary size-4" />
<span className="text-text-neutral-secondary text-sm">
Current alias:{" "}
<span className="text-text-neutral-primary font-medium">
{currentAlias || "Unnamed"}
</span>
</span>
</div>
<Field>
<FieldLabel htmlFor="edit-alias-input">Alias</FieldLabel>
<Input
id="edit-alias-input"
aria-label="Alias"
placeholder={currentAlias || "Enter scan alias"}
{...form.register("alias")}
/>
{aliasError && <FieldError>{aliasError}</FieldError>}
</Field>
<FormButtons
onCancel={onClose}
submitText={isSubmitting ? "Saving..." : "Save"}
loadingText="Saving..."
isDisabled={isSubmitting}
/>
</form>
);
}
export function EditAliasModal({
open,
onOpenChange,
scanId,
currentAlias,
}: EditAliasModalProps) {
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Edit Alias"
size="xl"
className="gap-8"
>
<EditAliasForm
scanId={scanId}
currentAlias={currentAlias}
onClose={() => onOpenChange(false)}
/>
</Modal>
);
}
@@ -1,92 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { updateScan } from "@/actions/scans";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
import { editScanFormSchema } from "@/types";
export const EditScanForm = ({
scanId,
scanName,
setIsOpen,
}: {
scanId: string;
scanName: string;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}) => {
const formSchema = editScanFormSchema(scanName);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
scanId: scanId,
scanName: scanName || "",
},
});
const { toast } = useToast();
const isLoading = form.formState.isSubmitting;
const onSubmitClient = async (values: z.infer<typeof formSchema>) => {
const formData = new FormData();
Object.entries(values).forEach(
([key, value]) => value !== undefined && formData.append(key, value),
);
const data = await updateScan(formData);
if (data?.errors && data.errors.length > 0) {
const error = data.errors[0];
const errorMessage = `${error.detail}`;
// show error
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: errorMessage,
});
} else {
toast({
title: "Success!",
description: "The scan was updated successfully.",
});
setIsOpen(false); // Close the modal on success
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
<div className="text-md">
Current name:{" "}
<span className="font-bold">{scanName || "Unnamed"}</span>
</div>
<div>
<CustomInput
control={form.control}
name="scanName"
type="text"
label="Name"
labelPlacement="outside"
placeholder={scanName || "Enter scan name"}
variant="bordered"
isRequired={false}
/>
</div>
<input type="hidden" name="scanId" value={scanId} />
<FormButtons setIsOpen={setIsOpen} isDisabled={isLoading} />
</form>
</Form>
);
};
-2
View File
@@ -1,2 +0,0 @@
export * from "./edit-scan-form";
export * from "./schedule-form";
@@ -1,86 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dispatch, SetStateAction } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { updateProvider } from "@/actions/providers";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form, FormButtons } from "@/components/ui/form";
import { scheduleScanFormSchema } from "@/types";
export const ScheduleForm = ({
providerId,
scheduleDate,
setIsOpen,
}: {
providerId: string;
scheduleDate: string;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}) => {
const formSchema = scheduleScanFormSchema();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
providerId: providerId,
scheduleDate: scheduleDate,
},
});
const { toast } = useToast();
const onSubmitClient = async (values: z.infer<typeof formSchema>) => {
const formData = new FormData();
Object.entries(values).forEach(
([key, value]) => value !== undefined && formData.append(key, value),
);
const data = await updateProvider(formData);
if (data?.errors && data.errors.length > 0) {
const error = data.errors[0];
const errorMessage = `${error.detail}`;
// show error
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: errorMessage,
});
} else {
toast({
title: "Success!",
description: "The scan was scheduled successfully.",
});
setIsOpen(false); // Close the modal on success
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
<input type="hidden" name="providerId" value={providerId} />
<CustomInput
control={form.control}
name="scheduleDate"
type="date"
label="Schedule Date"
labelPlacement="inside"
variant="bordered"
isRequired={false}
/>
<FormButtons
setIsOpen={setIsOpen}
submitText="Schedule"
isDisabled={true}
/>
</form>
</Form>
);
};
-3
View File
@@ -1,4 +1 @@
export * from "./auto-refresh";
export * from "./no-providers-added";
export * from "./no-providers-connected";
export * from "./scans-filters";
@@ -0,0 +1,306 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { refreshMock, scanOnDemandMock, searchParamsValue, toastMock } =
vi.hoisted(() => ({
refreshMock: vi.fn(),
scanOnDemandMock: vi.fn(),
searchParamsValue: { current: "" },
toastMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: refreshMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("@/actions/scans", () => ({
scanOnDemand: scanOnDemandMock,
}));
vi.mock("@/components/ui/toast", () => ({
ToastAction: ({ children, ...props }: ComponentProps<"button">) => (
<button {...props}>{children}</button>
),
toast: toastMock,
}));
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
children,
open,
title,
}: {
children: React.ReactNode;
open: boolean;
title: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
{children}
</div>
) : null,
}));
vi.mock("@/components/ui/entities", () => ({
EntityInfo: ({
entityAlias,
entityId,
}: {
entityAlias?: string;
entityId?: string;
}) => <>{entityAlias || entityId}</>,
}));
vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
AccountsSelector: ({
disabledValues = [],
providers,
onBatchChange,
selectedValues,
id,
}: {
disabledValues?: string[];
providers: { id: string; attributes: { alias: string; uid: string } }[];
onBatchChange: (filterKey: string, values: string[]) => void;
selectedValues: string[];
id?: string;
}) => (
<div>
<input aria-label="Search Providers" placeholder="Search Providers..." />
<select
id={id}
aria-label="Providers"
value={selectedValues[0] ?? ""}
onChange={(event) =>
onBatchChange("provider_id__in", [event.target.value])
}
>
<option value="">All Providers</option>
{providers.map((provider) => (
<option
key={provider.id}
value={provider.id}
disabled={disabledValues.includes(provider.id)}
>
{provider.attributes.alias || provider.attributes.uid}
</option>
))}
</select>
</div>
),
}));
import { LaunchScanModal } from "./launch-scan-modal";
const provider = {
id: "provider-1",
type: "providers" as const,
attributes: {
provider: "aws" as const,
uid: "123456789012",
alias: "Production",
status: "completed" as const,
resources: 0,
connection: {
connected: true,
last_checked_at: "2026-04-13T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-13T00:00:00Z",
updated_at: "2026-04-13T00:00:00Z",
created_by: {
object: "user",
id: "user-1",
},
},
relationships: {
secret: {
data: null,
},
provider_groups: {
meta: {
count: 0,
},
data: [],
},
},
};
const disconnectedProvider = {
...provider,
id: "provider-2",
attributes: {
...provider.attributes,
alias: "Disconnected",
uid: "210987654321",
connection: {
connected: false,
last_checked_at: "2026-05-20T11:46:38.834045Z",
},
},
};
describe("LaunchScanModal", () => {
beforeEach(() => {
vi.clearAllMocks();
searchParamsValue.current = "";
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
it("shows a searchable provider selector", () => {
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
);
expect(screen.getByPlaceholderText("Search Providers...")).toBeVisible();
});
it("disables disconnected providers in the launch selector", () => {
render(
<LaunchScanModal
open
onOpenChange={vi.fn()}
providers={[provider, disconnectedProvider]}
/>,
);
expect(screen.getByRole("option", { name: "Disconnected" })).toBeDisabled();
});
it("submits alias as scanName so the API stores it as the scan alias", async () => {
const user = userEvent.setup();
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
);
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.type(screen.getByLabelText("Alias"), "Production audit");
await user.click(screen.getByRole("button", { name: /launch scan/i }));
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalled());
const formData = scanOnDemandMock.mock.calls[0][0] as FormData;
expect(formData.get("providerId")).toBe(provider.id);
expect(formData.get("scanName")).toBe("Production audit");
expect(formData.get("scanNote")).toBeNull();
});
it("accepts scan aliases up to the API limit of 100 characters", async () => {
const user = userEvent.setup();
const alias = "a".repeat(100);
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
);
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.type(screen.getByLabelText("Alias"), alias);
await user.click(screen.getByRole("button", { name: /launch scan/i }));
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalled());
const formData = scanOnDemandMock.mock.calls[0][0] as FormData;
expect(formData.get("scanName")).toBe(alias);
});
it("adds a toast action to view the scan in progress when another tab is active", async () => {
const user = userEvent.setup();
searchParamsValue.current =
"tab=completed&filter%5Bstate__in%5D=failed&page=3";
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
);
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("button", { name: /launch scan/i }));
await waitFor(() => expect(toastMock).toHaveBeenCalled());
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action).toBeDefined();
expect(toastPayload.action.props.children.props.href).toBe(
"/scans?tab=active",
);
expect(toastPayload.action.props.children.props.children).toBe("View scan");
});
it("does not add a toast action when the in progress tab is active", async () => {
const user = userEvent.setup();
searchParamsValue.current = "tab=active";
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
);
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("button", { name: /launch scan/i }));
await waitFor(() => expect(toastMock).toHaveBeenCalled());
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action).toBeUndefined();
});
it("rejects scan aliases over the API limit of 100 characters", async () => {
const user = userEvent.setup();
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
);
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.type(screen.getByLabelText("Alias"), "a".repeat(101));
await user.click(screen.getByRole("button", { name: /launch scan/i }));
expect(
await screen.findByText(/alias must not exceed 100 characters/i),
).toBeInTheDocument();
expect(scanOnDemandMock).not.toHaveBeenCalled();
});
it("does not show the old scan note label", () => {
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
);
expect(screen.queryByLabelText("Scan Note")).not.toBeInTheDocument();
expect(screen.queryByText("Scan Note (optional)")).not.toBeInTheDocument();
});
it("surfaces JSON:API errors from scanOnDemand and skips the success toast", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
scanOnDemandMock.mockResolvedValueOnce({
errors: [{ detail: "Provider already has a scan in progress" }],
});
render(
<LaunchScanModal
open
onOpenChange={onOpenChange}
providers={[provider]}
/>,
);
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("button", { name: /launch scan/i }));
expect(
await screen.findByText("Provider already has a scan in progress"),
).toBeInTheDocument();
expect(toastMock).not.toHaveBeenCalled();
expect(refreshMock).not.toHaveBeenCalled();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
});
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { CloudCog, Rocket } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { scanOnDemand } from "@/actions/scans";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { FormButtons } from "@/components/ui/form";
import { toast, ToastAction } from "@/components/ui/toast";
import { SCAN_JOBS_TAB } from "@/types";
import type { ProviderProps } from "@/types/providers";
import { scanAliasSchema } from "./scan-alias-validation";
import { getScanJobsTab } from "./scans.utils";
const launchScanSchema = z.object({
providerId: z.string().min(1, "Select a provider to launch a scan."),
scanAlias: scanAliasSchema.optional(),
});
type LaunchScanFormValues = z.infer<typeof launchScanSchema>;
interface LaunchScanModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
providers: ProviderProps[];
}
interface LaunchScanFormProps {
providers: ProviderProps[];
onClose: () => void;
}
function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const form = useForm<LaunchScanFormValues>({
resolver: zodResolver(launchScanSchema),
defaultValues: { providerId: "", scanAlias: "" },
});
const providerId = form.watch("providerId");
const activeTab = getScanJobsTab(searchParams.get("tab") ?? undefined);
const shouldShowActiveTabAction = activeTab !== SCAN_JOBS_TAB.ACTIVE;
const disconnectedProviderIds = providers
.filter((provider) => provider.attributes.connection.connected !== true)
.map((provider) => provider.id);
const onSubmit = form.handleSubmit(async ({ providerId, scanAlias }) => {
const formData = new FormData();
formData.set("providerId", providerId);
const trimmedAlias = scanAlias?.trim();
if (trimmedAlias) {
formData.set("scanName", trimmedAlias);
}
const result = await scanOnDemand(formData);
if (result?.error) {
form.setError("root", { message: String(result.error) });
return;
}
if (result?.errors && result.errors.length > 0) {
form.setError("root", {
message: String(result.errors[0]?.detail ?? "Failed to launch scan."),
});
return;
}
toast({
title: "Scan launched",
description: "The scan was launched successfully.",
action: shouldShowActiveTabAction ? (
<ToastAction altText="View scan in progress" asChild>
<Link href="/scans?tab=active">View scan</Link>
</ToastAction>
) : undefined,
});
onClose();
router.refresh();
});
const providerError = form.formState.errors.providerId?.message;
const aliasError = form.formState.errors.scanAlias?.message;
const rootError = form.formState.errors.root?.message;
const isSubmitting = form.formState.isSubmitting;
return (
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<div className="flex items-center gap-2">
<CloudCog className="text-text-neutral-secondary size-4" />
<span className="text-text-neutral-secondary text-sm">
Select the provider you would like to scan
</span>
</div>
<Field>
<FieldLabel htmlFor="launch-scan-account">Providers</FieldLabel>
<AccountsSelector
id="launch-scan-account"
providers={providers}
disabledValues={disconnectedProviderIds}
onBatchChange={(_, values) =>
form.setValue("providerId", values.at(-1) ?? "", {
shouldValidate: true,
})
}
selectedValues={providerId ? [providerId] : []}
closeOnSelect
/>
{providerError && <FieldError>{providerError}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor="launch-scan-alias">Alias (optional)</FieldLabel>
<Input
id="launch-scan-alias"
aria-label="Alias"
{...form.register("scanAlias")}
/>
{aliasError && <FieldError>{aliasError}</FieldError>}
</Field>
{rootError && <FieldError>{rootError}</FieldError>}
<FormButtons
onCancel={onClose}
submitText={isSubmitting ? "Launching..." : "Launch Scan"}
loadingText="Launching..."
isDisabled={isSubmitting || !providers.length}
rightIcon={<Rocket className="size-4" />}
/>
</form>
);
}
export function LaunchScanModal({
open,
onOpenChange,
providers,
}: LaunchScanModalProps) {
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Launch A Scan"
size="xl"
className="gap-8"
>
<LaunchScanForm
providers={providers}
onClose={() => onOpenChange(false)}
/>
</Modal>
);
}
@@ -1,2 +0,0 @@
export * from "./launch-scan-workflow-form";
export * from "./select-scan-provider";
@@ -1,154 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { useForm, useWatch } from "react-hook-form";
import * as z from "zod";
import { scanOnDemand } from "@/actions/scans";
import { RocketIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { toast } from "@/components/ui/toast";
import { onDemandScanFormSchema, ScanProviderInfo } from "@/types";
import { SCAN_LAUNCHED_EVENT } from "../table/scans/scans-table-with-polling";
import { SelectScanProvider } from "./select-scan-provider";
export const LaunchScanWorkflow = ({
providers,
}: {
providers: ScanProviderInfo[];
}) => {
const formSchema = z.object({
...onDemandScanFormSchema().shape,
scanName: z
.union([
z
.string()
.min(3, "Must be at least 3 characters")
.max(32, "Must not exceed 32 characters"),
z.literal(""),
])
.optional(),
});
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
providerId: "",
scanName: "",
scannerArgs: undefined,
},
});
const providerId = useWatch({ control: form.control, name: "providerId" });
const hasProviderSelected = Boolean(providerId);
const isLoading = form.formState.isSubmitting;
const onSubmitClient = async (values: z.infer<typeof formSchema>) => {
const formValues = { ...values };
const formData = new FormData();
// Loop through form values and add to formData
Object.entries(formValues).forEach(
([key, value]) =>
value !== undefined &&
formData.append(
key,
typeof value === "object" ? JSON.stringify(value) : value,
),
);
const data = await scanOnDemand(formData);
if (data?.error) {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: data.error,
});
} else {
toast({
title: "Success!",
description: "The scan was launched successfully.",
});
// Reset form after successful submission
form.reset();
// Notify the scans table to refresh and pick up the new scan
window.dispatchEvent(new Event(SCAN_LAUNCHED_EVENT));
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-wrap justify-start gap-4"
>
<div className="w-72">
<SelectScanProvider
providers={providers}
control={form.control}
name="providerId"
/>
</div>
<AnimatePresence>
{hasProviderSelected && (
<>
<div className="flex flex-wrap gap-6 md:gap-4">
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.3 }}
className="h-[3.4rem] min-w-[15.2rem] self-end"
>
<CustomInput
control={form.control}
name="scanName"
type="text"
label="Scan label (optional)"
labelPlacement="outside"
placeholder="Scan label"
size="sm"
variant="bordered"
isRequired={false}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.3 }}
className="flex items-end gap-4"
>
<Button
type="submit"
size="default"
disabled={isLoading}
className="gap-2"
>
{!isLoading && <RocketIcon size={16} />}
{isLoading ? "Loading..." : "Start now"}
</Button>
<Button
type="button"
onClick={() => form.reset()}
variant="outline"
size="default"
>
Cancel
</Button>
</motion.div>
</div>
</>
)}
</AnimatePresence>
</form>
</Form>
);
};
@@ -1,95 +0,0 @@
"use client";
import { Control, FieldPath, FieldValues } from "react-hook-form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn";
import { EntityInfo } from "@/components/ui/entities";
import { FormControl, FormField, FormMessage } from "@/components/ui/form";
import { ScanProviderInfo } from "@/types";
interface SelectScanProviderProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> {
providers: ScanProviderInfo[];
control: Control<TFieldValues>;
name: TName;
}
export const SelectScanProvider = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
providers,
control,
name,
}: SelectScanProviderProps<TFieldValues, TName>) => {
return (
<FormField
control={control}
name={name}
render={({ field }) => {
const selectedItem = providers.find(
(item) => item.providerId === field.value,
);
return (
<div className="flex flex-col gap-2">
<span className="text-text-neutral-primary text-sm font-medium">
Select a provider to launch a scan
</span>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Choose a provider">
{selectedItem ? (
<EntityInfo
cloudProvider={
selectedItem.providerType as
| "aws"
| "azure"
| "gcp"
| "kubernetes"
}
entityAlias={selectedItem.alias}
entityId={selectedItem.uid}
showCopyAction={false}
/>
) : (
"Choose a provider"
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{providers.map((item) => (
<SelectItem key={item.providerId} value={item.providerId}>
<EntityInfo
cloudProvider={
item.providerType as
| "aws"
| "azure"
| "gcp"
| "kubernetes"
}
entityAlias={item.alias}
entityId={item.uid}
showCopyAction={false}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage className="text-sm text-red-600 dark:text-red-400" />
</div>
);
}}
/>
);
};
@@ -0,0 +1,55 @@
import { Card, CardContent } from "@/components/shadcn";
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
import { InfoIcon } from "../icons/Icons";
interface EmptyStateCopy {
title: string;
description: string;
hint: string;
}
const EMPTY_STATE_COPY: Record<ScanJobsTab, EmptyStateCopy> = {
[SCAN_JOBS_TAB.ACTIVE]: {
title: "No scans in progress",
description:
"Scans currently running or queued will appear here when available.",
hint: `Switch to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.COMPLETED]} to review past results, or to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.SCHEDULED]}.`,
},
[SCAN_JOBS_TAB.COMPLETED]: {
title: "No completed scans yet",
description:
"Finished, failed, or cancelled scans will appear here once they wrap up.",
hint: `Switch to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.ACTIVE]} to monitor ongoing scans, or to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.SCHEDULED]} to plan future runs.`,
},
[SCAN_JOBS_TAB.SCHEDULED]: {
title: "No scheduled scans",
description: "Scans scheduled to run later will appear here.",
hint: `Switch to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.ACTIVE]} to monitor ongoing scans, or to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.COMPLETED]} to review past results.`,
},
};
interface NoScansEmptyStateProps {
tab: ScanJobsTab;
}
export function NoScansEmptyState({ tab }: NoScansEmptyStateProps) {
const copy = EMPTY_STATE_COPY[tab];
return (
<Card variant="base">
<CardContent className="flex w-full flex-col items-center gap-3 px-4 py-10 text-center">
<InfoIcon className="h-8 w-8 text-gray-800 dark:text-white" />
<h2 className="text-lg font-bold text-gray-800 dark:text-white">
{copy.title}
</h2>
<p className="max-w-prose text-sm text-gray-600 dark:text-gray-300">
{copy.description}
</p>
<p className="max-w-prose text-sm text-gray-600 dark:text-gray-300">
{copy.hint}
</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,16 @@
import { z } from "zod";
export const SCAN_ALIAS_MIN_LENGTH = 3;
export const SCAN_ALIAS_MAX_LENGTH = 100;
export const scanAliasSchema = z
.string()
.max(
SCAN_ALIAS_MAX_LENGTH,
`Alias must not exceed ${SCAN_ALIAS_MAX_LENGTH} characters.`,
)
.refine(
(value) =>
value.trim().length === 0 || value.trim().length >= SCAN_ALIAS_MIN_LENGTH,
`Alias must be empty or have at least ${SCAN_ALIAS_MIN_LENGTH} characters.`,
);
@@ -0,0 +1,102 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
ScanErrorDetailsModal,
type ScanErrorDetailsState,
} from "./scan-error-details-modal";
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
CodeSnippet: ({
value,
formatter,
ariaLabel,
}: {
value: string;
formatter?: (value: string) => string;
ariaLabel?: string;
}) => (
<>
<span>{formatter ? formatter(value) : value}</span>
<button type="button" aria-label={ariaLabel ?? "Copy to clipboard"}>
copy
</button>
</>
),
}));
const loadedState: ScanErrorDetailsState = {
kind: "loaded",
details: {
type: "ValidationError",
messages: ["Missing cloud credentials", "Retry scan setup"],
module: "scan.runner",
copyValue:
"ErrorType: ValidationError\nError: Missing cloud credentials\nRetry scan setup",
},
};
describe("ScanErrorDetailsModal", () => {
it("renders nothing visible when closed", () => {
render(
<ScanErrorDetailsModal
open={false}
onOpenChange={vi.fn()}
state={{ kind: "idle" }}
/>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("shows the loading placeholder while state is loading", () => {
render(
<ScanErrorDetailsModal
open
onOpenChange={vi.fn()}
state={{ kind: "loading" }}
/>,
);
expect(screen.getByText(/loading error details/i)).toBeInTheDocument();
});
it("renders the error message when state is error", () => {
render(
<ScanErrorDetailsModal
open
onOpenChange={vi.fn()}
state={{ kind: "error", message: "Task not found" }}
/>,
);
expect(screen.getByText("Task not found")).toBeInTheDocument();
});
it("renders error type, module and messages when loaded", () => {
render(
<ScanErrorDetailsModal open onOpenChange={vi.fn()} state={loadedState} />,
);
expect(screen.getByText("ValidationError")).toBeInTheDocument();
expect(screen.getByText("scan.runner")).toBeInTheDocument();
expect(screen.getByText(/Missing cloud credentials/)).toBeInTheDocument();
expect(screen.getByText(/Retry scan setup/)).toBeInTheDocument();
});
it("shows the copy action only when state is loaded", () => {
const { rerender } = render(
<ScanErrorDetailsModal
open
onOpenChange={vi.fn()}
state={{ kind: "loading" }}
/>,
);
expect(
screen.queryByRole("button", { name: /copy error details/i }),
).not.toBeInTheDocument();
rerender(
<ScanErrorDetailsModal open onOpenChange={vi.fn()} state={loadedState} />,
);
expect(
screen.getByRole("button", { name: /copy error details/i }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
"use client";
import type { ScanErrorDetails } from "@/actions/task/task.adapter";
import { Button, Card, CardContent } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { ScanErrorDetailsView } from "./scan-error-details-view";
export type ScanErrorDetailsState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "loaded"; details: ScanErrorDetails };
interface ScanErrorDetailsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
state: ScanErrorDetailsState;
}
function LoadingView() {
return <LoadingState label="Loading error details..." />;
}
function ErrorView({ message }: { message: string }) {
return (
<Card variant="danger">
<CardContent>
<p className="text-text-error-primary text-sm">{message}</p>
</CardContent>
</Card>
);
}
export function ScanErrorDetailsModal({
open,
onOpenChange,
state,
}: ScanErrorDetailsModalProps) {
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Scan Error Details"
description="Failure details returned by the scan task."
size="2xl"
>
{state.kind === "loading" && <LoadingView />}
{state.kind === "error" && <ErrorView message={state.message} />}
{state.kind === "loaded" && (
<ScanErrorDetailsView details={state.details} />
)}
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={() => onOpenChange(false)}
>
Close
</Button>
</div>
</Modal>
);
}
@@ -0,0 +1,69 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ScanErrorDetails } from "@/actions/task/task.adapter";
import { ScanErrorDetailsView } from "./scan-error-details-view";
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
CodeSnippet: ({
value,
formatter,
ariaLabel,
}: {
value: string;
formatter?: (value: string) => string;
ariaLabel?: string;
}) => (
<>
<span>{formatter ? formatter(value) : value}</span>
<button type="button" aria-label={ariaLabel ?? "Copy to clipboard"}>
copy
</button>
</>
),
}));
const details: ScanErrorDetails = {
type: "ValidationError",
messages: ["Missing cloud credentials", "Retry scan setup"],
module: "scan.runner",
copyValue:
"ErrorType: ValidationError\nError: Missing cloud credentials\nRetry scan setup",
};
describe("ScanErrorDetailsView", () => {
it("renders error type, module and joined messages", () => {
render(<ScanErrorDetailsView details={details} />);
expect(screen.getByText("Error Type")).toBeInTheDocument();
expect(screen.getByText("ValidationError")).toBeInTheDocument();
expect(screen.getByText("Module")).toBeInTheDocument();
expect(screen.getByText("scan.runner")).toBeInTheDocument();
expect(screen.getByText(/Missing cloud credentials/)).toBeInTheDocument();
expect(screen.getByText(/Retry scan setup/)).toBeInTheDocument();
});
it("omits the module field when not provided", () => {
render(
<ScanErrorDetailsView details={{ ...details, module: undefined }} />,
);
expect(screen.queryByText("Module")).not.toBeInTheDocument();
});
it("uses the provided copy aria label", () => {
render(
<ScanErrorDetailsView details={details} copyAriaLabel="Copy custom" />,
);
expect(
screen.getByRole("button", { name: /copy custom/i }),
).toBeInTheDocument();
});
it("defaults the copy aria label to 'Copy error details'", () => {
render(<ScanErrorDetailsView details={details} />);
expect(
screen.getByRole("button", { name: /copy error details/i }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,35 @@
"use client";
import type { ScanErrorDetails } from "@/actions/task/task.adapter";
import { Field, FieldLabel, LabeledField } from "@/components/shadcn";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
interface ScanErrorDetailsViewProps {
details: ScanErrorDetails;
copyAriaLabel?: string;
}
export function ScanErrorDetailsView({
details,
copyAriaLabel = "Copy error details",
}: ScanErrorDetailsViewProps) {
return (
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<LabeledField label="Error Type">{details.type}</LabeledField>
{details.module && (
<LabeledField label="Module">{details.module}</LabeledField>
)}
</div>
<Field>
<FieldLabel>Error</FieldLabel>
<CodeSnippet
value={details.copyValue}
formatter={() => details.messages.join("\n")}
multiline
ariaLabel={copyAriaLabel}
/>
</Field>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn";
import type { ScanJobsTab } from "@/types";
import type { ProviderProps } from "@/types/providers";
import {
getScanStatusFilterOptions,
getScanTriggerFilterOptions,
} from "./scans.utils";
interface ScansFilterBarProps {
providers: ProviderProps[];
activeTab: ScanJobsTab;
scheduleType: string;
scanStatus: string;
showStatusFilter: boolean;
onScheduleTypeChange: (value: string) => void;
onScanStatusChange: (value: string) => void;
}
const filterItemClass = "w-full md:w-[calc(50%-0.375rem)] xl:w-60";
export function ScansFilterBar({
providers,
activeTab,
scheduleType,
scanStatus,
showStatusFilter,
onScheduleTypeChange,
onScanStatusChange,
}: ScansFilterBarProps) {
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const triggerFilterOptions = getScanTriggerFilterOptions(isCloudEnvironment);
const statusFilterOptions = getScanStatusFilterOptions(activeTab);
return (
<>
<ProviderAccountSelectors
providers={providers}
accountFilterKey="provider_uid__in"
accountValue="uid"
paramsToDeleteOnChange={["page", "scanId"]}
providerSelectorClassName={filterItemClass}
accountSelectorClassName={filterItemClass}
/>
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
<SelectTrigger aria-label="All Types" className={filterItemClass}>
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
{triggerFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{showStatusFilter && (
<Select value={scanStatus} onValueChange={onScanStatusChange}>
<SelectTrigger aria-label="All Statuses" className={filterItemClass}>
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
{statusFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</>
);
}
-103
View File
@@ -1,103 +0,0 @@
"use client";
import { X } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ScanSelector } from "@/components/compliance/compliance-header";
import { filterScans } from "@/components/filters/data-filters";
import { FilterControls } from "@/components/filters/filter-controls";
import { Badge } from "@/components/shadcn/badge/badge";
import { useRelatedFilters } from "@/hooks";
import { ExpandedScanData, FilterEntity, FilterType } from "@/types";
interface ScansFiltersProps {
providerUIDs: string[];
providerDetails: { [uid: string]: FilterEntity }[];
completedScans?: ExpandedScanData[];
}
export const ScansFilters = ({
providerUIDs,
providerDetails,
completedScans = [],
}: ScansFiltersProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const idFilter = searchParams.get("filter[id__in]");
const { availableProviderUIDs } = useRelatedFilters({
providerUIDs,
providerDetails,
enableScanRelation: false,
providerFilterType: FilterType.PROVIDER_UID,
});
const handleDismissIdFilter = () => {
const params = new URLSearchParams(searchParams.toString());
params.delete("filter[id__in]");
router.push(`${pathname}?${params.toString()}`);
};
const handleScanChange = (selectedScanId: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("filter[id__in]", selectedScanId);
router.push(`${pathname}?${params.toString()}`);
};
const scanIdElement = idFilter ? (
completedScans.length > 0 ? (
<div className="flex items-center gap-2">
<ScanSelector
scans={completedScans}
selectedScanId={idFilter}
onSelectionChange={handleScanChange}
/>
<button
type="button"
aria-label="Clear scan filter"
className="text-text-neutral-secondary hover:text-text-neutral-primary shrink-0"
onClick={handleDismissIdFilter}
>
<X className="size-4" />
</button>
</div>
) : (
<div className="flex items-center">
<Badge
variant="tag"
className="max-w-[300px] shrink-0 cursor-default gap-1 truncate"
>
<span className="text-text-neutral-secondary mr-1 text-xs">
Scan:
</span>
<span className="truncate">{idFilter}</span>
<button
type="button"
aria-label="Clear scan filter"
className="hover:text-text-neutral-primary ml-0.5 shrink-0"
onClick={handleDismissIdFilter}
>
<X className="size-3" />
</button>
</Badge>
</div>
)
) : null;
return (
<FilterControls
customFilters={[
...filterScans,
{
key: FilterType.PROVIDER_UID,
labelCheckboxGroup: "Provider UID",
values: availableProviderUIDs,
valueLabelMapping: providerDetails,
index: 1,
},
]}
prependElement={scanIdElement}
/>
);
};
@@ -1,62 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ScansLaunchSection } from "./scans-launch-section";
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: ({ open }: { open: boolean }) =>
open ? <div role="dialog">Provider wizard</div> : null,
}));
vi.mock("@/components/scans/launch-workflow", () => ({
LaunchScanWorkflow: () => <div>Launch scan workflow</div>,
}));
vi.mock("@/components/scans/no-providers-connected", () => ({
NoProvidersConnected: () => <div>No providers connected</div>,
}));
vi.mock("@/components/ui/custom/custom-banner", () => ({
CustomBanner: ({ title }: { title: string }) => <div>{title}</div>,
}));
const connectedProvider = {
providerId: "provider-1",
alias: "Production",
providerType: "aws",
uid: "123456789012",
connected: true,
};
describe("ScansLaunchSection", () => {
it("should keep the provider wizard open when providers data refreshes after adding the first provider", async () => {
// Given
const user = userEvent.setup();
const { rerender } = render(
<ScansLaunchSection
providers={[]}
hasManageScansPermission
thereIsNoProviders
thereIsNoProvidersConnected
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
rerender(
<ScansLaunchSection
providers={[connectedProvider]}
hasManageScansPermission
thereIsNoProviders={false}
thereIsNoProvidersConnected={false}
/>,
);
// Then
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
expect(screen.getByText("Launch scan workflow")).toBeInTheDocument();
});
});
@@ -1,47 +0,0 @@
"use client";
import { useState } from "react";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
import { NoProvidersAdded } from "@/components/scans/no-providers-added";
import { NoProvidersConnected } from "@/components/scans/no-providers-connected";
import { CustomBanner } from "@/components/ui/custom/custom-banner";
import { ScanProviderInfo } from "@/types";
interface ScansLaunchSectionProps {
providers: ScanProviderInfo[];
hasManageScansPermission: boolean;
thereIsNoProviders: boolean;
thereIsNoProvidersConnected: boolean;
}
export function ScansLaunchSection({
providers,
hasManageScansPermission,
thereIsNoProviders,
thereIsNoProvidersConnected,
}: ScansLaunchSectionProps) {
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
return (
<>
{thereIsNoProviders ? (
<NoProvidersAdded onOpenWizard={() => setIsProviderWizardOpen(true)} />
) : !hasManageScansPermission ? (
<CustomBanner
title={"Access Denied"}
message={"You don't have permission to launch the scan."}
/>
) : thereIsNoProvidersConnected ? (
<NoProvidersConnected />
) : (
<LaunchScanWorkflow providers={providers} />
)}
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={setIsProviderWizardOpen}
/>
</>
);
}
@@ -0,0 +1,335 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useScansStore } from "@/store";
import { ScansPageShell } from "./scans-page-shell";
const { pushMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
pushMock: vi.fn(),
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
}));
const { scansFilterBarSpy } = vi.hoisted(() => ({
scansFilterBarSpy: vi.fn(),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/scans",
useRouter: () => ({
push: pushMock,
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("./scans-filter-bar", () => ({
ScansFilterBar: (props: {
showStatusFilter: boolean;
onScheduleTypeChange: (value: string) => void;
onScanStatusChange: (value: string) => void;
}) => {
scansFilterBarSpy(props);
return (
<>
<div>Shared scan filters</div>
<select
aria-label="All Types"
onChange={(event) => props.onScheduleTypeChange(event.target.value)}
>
<option value="all">All Types</option>
</select>
{props.showStatusFilter && (
<select
aria-label="All Statuses"
onChange={(event) => props.onScanStatusChange(event.target.value)}
>
<option value="all">All Statuses</option>
</select>
)}
</>
);
},
}));
vi.mock("./launch-scan-modal", () => ({
LaunchScanModal: ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div role="dialog">
Launch scan
<button type="button" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
}));
vi.mock("@/components/providers/muted-findings-config-button", () => ({
MutedFindingsConfigButton: () => <a href="/mutelist">Configure Mutelist</a>,
}));
const providers = [
{
id: "provider-1",
type: "providers" as const,
attributes: {
provider: "aws" as const,
uid: "123456789012",
alias: "Production",
status: "completed" as const,
resources: 0,
connection: {
connected: true,
last_checked_at: "2026-04-13T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-13T00:00:00Z",
updated_at: "2026-04-13T00:00:00Z",
created_by: {
object: "user",
id: "user-1",
},
},
relationships: {
secret: {
data: null,
},
provider_groups: {
meta: {
count: 0,
},
data: [],
},
},
},
];
describe("ScansPageShell", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
searchParamsValue.current = "";
useScansStore.getState().closeLaunchScanModal();
});
it("does not render an imported findings tab", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(
screen.queryByRole("tab", { name: /imported findings/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /import findings/i }),
).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("uses the shared scan filter bar for scan filters", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByText("Shared scan filters")).toBeInTheDocument();
expect(scansFilterBarSpy).toHaveBeenCalledWith(
expect.objectContaining({
providers,
scheduleType: "all",
}),
);
});
it("clears the active sort when switching tabs", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
searchParamsValue.current = "tab=active&sort=trigger";
const user = userEvent.setup();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
await user.click(screen.getByRole("tab", { name: /completed/i }));
expect(pushMock).toHaveBeenCalled();
const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string;
expect(calledUrl).toContain("tab=completed");
expect(calledUrl).not.toContain("sort=");
});
it("uses a generic type filter label in Cloud", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByRole("combobox", { name: /all types/i })).toBeVisible();
});
it("keeps launch scan with filters and mutelist with tabs", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(
screen.getByRole("group", { name: /scan filters and actions/i }),
).toContainElement(screen.getByRole("button", { name: /launch scan/i }));
expect(
screen.getByRole("group", { name: /scan filters and actions/i }),
).not.toContainElement(
screen.getByRole("link", { name: /configure mutelist/i }),
);
expect(screen.getByRole("group", { name: /scan tabs/i })).toContainElement(
screen.getByRole("link", { name: /configure mutelist/i }),
);
});
it("shows the active scans count in the in progress tab", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
render(
<ScansPageShell
providers={providers}
hasManageScansPermission
activeScanCount={3}
>
<div>Scans table</div>
</ScansPageShell>,
);
expect(
screen.getByRole("tab", { name: /in progress \(3\)/i }),
).toBeVisible();
expect(screen.getByRole("tab", { name: /^completed$/i })).toBeVisible();
});
it("opens the launch scan modal from the URL", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
searchParamsValue.current = "launchScan=true";
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByRole("dialog")).toHaveTextContent(/launch scan/i);
});
it("strips the launchScan URL param when closing the URL-opened modal", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
searchParamsValue.current = "tab=completed&launchScan=true";
const user = userEvent.setup();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
await user.click(screen.getByRole("button", { name: /close/i }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(replaceMock).toHaveBeenCalledWith(
"/scans?tab=completed",
expect.objectContaining({ scroll: false }),
);
expect(pushMock).not.toHaveBeenCalled();
});
it("opens and closes the launch scan modal from client state without navigation", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
const user = userEvent.setup();
useScansStore.getState().openLaunchScanModal();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByRole("dialog")).toHaveTextContent(/launch scan/i);
await user.click(screen.getByRole("button", { name: /close/i }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(pushMock).not.toHaveBeenCalled();
});
it("shows the status filter only on the completed tab", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
searchParamsValue.current = "tab=completed";
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(
screen.getByRole("combobox", { name: /all statuses/i }),
).toBeVisible();
});
it("hides the status filter outside of the completed tab", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
searchParamsValue.current = "tab=active";
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(
screen.queryByRole("combobox", { name: /all statuses/i }),
).not.toBeInTheDocument();
});
it("clears status filter when switching scan tabs", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
searchParamsValue.current = "tab=completed&filter%5Bstate__in%5D=failed";
const user = userEvent.setup();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
await user.click(screen.getByRole("tab", { name: /in progress/i }));
const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string;
expect(calledUrl).toContain("tab=active");
expect(calledUrl).not.toContain("filter%5Bstate__in%5D");
});
});
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { type ReactNode, useState } from "react";
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
import {
Button,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/shadcn";
import {
LAUNCH_SCAN_SEARCH_PARAM,
LAUNCH_SCAN_SEARCH_VALUE,
} from "@/lib/scans-navigation";
import { useScansStore } from "@/store";
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
import type { ProviderProps } from "@/types/providers";
import { LaunchScanModal } from "./launch-scan-modal";
import { ScansFilterBar } from "./scans-filter-bar";
import { useScansFilters } from "./use-scans-filters";
interface ScansPageShellProps {
providers: ProviderProps[];
hasManageScansPermission: boolean;
activeScanCount?: number;
children: ReactNode;
}
export function ScansPageShell({
providers,
hasManageScansPermission,
activeScanCount = 0,
children,
}: ScansPageShellProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [urlLaunchOpen, setUrlLaunchOpen] = useState(
() =>
searchParams.get(LAUNCH_SCAN_SEARCH_PARAM) === LAUNCH_SCAN_SEARCH_VALUE,
);
const isLaunchScanModalOpen = useScansStore(
(state) => state.isLaunchScanModalOpen,
);
const setLaunchScanModalOpen = useScansStore(
(state) => state.setLaunchScanModalOpen,
);
const filters = useScansFilters();
const hasConnectedProviders = providers.some(
(provider) => provider.attributes.connection.connected === true,
);
const launchDisabled = !hasManageScansPermission || !hasConnectedProviders;
const launchOpen = isLaunchScanModalOpen || urlLaunchOpen;
const getTabLabel = (tab: ScanJobsTab) => {
const label = SCAN_TAB_LABELS[tab];
if (tab !== SCAN_JOBS_TAB.ACTIVE) return label;
return `${label} (${activeScanCount})`;
};
const handleLaunchOpenChange = (open: boolean) => {
setLaunchScanModalOpen(open);
if (open) return;
setUrlLaunchOpen(false);
if (!searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) return;
const params = new URLSearchParams(searchParams.toString());
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
};
return (
<div className="flex flex-col gap-[18px]">
<div
role="group"
aria-label="Scan filters and actions"
className="flex flex-wrap items-center gap-3"
>
<ScansFilterBar
providers={providers}
activeTab={filters.activeTab}
scheduleType={filters.scheduleType}
scanStatus={filters.scanStatus}
showStatusFilter={filters.showStatusFilter}
onScheduleTypeChange={filters.setScheduleType}
onScanStatusChange={filters.setScanStatus}
/>
<Button
type="button"
size="lg"
onClick={() => handleLaunchOpenChange(true)}
disabled={launchDisabled}
className="w-full md:w-auto"
>
Launch Scan
</Button>
</div>
<Tabs
value={filters.activeTab}
onValueChange={filters.setTab}
className="flex flex-col gap-[18px]"
>
<div
role="group"
aria-label="Scan tabs"
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<TabsList className="overflow-x-auto">
{Object.values(SCAN_JOBS_TAB).map((tab) => (
<TabsTrigger key={tab} value={tab}>
{getTabLabel(tab as ScanJobsTab)}
</TabsTrigger>
))}
</TabsList>
<div className="shrink-0">
<MutedFindingsConfigButton />
</div>
</div>
<TabsContent value={filters.activeTab} className="mt-0">
{children}
</TabsContent>
</Tabs>
<LaunchScanModal
open={launchOpen}
onOpenChange={handleLaunchOpenChange}
providers={providers}
/>
</div>
);
}
@@ -0,0 +1,37 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: ({ open }: { open: boolean }) =>
open ? <div role="dialog">Provider wizard</div> : null,
}));
vi.mock("./no-providers-connected", () => ({
NoProvidersConnected: () => <div>No Connected Providers</div>,
}));
describe("ScansProvidersEmptyState", () => {
it("shows the add provider message and opens the provider wizard", async () => {
const user = userEvent.setup();
render(<ScansProvidersEmptyState thereIsNoProviders />);
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("shows the no connected providers message", () => {
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
expect(screen.getByText("No Connected Providers")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,32 @@
"use client";
import { useState } from "react";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { NoProvidersAdded } from "./no-providers-added";
import { NoProvidersConnected } from "./no-providers-connected";
interface ScansProvidersEmptyStateProps {
thereIsNoProviders: boolean;
}
export function ScansProvidersEmptyState({
thereIsNoProviders,
}: ScansProvidersEmptyStateProps) {
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
return (
<>
{thereIsNoProviders ? (
<NoProvidersAdded onOpenWizard={() => setIsProviderWizardOpen(true)} />
) : (
<NoProvidersConnected />
)}
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={setIsProviderWizardOpen}
/>
</>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import {
SCAN_JOBS_TAB,
type ScanAttributes,
type ScanProps,
type ScanTrigger,
} from "@/types";
import {
formatScanDuration,
getScanAlias,
getScanFindingsSummary,
getScanJobsTab,
getScanJobsTabFilters,
getScanJobsUserFilters,
getScanScheduleLabel,
getScanStatusLabel,
getScanTriggerFilterOptions,
} from "./scans.utils";
const makeScan = (
name: string | null,
trigger: ScanTrigger = "manual",
): ScanProps => ({
type: "scans",
id: "scan-1",
attributes: {
name: name ?? "",
trigger,
state: "completed",
unique_resource_count: 0,
progress: 100,
scanner_args: null,
duration: 0,
started_at: "",
inserted_at: "",
completed_at: "",
scheduled_at: "",
next_scan_at: "",
},
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
task: { data: { type: "tasks", id: "task-1" } },
},
});
describe("scans.utils", () => {
it("falls back to completed tab for unknown tab values", () => {
expect(getScanJobsTab("unknown")).toBe(SCAN_JOBS_TAB.COMPLETED);
expect(getScanJobsTab(SCAN_JOBS_TAB.COMPLETED)).toBe(
SCAN_JOBS_TAB.COMPLETED,
);
});
it("maps scan job tabs to the state filters expected by the API", () => {
expect(getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE)).toEqual({
"filter[state__in]": "available,executing",
});
expect(getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED)).toEqual({
"filter[state__in]": "completed,failed,cancelled",
});
expect(getScanJobsTabFilters(SCAN_JOBS_TAB.SCHEDULED)).toEqual({
"filter[state__in]": "scheduled",
});
});
it("narrows tab state filters when a matching status is selected", () => {
expect(getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED, "failed")).toEqual({
"filter[state__in]": "failed",
});
expect(
getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED, "failed,cancelled"),
).toEqual({
"filter[state__in]": "failed,cancelled",
});
expect(getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE, "failed")).toEqual({
"filter[state__in]": "available,executing",
});
});
it("keeps user filters while excluding scan state filters", () => {
expect(
getScanJobsUserFilters({
tab: "completed",
page: "2",
"filter[provider_uid]": "123456789012",
"filter[state__in]": "failed,cancelled",
"filter[search]": "production",
}),
).toEqual({
"filter[provider_uid]": "123456789012",
"filter[search]": "production",
});
});
it("formats scan labels and durations for table display", () => {
expect(getScanAlias(makeScan(""))).toBe("-");
expect(getScanAlias(makeScan("Daily scheduled scan", "scheduled"))).toBe(
"scheduled scan",
);
expect(getScanAlias(makeScan("", "scheduled"))).toBe("scheduled scan");
expect(getScanAlias(makeScan("Production scan"))).toBe("Production scan");
expect(formatScanDuration(73)).toBe("1 min 13 sec");
expect(formatScanDuration(null)).toBe("-");
});
it("maps trigger and state values to product labels", () => {
expect(getScanScheduleLabel("manual")).toBe("Manual");
expect(getScanScheduleLabel("scheduled")).toBe("Scheduled");
expect(getScanScheduleLabel("imported")).toBe("Imported");
expect(getScanStatusLabel("available")).toBe("Queued");
expect(getScanStatusLabel("completed")).toBe("Completed");
});
it("includes imported in the trigger filter only for Cloud", () => {
expect(getScanTriggerFilterOptions(false)).toEqual([
{ value: "all", label: "All Types" },
{ value: "manual", label: "Manual" },
{ value: "scheduled", label: "Scheduled" },
]);
expect(getScanTriggerFilterOptions(true)).toEqual([
{ value: "all", label: "All Types" },
{ value: "manual", label: "Manual" },
{ value: "scheduled", label: "Scheduled" },
{ value: "imported", label: "Imported" },
]);
});
it("reads findings summary from root or nested API fields", () => {
expect(
getScanFindingsSummary({
fail: 2,
pass: 3,
fail_new: 1,
} as unknown as ScanAttributes),
).toEqual({ fail: 2, pass: 3, failNew: 1 });
expect(
getScanFindingsSummary({
findings: {
failed_findings: 4,
passed_findings: 8,
new_passed_findings: 2,
},
} as unknown as ScanAttributes),
).toEqual({ fail: 4, pass: 8, passNew: 2 });
expect(getScanFindingsSummary(makeScan("x").attributes)).toBeNull();
});
});
+219
View File
@@ -0,0 +1,219 @@
import {
DEFAULT_SCAN_JOBS_TAB,
SCAN_JOBS_TAB,
SCAN_STATE,
SCAN_TRIGGER,
type ScanAttributes,
type ScanFindingsSummary,
type ScanJobsTab,
type ScanProps,
type ScanState,
type ScanTrigger,
type SearchParamsProps,
} from "@/types";
export const SCAN_STATE_FILTER_KEYS = [
"filter[state]",
"filter[state__in]",
] as const;
const ALL_VALUE = "all";
const SCAN_JOBS_TAB_STATES: Record<ScanJobsTab, ScanState[]> = {
[SCAN_JOBS_TAB.ACTIVE]: [SCAN_STATE.AVAILABLE, SCAN_STATE.EXECUTING],
[SCAN_JOBS_TAB.COMPLETED]: [
SCAN_STATE.COMPLETED,
SCAN_STATE.FAILED,
SCAN_STATE.CANCELLED,
],
[SCAN_JOBS_TAB.SCHEDULED]: [SCAN_STATE.SCHEDULED],
};
const toStateFilter = (states: ScanState[]): Record<string, string> => ({
"filter[state__in]": states.join(","),
});
const SCAN_JOBS_TAB_FILTERS = Object.fromEntries(
Object.entries(SCAN_JOBS_TAB_STATES).map(([tab, states]) => [
tab,
toStateFilter(states),
]),
) as Record<ScanJobsTab, Record<string, string>>;
export interface ScanTriggerFilterOption {
value: typeof ALL_VALUE | ScanTrigger;
label: string;
}
export interface ScanStatusFilterOption {
value: typeof ALL_VALUE | ScanState;
label: string;
}
export function getScanTriggerFilterOptions(
isCloudEnvironment: boolean,
): ScanTriggerFilterOption[] {
const options: ScanTriggerFilterOption[] = [
{ value: ALL_VALUE, label: "All Types" },
{ value: SCAN_TRIGGER.MANUAL, label: "Manual" },
{ value: SCAN_TRIGGER.SCHEDULED, label: "Scheduled" },
];
if (isCloudEnvironment) {
options.push({ value: SCAN_TRIGGER.IMPORTED, label: "Imported" });
}
return options;
}
export function isScanStateFilterKey(key: string): boolean {
return SCAN_STATE_FILTER_KEYS.some((filterKey) => filterKey === key);
}
function isSearchParamValue(value: unknown): value is string | string[] {
return typeof value === "string" || Array.isArray(value);
}
export function getScanJobsUserFilters(
searchParams: SearchParamsProps,
): Record<string, string | string[]> {
return Object.entries(searchParams).reduce<Record<string, string | string[]>>(
(filters, [key, value]) => {
if (
key.startsWith("filter[") &&
!isScanStateFilterKey(key) &&
isSearchParamValue(value)
) {
filters[key] = value;
}
return filters;
},
{},
);
}
function parseStateFilter(value?: string | string[]): ScanState[] {
const rawValue = Array.isArray(value) ? value.join(",") : value;
if (!rawValue || rawValue === ALL_VALUE) return [];
return rawValue
.split(",")
.filter((item): item is ScanState =>
Object.values(SCAN_STATE).includes(item as ScanState),
);
}
export function getScanJobsTab(value?: string | string[]): ScanJobsTab {
const rawValue = Array.isArray(value) ? value[0] : value;
const tabs = Object.values(SCAN_JOBS_TAB);
return tabs.includes(rawValue as ScanJobsTab)
? (rawValue as ScanJobsTab)
: DEFAULT_SCAN_JOBS_TAB;
}
export function getScanJobsTabFilters(
tab: ScanJobsTab,
stateFilter?: string | string[],
): Record<string, string> {
const selectedStates = parseStateFilter(stateFilter);
const allowedStates = SCAN_JOBS_TAB_STATES[tab];
const matchingStates = selectedStates.filter((state) =>
allowedStates.includes(state),
);
if (matchingStates.length === 0) return { ...SCAN_JOBS_TAB_FILTERS[tab] };
return { "filter[state__in]": matchingStates.join(",") };
}
export function getScanAlias(scan: ScanProps): string {
if (scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED)
return "scheduled scan";
return scan.attributes.name?.trim() || "-";
}
export function formatScanDuration(duration?: number | null): string {
if (duration === null || duration === undefined || duration < 0) return "-";
const totalSeconds = Math.round(duration);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours > 0) return `${hours}h ${remainingMinutes}m ${seconds}s`;
if (minutes > 0) return `${minutes} min ${seconds} sec`;
return `${seconds} sec`;
}
export function getScanScheduleLabel(trigger?: ScanTrigger | string): string {
if (trigger === "scheduled") return "Scheduled";
if (trigger === "manual") return "Manual";
if (trigger === "imported") return "Imported";
return "-";
}
export function getScanStatusLabel(state?: ScanState | string): string {
if (state === "available") return "Queued";
if (!state) return "-";
return state.charAt(0).toUpperCase() + state.slice(1);
}
export function getScanStatusFilterOptions(
tab: ScanJobsTab,
): ScanStatusFilterOption[] {
return [
{ value: ALL_VALUE, label: "All Statuses" },
...SCAN_JOBS_TAB_STATES[tab].map((state) => ({
value: state,
label: getScanStatusLabel(state),
})),
];
}
function getNumericValue(
source: Record<string, unknown>,
keys: string[],
): number | undefined {
for (const key of keys) {
const value = source[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return undefined;
}
export function getScanFindingsSummary(
attributes: ScanAttributes,
): ScanFindingsSummary | null {
const root = attributes as unknown as Record<string, unknown>;
const nested =
typeof root.findings === "object" && root.findings !== null
? (root.findings as Record<string, unknown>)
: {};
const source = { ...root, ...nested };
const fail = getNumericValue(source, [
"fail",
"failed",
"failed_findings",
"fail_findings",
]);
const pass = getNumericValue(source, [
"pass",
"passed",
"passed_findings",
"pass_findings",
]);
if (fail === undefined || pass === undefined) return null;
return {
fail,
pass,
failNew: getNumericValue(source, ["fail_new", "new_failed_findings"]),
passNew: getNumericValue(source, ["pass_new", "new_passed_findings"]),
};
}
@@ -0,0 +1,22 @@
"use client";
import { EntityInfo } from "@/components/ui/entities";
import type { ProviderType, ScanProps } from "@/types";
export function AccountCell({ scan }: { scan: ScanProps }) {
const providerInfo = scan.providerInfo;
if (!providerInfo) {
return <span className="text-text-neutral-tertiary text-sm">-</span>;
}
return (
<div className="max-w-[240px] min-w-0">
<EntityInfo
cloudProvider={providerInfo.provider as ProviderType}
entityAlias={providerInfo.alias}
entityId={providerInfo.uid}
/>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { AccountCell } from "./account-cell";
export { ProgressCell } from "./progress-cell";
export { ResourceCountCell } from "./resource-count-cell";
export { ScanInfoCell } from "./scan-info-cell";
export { ScheduleCell } from "./schedule-cell";
@@ -0,0 +1,22 @@
"use client";
import { Badge, Progress } from "@/components/shadcn";
import type { ScanProps } from "@/types";
export function ProgressCell({ scan }: { scan: ScanProps }) {
const progress = scan.attributes.progress ?? 0;
const isQueued = scan.attributes.state === "available";
if (isQueued) {
return <Badge variant="warning">Queued for scan</Badge>;
}
return (
<div className="flex min-w-[220px] items-center gap-3">
<Progress value={progress} className="h-2 min-w-[140px]" />
<span className="text-text-neutral-secondary min-w-9 text-xs font-medium">
{progress}%
</span>
</div>
);
}
@@ -0,0 +1,11 @@
"use client";
import { Badge } from "@/components/shadcn";
export function ResourceCountCell({ count }: { count?: number }) {
return (
<Badge variant="tag" className="rounded text-sm">
<span className="font-bold">{(count ?? 0).toLocaleString()}</span>
</Badge>
);
}
@@ -0,0 +1,17 @@
"use client";
import { getScanAlias } from "@/components/scans/scans.utils";
import { EntityInfo } from "@/components/ui/entities";
import type { ScanProps } from "@/types";
export function ScanInfoCell({ scan }: { scan: ScanProps }) {
return (
<div className="max-w-[240px] min-w-0">
<EntityInfo
entityAlias={getScanAlias(scan)}
entityId={scan.id}
idLabel="ID"
/>
</div>
);
}
@@ -0,0 +1,18 @@
"use client";
import { getScanScheduleLabel } from "@/components/scans/scans.utils";
import { DateWithTime } from "@/components/ui/entities";
import type { ScanProps } from "@/types";
export function ScheduleCell({ scan }: { scan: ScanProps }) {
return (
<div className="flex flex-col gap-1">
<span className="text-text-neutral-primary text-sm">
{getScanScheduleLabel(scan.attributes.trigger)}
</span>
{scan.attributes.scheduled_at && (
<DateWithTime dateTime={scan.attributes.scheduled_at} showTime />
)}
</div>
);
}
+10 -1
View File
@@ -1,2 +1,11 @@
export * from "./scan-detail";
export {
AccountCell,
ProgressCell,
ResourceCountCell,
ScanInfoCell,
ScheduleCell,
} from "./cells";
export * from "./scan-jobs-columns";
export * from "./scan-jobs-row-actions";
export * from "./scan-jobs-table";
export * from "./skeleton-table-scans";
-107
View File
@@ -1,107 +0,0 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { DateWithTime, EntityInfo } from "@/components/ui/entities";
import { StatusBadge } from "@/components/ui/table/status-badge";
import { formatDuration } from "@/lib/date-utils";
import { ProviderProps, ProviderType, ScanProps, TaskDetails } from "@/types";
const renderValue = (value: string | null | undefined) => {
return value && value.trim() !== "" ? value : "-";
};
export const ScanDetail = ({
scanDetails,
}: {
scanDetails: ScanProps & {
taskDetails?: TaskDetails;
// TODO: Remove the "?" once we have a proper provider details type
providerDetails?: ProviderProps;
};
}) => {
const scan = scanDetails.attributes;
const taskDetails = scanDetails.taskDetails;
const providerDetails = scanDetails.providerDetails?.attributes;
return (
<div className="flex flex-col gap-6 rounded-lg">
{/* Header */}
<div className="flex items-center gap-4">
<div className="flex items-center">
<StatusBadge
size="md"
className="w-fit"
status={scan.state}
loadingProgress={scan.progress}
/>
</div>
<EntityInfo
cloudProvider={providerDetails?.provider as ProviderType}
entityAlias={providerDetails?.alias}
entityId={providerDetails?.uid}
showConnectionStatus={providerDetails?.connection.connected}
/>
</div>
{/* Scan Details */}
<Card variant="base" padding="lg">
<CardHeader>
<CardTitle>Scan Details</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Scan Name">{renderValue(scan.name)}</InfoField>
<InfoField label="Resources Scanned">
{scan.unique_resource_count}
</InfoField>
<InfoField label="Progress">{scan.progress}%</InfoField>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Trigger">{renderValue(scan.trigger)}</InfoField>
<InfoField label="State">{renderValue(scan.state)}</InfoField>
<InfoField label="Duration">
{formatDuration(scan.duration)}
</InfoField>
</div>
<InfoField label="Scan ID" variant="simple">
<CodeSnippet value={scanDetails.id} />
</InfoField>
{scan.state === "failed" && taskDetails?.attributes.result && (
<>
{taskDetails.attributes.result.exc_message && (
<InfoField label="Error Message" variant="simple">
<CodeSnippet
value={taskDetails.attributes.result.exc_message.join("\n")}
multiline
/>
</InfoField>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoField label="Error Type">
{renderValue(taskDetails.attributes.result.exc_type)}
</InfoField>
</div>
</>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoField label="Started At">
<DateWithTime inline dateTime={scan.started_at || "-"} />
</InfoField>
<InfoField label="Completed At">
<DateWithTime inline dateTime={scan.completed_at || "-"} />
</InfoField>
<InfoField label="Scheduled At">
<DateWithTime inline dateTime={scan.scheduled_at || "-"} />
</InfoField>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,166 @@
import type { CellContext, HeaderContext } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ScanProps } from "@/types";
vi.mock("@/components/shadcn", () => ({
Badge: ({ children }: { children: React.ReactNode }) => (
<span>{children}</span>
),
Progress: () => <div />,
}));
vi.mock("@/components/ui/entities", () => ({
DateWithTime: () => <time />,
EntityInfo: ({
entityAlias,
entityId,
idLabel,
}: {
entityAlias?: string;
entityId?: string;
idLabel?: string;
}) => (
<div>
<span>{entityAlias}</span>
<span>
{idLabel}: {entityId}
</span>
</div>
),
}));
vi.mock("@/components/ui/custom", () => ({
TableLink: ({
href,
isDisabled,
label,
}: {
href: string;
isDisabled?: boolean;
label: string;
}) => (isDisabled ? <span>{label}</span> : <a href={href}>{label}</a>),
}));
vi.mock("@/components/ui/table", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
}));
vi.mock("./scan-jobs-row-actions", () => ({
ScanJobsRowActions: () => <button type="button" />,
}));
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
import { getScanJobsColumns } from "./scan-jobs-columns";
const getColumnIds = (tab: ScanJobsTab) =>
getScanJobsColumns({ tab }).map((column) => column.id);
const makeCompletedScan = (): ScanProps => ({
type: "scans",
id: "scan-1",
attributes: {
name: "Production scan",
trigger: "manual",
state: "completed",
unique_resource_count: 7,
progress: 100,
scanner_args: null,
duration: 73,
started_at: "2026-01-01T10:00:00Z",
inserted_at: "2026-01-01T10:00:00Z",
completed_at: "2026-01-01T10:05:00Z",
scheduled_at: "",
next_scan_at: "",
},
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
task: { data: { type: "tasks", id: "task-1" } },
},
});
const renderCell = (
columnId: string,
scan: ScanProps,
tab: ScanJobsTab = SCAN_JOBS_TAB.COMPLETED,
) => {
const column = getScanJobsColumns({
tab,
}).find((item) => item.id === columnId);
const cell = column?.cell as
| ((context: CellContext<ScanProps, unknown>) => React.ReactNode)
| undefined;
if (!cell) throw new Error(`Column ${columnId} does not define a cell`);
render(
<>{cell({ row: { original: scan } } as CellContext<ScanProps, unknown>)}</>,
);
};
const renderHeader = (tab: ScanJobsTab, columnId: string) => {
const column = getScanJobsColumns({ tab }).find(
(item) => item.id === columnId,
);
const header = column?.header;
if (typeof header !== "function") {
throw new Error(`Column ${columnId} does not define a header`);
}
render(<>{header({ column: {} } as HeaderContext<ScanProps, unknown>)}</>);
};
describe("getScanJobsColumns", () => {
it("uses the expected columns for each scan tab", () => {
expect(getColumnIds(SCAN_JOBS_TAB.ACTIVE)).toEqual([
"account",
"scanInfo",
"progress",
"scanSchedule",
"launched",
"actions",
]);
expect(getColumnIds(SCAN_JOBS_TAB.COMPLETED)).toEqual([
"account",
"scanInfo",
"resources",
"duration",
"status",
"scanSchedule",
"scanDate",
"actions",
]);
expect(getColumnIds(SCAN_JOBS_TAB.SCHEDULED)).toEqual([
"account",
"scanInfo",
"scanSchedule",
"nextScan",
"actions",
]);
});
it("labels the scan info column as Info in scan tables", () => {
renderHeader(SCAN_JOBS_TAB.ACTIVE, "scanInfo");
renderHeader(SCAN_JOBS_TAB.COMPLETED, "scanInfo");
expect(screen.getAllByText("Info")).toHaveLength(2);
expect(screen.queryByText("Alias")).not.toBeInTheDocument();
expect(screen.queryByText("Scan Note")).not.toBeInTheDocument();
});
it("renders the scan alias with the scan id underneath", () => {
renderCell("scanInfo", makeCompletedScan());
expect(screen.getByText("Production scan")).toBeInTheDocument();
expect(screen.getByText("ID: scan-1")).toBeInTheDocument();
});
it("renders the completed duration column", () => {
renderCell("duration", makeCompletedScan());
expect(screen.getByText("1 min 13 sec")).toBeInTheDocument();
});
});
@@ -0,0 +1,179 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { DateWithTime } from "@/components/ui/entities";
import { DataTableColumnHeader } from "@/components/ui/table";
import { StatusBadge } from "@/components/ui/table/status-badge";
import { SCAN_JOBS_TAB, type ScanJobsTab, type ScanProps } from "@/types";
import { formatScanDuration } from "../scans.utils";
import {
AccountCell,
ProgressCell,
ResourceCountCell,
ScanInfoCell,
ScheduleCell,
} from "./cells";
import { ScanJobsRowActions } from "./scan-jobs-row-actions";
interface GetScanJobsColumnsOptions {
tab: ScanJobsTab;
}
const accountColumn: ColumnDef<ScanProps> = {
id: "account",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Provider" />
),
cell: ({ row }) => <AccountCell scan={row.original} />,
enableSorting: false,
};
const scanInfoColumn: ColumnDef<ScanProps> = {
id: "scanInfo",
accessorFn: (row) => row.attributes.name,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Info" param="name" />
),
cell: ({ row }) => <ScanInfoCell scan={row.original} />,
};
const scanScheduleColumn: ColumnDef<ScanProps> = {
id: "scanSchedule",
accessorFn: (row) => row.attributes.trigger,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Schedule" param="trigger" />
),
cell: ({ row }) => <ScheduleCell scan={row.original} />,
};
const resourcesColumn: ColumnDef<ScanProps> = {
id: "resources",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resources" />
),
cell: ({ row }) => (
<ResourceCountCell count={row.original.attributes.unique_resource_count} />
),
enableSorting: false,
};
const actionsColumn: ColumnDef<ScanProps> = {
id: "actions",
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => <ScanJobsRowActions scan={row.original} />,
enableSorting: false,
};
const durationColumn: ColumnDef<ScanProps> = {
id: "duration",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Duration" />
),
cell: ({ row }) => formatScanDuration(row.original.attributes.duration),
enableSorting: false,
};
const activeColumns = (): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
{
id: "progress",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Progress" />
),
cell: ({ row }) => <ProgressCell scan={row.original} />,
enableSorting: false,
},
scanScheduleColumn,
{
id: "launched",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Launched" />
),
cell: ({ row }) => (
<DateWithTime
dateTime={
row.original.attributes.started_at ||
row.original.attributes.inserted_at
}
/>
),
enableSorting: false,
},
actionsColumn,
];
const completedColumns = (): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
resourcesColumn,
durationColumn,
{
id: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => <StatusBadge status={row.original.attributes.state} />,
enableSorting: false,
},
scanScheduleColumn,
{
id: "scanDate",
accessorFn: (row) => row.attributes.completed_at,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Completed"
param="updated_at"
/>
),
cell: ({ row }) => (
<DateWithTime dateTime={row.original.attributes.completed_at} />
),
},
actionsColumn,
];
const scheduledColumns = (): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
scanScheduleColumn,
/*
* TODO: Restore this column when the API exposes the last completed scan date for this schedule.
* {
* id: "lastScan",
* header: ({ column }) => (
* <DataTableColumnHeader column={column} title="Last Run" />
* ),
* cell: ({ row }) => (
* <DateWithTime dateTime={row.original.attributes.completed_at} />
* ),
* enableSorting: false,
* },
*/
{
id: "nextScan",
accessorFn: (row) => row.attributes.next_scan_at,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Next Run"
param="next_scan_at"
/>
),
cell: ({ row }) => (
<DateWithTime dateTime={row.original.attributes.next_scan_at} />
),
},
actionsColumn,
];
export function getScanJobsColumns(
options: GetScanJobsColumnsOptions,
): ColumnDef<ScanProps>[] {
if (options.tab === SCAN_JOBS_TAB.SCHEDULED) return scheduledColumns();
if (options.tab === SCAN_JOBS_TAB.ACTIVE) return activeColumns();
return completedColumns();
}
@@ -0,0 +1,307 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ScanProps } from "@/types";
import { ScanJobsRowActions } from "./scan-jobs-row-actions";
const { downloadScanZipMock, getTaskMock, pushMock, toastMock } = vi.hoisted(
() => ({
downloadScanZipMock: vi.fn(),
getTaskMock: vi.fn(),
pushMock: vi.fn(),
toastMock: vi.fn(),
}),
);
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({ toast: toastMock }),
}));
vi.mock("@/actions/task", () => ({
getTask: getTaskMock,
}));
vi.mock("@/lib/helper", () => ({
downloadScanZip: downloadScanZipMock,
}));
vi.mock("@/lib/date-utils", () => ({
toLocalDateString: (value: string | null | undefined) =>
value ? "2026-01-01" : undefined,
}));
vi.mock("@/components/scans/edit-alias-modal", () => ({
EditAliasModal: ({
open,
currentAlias,
}: {
open: boolean;
currentAlias: string;
}) =>
open ? (
<div role="dialog" aria-label="Edit Alias">
Editing {currentAlias}
</div>
) : null,
}));
const makeScan = (
overrides: Partial<ScanProps["attributes"]> = {},
): ScanProps => ({
type: "scans",
id: "scan-1",
attributes: {
name: "Production scan",
trigger: "scheduled",
state: "scheduled",
unique_resource_count: 0,
progress: 0,
scanner_args: null,
duration: 0,
started_at: "",
inserted_at: "",
completed_at: "",
scheduled_at: "",
next_scan_at: "",
...overrides,
},
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
task: { data: { type: "tasks", id: "task-1" } },
},
});
describe("ScanJobsRowActions", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
it("opens the Edit modal seeded with the current scan name", async () => {
// Given
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan()} />);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
await user.click(screen.getByRole("menuitem", { name: /^edit$/i }));
// Then
expect(
screen.getByRole("dialog", { name: /edit alias/i }),
).toHaveTextContent("Editing Production scan");
});
it("does not render the legacy Edit Scan Schedule option", async () => {
// Given
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan()} />);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
// Then
expect(
screen.queryByRole("menuitem", { name: /edit scan schedule/i }),
).not.toBeInTheDocument();
});
it("does not render cancel scan while the scan cancellation API is missing", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan()} />);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
// Then
expect(
screen.queryByRole("menuitem", { name: /cancel scan/i }),
).not.toBeInTheDocument();
});
it("links completed scans to compliance from the actions menu", async () => {
// Given
const user = userEvent.setup();
render(
<ScanJobsRowActions
scan={makeScan({
state: "completed",
completed_at: "2026-01-01T10:05:00Z",
})}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
await user.click(
screen.getByRole("menuitem", { name: /view compliance/i }),
);
// Then
expect(pushMock).toHaveBeenCalledWith("/compliance?scanId=scan-1");
});
it("renames the completed scan report download action", async () => {
// Given
const user = userEvent.setup();
render(
<ScanJobsRowActions
scan={makeScan({
state: "completed",
completed_at: "2026-01-01T10:05:00Z",
})}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
// Then
expect(
screen.getByRole("menuitem", { name: /download scan reports/i }),
).toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: /download findings/i }),
).not.toBeInTheDocument();
});
it("links completed scans to filtered findings", async () => {
// Given
const user = userEvent.setup();
render(
<ScanJobsRowActions
scan={makeScan({
state: "completed",
completed_at: "2026-01-01T10:05:00Z",
})}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
await user.click(screen.getByRole("menuitem", { name: /view findings/i }));
// Then
expect(pushMock).toHaveBeenCalledWith(
"/findings?filter[scan]=scan-1&filter[inserted_at]=2026-01-01&filter[status__in]=FAIL",
);
});
it("triggers downloadScanZip with the scan id when downloading reports", async () => {
// Given
const user = userEvent.setup();
render(
<ScanJobsRowActions
scan={makeScan({
state: "completed",
completed_at: "2026-01-01T10:05:00Z",
})}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
await user.click(
screen.getByRole("menuitem", { name: /download scan reports/i }),
);
// Then
expect(downloadScanZipMock).toHaveBeenCalledWith("scan-1", toastMock);
});
it("opens failed scan error details from the actions menu", async () => {
// Given
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
getTaskMock.mockResolvedValue({
data: {
attributes: {
result: {
exc_type: "ValidationError",
exc_message: ["Missing cloud credentials", "Retry scan setup"],
},
},
},
});
render(
<ScanJobsRowActions
scan={makeScan({
state: "failed",
completed_at: "2026-01-01T10:05:00Z",
})}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
await user.click(
screen.getByRole("menuitem", { name: /view error details/i }),
);
// Then
expect(getTaskMock).toHaveBeenCalledWith("task-1");
expect(
await screen.findByRole("dialog", { name: /scan error details/i }),
).toBeInTheDocument();
expect(screen.getByText("ValidationError")).toBeInTheDocument();
expect(screen.getByText(/Missing cloud credentials/)).toBeInTheDocument();
expect(screen.getByText(/Retry scan setup/)).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: /copy error details/i }),
);
expect(writeText).toHaveBeenCalledWith(
"ErrorType: ValidationError\nError: Missing cloud credentials\nRetry scan setup",
);
});
it("does not show error details for non-failed scans", async () => {
// Given
const user = userEvent.setup();
render(<ScanJobsRowActions scan={makeScan({ state: "completed" })} />);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
// Then
expect(
screen.queryByRole("menuitem", { name: /view error details/i }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,152 @@
"use client";
import {
Download,
Eye,
Pencil,
ShieldCheck,
TriangleAlert,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { getTask } from "@/actions/task";
import { getScanErrorDetails } from "@/actions/task/task.adapter";
import { EditAliasModal } from "@/components/scans/edit-alias-modal";
import {
ScanErrorDetailsModal,
type ScanErrorDetailsState,
} from "@/components/scans/scan-error-details-modal";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { useToast } from "@/components/ui";
import { toLocalDateString } from "@/lib/date-utils";
import { downloadScanZip } from "@/lib/helper";
import type { ScanProps } from "@/types";
interface ScanJobsRowActionsProps {
scan: ScanProps;
}
export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
const router = useRouter();
const { toast } = useToast();
const [editOpen, setEditOpen] = useState(false);
const [errorOpen, setErrorOpen] = useState(false);
const [errorState, setErrorState] = useState<ScanErrorDetailsState>({
kind: "idle",
});
const scanState = scan.attributes.state;
const isCompleted = scanState === "completed";
const isFailed = scanState === "failed";
const taskId = scan.relationships.task.data?.id;
const scanDate = toLocalDateString(scan.attributes.completed_at);
const openFindings = () => {
if (!isCompleted || !scanDate) return;
router.push(
`/findings?filter[scan]=${scan.id}&filter[inserted_at]=${scanDate}&filter[status__in]=FAIL`,
);
};
const openCompliance = () => {
if (!isCompleted) return;
router.push(`/compliance?scanId=${scan.id}`);
};
const openErrorDetails = async () => {
setErrorOpen(true);
setErrorState({ kind: "loading" });
if (!taskId) {
setErrorState({
kind: "error",
message: "Task ID is not available for this scan.",
});
return;
}
const response: unknown = await getTask(taskId);
if (
typeof response === "object" &&
response !== null &&
"error" in response &&
typeof (response as { error: unknown }).error === "string"
) {
setErrorState({
kind: "error",
message: (response as { error: string }).error,
});
return;
}
const details = getScanErrorDetails(response);
if (!details) {
setErrorState({
kind: "error",
message: "No error details were found for this failed scan.",
});
return;
}
setErrorState({ kind: "loaded", details });
};
return (
<div className="flex items-center justify-end">
<ActionDropdown>
{isCompleted && (
<>
<ActionDropdownItem
icon={<Eye />}
label="View Findings"
onSelect={openFindings}
disabled={!isCompleted || !scanDate}
/>
<ActionDropdownItem
icon={<ShieldCheck />}
label="View Compliance"
onSelect={openCompliance}
/>
<ActionDropdownItem
icon={<Download />}
label="Download Scan Reports"
onSelect={() => downloadScanZip(scan.id, toast)}
/>
</>
)}
{isFailed && (
<ActionDropdownItem
icon={<TriangleAlert />}
label="View error details"
onSelect={() => void openErrorDetails()}
/>
)}
{/* TODO: Expand Edit to also cover schedule once the backend exposes a schedule update endpoint. */}
<ActionDropdownItem
icon={<Pencil />}
label="Edit"
onSelect={() => setEditOpen(true)}
/>
{/* TODO: Restore Cancel Scan once the backend exposes a public scan cancellation endpoint. */}
</ActionDropdown>
<EditAliasModal
open={editOpen}
onOpenChange={setEditOpen}
scanId={scan.id}
currentAlias={scan.attributes.name ?? ""}
/>
<ScanErrorDetailsModal
open={errorOpen}
onOpenChange={setErrorOpen}
state={errorState}
/>
</div>
);
}
@@ -0,0 +1,99 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SCAN_JOBS_TAB, type ScanProps } from "@/types";
import { ScanJobsTable } from "./scan-jobs-table";
vi.mock("@/components/ui/table", () => ({
DataTable: ({ data }: { data: ScanProps[] }) => (
<div data-testid="scan-jobs-data-table">{data.length}</div>
),
}));
vi.mock("./scan-jobs-columns", () => ({
getScanJobsColumns: () => [],
}));
vi.mock("../auto-refresh", () => ({
AutoRefresh: ({ hasExecutingScan }: { hasExecutingScan: boolean }) => (
<div data-testid="scan-jobs-auto-refresh">{String(hasExecutingScan)}</div>
),
}));
vi.mock("../no-scans-empty-state", () => ({
NoScansEmptyState: ({ tab }: { tab: string }) => (
<div data-testid="no-scans-empty-state">{tab}</div>
),
}));
const makeScan = (state: ScanProps["attributes"]["state"]): ScanProps => ({
type: "scans",
id: `scan-${state}`,
attributes: {
name: "Production scan",
trigger: "manual",
state,
unique_resource_count: 0,
progress: 100,
scanner_args: null,
duration: 0,
started_at: "",
inserted_at: "",
completed_at: "",
scheduled_at: "",
next_scan_at: "",
},
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
task: { data: { type: "tasks", id: "task-1" } },
},
});
describe("ScanJobsTable", () => {
it("enables auto refresh while queued or executing scans are visible", () => {
render(
<ScanJobsTable
data={[makeScan("available"), makeScan("completed")]}
tab={SCAN_JOBS_TAB.ACTIVE}
/>,
);
expect(screen.getByTestId("scan-jobs-auto-refresh")).toHaveTextContent(
"true",
);
});
it("disables auto refresh when visible scans are not running", () => {
render(
<ScanJobsTable
data={[makeScan("completed"), makeScan("failed")]}
tab={SCAN_JOBS_TAB.COMPLETED}
/>,
);
expect(screen.getByTestId("scan-jobs-auto-refresh")).toHaveTextContent(
"false",
);
});
it("renders the empty state when there are no scans and no filters applied", () => {
render(<ScanJobsTable data={[]} tab={SCAN_JOBS_TAB.ACTIVE} />);
expect(screen.getByTestId("no-scans-empty-state")).toHaveTextContent(
SCAN_JOBS_TAB.ACTIVE,
);
expect(
screen.queryByTestId("scan-jobs-data-table"),
).not.toBeInTheDocument();
});
it("falls back to the data table when there are no scans but filters are applied", () => {
render(<ScanJobsTable data={[]} tab={SCAN_JOBS_TAB.ACTIVE} hasFilters />);
expect(screen.getByTestId("scan-jobs-data-table")).toBeInTheDocument();
expect(
screen.queryByTestId("no-scans-empty-state"),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,48 @@
"use client";
import { DataTable } from "@/components/ui/table";
import type { MetaDataProps, ScanJobsTab, ScanProps } from "@/types";
import { AutoRefresh } from "../auto-refresh";
import { NoScansEmptyState } from "../no-scans-empty-state";
import { getScanJobsColumns } from "./scan-jobs-columns";
interface ScanJobsTableProps {
data: ScanProps[];
meta?: MetaDataProps;
tab: ScanJobsTab;
hasFilters?: boolean;
}
const REFRESHING_STATES = ["available", "executing"] as const;
export function ScanJobsTable({
data,
meta,
tab,
hasFilters = false,
}: ScanJobsTableProps) {
const hasRefreshingScan = data.some((scan) =>
REFRESHING_STATES.includes(
scan.attributes.state as (typeof REFRESHING_STATES)[number],
),
);
const columns = getScanJobsColumns({ tab });
const showEmptyState = data.length === 0 && !hasFilters;
return (
<>
<AutoRefresh hasExecutingScan={hasRefreshingScan} />
{showEmptyState ? (
<NoScansEmptyState tab={tab} />
) : (
<DataTable
key={`scan-jobs-${tab}-${meta?.pagination?.page ?? 1}`}
columns={columns}
data={data}
metadata={meta}
/>
)}
</>
);
}
@@ -1,22 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("column-get-scans", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "column-get-scans.tsx");
const source = readFileSync(filePath, "utf8");
it("links scan findings to the historical finding-groups filters", () => {
expect(source).toContain("filter[scan]=");
expect(source).toContain("filter[inserted_at]=");
expect(source).not.toContain("filter[scan__in]");
});
it("links the findings filter against the scan's completed_at (what the backend expects)", () => {
expect(source).toMatch(/attributes:\s*{\s*completed_at\s*}/);
expect(source).toMatch(/toLocalDateString\(completed_at\)/);
});
});
@@ -1,280 +0,0 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { useRouter, useSearchParams } from "next/navigation";
import { InfoIcon } from "@/components/icons";
import { TableLink } from "@/components/ui/custom";
import { DateWithTime, EntityInfo } from "@/components/ui/entities";
import { TriggerSheet } from "@/components/ui/sheet";
import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table";
import { toLocalDateString } from "@/lib/date-utils";
import { ProviderType, ScanProps } from "@/types";
import { TriggerIcon } from "../../trigger-icon";
import { DataTableRowActions } from "./data-table-row-actions";
import { DataTableRowDetails } from "./data-table-row-details";
const getScanData = (row: { original: ScanProps }) => {
return row.original;
};
const ScanDetailsCell = ({ row }: { row: any }) => {
const router = useRouter();
const searchParams = useSearchParams();
const scanId = searchParams.get("scanId");
const isOpen = scanId === row.original.id;
const scanState = row.original.attributes?.state;
const isExecuting = scanState === "executing" || scanState === "available";
const handleOpenChange = (open: boolean) => {
if (isExecuting) return;
const params = new URLSearchParams(searchParams.toString());
if (open) {
params.set("scanId", row.original.id);
} else {
params.delete("scanId");
}
router.push(`?${params.toString()}`, { scroll: false });
};
return (
<div className="flex w-9 items-center justify-center">
<TriggerSheet
triggerComponent={
<InfoIcon
className={
isExecuting ? "cursor-default text-gray-400" : "text-primary"
}
size={16}
/>
}
title="Scan Details"
description="View the scan details"
open={isOpen}
onOpenChange={handleOpenChange}
>
{isOpen && <DataTableRowDetails entityId={row.original.id} />}
</TriggerSheet>
</div>
);
};
export const ColumnGetScans: ColumnDef<ScanProps>[] = [
{
id: "moreInfo",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Details" />
),
cell: ({ row }) => <ScanDetailsCell row={row} />,
enableSorting: false,
},
{
accessorKey: "cloudProvider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Provider" />
),
cell: ({ row }) => {
const providerInfo = row.original.providerInfo;
if (!providerInfo) {
return <span className="font-medium">No provider info</span>;
}
const { provider, uid, alias } = providerInfo;
return (
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias}
entityId={uid}
/>
);
},
enableSorting: false,
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const {
attributes: { state },
} = getScanData(row);
return (
<div className="flex items-center justify-center">
<StatusBadge
status={state}
loadingProgress={row.original.attributes.progress}
/>
</div>
);
},
enableSorting: false,
},
{
accessorKey: "findings",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Findings" />
),
cell: ({ row }) => {
const {
id,
attributes: { completed_at },
} = getScanData(row);
const scanState = row.original.attributes?.state;
// Source is `completed_at` (scan finish time) because findings are
// persisted when the scan ends — that's when their `inserted_at` is
// written. The URL key stays `filter[inserted_at]` because the findings
// table is partitioned by the finding's `inserted_at` date; this filter
// is the partition hint the backend uses to avoid scanning every
// partition. Names differ by design: scan.completed_at ≈ finding.inserted_at.
const scanDate = toLocalDateString(completed_at);
return (
<TableLink
href={`/findings?filter[scan]=${id}&filter[inserted_at]=${scanDate}&filter[status__in]=FAIL`}
isDisabled={scanState !== "completed" || !scanDate}
label="See Findings"
/>
);
},
enableSorting: false,
},
{
accessorKey: "compliance",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Compliance" />
),
cell: ({ row }) => {
const { id } = getScanData(row);
const scanState = row.original.attributes?.state;
return (
<TableLink
href={`/compliance?scanId=${id}`}
isDisabled={!["completed"].includes(scanState)}
label="See Compliance"
/>
);
},
enableSorting: false,
},
{
accessorKey: "resources",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Impacted Resources" />
),
cell: ({ row }) => {
const {
attributes: { unique_resource_count },
} = getScanData(row);
return (
<div className="flex w-fit items-center justify-center">
<span className="text-xs font-medium">{unique_resource_count}</span>
</div>
);
},
enableSorting: false,
},
{
accessorKey: "started_at",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Started at" />
),
cell: ({ row }) => {
const {
attributes: { started_at },
} = getScanData(row);
return (
<div className="w-[100px]">
<DateWithTime dateTime={started_at} />
</div>
);
},
enableSorting: false,
},
{
accessorKey: "scheduled_at",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Scheduled at" />
),
cell: ({ row }) => {
const {
attributes: { scheduled_at },
} = getScanData(row);
return <DateWithTime dateTime={scheduled_at} />;
},
enableSorting: false,
},
{
accessorKey: "completed_at",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Completed at"}
param="updated_at"
/>
),
cell: ({ row }) => {
const {
attributes: { completed_at },
} = getScanData(row);
return <DateWithTime dateTime={completed_at} />;
},
},
{
accessorKey: "trigger",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Trigger"}
param="trigger"
/>
),
cell: ({ row }) => {
const {
attributes: { trigger },
} = getScanData(row);
return (
<div className="flex w-9 items-center justify-center">
<TriggerIcon trigger={trigger} iconSize={16} />
</div>
);
},
},
{
accessorKey: "scanName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Scan name"} param="name" />
),
cell: ({ row }) => {
const {
attributes: { name },
} = getScanData(row);
if (!name || name.length === 0) {
return <span className="font-medium">-</span>;
}
return (
<div className="flex w-fit items-center justify-center">
<span className="text-xs font-medium">
{name === "Daily scheduled scan" ? "scheduled scan" : name}
</span>
</div>
);
},
},
{
id: "actions",
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => {
return <DataTableRowActions row={row} />;
},
enableSorting: false,
},
];
@@ -1,62 +0,0 @@
"use client";
import { Row } from "@tanstack/react-table";
import { Download, Pencil } from "lucide-react";
import { useState } from "react";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { downloadScanZip } from "@/lib/helper";
import { EditScanForm } from "../../forms";
interface DataTableRowActionsProps<ScanProps> {
row: Row<ScanProps>;
}
export function DataTableRowActions<ScanProps>({
row,
}: DataTableRowActionsProps<ScanProps>) {
const { toast } = useToast();
const [isEditOpen, setIsEditOpen] = useState(false);
const scanId = (row.original as { id: string }).id;
const scanName = (row.original as any).attributes?.name;
const scanState = (row.original as any).attributes?.state;
return (
<>
<Modal
open={isEditOpen}
onOpenChange={setIsEditOpen}
title="Edit Scan Name"
>
<EditScanForm
scanId={scanId}
scanName={scanName}
setIsOpen={setIsEditOpen}
/>
</Modal>
<div className="relative flex items-center justify-end gap-2">
<ActionDropdown>
<ActionDropdownItem
icon={<Download />}
label="Download .zip"
description="Available only for completed scans"
onSelect={() => downloadScanZip(scanId, toast)}
disabled={scanState !== "completed"}
/>
<ActionDropdownItem
icon={<Pencil />}
label="Edit Scan Name"
onSelect={() => setIsEditOpen(true)}
/>
</ActionDropdown>
</div>
</>
);
}
@@ -1,70 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { getProvider } from "@/actions/providers";
import { getScan } from "@/actions/scans";
import { getTask } from "@/actions/task";
import { ScanDetail } from "@/components/scans/table";
import { checkTaskStatus } from "@/lib";
import { ScanProps } from "@/types";
import { SkeletonScanDetail } from "./skeleton-scan-detail";
export const DataTableRowDetails = ({ entityId }: { entityId: string }) => {
const [scanDetails, setScanDetails] = useState<ScanProps | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchScanDetails = async () => {
try {
const result = await getScan(entityId);
const taskId = result.data.relationships.task?.data?.id;
const providerId = result.data.relationships.provider?.data?.id;
let providerDetails = null;
if (providerId) {
const formData = new FormData();
formData.append("id", providerId);
const providerResult = await getProvider(formData);
providerDetails = providerResult.data;
}
if (taskId) {
const taskResult = await checkTaskStatus(taskId);
if (taskResult.completed !== undefined) {
const task = await getTask(taskId);
setScanDetails({
...result.data,
taskDetails: task.data,
providerDetails: providerDetails,
});
}
} else {
setScanDetails({
...result.data,
providerDetails: providerDetails,
});
}
} catch (error) {
console.error("Error in fetchScanDetails:", error);
} finally {
setIsLoading(false);
}
};
fetchScanDetails();
}, [entityId]);
if (isLoading) {
return <SkeletonScanDetail />;
}
if (!scanDetails) {
return <div>No scan details available</div>;
}
return <ScanDetail scanDetails={scanDetails} />;
};
-4
View File
@@ -1,4 +0,0 @@
export * from "./column-get-scans";
export * from "./data-table-row-actions";
export * from "./data-table-row-details";
export * from "./scans-table-with-polling";
@@ -1,134 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { getScans } from "@/actions/scans";
import { AutoRefresh } from "@/components/scans";
import { DataTable } from "@/components/ui/table";
import { MetaDataProps, ScanProps, SearchParamsProps } from "@/types";
import { ColumnGetScans } from "./column-get-scans";
export const SCAN_LAUNCHED_EVENT = "scan-launched";
interface ScansTableWithPollingProps {
initialData: ScanProps[];
initialMeta?: MetaDataProps;
searchParams: SearchParamsProps;
}
const EXECUTING_STATES = ["executing", "available"] as const;
function expandScansWithProviderInfo(
scans: ScanProps[],
included?: Array<{ type: string; id: string; attributes: any }>,
) {
return (
scans?.map((scan) => {
const providerId = scan.relationships?.provider?.data?.id;
if (!providerId) {
return { ...scan, providerInfo: undefined };
}
const providerData = included?.find(
(item) => item.type === "providers" && item.id === providerId,
);
if (!providerData) {
return { ...scan, providerInfo: undefined };
}
return {
...scan,
providerInfo: {
provider: providerData.attributes.provider,
uid: providerData.attributes.uid,
alias: providerData.attributes.alias,
},
};
}) || []
);
}
export function ScansTableWithPolling({
initialData,
initialMeta,
searchParams,
}: ScansTableWithPollingProps) {
const [scansData, setScansData] = useState<ScanProps[]>(initialData);
const [meta, setMeta] = useState<MetaDataProps | undefined>(initialMeta);
// Sync state with server data when props change (e.g., pagination or filter changes).
// useState only uses its argument on first mount, so without this effect,
// navigating to page 2 would change the URL but keep showing page 1 data.
useEffect(() => {
setScansData(initialData);
setMeta(initialMeta);
}, [initialData, initialMeta]);
const hasExecutingScan = scansData.some((scan) =>
EXECUTING_STATES.includes(
scan.attributes.state as (typeof EXECUTING_STATES)[number],
),
);
const handleRefresh = useCallback(async () => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const sort = searchParams.sort?.toString();
const filters = Object.fromEntries(
Object.entries(searchParams).filter(
([key]) => key.startsWith("filter[") && key !== "scanId",
),
);
const query = (filters["filter[search]"] as string) || "";
const result = await getScans({
query,
page,
sort,
filters,
pageSize,
include: "provider",
});
if (result?.data) {
const expanded = expandScansWithProviderInfo(
result.data,
result.included,
);
setScansData(expanded);
if (result && "meta" in result) {
setMeta(result.meta as MetaDataProps);
}
}
}, [searchParams]);
// Listen for scan launch events to trigger an immediate refresh
useEffect(() => {
const handler = () => {
handleRefresh();
};
window.addEventListener(SCAN_LAUNCHED_EVENT, handler);
return () => window.removeEventListener(SCAN_LAUNCHED_EVENT, handler);
}, [handleRefresh]);
return (
<>
<AutoRefresh
hasExecutingScan={hasExecutingScan}
onRefresh={handleRefresh}
/>
<DataTable
key={`scans-${scansData.length}-${meta?.pagination?.page}`}
columns={ColumnGetScans}
data={scansData}
metadata={meta}
/>
</>
);
}
@@ -1,64 +0,0 @@
import { Card, CardContent, CardHeader } from "@/components/shadcn";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
export const SkeletonScanDetail = () => {
return (
<div className="flex flex-col gap-6 rounded-lg">
{/* Header Skeleton */}
<div className="flex items-center gap-4">
<Skeleton className="h-8 w-24 rounded-full" />
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex flex-col gap-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</div>
{/* Scan Details Section Skeleton */}
<Card variant="base" padding="lg">
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="flex flex-col gap-4">
{/* First grid row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`grid1-${index}`} className="flex flex-col gap-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-full" />
</div>
))}
</div>
{/* Second grid row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`grid2-${index}`} className="flex flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-full" />
</div>
))}
</div>
{/* Scan ID field */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
{/* Third grid row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`grid3-${index}`} className="flex flex-col gap-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-full" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
};
@@ -1,39 +1,247 @@
import React from "react";
import { Card } from "@/components/shadcn/card/card";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import {
DEFAULT_SCAN_JOBS_TAB,
SCAN_JOBS_TAB,
type ScanJobsTab,
} from "@/types";
const AccountCellSkeleton = () => (
<td className="px-3 py-4">
<div className="flex items-center gap-4">
<Skeleton className="size-9 rounded-xl" />
<div className="flex flex-col gap-1">
<Skeleton className="h-3.5 w-20 rounded" />
<div className="bg-bg-neutral-tertiary border-border-neutral-tertiary flex h-6 w-32 items-center gap-1 rounded-full border-2 px-2">
<Skeleton className="h-3 w-20 rounded" />
<Skeleton className="h-3.5 w-3.5 rounded" />
</div>
</div>
</div>
</td>
);
const ScanInfoCellSkeleton = () => (
<td className="px-3 py-4">
<div className="flex flex-col gap-1">
<Skeleton className="h-3.5 w-32 rounded" />
<div className="bg-bg-neutral-tertiary border-border-neutral-tertiary flex h-6 w-32 items-center gap-1 rounded-full border-2 px-2">
<Skeleton className="h-3 w-20 rounded" />
<Skeleton className="h-3.5 w-3.5 rounded" />
</div>
</div>
</td>
);
const ProgressCellSkeleton = () => (
<td className="px-3 py-4">
<div className="flex min-w-[220px] items-center gap-3">
<Skeleton className="h-2 w-[140px] rounded-full" />
<Skeleton className="h-3.5 w-9 rounded" />
</div>
</td>
);
const ScheduleCellSkeleton = () => (
<td className="px-3 py-4">
<div className="flex flex-col gap-1">
<Skeleton className="h-4 w-20 rounded" />
<Skeleton className="h-3 w-32 rounded" />
</div>
</td>
);
const DateCellSkeleton = () => (
<td className="px-3 py-4">
<div className="flex flex-col gap-1">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-3 w-20 rounded" />
</div>
</td>
);
const ResourcesCellSkeleton = () => (
<td className="px-3 py-4">
<Skeleton className="h-6 w-12 rounded" />
</td>
);
const DurationCellSkeleton = () => (
<td className="px-3 py-4">
<Skeleton className="h-4 w-16 rounded" />
</td>
);
const StatusCellSkeleton = () => (
<td className="px-3 py-4">
<Skeleton className="h-6 w-20 rounded-md" />
</td>
);
const ActionsCellSkeleton = () => (
<td className="px-2 py-4">
<Skeleton className="size-7 rounded" />
</td>
);
const HeaderLabel = ({
width,
sortable = false,
}: {
width: string;
sortable?: boolean;
}) => (
<div className="flex h-8 items-center gap-1">
<Skeleton className={`h-4 ${width} rounded`} />
{sortable && <Skeleton className="size-3.5 rounded" />}
</div>
);
interface ColumnDescriptor {
headerWidth: string;
sortable?: boolean;
Cell: () => React.JSX.Element;
}
const ACCOUNT_COLUMN: ColumnDescriptor = {
headerWidth: "w-14",
Cell: AccountCellSkeleton,
};
const SCAN_INFO_COLUMN: ColumnDescriptor = {
headerWidth: "w-8",
sortable: true,
Cell: ScanInfoCellSkeleton,
};
const PROGRESS_COLUMN: ColumnDescriptor = {
headerWidth: "w-14",
Cell: ProgressCellSkeleton,
};
const SCHEDULE_COLUMN: ColumnDescriptor = {
headerWidth: "w-14",
sortable: true,
Cell: ScheduleCellSkeleton,
};
const LAUNCHED_COLUMN: ColumnDescriptor = {
headerWidth: "w-14",
Cell: DateCellSkeleton,
};
const RESOURCES_COLUMN: ColumnDescriptor = {
headerWidth: "w-16",
Cell: ResourcesCellSkeleton,
};
const DURATION_COLUMN: ColumnDescriptor = {
headerWidth: "w-14",
Cell: DurationCellSkeleton,
};
const STATUS_COLUMN: ColumnDescriptor = {
headerWidth: "w-10",
Cell: StatusCellSkeleton,
};
const COMPLETED_COLUMN: ColumnDescriptor = {
headerWidth: "w-16",
sortable: true,
Cell: DateCellSkeleton,
};
const NEXT_RUN_COLUMN: ColumnDescriptor = {
headerWidth: "w-14",
sortable: true,
Cell: DateCellSkeleton,
};
const COLUMNS_BY_TAB: Record<ScanJobsTab, ColumnDescriptor[]> = {
[SCAN_JOBS_TAB.ACTIVE]: [
ACCOUNT_COLUMN,
SCAN_INFO_COLUMN,
PROGRESS_COLUMN,
SCHEDULE_COLUMN,
LAUNCHED_COLUMN,
],
[SCAN_JOBS_TAB.COMPLETED]: [
ACCOUNT_COLUMN,
SCAN_INFO_COLUMN,
RESOURCES_COLUMN,
DURATION_COLUMN,
STATUS_COLUMN,
SCHEDULE_COLUMN,
COMPLETED_COLUMN,
],
[SCAN_JOBS_TAB.SCHEDULED]: [
ACCOUNT_COLUMN,
SCAN_INFO_COLUMN,
SCHEDULE_COLUMN,
NEXT_RUN_COLUMN,
],
};
interface SkeletonTableScansProps {
tab?: ScanJobsTab;
rows?: number;
}
export const SkeletonTableScans = ({
tab = DEFAULT_SCAN_JOBS_TAB,
rows = 6,
}: SkeletonTableScansProps = {}) => {
const columns = COLUMNS_BY_TAB[tab];
export const SkeletonTableScans = () => {
return (
<Card variant="base" padding="md" className="flex flex-col gap-4">
{/* Table headers */}
<div className="hidden gap-4 md:flex">
<Skeleton className="h-8 w-1/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-2/12" />
<Skeleton className="h-8 w-1/12" />
<Skeleton className="h-8 w-1/12" />
<div className="rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col justify-between gap-4 overflow-hidden border p-4">
{/* Toolbar — mirrors DataTable's flex-col → md:flex-row layout (no search, only total entries) */}
<div className="flex flex-col items-start gap-3 md:flex-row md:items-center md:justify-between">
<div className="w-full md:w-auto" />
<div className="flex w-full flex-col items-start gap-2 md:ml-auto md:w-auto md:flex-row md:items-center md:gap-4">
<Skeleton className="h-4 w-28 rounded" />
</div>
</div>
{/* Table body */}
<div className="flex flex-col gap-3">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="flex flex-col gap-4 md:flex-row md:items-center"
>
<Skeleton className="h-12 w-full md:w-1/12" />
<Skeleton className="h-12 w-full md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-2/12" />
<Skeleton className="hidden h-12 md:block md:w-1/12" />
<Skeleton className="hidden h-12 md:block md:w-1/12" />
{/* Table */}
<table className="w-full">
<thead>
<tr className="border-border-neutral-secondary border-b">
{columns.map((column, i) => (
<th key={i} className="px-3 py-3 text-left">
<HeaderLabel
width={column.headerWidth}
sortable={column.sortable}
/>
</th>
))}
{/* Actions - empty header */}
<th className="w-10 py-3" />
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, rowIdx) => (
<tr
key={rowIdx}
className="border-border-neutral-secondary border-b last:border-b-0"
>
{columns.map(({ Cell }, colIdx) => (
<Cell key={colIdx} />
))}
<ActionsCellSkeleton />
</tr>
))}
</tbody>
</table>
{/* Pagination — mirrors DataTablePagination's "justify-end gap-6 py-1.5" */}
<div className="flex w-full items-center justify-end gap-6 py-1.5">
{/* Rows per page group */}
<div className="flex items-center gap-3">
<Skeleton className="h-3 w-20 rounded" />
<Skeleton className="h-8 w-12 rounded-full" />
</div>
{/* Page info + 4 chevron nav buttons */}
<div className="flex items-center gap-3">
<Skeleton className="hidden h-3 w-20 rounded sm:block" />
<div className="flex items-center gap-3">
<Skeleton className="size-6 rounded" />
<Skeleton className="size-6 rounded" />
<Skeleton className="size-6 rounded" />
<Skeleton className="size-6 rounded" />
</div>
))}
</div>
</div>
</Card>
</div>
);
};
-25
View File
@@ -1,25 +0,0 @@
import { Tooltip } from "@heroui/tooltip";
import { ManualIcon, ScheduleIcon } from "@/components/icons";
interface TriggerIconProps {
trigger: "scheduled" | "manual";
iconSize?: number;
}
export function TriggerIcon({ trigger, iconSize = 24 }: TriggerIconProps) {
return (
<Tooltip
className="text-xs"
content={trigger === "scheduled" ? "Scheduled" : "Manual"}
>
<div className="h-fit">
{trigger === "scheduled" ? (
<ScheduleIcon size={iconSize} />
) : (
<ManualIcon size={iconSize} />
)}
</div>
</Tooltip>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { getScanJobsTab } from "@/components/scans/scans.utils";
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
const ALL_VALUE = "all";
function getFirstFilterValue(value: string | null): string {
return value?.split(",")[0] || ALL_VALUE;
}
export interface UseScansFiltersReturn {
activeTab: ScanJobsTab;
scheduleType: string;
scanStatus: string;
showStatusFilter: boolean;
setTab: (tab: string) => void;
setScheduleType: (value: string) => void;
setScanStatus: (value: string) => void;
}
export function useScansFilters(): UseScansFiltersReturn {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const activeTab = getScanJobsTab(searchParams.get("tab") ?? undefined);
const showStatusFilter = activeTab === SCAN_JOBS_TAB.COMPLETED;
const scheduleType = getFirstFilterValue(searchParams.get("filter[trigger]"));
const scanStatus = getFirstFilterValue(
searchParams.get("filter[state__in]") ?? searchParams.get("filter[state]"),
);
const updateParams = (updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (!value || value === ALL_VALUE) params.delete(key);
else params.set(key, value);
});
params.delete("page");
params.delete("scanId");
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
const setTab = (tab: string) =>
updateParams({
tab,
sort: null,
"filter[state]": null,
"filter[state__in]": null,
});
const setScheduleType = (value: string) =>
updateParams({ "filter[trigger]": value });
const setScanStatus = (value: string) =>
updateParams({ "filter[state]": null, "filter[state__in]": value });
return {
activeTab,
scheduleType,
scanStatus,
showStatusFilter,
setTab,
setScheduleType,
setScanStatus,
};
}
+7 -1
View File
@@ -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: {
@@ -18,6 +18,12 @@ const badgeVariants = cva(
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
tag: "bg-bg-tag border-border-tag text-text-neutral-primary",
success:
"border-transparent bg-bg-pass-secondary text-text-success-primary",
warning:
"border-bg-warning/30 bg-bg-warning-secondary/20 text-text-warning-primary",
error:
"border-transparent bg-bg-fail-secondary text-text-error-primary",
},
},
defaultVariants: {
+67 -1
View File
@@ -3,7 +3,24 @@ import { describe, expect, it } from "vitest";
import { Button } from "./button";
describe("shadcn Button", () => {
describe("Button", () => {
it("uses semibold text for primary buttons", () => {
const { rerender } = render(<Button>Primary</Button>);
expect(screen.getByRole("button", { name: "Primary" })).toHaveClass(
"font-semibold",
);
rerender(<Button variant="outline">Outline</Button>);
expect(screen.getByRole("button", { name: "Outline" })).toHaveClass(
"font-medium",
);
expect(screen.getByRole("button", { name: "Outline" })).not.toHaveClass(
"font-semibold",
);
});
it("supports extra-small link buttons", () => {
render(
<Button variant="link" size="link-xs">
@@ -15,4 +32,53 @@ describe("shadcn Button", () => {
"text-xs",
);
});
it("applies the shared press and reduced-motion contract to button-like variants", () => {
// Given
render(<Button>Start scan</Button>);
// When
const button = screen.getByRole("button", { name: "Start scan" });
// Then
expect(button).toHaveClass(
"transition-[background-color,border-color,color,box-shadow,transform,scale]",
"duration-150",
"ease-out",
"active:scale-[0.98]",
"motion-reduce:active:scale-100",
"motion-reduce:transform-none",
"motion-reduce:transition-none",
);
expect(button).not.toHaveClass("transition-all");
});
it("keeps link buttons from scaling on press", () => {
// Given
render(<Button variant="link">Open details</Button>);
// When
const button = screen.getByRole("button", { name: "Open details" });
// Then
expect(button).toHaveClass("active:scale-100");
expect(button).not.toHaveClass("active:scale-[0.98]");
});
it("keeps menu buttons on the shared targeted transition recipe", () => {
// Given
render(<Button variant="menu">Open menu</Button>);
// When
const button = screen.getByRole("button", { name: "Open menu" });
// Then
expect(button).toHaveClass(
"transition-[background-color,border-color,color,box-shadow,transform,scale]",
"duration-200",
"active:scale-[0.98]",
"motion-reduce:active:scale-100",
);
expect(button).not.toHaveClass("transition-all");
});
});
+9 -9
View File
@@ -5,12 +5,12 @@ import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[8px] text-sm font-medium transition-[background-color,border-color,color,box-shadow,transform,scale] duration-150 ease-out active:scale-[0.98] disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary motion-reduce:active:scale-100 motion-reduce:transform-none motion-reduce:transition-none outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"border border-transparent bg-button-primary text-black hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50",
"border border-transparent bg-button-primary text-black font-semibold hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50",
secondary:
"border border-transparent bg-button-secondary text-white hover:bg-button-secondary/90 active:bg-button-secondary-press focus-visible:ring-button-secondary/50 dark:text-black",
tertiary:
@@ -21,19 +21,19 @@ const buttonVariants = cva(
"border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary text-text-neutral-primary focus-visible:ring-border-neutral-tertiary/50",
ghost:
"border border-transparent text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover active:scale-100 disabled:bg-transparent",
// Menu variant like secondary but more padding and the back is almost transparent
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 focus-visible:ring-button-primary/50 duration-200",
"menu-active":
"backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
"backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 focus-visible:ring-button-primary/50 duration-200",
"menu-inactive":
"text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-border-neutral-secondary/50 transition-all duration-200",
"text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 focus-visible:ring-border-neutral-secondary/50 duration-200",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
xl: "h-12 rounded-md px-8 text-base has-[>svg]:px-6",
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 px-6 has-[>svg]:px-4",
xl: "h-12 px-8 text-base has-[>svg]:px-6",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { Checkbox } from "./checkbox";
function ControlledCheckbox() {
const [checked, setChecked] = useState(false);
return (
<Checkbox
aria-label="Select provider"
checked={checked}
onCheckedChange={(value) => setChecked(value === true)}
/>
);
}
describe("Checkbox", () => {
it("animates the background and check mark as one state change", async () => {
// Given - A controlled checkbox in the unchecked state
const user = userEvent.setup();
render(<ControlledCheckbox />);
const checkbox = screen.getByRole("checkbox", { name: /select provider/i });
const indicator = checkbox.querySelector(
"[data-slot='checkbox-indicator']",
);
// When - The user checks the checkbox
await user.click(checkbox);
// Then - The background and check mark transitions use the same timing
expect(checkbox).toHaveClass(
"transition-colors",
"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",
);
});
});

Some files were not shown because too many files have changed in this diff Show More