mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-09 21:04:53 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20b93388ec | |||
| 0fb0807a22 | |||
| 753a4eda62 | |||
| f4051d52d9 | |||
| adbe67d2f3 | |||
| 65c0425729 | |||
| 5828cce644 | |||
| 87bd2e78a1 | |||
| ccae4afe68 | |||
| 0e2bb99f02 | |||
| 8fb59682d5 | |||
| 799f062ee0 | |||
| 51945f5cc5 | |||
| b93e3f9d04 | |||
| ef4d05a782 | |||
| 7185e539c8 | |||
| 74251350bc |
@@ -12,135 +12,139 @@ Use these skills for detailed patterns on-demand:
|
||||
|
||||
### Generic Skills (Any Project)
|
||||
|
||||
| Skill | Description | URL |
|
||||
|-------|-------------|-----|
|
||||
| `typescript` | Const types, flat interfaces, utility types | [SKILL.md](skills/typescript/SKILL.md) |
|
||||
| `react-19` | No useMemo/useCallback, React Compiler | [SKILL.md](skills/react-19/SKILL.md) |
|
||||
| `nextjs-16` | App Router, Server Actions, proxy.ts, streaming | [SKILL.md](skills/nextjs-16/SKILL.md) |
|
||||
| `tailwind-4` | cn() utility, no var() in className | [SKILL.md](skills/tailwind-4/SKILL.md) |
|
||||
| `playwright` | Page Object Model, MCP workflow, selectors | [SKILL.md](skills/playwright/SKILL.md) |
|
||||
| `pytest` | Fixtures, mocking, markers, parametrize | [SKILL.md](skills/pytest/SKILL.md) |
|
||||
| `django-drf` | ViewSets, Serializers, Filters | [SKILL.md](skills/django-drf/SKILL.md) |
|
||||
| `jsonapi` | Strict JSON:API v1.1 spec compliance | [SKILL.md](skills/jsonapi/SKILL.md) |
|
||||
| `zod-4` | New API (z.email(), z.uuid()) | [SKILL.md](skills/zod-4/SKILL.md) |
|
||||
| `zustand-5` | Persist, selectors, slices | [SKILL.md](skills/zustand-5/SKILL.md) |
|
||||
| `ai-sdk-5` | UIMessage, streaming, LangChain | [SKILL.md](skills/ai-sdk-5/SKILL.md) |
|
||||
| `vitest` | Unit testing, React Testing Library | [SKILL.md](skills/vitest/SKILL.md) |
|
||||
| `tdd` | Test-Driven Development workflow | [SKILL.md](skills/tdd/SKILL.md) |
|
||||
| Skill | Description | URL |
|
||||
| ------------ | ----------------------------------------------- | -------------------------------------- |
|
||||
| `typescript` | Const types, flat interfaces, utility types | [SKILL.md](skills/typescript/SKILL.md) |
|
||||
| `react-19` | No useMemo/useCallback, React Compiler | [SKILL.md](skills/react-19/SKILL.md) |
|
||||
| `nextjs-16` | App Router, Server Actions, proxy.ts, streaming | [SKILL.md](skills/nextjs-16/SKILL.md) |
|
||||
| `tailwind-4` | cn() utility, no var() in className | [SKILL.md](skills/tailwind-4/SKILL.md) |
|
||||
| `playwright` | Page Object Model, MCP workflow, selectors | [SKILL.md](skills/playwright/SKILL.md) |
|
||||
| `pytest` | Fixtures, mocking, markers, parametrize | [SKILL.md](skills/pytest/SKILL.md) |
|
||||
| `django-drf` | ViewSets, Serializers, Filters | [SKILL.md](skills/django-drf/SKILL.md) |
|
||||
| `jsonapi` | Strict JSON:API v1.1 spec compliance | [SKILL.md](skills/jsonapi/SKILL.md) |
|
||||
| `zod-4` | New API (z.email(), z.uuid()) | [SKILL.md](skills/zod-4/SKILL.md) |
|
||||
| `zustand-5` | Persist, selectors, slices | [SKILL.md](skills/zustand-5/SKILL.md) |
|
||||
| `ai-sdk-5` | UIMessage, streaming, LangChain | [SKILL.md](skills/ai-sdk-5/SKILL.md) |
|
||||
| `vitest` | Unit testing, React Testing Library | [SKILL.md](skills/vitest/SKILL.md) |
|
||||
| `tdd` | Test-Driven Development workflow | [SKILL.md](skills/tdd/SKILL.md) |
|
||||
|
||||
### Prowler-Specific Skills
|
||||
|
||||
| Skill | Description | URL |
|
||||
|-------|-------------|-----|
|
||||
| `prowler` | Project overview, component navigation | [SKILL.md](skills/prowler/SKILL.md) |
|
||||
| `prowler-api` | Django + RLS + JSON:API patterns | [SKILL.md](skills/prowler-api/SKILL.md) |
|
||||
| `prowler-ui` | Next.js + shadcn conventions | [SKILL.md](skills/prowler-ui/SKILL.md) |
|
||||
| `prowler-sdk-check` | Create new security checks | [SKILL.md](skills/prowler-sdk-check/SKILL.md) |
|
||||
| `prowler-mcp` | MCP server tools and models | [SKILL.md](skills/prowler-mcp/SKILL.md) |
|
||||
| `prowler-test-sdk` | SDK testing (pytest + moto) | [SKILL.md](skills/prowler-test-sdk/SKILL.md) |
|
||||
| `prowler-test-api` | API testing (pytest-django + RLS) | [SKILL.md](skills/prowler-test-api/SKILL.md) |
|
||||
| `prowler-test-ui` | E2E testing (Playwright) | [SKILL.md](skills/prowler-test-ui/SKILL.md) |
|
||||
| `prowler-compliance` | Compliance framework structure | [SKILL.md](skills/prowler-compliance/SKILL.md) |
|
||||
| `prowler-compliance-review` | Review compliance framework PRs | [SKILL.md](skills/prowler-compliance-review/SKILL.md) |
|
||||
| `prowler-provider` | Add new cloud providers | [SKILL.md](skills/prowler-provider/SKILL.md) |
|
||||
| `prowler-changelog` | Changelog entries (keepachangelog.com) | [SKILL.md](skills/prowler-changelog/SKILL.md) |
|
||||
| `prowler-ci` | CI checks and PR gates (GitHub Actions) | [SKILL.md](skills/prowler-ci/SKILL.md) |
|
||||
| `prowler-commit` | Professional commits (conventional-commits) | [SKILL.md](skills/prowler-commit/SKILL.md) |
|
||||
| `prowler-pr` | Pull request conventions | [SKILL.md](skills/prowler-pr/SKILL.md) |
|
||||
| `prowler-docs` | Documentation style guide | [SKILL.md](skills/prowler-docs/SKILL.md) |
|
||||
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
|
||||
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
|
||||
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
|
||||
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
|
||||
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
|
||||
| Skill | Description | URL |
|
||||
| ---------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| `prowler` | Project overview, component navigation | [SKILL.md](skills/prowler/SKILL.md) |
|
||||
| `prowler-api` | Django + RLS + JSON:API patterns | [SKILL.md](skills/prowler-api/SKILL.md) |
|
||||
| `prowler-ui` | Next.js + shadcn conventions | [SKILL.md](skills/prowler-ui/SKILL.md) |
|
||||
| `prowler-ui-motion` | shadcn/Radix visible microinteraction conventions | [SKILL.md](skills/prowler-ui-motion/SKILL.md) |
|
||||
| `prowler-ui-skeletons` | shadcn skeleton loading and content reveal conventions | [SKILL.md](skills/prowler-ui-skeletons/SKILL.md) |
|
||||
| `prowler-sdk-check` | Create new security checks | [SKILL.md](skills/prowler-sdk-check/SKILL.md) |
|
||||
| `prowler-mcp` | MCP server tools and models | [SKILL.md](skills/prowler-mcp/SKILL.md) |
|
||||
| `prowler-test-sdk` | SDK testing (pytest + moto) | [SKILL.md](skills/prowler-test-sdk/SKILL.md) |
|
||||
| `prowler-test-api` | API testing (pytest-django + RLS) | [SKILL.md](skills/prowler-test-api/SKILL.md) |
|
||||
| `prowler-test-ui` | E2E testing (Playwright) | [SKILL.md](skills/prowler-test-ui/SKILL.md) |
|
||||
| `prowler-compliance` | Compliance framework structure | [SKILL.md](skills/prowler-compliance/SKILL.md) |
|
||||
| `prowler-compliance-review` | Review compliance framework PRs | [SKILL.md](skills/prowler-compliance-review/SKILL.md) |
|
||||
| `prowler-provider` | Add new cloud providers | [SKILL.md](skills/prowler-provider/SKILL.md) |
|
||||
| `prowler-changelog` | Changelog entries (keepachangelog.com) | [SKILL.md](skills/prowler-changelog/SKILL.md) |
|
||||
| `prowler-ci` | CI checks and PR gates (GitHub Actions) | [SKILL.md](skills/prowler-ci/SKILL.md) |
|
||||
| `prowler-commit` | Professional commits (conventional-commits) | [SKILL.md](skills/prowler-commit/SKILL.md) |
|
||||
| `prowler-pr` | Pull request conventions | [SKILL.md](skills/prowler-pr/SKILL.md) |
|
||||
| `prowler-docs` | Documentation style guide | [SKILL.md](skills/prowler-docs/SKILL.md) |
|
||||
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
|
||||
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
|
||||
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
|
||||
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
|
||||
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
|
||||
|
||||
### Auto-invoke Skills
|
||||
|
||||
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|
||||
| Action | Skill |
|
||||
|--------|-------|
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
|
||||
| Adding indexes or constraints to database tables | `django-migration-psql` |
|
||||
| Adding new providers | `prowler-provider` |
|
||||
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
|
||||
| Adding services to existing providers | `prowler-provider` |
|
||||
| After creating/modifying a skill | `skill-sync` |
|
||||
| App Router / Server Actions | `nextjs-16` |
|
||||
| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Configuring MCP servers in agentic workflows | `gh-aw` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
| Create a PR with gh pr create | `prowler-pr` |
|
||||
| Creating API endpoints | `jsonapi` |
|
||||
| Creating Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Creating GitHub Agentic Workflows | `gh-aw` |
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating new checks | `prowler-sdk-check` |
|
||||
| Creating new skills | `skill-creator` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
| Creating/modifying Prowler UI components | `prowler-ui` |
|
||||
| Creating/modifying models, views, serializers | `prowler-api` |
|
||||
| Creating/updating compliance frameworks | `prowler-compliance` |
|
||||
| Debug why a GitHub Actions job is failing | `prowler-ci` |
|
||||
| Debugging gh-aw compilation errors | `gh-aw` |
|
||||
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` |
|
||||
| General Prowler development questions | `prowler` |
|
||||
| Implementing JSON:API endpoints | `django-drf` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Importing Copilot Custom Agents into workflows | `gh-aw` |
|
||||
| Inspect PR CI checks and gates (.github/workflows/*) | `prowler-ci` |
|
||||
| Inspect PR CI workflows (.github/workflows/*): conventional-commit, pr-check-changelog, pr-conflict-checker, labeler | `prowler-pr` |
|
||||
| Mapping checks to compliance controls | `prowler-compliance` |
|
||||
| Mocking AWS with moto in tests | `prowler-test-sdk` |
|
||||
| Modifying API responses | `jsonapi` |
|
||||
| Modifying component | `tdd` |
|
||||
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
|
||||
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Reviewing JSON:API compliance | `jsonapi` |
|
||||
| Reviewing compliance framework PRs | `prowler-compliance-review` |
|
||||
| Running makemigrations or pgmakemigrations | `django-migration-psql` |
|
||||
| Syncing compliance framework with upstream catalog | `prowler-compliance` |
|
||||
| Testing RLS tenant isolation | `prowler-test-api` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Troubleshoot why a skill is missing from AGENTS.md auto-invoke | `skill-sync` |
|
||||
| Understand CODEOWNERS/labeler-based automation | `prowler-ci` |
|
||||
| Understand PR title conventional-commit validation | `prowler-ci` |
|
||||
| Understand changelog gate and no-changelog label behavior | `prowler-ci` |
|
||||
| Understand review ownership with CODEOWNERS | `prowler-pr` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Updating README.md provider statistics table | `prowler-readme-table` |
|
||||
| Updating checks, services, compliance, or categories count in README.md | `prowler-readme-table` |
|
||||
| Updating existing Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Updating existing checks and metadata | `prowler-sdk-check` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
| Working on MCP server tools | `prowler-mcp` |
|
||||
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
|
||||
| Working on task | `tdd` |
|
||||
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
|
||||
| Working with Tailwind classes | `tailwind-4` |
|
||||
| Writing Playwright E2E tests | `playwright` |
|
||||
| Writing Prowler API tests | `prowler-test-api` |
|
||||
| Writing Prowler SDK tests | `prowler-test-sdk` |
|
||||
| Writing Prowler UI E2E tests | `prowler-test-ui` |
|
||||
| Writing Python tests with pytest | `pytest` |
|
||||
| Writing React component tests | `vitest` |
|
||||
| Writing React components | `react-19` |
|
||||
| Writing TypeScript types/interfaces | `typescript` |
|
||||
| Writing Vitest tests | `vitest` |
|
||||
| Writing data backfill or data migration | `django-migration-psql` |
|
||||
| Writing documentation | `prowler-docs` |
|
||||
| Writing unit tests for UI | `vitest` |
|
||||
| Action | Skill |
|
||||
| --------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
|
||||
| Adding indexes or constraints to database tables | `django-migration-psql` |
|
||||
| Adding new providers | `prowler-provider` |
|
||||
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
|
||||
| Adding services to existing providers | `prowler-provider` |
|
||||
| After creating/modifying a skill | `skill-sync` |
|
||||
| App Router / Server Actions | `nextjs-16` |
|
||||
| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Configuring MCP servers in agentic workflows | `gh-aw` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
| Create a PR with gh pr create | `prowler-pr` |
|
||||
| Creating API endpoints | `jsonapi` |
|
||||
| Creating Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Creating GitHub Agentic Workflows | `gh-aw` |
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating new checks | `prowler-sdk-check` |
|
||||
| Creating new skills | `skill-creator` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
| Creating/modifying Prowler UI components | `prowler-ui` |
|
||||
| Creating/modifying UI motion, transitions, or microinteractions | `prowler-ui-motion` |
|
||||
| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` |
|
||||
| Creating/modifying models, views, serializers | `prowler-api` |
|
||||
| Creating/updating compliance frameworks | `prowler-compliance` |
|
||||
| Debug why a GitHub Actions job is failing | `prowler-ci` |
|
||||
| Debugging gh-aw compilation errors | `gh-aw` |
|
||||
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` |
|
||||
| General Prowler development questions | `prowler` |
|
||||
| Implementing JSON:API endpoints | `django-drf` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Importing Copilot Custom Agents into workflows | `gh-aw` |
|
||||
| Inspect PR CI checks and gates (.github/workflows/\*) | `prowler-ci` |
|
||||
| Inspect PR CI workflows (.github/workflows/\*): conventional-commit, pr-check-changelog, pr-conflict-checker, labeler | `prowler-pr` |
|
||||
| Mapping checks to compliance controls | `prowler-compliance` |
|
||||
| Mocking AWS with moto in tests | `prowler-test-sdk` |
|
||||
| Modifying API responses | `jsonapi` |
|
||||
| Modifying component | `tdd` |
|
||||
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
|
||||
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Reviewing JSON:API compliance | `jsonapi` |
|
||||
| Reviewing compliance framework PRs | `prowler-compliance-review` |
|
||||
| Running makemigrations or pgmakemigrations | `django-migration-psql` |
|
||||
| Syncing compliance framework with upstream catalog | `prowler-compliance` |
|
||||
| Testing RLS tenant isolation | `prowler-test-api` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Troubleshoot why a skill is missing from AGENTS.md auto-invoke | `skill-sync` |
|
||||
| Understand CODEOWNERS/labeler-based automation | `prowler-ci` |
|
||||
| Understand PR title conventional-commit validation | `prowler-ci` |
|
||||
| Understand changelog gate and no-changelog label behavior | `prowler-ci` |
|
||||
| Understand review ownership with CODEOWNERS | `prowler-pr` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Updating README.md provider statistics table | `prowler-readme-table` |
|
||||
| Updating checks, services, compliance, or categories count in README.md | `prowler-readme-table` |
|
||||
| Updating existing Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Updating existing checks and metadata | `prowler-sdk-check` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
| Working on MCP server tools | `prowler-mcp` |
|
||||
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
|
||||
| Working on task | `tdd` |
|
||||
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
|
||||
| Working with Tailwind classes | `tailwind-4` |
|
||||
| Writing Playwright E2E tests | `playwright` |
|
||||
| Writing Prowler API tests | `prowler-test-api` |
|
||||
| Writing Prowler SDK tests | `prowler-test-sdk` |
|
||||
| Writing Prowler UI E2E tests | `prowler-test-ui` |
|
||||
| Writing Python tests with pytest | `pytest` |
|
||||
| Writing React component tests | `vitest` |
|
||||
| Writing React components | `react-19` |
|
||||
| Writing TypeScript types/interfaces | `typescript` |
|
||||
| Writing Vitest tests | `vitest` |
|
||||
| Writing data backfill or data migration | `django-migration-psql` |
|
||||
| Writing documentation | `prowler-docs` |
|
||||
| Writing unit tests for UI | `vitest` |
|
||||
|
||||
---
|
||||
|
||||
@@ -148,13 +152,13 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|
||||
Prowler is an open-source cloud security assessment tool supporting AWS, Azure, GCP, Kubernetes, GitHub, M365, and more.
|
||||
|
||||
| Component | Location | Tech Stack |
|
||||
|-----------|----------|------------|
|
||||
| SDK | `prowler/` | Python 3.10+, uv |
|
||||
| API | `api/` | Django 5.1, DRF, Celery |
|
||||
| UI | `ui/` | Next.js 16, React 19, Tailwind 4 |
|
||||
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
|
||||
| Dashboard | `dashboard/` | Dash, Plotly |
|
||||
| Component | Location | Tech Stack |
|
||||
| ---------- | ------------- | -------------------------------- |
|
||||
| SDK | `prowler/` | Python 3.10+, uv |
|
||||
| API | `api/` | Django 5.1, DRF, Celery |
|
||||
| UI | `ui/` | Next.js 16, React 19, Tailwind 4 |
|
||||
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
|
||||
| Dashboard | `dashboard/` | Dash, Plotly |
|
||||
|
||||
---
|
||||
|
||||
@@ -180,6 +184,7 @@ Follow conventional-commit style: `<type>[scope]: <description>`
|
||||
**Types:** `feat`, `fix`, `docs`, `chore`, `perf`, `refactor`, `style`, `test`
|
||||
|
||||
Before creating a PR:
|
||||
|
||||
1. Complete checklist in `.github/pull_request_template.md`
|
||||
2. Run all relevant tests and linters
|
||||
3. Link screenshots for UI changes
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: prowler-ui-motion
|
||||
description: >
|
||||
Prowler UI visible microinteraction rules for shadcn primitives, forms, tabs, expandable rows, status states, and motion QA.
|
||||
Trigger: Creating/modifying UI motion, transitions, animations, microinteractions, Radix/shadcn primitives, or interactive table rows.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
version: "1.0"
|
||||
scope: [root, ui]
|
||||
auto_invoke:
|
||||
- "Creating/modifying UI motion"
|
||||
- "Creating/modifying microinteractions"
|
||||
- "Creating/modifying shadcn primitives"
|
||||
- "Creating/modifying expandable rows"
|
||||
---
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill before adding or changing visible UI motion in Prowler: shadcn/Radix primitives, form controls, overlays, tabs, expandable rows, table affordances, status badges, progress, spinners, skeleton handoffs, and icon-only actions.
|
||||
|
||||
## Critical Patterns
|
||||
|
||||
- Prefer shadcn/Tailwind motion; do not add new HeroUI motion surfaces.
|
||||
- Motion must be visible, not theoretical: use durations humans can perceive (`200ms–700ms` depending on scope).
|
||||
- Preserve Radix state semantics; animate via classes, force-mounted indicators, `data-state`, or `asChild` wrappers without breaking accessibility.
|
||||
- Always include `motion-reduce` behavior for transform/animation-heavy changes.
|
||||
- Opening and closing must both animate when the component supports unmount/exit behavior.
|
||||
- Keep row/background transitions separate from control/checkmark transitions; do not couple table selection backgrounds to checkbox internals.
|
||||
- Avoid feature-local motion copies when a shared primitive owns the interaction.
|
||||
- For skeleton/loading handoffs, load `prowler-ui-skeletons` and follow its boundary/reveal rules.
|
||||
|
||||
## Decision Gates
|
||||
|
||||
| Surface | Required motion contract |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| Dialog, Drawer, Popover, Dropdown, Select, Combobox | Enter and exit motion; preserve focus/portal behavior. |
|
||||
| Tabs | Content switch should fade/slide; inactive panels must not flash. |
|
||||
| Checkbox, Radio, pills, badges | Background/icon/content state changes should transition together. |
|
||||
| Input, SearchInput, Textarea, Dropzone | Focus border, clear button, placeholder/selection affordances need visible timing. |
|
||||
| Collapsible, Tree, expandable table row | Expand and collapse must both animate height/opacity/chevron. |
|
||||
| StatusBadge, Progress, Spinner | State/color/value changes should feel smooth and respect reduced motion. |
|
||||
| Data table affordances | Row hover/selection may animate, but do not break table layout semantics. |
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. Identify whether the interaction belongs to a shared primitive or a feature-local surface.
|
||||
2. Add the smallest shared motion primitive that covers the behavior.
|
||||
3. Verify enter and exit paths, including closed/unmounted states.
|
||||
4. Add focused unit tests for shared primitives or reusable motion contracts.
|
||||
5. Provide at least one real UI route/flow where the user can visually test the motion.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cd ui && pnpm run typecheck
|
||||
cd ui && pnpm test:unit <focused-test-files>
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- `skills/prowler-ui-skeletons/SKILL.md` — skeleton scanner and content reveal rules.
|
||||
- `skills/prowler-ui/SKILL.md` — component placement and shadcn vs HeroUI rules.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: prowler-ui-skeletons
|
||||
description: "Trigger: skeleton, loading state, Suspense fallback, content reveal, shimmer. Use Prowler shadcn skeletons correctly."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
version: "1.0"
|
||||
scope: [root, ui]
|
||||
auto_invoke:
|
||||
- "Creating/modifying skeletons"
|
||||
- "Creating/modifying loading states"
|
||||
- "Adding Suspense fallbacks"
|
||||
---
|
||||
|
||||
## Activation Contract
|
||||
|
||||
Use this skill before creating or modifying any Prowler UI skeleton, loading placeholder, Suspense fallback, or loading-to-content transition.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Prefer shadcn `Skeleton` from `@/components/shadcn`; do not add new HeroUI skeletons.
|
||||
- Do not mix HeroUI and shadcn inside the same new loading surface.
|
||||
- Keep scanner/shimmer behavior centralized in shadcn `Skeleton`; never duplicate scanner CSS in feature files.
|
||||
- For Suspense data loading, wrap the boundary with `SkeletonBoundary` so fallback removal and real content reveal are paired.
|
||||
- For client-state loading (`isLoading`, drawers, modals, expanded rows), add a reveal wrapper around the resolved content, not around the skeleton.
|
||||
- Respect `motion-reduce`; every animation must degrade to no transform/transition.
|
||||
- Preserve layout stability: skeleton dimensions must match the final content as closely as practical.
|
||||
- Do not migrate legacy/HeroUI skeletons unless the task explicitly includes that migration.
|
||||
|
||||
## Decision Gates
|
||||
|
||||
| Situation | Action |
|
||||
| --- | --- |
|
||||
| Page/server data with `Suspense` fallback | Use `SkeletonBoundary` with the skeleton fallback. |
|
||||
| Nested Suspense inside tab/chart content | Use `SkeletonBoundary` unless the fallback is legacy/HeroUI. |
|
||||
| Client state swaps skeleton to content | Keep shadcn `Skeleton`; wrap resolved content with `SkeletonContentReveal` or an equivalent shared reveal. |
|
||||
| Existing HeroUI skeleton | Leave unchanged unless migration is explicitly requested. |
|
||||
| Text-only `Loading...` fallback | Replace only if the requested scope includes that surface. |
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. Identify whether the skeleton is shadcn, HeroUI legacy, or text-only fallback.
|
||||
2. If shadcn + Suspense, use `SkeletonBoundary` instead of raw `Suspense`.
|
||||
3. If shadcn + client state, keep the skeleton fallback and reveal only the loaded content.
|
||||
4. Verify reduced-motion classes remain present.
|
||||
5. Add or update focused tests when changing shared skeleton primitives or reusable boundaries.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report:
|
||||
- Which loading surfaces changed.
|
||||
- Whether each surface is Suspense-boundary or client-state loading.
|
||||
- Which legacy/HeroUI skeletons were intentionally left untouched.
|
||||
- Test/typecheck evidence when implementation changes are made.
|
||||
|
||||
## References
|
||||
|
||||
- `ui/components/shadcn/skeleton/skeleton.tsx`
|
||||
- `ui/components/shadcn/skeleton/skeleton-boundary.tsx`
|
||||
- `ui/components/shadcn/skeleton/skeleton-content-reveal.tsx`
|
||||
+32
-29
@@ -3,6 +3,8 @@
|
||||
> **Skills Reference**: For detailed patterns, use these skills:
|
||||
>
|
||||
> - [`prowler-ui`](../skills/prowler-ui/SKILL.md) - Prowler-specific UI patterns
|
||||
> - [`prowler-ui-motion`](../skills/prowler-ui-motion/SKILL.md) - shadcn/Radix visible microinteraction conventions
|
||||
> - [`prowler-ui-skeletons`](../skills/prowler-ui-skeletons/SKILL.md) - shadcn skeleton loading and content reveal conventions
|
||||
> - [`prowler-test-ui`](../skills/prowler-test-ui/SKILL.md) - Playwright E2E testing (comprehensive)
|
||||
> - [`typescript`](../skills/typescript/SKILL.md) - Const types, flat interfaces
|
||||
> - [`react-19`](../skills/react-19/SKILL.md) - No useMemo/useCallback, compiler
|
||||
@@ -19,35 +21,36 @@
|
||||
|
||||
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|
||||
| Action | Skill |
|
||||
| -------------------------------------------------------------- | ------------------- |
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| App Router / Server Actions | `nextjs-16` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating/modifying Prowler UI components | `prowler-ui` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Modifying component | `tdd` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
|
||||
| Working on task | `tdd` |
|
||||
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
|
||||
| Working with Tailwind classes | `tailwind-4` |
|
||||
| Writing Playwright E2E tests | `playwright` |
|
||||
| Writing Prowler UI E2E tests | `prowler-test-ui` |
|
||||
| Writing React component tests | `vitest` |
|
||||
| Writing React components | `react-19` |
|
||||
| Writing TypeScript types/interfaces | `typescript` |
|
||||
| Writing Vitest tests | `vitest` |
|
||||
| Writing unit tests for UI | `vitest` |
|
||||
| Action | Skill |
|
||||
| ------------------------------------------------------------------- | ---------------------- |
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| App Router / Server Actions | `nextjs-16` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating/modifying Prowler UI components | `prowler-ui` |
|
||||
| Creating/modifying skeletons, loading states, or Suspense fallbacks | `prowler-ui-skeletons` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Modifying component | `tdd` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
|
||||
| Working on task | `tdd` |
|
||||
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
|
||||
| Working with Tailwind classes | `tailwind-4` |
|
||||
| Writing Playwright E2E tests | `playwright` |
|
||||
| Writing Prowler UI E2E tests | `prowler-test-ui` |
|
||||
| Writing React component tests | `vitest` |
|
||||
| Writing React components | `react-19` |
|
||||
| Writing TypeScript types/interfaces | `typescript` |
|
||||
| Writing Vitest tests | `vitest` |
|
||||
| Writing unit tests for UI | `vitest` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
export * from "./poll";
|
||||
export * from "./task.adapter";
|
||||
export * from "./tasks";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>,
|
||||
];
|
||||
}),
|
||||
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Info } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
getComplianceOverviewMetadataInfo,
|
||||
@@ -16,6 +15,7 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com
|
||||
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { SkeletonBoundary } from "@/components/shadcn/skeleton/skeleton-boundary";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
|
||||
import {
|
||||
@@ -156,7 +156,7 @@ export default async function Compliance({
|
||||
)}
|
||||
|
||||
{/* Row 3: Compliance grid with client-side search */}
|
||||
<Suspense
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={
|
||||
<ComplianceOverviewPanel>
|
||||
@@ -169,7 +169,7 @@ export default async function Compliance({
|
||||
scanId={selectedScanId}
|
||||
selectedScan={selectedScanData}
|
||||
/>
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</>
|
||||
) : (
|
||||
<NoScansAvailable />
|
||||
|
||||
@@ -1,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 || [];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getInvitations } from "@/actions/invitations/invitation";
|
||||
import { getRoles } from "@/actions/roles";
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
ColumnsInvitation,
|
||||
SkeletonTableInvitation,
|
||||
} from "@/components/invitations/table";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Button, SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { InvitationProps, Role, SearchParamsProps } from "@/types";
|
||||
@@ -39,9 +38,12 @@ export default async function Invitations({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableInvitation />}>
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={<SkeletonTableInvitation />}
|
||||
>
|
||||
<SSRDataTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/actions/processors";
|
||||
import { Button, Card, Skeleton } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { fontMono } from "@/config/fonts";
|
||||
@@ -187,111 +188,114 @@ export function AdvancedMutelistForm() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Card variant="base" className="p-6">
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
{config && <input type="hidden" name="id" value={config.id} />}
|
||||
<SkeletonContentReveal>
|
||||
<Card variant="base" className="p-6">
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
{config && <input type="hidden" name="id" value={config.id} />}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-default-700 mb-2 text-lg font-semibold">
|
||||
Advanced Mutelist Configuration
|
||||
</h3>
|
||||
<ul className="text-default-600 mb-4 list-disc pl-5 text-sm">
|
||||
<li>
|
||||
<strong>
|
||||
This Mutelist configuration will take effect on the next
|
||||
scan.
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Use this for pattern-based muting with wildcards, regions, and
|
||||
tags.
|
||||
</li>
|
||||
<li>
|
||||
Learn more about configuring the Mutelist{" "}
|
||||
<CustomLink
|
||||
size="sm"
|
||||
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-mute-findings"
|
||||
>
|
||||
here
|
||||
</CustomLink>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
A default Mutelist is used to exclude certain predefined
|
||||
resources if no Mutelist is provided.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="configuration"
|
||||
className="text-default-700 text-sm font-medium"
|
||||
>
|
||||
Mutelist Configuration (YAML)
|
||||
</label>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Textarea
|
||||
id="configuration"
|
||||
name="configuration"
|
||||
placeholder={defaultMutedFindingsConfig}
|
||||
variant="bordered"
|
||||
value={configText}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
minRows={20}
|
||||
maxRows={20}
|
||||
isInvalid={
|
||||
(!hasUserStartedTyping && !!state?.errors?.configuration) ||
|
||||
!yamlValidation.isValid
|
||||
}
|
||||
errorMessage={
|
||||
(!hasUserStartedTyping && state?.errors?.configuration) ||
|
||||
(!yamlValidation.isValid ? yamlValidation.error : "")
|
||||
}
|
||||
classNames={{
|
||||
input: fontMono.className + " text-sm",
|
||||
base: "min-h-[400px]",
|
||||
errorMessage: "whitespace-pre-wrap",
|
||||
}}
|
||||
/>
|
||||
{yamlValidation.isValid &&
|
||||
configText &&
|
||||
hasUserStartedTyping && (
|
||||
<div className="text-tiny text-success my-1 flex items-center px-1">
|
||||
<span>Valid YAML format</span>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-default-700 mb-2 text-lg font-semibold">
|
||||
Advanced Mutelist Configuration
|
||||
</h3>
|
||||
<ul className="text-default-600 mb-4 list-disc pl-5 text-sm">
|
||||
<li>
|
||||
<strong>
|
||||
This Mutelist configuration will take effect on the next
|
||||
scan.
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Use this for pattern-based muting with wildcards, regions,
|
||||
and tags.
|
||||
</li>
|
||||
<li>
|
||||
Learn more about configuring the Mutelist{" "}
|
||||
<CustomLink
|
||||
size="sm"
|
||||
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-mute-findings"
|
||||
>
|
||||
here
|
||||
</CustomLink>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
A default Mutelist is used to exclude certain predefined
|
||||
resources if no Mutelist is provided.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="configuration"
|
||||
className="text-default-700 text-sm font-medium"
|
||||
>
|
||||
Mutelist Configuration (YAML)
|
||||
</label>
|
||||
<div>
|
||||
<Textarea
|
||||
id="configuration"
|
||||
name="configuration"
|
||||
placeholder={defaultMutedFindingsConfig}
|
||||
variant="bordered"
|
||||
value={configText}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
minRows={20}
|
||||
maxRows={20}
|
||||
isInvalid={
|
||||
(!hasUserStartedTyping &&
|
||||
!!state?.errors?.configuration) ||
|
||||
!yamlValidation.isValid
|
||||
}
|
||||
errorMessage={
|
||||
(!hasUserStartedTyping && state?.errors?.configuration) ||
|
||||
(!yamlValidation.isValid ? yamlValidation.error : "")
|
||||
}
|
||||
classNames={{
|
||||
input: fontMono.className + " text-sm",
|
||||
base: "min-h-[400px]",
|
||||
errorMessage: "whitespace-pre-wrap",
|
||||
}}
|
||||
/>
|
||||
{yamlValidation.isValid &&
|
||||
configText &&
|
||||
hasUserStartedTyping && (
|
||||
<div className="text-tiny text-success my-1 flex items-center px-1">
|
||||
<span>Valid YAML format</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
{config && (
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
{config && (
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Delete Configuration"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setShowDeleteConfirmation(true)}
|
||||
disabled={isPending || isDeleting}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Delete Configuration"
|
||||
variant="outline"
|
||||
type="submit"
|
||||
size="lg"
|
||||
onClick={() => setShowDeleteConfirmation(true)}
|
||||
disabled={isPending || isDeleting}
|
||||
disabled={
|
||||
isPending || !yamlValidation.isValid || !configText.trim()
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
{isPending ? "Saving..." : config ? "Update" : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={
|
||||
isPending || !yamlValidation.isValid || !configText.trim()
|
||||
}
|
||||
>
|
||||
{isPending ? "Saving..." : config ? "Update" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</SkeletonContentReveal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { SkeletonBoundary } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
|
||||
@@ -18,9 +17,12 @@ export default async function MutelistPage({
|
||||
<ContentLayout title="Mutelist" icon="lucide:volume-x">
|
||||
<MutelistTabs
|
||||
simpleContent={
|
||||
<Suspense key={searchParamsKey} fallback={<MuteRulesTableSkeleton />}>
|
||||
<SkeletonBoundary
|
||||
key={searchParamsKey}
|
||||
fallback={<MuteRulesTableSkeleton />}
|
||||
>
|
||||
<MuteRulesTable searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</SkeletonBoundary>
|
||||
}
|
||||
/>
|
||||
</ContentLayout>
|
||||
|
||||
+32
-26
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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't prepare this mute action.
|
||||
@@ -190,9 +191,9 @@ export function MuteFindingsModal({
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</SkeletonContentReveal>
|
||||
) : (
|
||||
<>
|
||||
<SkeletonContentReveal>
|
||||
<div className="space-y-4">
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
|
||||
<p className="text-text-neutral-tertiary text-xs font-medium tracking-[0.08em] uppercase">
|
||||
@@ -315,7 +316,7 @@ export function MuteFindingsModal({
|
||||
submitText="Mute Findings"
|
||||
isDisabled={isPending}
|
||||
/>
|
||||
</>
|
||||
</SkeletonContentReveal>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomBanner } from "@/components/ui/custom/custom-banner";
|
||||
import { Form, FormField, FormMessage } from "@/components/ui/form";
|
||||
@@ -270,151 +271,152 @@ export const SendToJiraModal = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration Selection */}
|
||||
{!isFetchingIntegrations && integrations.length > 1 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="integration"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-integration-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Jira Integration
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-integration-select"
|
||||
options={integrationOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset dependent fields
|
||||
form.setValue("project", "");
|
||||
form.setValue("issueType", "");
|
||||
setFetchedIssueTypes({});
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira integration"
|
||||
searchable={true}
|
||||
emptyIndicator="No integrations found."
|
||||
disabled={isFetchingIntegrations}
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
{!isFetchingIntegrations && (
|
||||
<SkeletonContentReveal className="flex flex-col gap-4">
|
||||
{/* Integration Selection */}
|
||||
{integrations.length > 1 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="integration"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-integration-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Jira Integration
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-integration-select"
|
||||
options={integrationOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset dependent fields
|
||||
form.setValue("project", "");
|
||||
form.setValue("issueType", "");
|
||||
setFetchedIssueTypes({});
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira integration"
|
||||
searchable={true}
|
||||
emptyIndicator="No integrations found."
|
||||
disabled={isFetchingIntegrations}
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Project Selection */}
|
||||
{!isFetchingIntegrations &&
|
||||
selectedIntegration &&
|
||||
projectEntries.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="project"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-project-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Project
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-project-select"
|
||||
options={projectOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset issue type when project changes
|
||||
form.setValue("issueType", "");
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira project"
|
||||
searchable={true}
|
||||
emptyIndicator="No projects found."
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Issue Type Selection */}
|
||||
{selectedProject && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issueType"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-issue-type-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Issue Type
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-issue-type-select"
|
||||
options={issueTypeOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder={
|
||||
isFetchingIssueTypes
|
||||
? "Loading issue types..."
|
||||
: "Select an issue type"
|
||||
}
|
||||
searchable={true}
|
||||
emptyIndicator="No issue types found."
|
||||
disabled={isFetchingIssueTypes}
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
{/* Project Selection */}
|
||||
{selectedIntegration && projectEntries.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="project"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-project-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Project
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-project-select"
|
||||
options={projectOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset issue type when project changes
|
||||
form.setValue("issueType", "");
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira project"
|
||||
searchable={true}
|
||||
emptyIndicator="No projects found."
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No integrations or none connected message */}
|
||||
{!isFetchingIntegrations &&
|
||||
(integrations.length === 0 || !hasConnectedIntegration) ? (
|
||||
<CustomBanner
|
||||
title="Jira integration is not available"
|
||||
message="Please add or connect an integration first"
|
||||
buttonLabel="Configure"
|
||||
buttonLink="/integrations/jira"
|
||||
/>
|
||||
) : (
|
||||
<FormButtons
|
||||
setIsOpen={setOpenForFormButtons}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
submitText="Send to Jira"
|
||||
cancelText="Cancel"
|
||||
loadingText="Sending..."
|
||||
isDisabled={
|
||||
!form.formState.isValid ||
|
||||
form.formState.isSubmitting ||
|
||||
isFetchingIntegrations ||
|
||||
isFetchingIssueTypes ||
|
||||
integrations.length === 0 ||
|
||||
!hasConnectedIntegration
|
||||
}
|
||||
rightIcon={<Send size={20} />}
|
||||
/>
|
||||
{/* Issue Type Selection */}
|
||||
{selectedProject && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issueType"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-issue-type-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Issue Type
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-issue-type-select"
|
||||
options={issueTypeOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder={
|
||||
isFetchingIssueTypes
|
||||
? "Loading issue types..."
|
||||
: "Select an issue type"
|
||||
}
|
||||
searchable={true}
|
||||
emptyIndicator="No issue types found."
|
||||
disabled={isFetchingIssueTypes}
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No integrations or none connected message */}
|
||||
{integrations.length === 0 || !hasConnectedIntegration ? (
|
||||
<CustomBanner
|
||||
title="Jira integration is not available"
|
||||
message="Please add or connect an integration first"
|
||||
buttonLabel="Configure"
|
||||
buttonLink="/integrations/jira"
|
||||
/>
|
||||
) : (
|
||||
<FormButtons
|
||||
setIsOpen={setOpenForFormButtons}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
submitText="Send to Jira"
|
||||
cancelText="Cancel"
|
||||
loadingText="Sending..."
|
||||
isDisabled={
|
||||
!form.formState.isValid ||
|
||||
form.formState.isSubmitting ||
|
||||
isFetchingIntegrations ||
|
||||
isFetchingIssueTypes ||
|
||||
integrations.length === 0 ||
|
||||
!hasConnectedIntegration
|
||||
}
|
||||
rightIcon={<Send size={20} />}
|
||||
/>
|
||||
)}
|
||||
</SkeletonContentReveal>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -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}
|
||||
|
||||
+3
-2
@@ -37,6 +37,7 @@ import {
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { SkeletonContentReveal } from "@/components/shadcn/skeleton/skeleton-content-reveal";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -494,7 +495,7 @@ export function ResourceDetailDrawerContent({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
<SkeletonContentReveal className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
||||
{/* Mute modal — rendered outside drawer content to avoid overlay conflicts */}
|
||||
{f && !f.isMuted && (
|
||||
<MuteFindingsModal
|
||||
@@ -1339,7 +1340,7 @@ export function ResourceDetailDrawerContent({
|
||||
Analyze This Finding With Lighthouse AI
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</SkeletonContentReveal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user