diff --git a/.claude/agents/README.md b/.claude/agents/README.md deleted file mode 100644 index 1d17db99106d..000000000000 --- a/.claude/agents/README.md +++ /dev/null @@ -1,325 +0,0 @@ -# Review Agents for DotCMS Core - -This directory contains **reusable specialized agents** for code review. These agents can be invoked by the `/review` skill or used independently for targeted reviews. - -**Location**: `.claude/agents/` (project-level, shared across all skills) - -**Key Point**: These are **independent workers**, not part of the review skill. Any skill or workflow can use them. - -## Available Agents - -### 🟠 File Classifier -**File**: `file-classifier.md` -**Model**: Sonnet -**Focus**: PR file triage and classification - -**Responsibilities**: -- Fetches PR metadata and diff -- Classifies every changed file by domain -- Maps files to the appropriate reviewer agents -- Calculates frontend vs non-frontend ratio -- Returns a structured file map for the orchestrator - -**Used as**: First step in the review pipeline, before launching specialized reviewers. - -### 🔷 TypeScript Type Reviewer -**File**: `typescript-reviewer.md` -**Model**: Sonnet -**Focus**: TypeScript type system, generics, null safety - -**Reviews**: -- Type safety violations (no `any`, proper generics) -- Null handling (optional chaining, nullish coalescing) -- Interface design and type quality -- Type guards and runtime safety -- Function signatures and return types - -**Confidence threshold**: ≥ 75 -**Excludes**: `.spec.ts` files (handled by test-reviewer) - -### 🟣 Angular Pattern Reviewer -**File**: `angular-reviewer.md` -**Model**: Sonnet -**Focus**: Angular framework patterns, modern syntax, architecture - -**Reviews**: -- Modern Angular syntax (`@if`, `@for`, `input()`, `output()`) -- Component architecture (standalone, prefix, change detection) -- Template patterns (safe navigation, trackBy, data-testid) -- Lifecycle management (subscription cleanup, signals) -- Service patterns (providedIn, signal stores) -- Import patterns and circular dependencies -- SCSS standards (variables, BEM) - -**Confidence threshold**: ≥ 75 -**Excludes**: `.spec.ts` files (handled by test-reviewer) - -### 🟢 Test Quality Reviewer -**File**: `test-reviewer.md` -**Model**: Sonnet -**Focus**: Test patterns, Spectator usage, coverage - -**Reviews**: -- Spectator patterns (`setInput()`, `detectChanges()`, `data-testid`) -- Test structure (AAA pattern, describe blocks, naming) -- Mock quality (proper mocking, reusable factories) -- Test coverage (critical paths, edge cases, errors) -- Async handling (fakeAsync, async/await) -- Common mistakes (test independence, assertions) - -**Confidence threshold**: ≥ 75 -**Only reviews**: `*.spec.ts` files - -## How They Work Together - -### Pipeline Execution - -The review skill follows a two-phase pipeline: - -```typescript -// Phase 1: File classification (single agent) -const fileMap = await Task( - subagent_type="file-classifier", - prompt="Classify PR #34553 files by domain", - description="Classify PR files" -); - -// Phase 2: Specialized review (parallel agents, only if REVIEW decision) -if (fileMap.decision === "REVIEW") { - const [typeResults, angularResults, testResults] = await Promise.all([ - Task( - subagent_type="typescript-reviewer", - prompt="Review TypeScript types for PR #34553. Files: ", - description="TypeScript review" - ), - Task( - subagent_type="angular-reviewer", - prompt="Review Angular patterns for PR #34553. Files: ", - description="Angular review" - ), - Task( - subagent_type="test-reviewer", - prompt="Review test quality for PR #34553. Files: ", - description="Test review" - ) - ]); - - // Consolidate results - mergeAndDeduplicateFindings(typeResults, angularResults, testResults); -} -``` - -### Non-Overlapping Domains - -Each agent has a **clear, exclusive focus**: - -| Concern | TypeScript Reviewer | Angular Reviewer | Test Reviewer | -|---------|-------------------|------------------|---------------| -| Type safety (`any`, generics) | ✅ | ❌ | ❌ | -| Angular syntax (`@if`, `input()`) | ❌ | ✅ | ❌ | -| Spectator patterns | ❌ | ❌ | ✅ | -| Null safety | ✅ | ❌ | ❌ | -| Component structure | ❌ | ✅ | ❌ | -| Test coverage | ❌ | ❌ | ✅ | -| Subscriptions | ❌ | ✅ | ❌ | -| Mock quality | ❌ | ❌ | ✅ | - -This prevents duplicate findings and ensures expert-level review in each domain. - -## Issue Severity Levels - -All agents use the same confidence scoring system: - -- **95-100** 🔴 **Critical**: Must fix before merge (security, breaks functionality, wrong patterns) -- **85-94** 🟡 **Important**: Should address (performance, memory leaks, poor patterns) -- **75-84** 🔵 **Quality**: Nice to have (improvements, optimizations, clarity) -- **< 75**: Not reported (too minor or uncertain) - -**Only issues with confidence ≥ 75 are reported.** - -## Agent Permissions & Tools - -Each agent has **pre-approved permissions** via `allowed-tools` in their frontmatter: - -```yaml -# Example: angular-reviewer.md -allowed-tools: - - Bash(gh pr diff:*) - - Bash(gh pr view:*) - - Read(core-web/**) - - Read(docs/frontend/**) - - Grep(*.ts) - - Grep(*.html) - - Glob(core-web/**) -``` - -**Critical**: Agents use **dedicated tools** (Glob, Grep, Read) instead of Bash commands with pipes: - -```bash -# ✅ CORRECT: Use dedicated tools -Glob('core-web/**/*.component.ts') -Read(core-web/libs/portlets/my-component.ts) -Grep('@if', path='core-web/', glob='*.html') - -# ❌ WRONG: Don't use git diff with pipes -# git diff main --name-only | grep -E '\.ts' | grep -v '\.spec\.ts' -``` - -**Why?** Bash pipes require complex permissions that trigger repeated user prompts. Dedicated tools have granular, pre-approved permissions. - -## Using Agents Independently - -While the main `review` skill orchestrates these agents automatically, you can also invoke them directly for focused reviews using their **registered agent types**: - -### TypeScript-Only Review -```bash -Task( - subagent_type="typescript-reviewer", - prompt="Review TypeScript types for PR #34553", - description="TypeScript type review" -) -``` - -### Angular-Only Review -```bash -Task( - subagent_type="angular-reviewer", - prompt="Review Angular patterns for PR #34553", - description="Angular pattern review" -) -``` - -### Test-Only Review -```bash -Task( - subagent_type="test-reviewer", - prompt="Review test quality for PR #34553", - description="Test quality review" -) -``` - -## Agent Output Format - -Each agent returns findings in this structure: - -```markdown -# [Agent Name] Review - -## Files Analyzed -- path/to/file1.ts (45 lines) -- path/to/file2.ts (23 lines) - -## Critical Issues 🔴 (95-100) -[Detailed findings with file paths, line numbers, code examples, fixes] - -## Important Issues 🟡 (85-94) -[Detailed findings...] - -## Quality Issues 🔵 (75-84) -[Detailed findings...] - -## Summary -- Critical: X -- Important: Y -- Quality: Z - -Recommendation: [Approve/Approve with Comments/Request Changes] -``` - -## Consolidation Strategy - -The main review skill consolidates agent outputs: - -1. **Collect** all agent findings -2. **Merge** by severity level (Critical, Important, Quality) -3. **Deduplicate** - If multiple agents flag the same line, keep highest confidence -4. **Organize** by domain section (TypeScript Types, Angular Patterns, Tests) -5. **Calculate** overall statistics -6. **Recommend** final approval status - -## Benefits of Specialized Agents - -✅ **Expertise**: Each agent is an expert in its domain -✅ **Efficiency**: Parallel execution reviews faster -✅ **Clarity**: Clear separation of concerns -✅ **No Duplicates**: Non-overlapping domains prevent redundant findings -✅ **Confidence**: Each agent deeply understands its focus area -✅ **Reusability**: Can be invoked independently when needed - -## Extending the System - -To add a new specialized agent: - -1. Create `agents/your-agent-reviewer.md` with: - - Clear mission and scope - - Non-overlapping domain - - Issue confidence scoring - - Output format matching others - - Self-validation checklist - -2. Update `SKILL.md` Stage 4 to launch the new agent - -3. Update this README with the new agent description - -4. Test with sample PRs to ensure no overlap with existing agents - -## Examples - -### Frontend PR with All Domains -``` -Files changed: -- 3 .component.ts files -- 2 .html templates -- 4 .spec.ts files - -Agents launched: -✅ typescript-reviewer → Reviews .component.ts for types -✅ angular-reviewer → Reviews .component.ts + .html for patterns -✅ test-reviewer → Reviews .spec.ts for test quality - -Result: Comprehensive review with 3 specialized sections -``` - -### Pure TypeScript Utility PR -``` -Files changed: -- 2 .util.ts files (no Angular, no tests) - -Agents launched: -✅ typescript-reviewer → Reviews type safety only - -Result: Focused type safety review, no Angular/test sections -``` - -### Test-Only PR -``` -Files changed: -- 5 .spec.ts files (test updates only) - -Agents launched: -✅ test-reviewer → Reviews test quality only - -Result: Focused test quality review, no type/pattern sections -``` - -## Troubleshooting - -**Agent reports issues outside its domain**: -- Review agent's "What NOT to Flag" section -- Ensure agent's scope is clear in the prompt -- Update agent's self-validation checklist - -**Duplicate findings across agents**: -- Check "Non-Overlapping Domains" table -- One agent may need refined scope -- Consolidation step should catch and merge - -**Low confidence scores**: -- Agent may need more reference examples -- Consider expanding pattern library -- Check if issue is too subjective - -**Agent too strict/lenient**: -- Adjust confidence scoring rubric -- Review "Red Flags" vs "What NOT to Flag" -- Calibrate threshold (currently 75) diff --git a/.claude/agents/angular-reviewer.md b/.claude/agents/dotcms-angular-reviewer.md similarity index 96% rename from .claude/agents/angular-reviewer.md rename to .claude/agents/dotcms-angular-reviewer.md index 3f397791ae43..299f6455902c 100644 --- a/.claude/agents/angular-reviewer.md +++ b/.claude/agents/dotcms-angular-reviewer.md @@ -1,5 +1,5 @@ --- -name: angular-reviewer +name: dotcms-angular-reviewer description: Angular patterns specialist. Use proactively after writing or modifying Angular components, services, or templates to ensure modern syntax and best practices. Focuses on Angular framework patterns without checking TypeScript types or tests. model: sonnet color: purple @@ -68,7 +68,7 @@ Analyze these Angular files from the PR diff: - `.html` template files - `.scss` style files -**Exclude**: `.spec.ts` files (handled by test-reviewer) +**Exclude**: `.spec.ts` files (handled by dotcms-test-reviewer) ## Core Review Areas @@ -457,8 +457,8 @@ ngOnInit() { ## What NOT to Flag **Pre-existing legacy code** - Only flag new code using legacy patterns -**Type safety issues** - `any`, generics, null checks (typescript-reviewer handles this) -**Test patterns** - Spectator usage, test structure (test-reviewer handles this) +**Type safety issues** - `any`, generics, null checks (dotcms-typescript-reviewer handles this) +**Test patterns** - Spectator usage, test structure (dotcms-test-reviewer handles this) **Non-Angular files** - Pure TypeScript utilities, models **Intentional legacy usage** - Code updating existing legacy components (not new code) @@ -474,7 +474,7 @@ ngOnInit() { ## Integration with Main Review You are invoked by the main `review` skill when Angular files are changed. You work alongside: -- `typescript-reviewer` - Handles type safety and TypeScript quality -- `test-reviewer` - Handles test patterns and coverage +- `dotcms-typescript-reviewer` - Handles type safety and TypeScript quality +- `dotcms-test-reviewer` - Handles test patterns and coverage Your output is merged into the final review under "Angular Patterns" section. diff --git a/.claude/agents/code-researcher.md b/.claude/agents/dotcms-code-researcher.md similarity index 99% rename from .claude/agents/code-researcher.md rename to .claude/agents/dotcms-code-researcher.md index 8272c804c270..cc0fdd4cc482 100644 --- a/.claude/agents/code-researcher.md +++ b/.claude/agents/dotcms-code-researcher.md @@ -1,5 +1,5 @@ --- -name: code-researcher +name: dotcms-code-researcher description: Researches the dotCMS codebase to produce a technical briefing for a GitHub issue. Identifies entry points, call chains, likely bug locations, relevant files, test gaps, and suspicious git commits. Output is used by both human triagers and downstream AI code agents. model: sonnet color: blue diff --git a/.claude/agents/duplicate-detector.md b/.claude/agents/dotcms-duplicate-detector.md similarity index 98% rename from .claude/agents/duplicate-detector.md rename to .claude/agents/dotcms-duplicate-detector.md index 8a00e1119eca..eca615421ff5 100644 --- a/.claude/agents/duplicate-detector.md +++ b/.claude/agents/dotcms-duplicate-detector.md @@ -1,5 +1,5 @@ --- -name: duplicate-detector +name: dotcms-duplicate-detector description: Detects duplicate or related GitHub issues by searching existing issues, PRs, and git history. Returns DUPLICATE, RELATED, or NO MATCH with references. model: haiku color: orange diff --git a/.claude/agents/file-classifier.md b/.claude/agents/dotcms-file-classifier.md similarity index 89% rename from .claude/agents/file-classifier.md rename to .claude/agents/dotcms-file-classifier.md index e04e241a6fe4..7e2fed06c14b 100644 --- a/.claude/agents/file-classifier.md +++ b/.claude/agents/dotcms-file-classifier.md @@ -1,5 +1,5 @@ --- -name: file-classifier +name: dotcms-file-classifier description: PR file classifier. Fetches a PR diff, classifies changed files by domain (Angular, TypeScript, tests, styles), and returns a structured mapping of which reviewers should analyze which files. Use as the first step before launching review agents. model: sonnet color: orange @@ -42,11 +42,11 @@ Assign each file to **one or more** reviewer buckets based on its path and exten | Bucket | File Patterns | Reviewer Agent | |--------|--------------|----------------| -| **angular** | `*.component.ts`, `*.component.html`, `*.component.scss`, `*.directive.ts`, `*.pipe.ts`, `*.service.ts` (in Angular libs/apps) | `angular-reviewer` | -| **typescript** | `*.ts` (excluding `*.spec.ts`, excluding Angular-specific files above) | `typescript-reviewer` | -| **test** | `*.spec.ts` | `test-reviewer` | -| **style** | `*.scss`, `*.css` (standalone, not `.component.scss`) | `angular-reviewer` (SCSS section) | -| **template** | `*.html` (standalone, not `.component.html`) | `angular-reviewer` (template section) | +| **angular** | `*.component.ts`, `*.component.html`, `*.component.scss`, `*.directive.ts`, `*.pipe.ts`, `*.service.ts` (in Angular libs/apps) | `dotcms-angular-reviewer` | +| **typescript** | `*.ts` (excluding `*.spec.ts`, excluding Angular-specific files above) | `dotcms-typescript-reviewer` | +| **test** | `*.spec.ts` | `dotcms-test-reviewer` | +| **style** | `*.scss`, `*.css` (standalone, not `.component.scss`) | `dotcms-angular-reviewer` (SCSS section) | +| **template** | `*.html` (standalone, not `.component.html`) | `dotcms-angular-reviewer` (template section) | | **out-of-scope** | `*.java`, `*.xml`, `*.json`, `*.md`, `*.yml`, `*.yaml`, `*.sh`, `Dockerfile`, `*.properties`, `*.vtl`, images, etc. | None (skip) | #### Classification Rules @@ -98,7 +98,7 @@ Even when skipping, still return the full classification so the orchestrator can ## File Map -### angular-reviewer +### dotcms-angular-reviewer Files for Angular pattern review: | File | Lines Changed | Type | @@ -107,7 +107,7 @@ Files for Angular pattern review: | `path/to/file.component.html` | +15 -5 | template | | `path/to/file.service.ts` | +20 -0 | service | -### typescript-reviewer +### dotcms-typescript-reviewer Files for TypeScript type safety review: | File | Lines Changed | Type | @@ -116,7 +116,7 @@ Files for TypeScript type safety review: | `path/to/utils.ts` | +50 -20 | utility | | `path/to/model.ts` | +10 -0 | model | -### test-reviewer +### dotcms-test-reviewer Files for test quality review: | File | Lines Changed | Type | @@ -133,9 +133,9 @@ Files excluded from frontend review: | `package.json` | +2 -1 | Config file | ## Summary -- **angular-reviewer**: files () -- **typescript-reviewer**: files () -- **test-reviewer**: files () +- **dotcms-angular-reviewer**: files () +- **dotcms-typescript-reviewer**: files () +- **dotcms-test-reviewer**: files () - **out-of-scope**: files ``` @@ -166,15 +166,15 @@ Still classify all files. The orchestrator decides what to do: - **Decision**: REVIEW - **Reason**: All changes are test files -### test-reviewer +### dotcms-test-reviewer | File | Lines Changed | Type | |------|--------------|------| | ... | ... | unit test | -### angular-reviewer +### dotcms-angular-reviewer _No files to review._ -### typescript-reviewer +### dotcms-typescript-reviewer _No files to review._ ``` diff --git a/.claude/agents/issue-validator.md b/.claude/agents/dotcms-issue-validator.md similarity index 98% rename from .claude/agents/issue-validator.md rename to .claude/agents/dotcms-issue-validator.md index 9700cd26ff2b..31a9d6aecc42 100644 --- a/.claude/agents/issue-validator.md +++ b/.claude/agents/dotcms-issue-validator.md @@ -1,5 +1,5 @@ --- -name: issue-validator +name: dotcms-issue-validator description: Validates GitHub issue completeness for triage. Checks if an issue has enough information to act on. Returns a structured report with a completeness score, status (SUFFICIENT or NEEDS_INFO), and a list of what is missing. model: haiku color: yellow diff --git a/.claude/agents/dotcms-scss-html-style-reviewer.md b/.claude/agents/dotcms-scss-html-style-reviewer.md new file mode 100644 index 000000000000..5b24988f794f --- /dev/null +++ b/.claude/agents/dotcms-scss-html-style-reviewer.md @@ -0,0 +1,170 @@ +--- +name: dotcms-scss-html-style-reviewer +description: "Use this agent when recently modified SCSS/CSS and HTML files need to be reviewed for styling standards compliance. This agent should be triggered after the dotcms-file-classifier agent has identified changed files, particularly when those files include Angular component styles (.scss), global stylesheets (.css), or HTML templates (.html). It verifies adherence to the project's styling standards defined in docs/frontend/STYLING_STANDARDS.md, checks for unused custom classes, and ensures BEM conventions and dotCMS UI standards are properly followed.\\n\\n\\nContext: The user has just written or modified Angular component files including SCSS and HTML, and the dotcms-file-classifier agent has already identified the changed files.\\nuser: \"I just updated the content-type card component with new styles\"\\nassistant: \"Let me use the dotcms-file-classifier agent first to identify the changed files, and then I'll launch the dotcms-scss-html-style-reviewer agent to verify the styling changes.\"\\n\\nAfter the dotcms-file-classifier agent returns the list of changed SCSS/HTML files, use the Task tool to launch the dotcms-scss-html-style-reviewer agent with those files to ensure styling standards compliance.\\n\\nassistant: \"Now let me use the Task tool to launch the dotcms-scss-html-style-reviewer agent with the identified SCSS and HTML files.\"\\n\\n\\n\\nContext: A developer has created a new Angular component with custom styles and wants to verify compliance before committing.\\nuser: \"Can you review the styles in my new sidebar component?\"\\nassistant: \"I'll use the Task tool to launch the dotcms-scss-html-style-reviewer agent to analyze the SCSS and HTML files in your sidebar component for styling standards compliance.\"\\n\\nSince the user is explicitly requesting a style review, use the dotcms-scss-html-style-reviewer agent immediately via the Task tool.\\n\\n\\n\\n\\nContext: The dotcms-file-classifier agent has returned a list of changed files that includes .scss and .html files among others.\\nuser: \"Review all the changes I just made\"\\nassistant: \"The dotcms-file-classifier has identified SCSS and HTML changes. Let me launch the dotcms-scss-html-style-reviewer agent to check the styling standards compliance.\"\\n\\nWhen dotcms-file-classifier output contains .scss, .css, or .html files, proactively use the Task tool to launch the dotcms-scss-html-style-reviewer agent.\\n\\n" +tools: Bash, Glob, Grep, Read, WebFetch, WebSearch, ListMcpResourcesTool, ReadMcpResourceTool +model: sonnet +color: orange +memory: project +--- + +You are an expert Angular/SCSS frontend code reviewer specializing in dotCMS styling standards, BEM methodology, and modern CSS architecture. You have deep expertise in SCSS best practices, PrimeNG component theming, CSS custom properties, and Angular component encapsulation patterns. Your mission is to perform thorough, actionable code reviews on SCSS/CSS and HTML template files to ensure they strictly comply with the project's styling standards documented in `docs/frontend/STYLING_STANDARDS.md`. + +## Core Responsibilities + +1. **Read the Styling Standards First**: Always start by reading `docs/frontend/STYLING_STANDARDS.md` using the Read tool to get the latest and authoritative styling rules before performing any review. + +2. **Receive Files from dotcms-file-classifier**: You work with the output of the dotcms-file-classifier agent. Focus ONLY on changed/new `.scss`, `.css`, and `.html` files provided to you. Do not review the entire codebase unless explicitly instructed. + +3. **Perform Comprehensive Style Review**: Analyze each file against the standards, identifying all violations, warnings, and recommendations. + +## Review Checklist + +### BEM Methodology Compliance +- Verify all custom CSS classes follow BEM naming convention (`block__element--modifier`) +- Check that block names are meaningful and describe the component purpose +- Ensure modifiers are used correctly and not overused +- Flag any class names that don't follow BEM (camelCase classes, arbitrary names, etc.) + +### Unused Custom Classes Detection +- Cross-reference every custom CSS class defined in `.scss`/`.css` files against their usage in corresponding `.html` templates +- Flag classes defined in SCSS that are never applied in any HTML template within the same component +- Flag classes applied in HTML that have no corresponding SCSS definition (if custom classes are expected to be styled) +- Check for dead code: rules that exist but are never triggered + +### CSS Custom Properties (Variables) +- Verify that colors, spacing, typography, and other design tokens use CSS custom properties (`var(--variable-name)`) instead of hardcoded values +- Flag any hardcoded hex colors, pixel values for spacing that should use variables, or font families not using variables +- Ensure custom properties follow the project's naming conventions from the standards doc + +### SCSS-Specific Standards +- Check nesting depth (flag if exceeding the limit defined in standards, typically 3-4 levels) +- Verify `@use` and `@forward` are used instead of deprecated `@import` +- Check for unnecessary `!important` declarations +- Validate mixin usage and ensure mixins from the design system are preferred over custom implementations +- Flag `&` selector misuse or overly complex selectors + +### Angular Component Encapsulation +- Verify `::ng-deep` is avoided or used only with proper justification and `:host` scoping +- Check that `:host` is used appropriately for host element styling +- Ensure component styles don't inadvertently bleed into child components + +### PrimeNG Integration +- Verify PrimeNG component overrides use the correct theming approach (CSS custom properties over deep selectors) +- Check that PrimeNG class overrides follow the project's established patterns + +### HTML Template Style Application +- Check that `class` bindings use the correct Angular syntax (`[class.modifier]="condition"` or `[ngClass]`) +- Flag inline `style` attributes (should be replaced with dynamic class bindings in most cases) +- Verify conditional class application follows Angular best practices +- Check for overly complex class strings that should be refactored + +### General Best Practices +- No hardcoded colors, sizes, or spacing outside of CSS variables +- Responsive design using the project's established breakpoint mixins/variables +- Accessibility: check for focus styles, color contrast considerations +- No vendor prefixes that are no longer needed +- No commented-out code blocks + +## Output Format + +Provide your review in this structured format: + +### 📋 Review Summary +- **Files Reviewed**: List all files analyzed +- **Overall Status**: ✅ PASS | ⚠️ WARNINGS | ❌ FAIL +- **Critical Issues**: Count of blocking issues +- **Warnings**: Count of non-blocking issues +- **Suggestions**: Count of optional improvements + +### ❌ Critical Issues (Must Fix) +For each critical issue: +``` +File: path/to/file.scss (line X) +Issue: [Clear description of the violation] +Standard Violated: [Reference to specific rule in STYLING_STANDARDS.md] +Current Code: + .myCustomClass { color: #FF0000; } ← Hardcoded color +Required Fix: + .my-custom-class { color: var(--color-danger); } ← Use CSS variable + BEM +``` + +### ⚠️ Warnings (Should Fix) +Same format as critical issues but for non-blocking violations. + +### 💡 Suggestions (Consider Improving) +Optional improvements for code quality, maintainability, or consistency. + +### 🔍 Unused Classes Report +List all classes found in SCSS that are not used in HTML templates: +``` +Defined but Unused in SCSS: +- .my-component__unused-element (my-component.component.scss:45) + +Applied in HTML but Missing SCSS Definition: +- .my-component__undefined-style (my-component.component.html:23) +``` + +### ✅ What Was Done Well +Acknowledge positive patterns and good practices observed. + +## Workflow + +1. Read `docs/frontend/STYLING_STANDARDS.md` with the Read tool +2. If file paths are provided, read each file with the Read tool +3. For each SCSS/CSS file, build a map of all defined classes and selectors +4. For each HTML file in the same component, build a map of all applied classes +5. Cross-reference to find unused or undefined custom classes +6. Apply all checklist items systematically +7. Generate the structured report + +## Important Rules + +- **Be specific**: Always include file name, line number, current code, and required fix +- **Reference standards**: Always cite which rule in `STYLING_STANDARDS.md` is being violated +- **Focus on changed files only**: Do not review files not provided by the dotcms-file-classifier agent +- **Distinguish custom from framework classes**: PrimeNG, Angular Material, and utility classes from the design system are NOT subject to BEM/unused checks — only custom project-specific classes +- **Context awareness**: If a class is defined in a shared/global stylesheet and used across multiple components, do not flag it as unused just because it's absent in one component's HTML +- **No false positives**: If you're uncertain whether a class is used (e.g., dynamically constructed class names), flag it as a warning rather than a critical issue, and explain why + +**Update your agent memory** as you discover recurring styling patterns, common violations, component-specific conventions, and architectural decisions about how styles are organized in this codebase. This builds up institutional knowledge across conversations. + +Examples of what to record: +- Common BEM patterns used across components +- CSS custom property naming conventions specific to this project +- Components that legitimately use `::ng-deep` and why +- Shared style files and their intended usage patterns +- Recurring mistakes found in reviews (to prioritize in future reviews) + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `/Users/arcadioquintero/Work/core/.claude/agent-memory/scss-html-style-reviewer/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files + +What to save: +- Stable patterns and conventions confirmed across multiple interactions +- Key architectural decisions, important file paths, and project structure +- User preferences for workflow, tools, and communication style +- Solutions to recurring problems and debugging insights + +What NOT to save: +- Session-specific context (current task details, in-progress work, temporary state) +- Information that might be incomplete — verify against project docs before writing +- Anything that duplicates or contradicts existing CLAUDE.md instructions +- Speculative or unverified conclusions from reading a single file + +Explicit user requests: +- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions +- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/agents/team-router.md b/.claude/agents/dotcms-team-router.md similarity index 86% rename from .claude/agents/team-router.md rename to .claude/agents/dotcms-team-router.md index da3d95e48e88..5ac13248ce64 100644 --- a/.claude/agents/team-router.md +++ b/.claude/agents/dotcms-team-router.md @@ -1,6 +1,6 @@ --- -name: team-router -description: Determines team ownership for a GitHub issue by running git blame on files provided by the code-researcher and matching the commit author against the triage config. +name: dotcms-team-router +description: Determines team ownership for a GitHub issue by running git blame on files provided by the dotcms-code-researcher and matching the commit author against the triage config. model: haiku color: green allowed-tools: @@ -10,7 +10,7 @@ allowed-tools: maxTurns: 5 --- -You are a **Team Router**. You receive a list of files already identified by the code-researcher. Your only job is to git blame them and determine team ownership. +You are a **Team Router**. You receive a list of files already identified by the dotcms-code-researcher. Your only job is to git blame them and determine team ownership. Do NOT search for files — they are provided in the input. diff --git a/.claude/agents/test-reviewer.md b/.claude/agents/dotcms-test-reviewer.md similarity index 97% rename from .claude/agents/test-reviewer.md rename to .claude/agents/dotcms-test-reviewer.md index 17aa5bec01e9..784700be90ed 100644 --- a/.claude/agents/test-reviewer.md +++ b/.claude/agents/dotcms-test-reviewer.md @@ -1,5 +1,5 @@ --- -name: test-reviewer +name: dotcms-test-reviewer description: Test quality specialist. Use proactively after writing or modifying test files to ensure proper Spectator patterns, coverage, and test quality. Focuses exclusively on .spec.ts files and testing patterns without reviewing production code. model: sonnet color: green @@ -522,8 +522,8 @@ it('should create component with default form values', () => { ## What NOT to Flag **Pre-existing test issues** - Only flag issues in changed test lines -**Component logic issues** - Component structure, Angular patterns (angular-reviewer handles this) -**Type safety in tests** - Type issues in test files (typescript-reviewer can help, but lower priority) +**Component logic issues** - Component structure, Angular patterns (dotcms-angular-reviewer handles this) +**Type safety in tests** - Type issues in test files (dotcms-typescript-reviewer can help, but lower priority) **E2E test patterns** - This reviewer focuses on unit tests with Jest/Spectator **Test files for legacy components** - Legacy components may have legacy test patterns (grandfathered) @@ -539,7 +539,7 @@ it('should create component with default form values', () => { ## Integration with Main Review You are invoked by the main `review` skill when test files are changed. You work alongside: -- `typescript-reviewer` - Handles type safety in production code -- `angular-reviewer` - Handles Angular patterns in production code +- `dotcms-typescript-reviewer` - Handles type safety in production code +- `dotcms-angular-reviewer` - Handles Angular patterns in production code Your output is merged into the final review under "Test Quality" section. diff --git a/.claude/agents/typescript-reviewer.md b/.claude/agents/dotcms-typescript-reviewer.md similarity index 95% rename from .claude/agents/typescript-reviewer.md rename to .claude/agents/dotcms-typescript-reviewer.md index 82f35d82cec9..2d4261c48fa7 100644 --- a/.claude/agents/typescript-reviewer.md +++ b/.claude/agents/dotcms-typescript-reviewer.md @@ -1,5 +1,5 @@ --- -name: typescript-reviewer +name: dotcms-typescript-reviewer description: TypeScript type safety specialist. Use proactively after writing or modifying TypeScript code to catch type issues early before code review. Focuses on type safety, generics, null handling without checking Angular patterns or tests. model: sonnet color: blue @@ -56,7 +56,7 @@ Read(core-web/libs/dotcms-models/src/lib/my.model.ts) ## Review Scope Analyze these TypeScript files from the PR diff: -- `.ts` files (but NOT `.spec.ts` - tests are handled by test-reviewer) +- `.ts` files (but NOT `.spec.ts` - tests are handled by dotcms-test-reviewer) - `.tsx` files if React components exist - Focus on type definitions, interfaces, generics, type guards @@ -328,8 +328,8 @@ getData(): Observable { ## What NOT to Flag **Pre-existing issues** - Only flag issues in changed lines -**Angular patterns** - Component structure, lifecycle, etc. (angular-reviewer handles this) -**Test files** - `*.spec.ts` files (test-reviewer handles this) +**Angular patterns** - Component structure, lifecycle, etc. (dotcms-angular-reviewer handles this) +**Test files** - `*.spec.ts` files (dotcms-test-reviewer handles this) **Legitimate any usage** - With `// eslint-disable-next-line @typescript-eslint/no-explicit-any` and comment **Third-party types** - Issues in node_modules or external libraries **Configuration files** - Type issues in `.json`, `.js` config files (not TypeScript source) @@ -346,7 +346,7 @@ getData(): Observable { ## Integration with Main Review You are invoked by the main `review` skill when TypeScript files are changed. You work alongside: -- `angular-reviewer` - Handles component patterns, Angular syntax -- `test-reviewer` - Handles test quality and Spectator patterns +- `dotcms-angular-reviewer` - Handles component patterns, Angular syntax +- `dotcms-test-reviewer` - Handles test quality and Spectator patterns Your output is merged into the final review under "TypeScript Type Safety" section. diff --git a/.claude/skills/README.md b/.claude/skills/README.md deleted file mode 100644 index a29d85ec2622..000000000000 --- a/.claude/skills/README.md +++ /dev/null @@ -1,238 +0,0 @@ -# Claude Code Skills (DotCMS Core Repository) - -This directory contains **repository-specific skills** for Claude Code that enhance development workflows for the entire DotCMS core project (both backend and frontend). - -## 📁 Directory Structure - -``` -.claude/ -├── agents/ # Reusable agents (independent workers) -│ ├── README.md # Agent documentation -│ ├── typescript-reviewer.md # TypeScript type safety specialist -│ ├── angular-reviewer.md # Angular patterns specialist -│ └── test-reviewer.md # Test quality specialist -│ -└── skills/ - └── review/ # Autonomous PR Review System - ├── SKILL.md # Main skill orchestrator (invokes agents) - └── README.md # Skill documentation -``` - -**Key Architectural Point**: -- **Skills** (`.claude/skills/`) = Workflows/processes that orchestrate work -- **Agents** (`.claude/agents/`) = Independent workers that execute specialized tasks -- Agents can be reused by multiple skills or invoked directly - -## 🎯 Available Skills - -### `/review` - Autonomous PR Review System - -Intelligent, multi-agent frontend PR reviewer that automatically: -1. **Detects frontend PRs** (>50% TypeScript/Angular/test files) -2. **Launches specialized agents** in parallel (TypeScript, Angular, Test) -3. **Consolidates findings** by severity with confidence scores -4. **Provides actionable feedback** with file:line references and fixes - -**Usage**: -```bash -/review -/review - -# Examples -/review 34553 -/review https://github.com/dotCMS/core/pull/34553 -``` - -**Specialized Agents** (run in parallel for frontend PRs): -- 🔷 **TypeScript Reviewer**: Type safety, generics, null handling (confidence ≥ 75) -- 🟣 **Angular Reviewer**: Modern syntax, component architecture, lifecycle (confidence ≥ 75) -- 🟢 **Test Reviewer**: Spectator patterns, coverage, test quality (confidence ≥ 75) - -**Review Criteria**: -- ✅ **Proceeds with review** if >50% of changed files are frontend (TypeScript, Angular, tests, SCSS) -- ✅ **Skips review** if <50% frontend files (suggests PR is not frontend-focused) -- ✅ **Launches 3 agents** in parallel for comprehensive coverage - -## 🔄 Local vs Global Skills - -**Repository-Local Skills** (`.claude/skills/`): -- ✅ Versioned with the project -- ✅ Shared with the entire team -- ✅ Project-specific workflows and patterns -- ✅ Updated via git commits/PRs - -**Global Skills** (`~/.claude/skills/`): -- Personal skills not specific to this project -- User-specific preferences and workflows -- Not shared with team - -## 🚀 How Skills Work - -When you invoke a skill (e.g., `/review 34553`), Claude: - -1. **Loads the skill** from `.claude/skills/review/SKILL.md` -2. **Follows the instructions** in the skill file -3. **Launches sub-agents** if needed (for parallel reviews) -4. **Returns consolidated results** to you - -Skills are essentially **structured prompts with logic** that guide Claude through complex, multi-step workflows. - -## 📊 Review Output Format - -```markdown -# PR Review: #34553 - Title - -## Summary -[Overview with risk level] - -## Risk Assessment -- Security: Low/Medium/High -- Breaking Changes: None/Potential/Confirmed -- Performance Impact: Low/Medium/High -- Test Coverage: Good/Partial/Missing - -## Frontend Findings (if applicable) -### TypeScript Type Safety -#### Critical Issues 🔴 (95-100) -#### Important Issues 🟡 (85-94) -#### Quality Issues 🔵 (75-84) - -### Angular Patterns -[Same structure] - -### Test Quality -[Same structure] - -## Approval Recommendation -✅ Approve | ⚠️ Approve with Comments | ❌ Request Changes -``` - -## 📝 Creating New Skills - -To add a new skill for this repository: - -1. Create a directory: `.claude/skills/your-skill-name/` -2. Add `SKILL.md` with the skill logic -3. Add `README.md` documenting the skill -4. Test the skill: `/your-skill-name ` -5. Commit to the repo for team use - -**Tip**: Use the `skill-creator` skill to help design new skills: -```bash -/skill-creator -``` - -## 🔧 Maintaining Skills - -**Updating Skills**: -- Edit the `.md` files in `.claude/skills/` -- Test your changes with real PRs -- Commit via git -- Team gets updates on next pull - -**Best Practices**: -- Keep skills focused on a single workflow -- Document usage clearly with examples -- Include error handling and edge cases -- Test with real PRs/data before committing -- Use sub-agents for parallelizable work -- Set appropriate confidence thresholds - -## 📚 Documentation - -Each skill has its own documentation: -- **`SKILL.md`**: The skill logic (what Claude executes) -- **`README.md`**: User-facing documentation - -## 🎨 Skill Design Patterns - -### Multi-Agent Pattern (Review Skill) -``` -Main Skill → Detects domain → Launches specialized agents in parallel - → Consolidates results → Presents unified review -``` - -### Confidence Scoring -``` -95-100 🔴 Critical: Must fix before merge -85-94 🟡 Important: Should address -75-84 🔵 Quality: Nice to have -< 75 Not reported (too minor/uncertain) -``` - -### Self-Validation -Every agent validates: -- File existence in PR diff -- Line number accuracy -- Domain scope adherence -- No duplicate findings -- Evidence-based conclusions - -## 🤝 Contributing - -To improve existing skills: - -1. **Edit locally**: Modify skill files in `.claude/skills/` -2. **Test thoroughly**: Use with real PRs covering edge cases -3. **Document changes**: Update README and examples -4. **Create PR**: Submit changes for team review -5. **Iterate**: Address feedback and refine - -## 💡 Tips for Using Skills - -- **Re-run reviews**: After fixing issues, run `/review ` again to verify -- **Focus reviews**: "Review PR 34553, focus on security" -- **Staged reviews**: For large PRs, review by domain: "Review only frontend files" -- **Check confidence**: Issues with confidence ≥ 75 are actionable -- **Trust agents**: Agents are trained on project patterns (CLAUDE.md) - -## 🐛 Troubleshooting - -**"Skill not found"**: -- Ensure you're in the repository root directory -- Check that `.claude/skills/review/SKILL.md` exists - -**"Wrong review lens selected"**: -- Check file classification in Stage 2 output -- Report if file types are misclassified - -**"Missing issues I expected"**: -- Check confidence threshold (75) -- Issues may be pre-existing (not in diff) -- Code might actually meet standards! - -**"Too many issues"**: -- Focus on Critical 🔴 first -- Important 🟡 should be addressed -- Quality 🔵 can wait for follow-up - -## 📖 Resources - -- [Claude Code Documentation](https://docs.anthropic.com/claude/docs) -- [Review Skill Documentation](review/README.md) -- [Review Agent Documentation](../agents/README.md) -- [DotCMS CLAUDE.md](../CLAUDE.md) - Project guidelines -- [Frontend Guidelines](../core-web/CLAUDE.md) - Angular/TypeScript standards - -## 📈 Metrics - -**Review Skill Performance** (PR #34553 example): -- **Files analyzed**: 47 files (37 frontend, 6 config, 2 docs, 2 other) -- **Agents launched**: 3 (TypeScript, Angular, Test) in parallel -- **Execution time**: ~3-4 minutes (parallel) vs ~10-15 minutes (sequential) -- **Issues found**: 4 critical, 9 important, 13 quality -- **Test coverage analyzed**: 1,112 lines across 5 test files -- **TypeScript analyzed**: 632 lines of production code - -**Quality Improvements**: -- Catches issues before human review -- Consistent application of standards -- Actionable feedback with fixes -- Reduces review iteration cycles - ---- - -**Repository**: [dotCMS/core](https://github.com/dotCMS/core) -**Last Updated**: 2026-02-10 -**Maintained By**: DotCMS Core Team -**Skill Version**: 1.0.0 diff --git a/.claude/skills/review/README.md b/.claude/skills/review/README.md deleted file mode 100644 index 9258e09e142b..000000000000 --- a/.claude/skills/review/README.md +++ /dev/null @@ -1,189 +0,0 @@ -# Autonomous PR Review Skill - -**Single-command, intelligent frontend PR reviews** that automatically detect whether a PR is frontend-focused and launches specialized review agents in parallel. - -## Problem Solved - -Previously, you had to: -- Manually determine if a PR had enough frontend changes to warrant review -- Run review agents individually (TypeScript, Angular, Test) -- Deal with reviewing non-frontend PRs that didn't need frontend analysis -- Manually consolidate findings from multiple review passes - -This skill **automates all of that** with a single command. - -## Quick Start - -```bash -# Review any PR - automatically detects if frontend-focused -/review 34535 -/review https://github.com/dotCMS/core/pull/34535 - -# That's it! The skill will: -# 1. Fetch the PR diff -# 2. Classify files (frontend vs non-frontend) -# 3. Determine if frontend-focused (>50% frontend files) -# 4. Launch specialized agents in parallel (TypeScript, Angular, Test) -# 5. Consolidate findings and self-validate before showing results -``` - -## What Makes This Special - -### 🎯 Automatic Frontend Detection -- Analyzes file extensions and paths to identify frontend files -- Calculates percentage of frontend changes in the PR -- Only proceeds with review if >50% of files are frontend (TypeScript, Angular, tests, SCSS) - -### 🤖 Specialized Review Agents (NEW!) -For frontend code, launches **3 parallel expert agents** using registered agent types: -- **TypeScript Reviewer** (`typescript-reviewer`): Type safety, generics, null handling -- **Angular Reviewer** (`angular-reviewer`): Modern syntax, component patterns, architecture -- **Test Reviewer** (`test-reviewer`): Spectator patterns, coverage, test quality - -Each agent is a domain expert with: -- Non-overlapping focus areas (no duplicate findings) -- Parallel execution (faster reviews) -- Confidence-based scoring (≥75 threshold) -- Self-validation before reporting -- Pre-approved permissions (no repeated prompts) -- Uses dedicated tools (Glob, Grep, Read) for efficiency - -**Key Innovation**: Agents are **registered types** in the system, not generic workers. They have specialized prompts, pre-configured permissions, and domain expertise built-in. - -See [`.claude/agents/README.md`](../../agents/README.md) for agent details. - -### 🛡️ Self-Validation -Before showing you the review, it verifies: -- All referenced files actually exist in the diff -- Line numbers are accurate -- All findings are within frontend domain scope -- No contradictory recommendations -- No duplicate findings across agents - -### 🔍 Comprehensive Frontend Standards -Three specialized agents work in parallel to review: -- **TypeScript Type Safety**: Generics, type quality, null handling, unsafe patterns -- **Angular Patterns**: Modern syntax (@if/@for), standalone components, lifecycle, subscriptions -- **Test Quality**: Spectator patterns, coverage, mocking, async handling - -## File Structure - -``` -.claude/ -├── agents/ # ⭐ Specialized review agents (reusable) -│ ├── README.md # Agent documentation -│ ├── typescript-reviewer.md # TypeScript type safety specialist -│ ├── angular-reviewer.md # Angular patterns specialist -│ └── test-reviewer.md # Test quality specialist -└── skills/review/ - ├── SKILL.md # Main skill logic (orchestrates agents) - └── README.md # This file -``` - -## Examples - -### Example 1: Frontend-Only PR -``` -$ /review 34535 - -Files changed: 1 SCSS, 12 VTL templates -Review Lens: Frontend-Only -Output: Focuses on SCSS standards, template patterns, no backend checks -``` - -### Example 2: Angular Component PR -``` -$ /review 34553 - -Files changed: 3 TypeScript components, 2 HTML templates, 3 spec files -Review Lens: Frontend-Only -Agents Launched: typescript-reviewer, angular-reviewer, test-reviewer -Output: Comprehensive frontend review with findings from all 3 agents -``` - -## Output Format - -Every review includes: - -```markdown -# PR Review: # - - -## Summary -[Brief overview + risk level] - -## Risk Assessment -- Security: [Low/Medium/High] -- Breaking Changes: [None/Potential/Confirmed] -- Performance Impact: [Low/Medium/High] -- Test Coverage: [Good/Partial/Missing] - -## [Domain] Findings -### Critical Issues 🔴 -[Must fix before merge] - -### Improvements 🟡 -[Should address] - -### Nitpicks 🔵 -[Nice to have] - -## Approval Recommendation -[✅ Approve | ⚠️ Approve with Comments | ❌ Request Changes] -``` - -## Architecture - -This skill **orchestrates** specialized agents: -- **Agents are workers**: Independent reviewers with focused expertise (`.claude/agents/`) -- **Skill is orchestrator**: Launches agents in parallel, consolidates findings -- **References are truth**: Single source of truth that agents read dynamically - -**Replaces**: `dotcms-code-reviewer-frontend` and manual domain detection - -Use `/review` as your **single entry point** for frontend PR reviews. - -## Tips - -- **Re-run after updates**: `/review 34535` again to check if issues were addressed -- **Focus on specific areas**: "Review PR 34535, focus on security" or "Check test coverage" -- **Large PRs**: For 50+ file PRs, you can ask Claude to focus on critical areas first -- **Draft PRs**: Mention "This is a draft PR" so expectations are adjusted - -## Integration with Your Workflow - -This skill fits into the autonomous PR review pipeline suggested in your insights report: - -1. **You**: `/review 34535` -2. **Claude**: Fetches diff, classifies files, selects lens, reviews, validates -3. **You**: Review findings, request changes or approve -4. **Developer**: Makes updates -5. **You**: `/review 34535` (re-run to verify fixes) - -## Future Enhancements - -As Claude's capabilities improve, this skill could: -- Auto-comment on the PR with findings -- Track which issues were addressed between reviews -- Compare against main branch to predict merge conflicts -- Suggest reviewers based on file ownership - -## Troubleshooting - -**"Unable to fetch PR"**: -- Verify PR number: `gh pr list --limit 20` -- Check you're in the correct repo directory -- Ensure `gh` CLI is authenticated: `gh auth status` - -**"Review seems to miss files"**: -- Large PRs may need focus: "Focus on TypeScript type issues" or "Review only test files" -- Check if PR is from a fork (may have permission issues) - -**"Wrong lens selected"**: -- This shouldn't happen with the new classification logic -- If it does, report the file distribution so we can refine - -## Created - -Based on usage insights analysis from 25 Claude Code sessions (2026-01-08 to 2026-02-06). - -Addresses friction pattern: "Wrong review domain wastes full review cycles" diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index f011f349701e..44437bf9ea5c 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -27,17 +27,17 @@ This skill performs an **autonomous, multi-stage frontend review** with intellig ### Stage 1-3: File Classification with Dedicated Agent -Launch the **File Classifier** agent (subagent type: `file-classifier`) to handle PR data collection, file classification, and review decision: +Launch the **File Classifier** agent (subagent type: `dotcms-file-classifier`) to handle PR data collection, file classification, and review decision: ``` Task( - subagent_type="file-classifier", + subagent_type="dotcms-file-classifier", prompt="Classify PR #<NUMBER> files by domain (Angular, TypeScript, tests, styles) and determine if frontend-focused review is needed.", description="Classify PR files" ) ``` -The `file-classifier` agent will: +The `dotcms-file-classifier` agent will: 1. **Fetch** PR metadata and diff (`gh pr view`, `gh pr diff`) 2. **Classify** every changed file into reviewer buckets (angular, typescript, test, out-of-scope) 3. **Calculate** frontend vs non-frontend ratio @@ -49,31 +49,38 @@ The `file-classifier` agent will: ### Stage 4: Domain-Specific Review with Specialized Agents -**Using the file map from the file-classifier agent**, launch **parallel specialized agents** only for buckets that have files: +**Using the file map from the dotcms-file-classifier agent**, launch **parallel specialized agents** only for buckets that have files: -1. **TypeScript Type Reviewer** (subagent type: `typescript-reviewer`) +1. **TypeScript Type Reviewer** (subagent type: `dotcms-typescript-reviewer`) - Receives the `typescript-reviewer` file list from the file map - Focus: Type safety, generics, null handling, type quality - Confidence threshold: ≥ 75 - **Skip if**: No files in the typescript bucket -2. **Angular Pattern Reviewer** (subagent type: `angular-reviewer`) +2. **Angular Pattern Reviewer** (subagent type: `dotcms-angular-reviewer`) - Receives the `angular-reviewer` file list from the file map - Focus: Modern syntax, component architecture, lifecycle, subscriptions - Confidence threshold: ≥ 75 - **Skip if**: No files in the angular bucket -3. **Test Quality Reviewer** (subagent type: `test-reviewer`) +3. **Test Quality Reviewer** (subagent type: `dotcms-test-reviewer`) - Receives the `test-reviewer` file list from the file map - Focus: Spectator patterns, coverage, test quality - Confidence threshold: ≥ 75 - **Skip if**: No files in the test bucket +4. **SCSS/HTML Style Reviewer** (subagent type: `dotcms-scss-html-style-reviewer`) + - Receives the `styles` file list from the file map (`.scss`, `.css`, `.html` files) + - Focus: BEM compliance, CSS custom properties, unused classes, SCSS standards, Angular encapsulation, PrimeNG theming + - Confidence threshold: ≥ 75 + - **Skip if**: No `.scss`, `.css`, or `.html` files in the styles bucket + **Launch agents in parallel** using the Task tool (only for non-empty buckets): ``` -Task(subagent_type="typescript-reviewer", prompt="Review TypeScript type safety for PR #<NUMBER>. Files: <file-list from file-classifier>", description="TypeScript review") -Task(subagent_type="angular-reviewer", prompt="Review Angular patterns for PR #<NUMBER>. Files: <file-list from file-classifier>", description="Angular review") -Task(subagent_type="test-reviewer", prompt="Review test quality for PR #<NUMBER>. Files: <file-list from file-classifier>", description="Test review") +Task(subagent_type="dotcms-typescript-reviewer", prompt="Review TypeScript type safety for PR #<NUMBER>. Files: <file-list from dotcms-file-classifier>", description="TypeScript review") +Task(subagent_type="dotcms-angular-reviewer", prompt="Review Angular patterns for PR #<NUMBER>. Files: <file-list from dotcms-file-classifier>", description="Angular review") +Task(subagent_type="dotcms-test-reviewer", prompt="Review test quality for PR #<NUMBER>. Files: <file-list from dotcms-file-classifier>", description="Test review") +Task(subagent_type="dotcms-scss-html-style-reviewer", prompt="Review SCSS/HTML styling standards for PR #<NUMBER>. Files: <styles file-list from dotcms-file-classifier>", description="Style review") ``` **For Backend/Config/Docs changes**: This skill focuses on frontend code review only. Backend reviews are handled separately. @@ -131,7 +138,7 @@ Task(subagent_type="test-reviewer", prompt="Review test quality for PR #<NUMBER> [Only if frontend files changed - consolidate from specialized agents] ### TypeScript Type Safety -[From typescript-reviewer agent] +[From dotcms-typescript-reviewer agent] #### Critical Issues 🔴 (95-100) [Type safety violations, raw generics, unsafe casts] @@ -143,7 +150,7 @@ Task(subagent_type="test-reviewer", prompt="Review test quality for PR #<NUMBER> [Type improvements, better generics] ### Angular Patterns -[From angular-reviewer agent] +[From dotcms-angular-reviewer agent] #### Critical Issues 🔴 (95-100) [Legacy syntax, missing standalone, memory leaks] @@ -155,7 +162,7 @@ Task(subagent_type="test-reviewer", prompt="Review test quality for PR #<NUMBER> [Pattern improvements, optimizations] ### Test Quality -[From test-reviewer agent] +[From dotcms-test-reviewer agent] #### Critical Issues 🔴 (95-100) [Wrong Spectator usage, missing detectChanges] @@ -166,6 +173,18 @@ Task(subagent_type="test-reviewer", prompt="Review test quality for PR #<NUMBER> #### Quality Issues 🔵 (75-84) [Test organization, clarity] +### Styling Standards +[From dotcms-scss-html-style-reviewer agent — only if .scss/.css/.html files changed] + +#### Critical Issues 🔴 (95-100) +[BEM violations, hardcoded colors/spacing, ::ng-deep misuse] + +#### Important Issues 🟡 (85-94) +[Unused classes, missing CSS variables, nesting depth exceeded] + +#### Quality Issues 🔵 (75-84) +[Selector improvements, mixin usage, PrimeNG theming patterns] + --- ## Approval Recommendation @@ -227,6 +246,6 @@ Use this as your **single entry point** for all PR reviews. ## Skill Metadata - **Author**: Generated from usage insights analysis -- **Last Updated**: 2026-02-10 +- **Last Updated**: 2026-02-24 - **Replaces**: dotcms-code-reviewer-frontend - **Dependencies**: `gh` CLI, access to repository diff --git a/.cursor/rules/frontend-context.mdc b/.cursor/rules/frontend-context.mdc index 1d8824d1a656..764fe0eac36f 100644 --- a/.cursor/rules/frontend-context.mdc +++ b/.cursor/rules/frontend-context.mdc @@ -9,7 +9,7 @@ alwaysApply: false **Nx monorepo** in `core-web/`: TypeScript, Angular apps/libs, and SDK for **Angular** and **React**. Full standards live in **`docs/frontend/`**. Index: **`@docs/frontend/README.md`** — load it for the full doc list and when to use each; then load the specific doc you need. ## Stack -- **Angular**: standalone, signals, `inject()`, `input()`/`output()`, `@if`/`@for`, OnPush, PrimeNG/PrimeFlex (Angular 20+). +- **Angular**: standalone, signals, `inject()`, `input()`/`output()`, `@if`/`@for`, OnPush, PrimeNG + Tailwind CSS (Angular 20+). PrimeFlex is deprecated/removed. - **SDK**: `sdk-angular`, `sdk-react`, `sdk-client`, `sdk-types`, etc. - **Apps**: `dotcms-ui`, `content-drive-ui`, `edit-ema-ui`, portlets, libs. @@ -31,7 +31,7 @@ cd core-web && yarn nx affected -t test --exclude='tag:skip:test' | Writing or refactoring components, templates, or Angular patterns | `@docs/frontend/ANGULAR_STANDARDS.md` | | Defining component structure, inputs/outputs, file layout | `@docs/frontend/COMPONENT_ARCHITECTURE.md` | | Adding or changing feature state (stores, async) | `@docs/frontend/STATE_MANAGEMENT.md` | -| Writing or updating styles, BEM, PrimeFlex | `@docs/frontend/STYLING_STANDARDS.md` | +| Writing or updating styles, Tailwind, BEM | `@docs/frontend/STYLING_STANDARDS.md` | | Writing or fixing tests (Spectator, Jest, data-testid) | `@docs/frontend/TESTING_FRONTEND.md` | | Using TypeScript (strict, inference, `unknown`, `as const`, `#` private) | `@docs/frontend/TYPESCRIPT_STANDARDS.md` | @@ -42,7 +42,7 @@ cd core-web && yarn nx affected -t test --exclude='tag:skip:test' - **File structure**: One component = one `.ts` + one `.html` + one `.scss` (or `.css`); `templateUrl`/`styleUrls` relative to the component file. - **Images**: Use `NgOptimizedImage` for static/asset URLs; not for inline base64. - **State**: Prefer NgRx Signal Store; avoid manual signal soup in components. See `@docs/frontend/STATE_MANAGEMENT.md`. -- **Styling**: Prefer PrimeFlex utility classes and PrimeNG components first; avoid custom SCSS when a utility exists. Use BEM only when custom styles are needed. See `@docs/frontend/STYLING_STANDARDS.md`. +- **Styling**: Prefer Tailwind utility classes and PrimeNG components first; PrimeFlex is deprecated/removed. Avoid custom SCSS when a Tailwind class exists. Use BEM only when custom styles are truly needed. See `@docs/frontend/STYLING_STANDARDS.md`. - **Testing**: Spectator + Jest/Vitest; `data-testid` on elements tests query; `byTestId()`, `spectator.setInput()`, `spectator.detectChanges()`, `spectator.click()`, `mockProvider()`; never set component inputs directly. Use `@dotcms/utils-testing` createFake functions (e.g. `createFakeContentlet`, `createFakeLanguage`) instead of manual mocks. See `@docs/frontend/TESTING_FRONTEND.md`. - **TypeScript**: Strict types, no `any` (use `unknown`), `as const` instead of enums, `#` for private. See `@docs/frontend/TYPESCRIPT_STANDARDS.md`. - **Services**: Single responsibility; `providedIn: 'root'`; use `inject()` instead of constructor injection. diff --git a/core-web/.claude/settings.json b/core-web/.claude/settings.json new file mode 100644 index 000000000000..a2fdc2bd4e9e --- /dev/null +++ b/core-web/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "nx-claude-plugins": { + "source": { + "source": "github", + "repo": "nrwl/nx-ai-agents-config" + } + } + }, + "enabledPlugins": { + "nx@nx-claude-plugins": true + } +} diff --git a/core-web/.cursor/rules/nx-rules.mdc b/core-web/.cursor/rules/nx-rules.mdc deleted file mode 100644 index 30ac77eb9f89..000000000000 --- a/core-web/.cursor/rules/nx-rules.mdc +++ /dev/null @@ -1,34 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- - -// This file is automatically generated by Nx Console - -You are in an nx workspace using Nx 20.5.1 and yarn as the package manager. - -You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user: - -# General Guidelines -- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture -- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration -- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors -- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool - -# Generation Guidelines -If the user wants to generate something, use the following flow: - -- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable -- get the available generators using the 'nx_generators' tool -- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them -- get generator details using the 'nx_generator_schema' tool -- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure -- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic -- open the generator UI using the 'nx_open_generate_ui' tool -- wait for the user to finish the generator -- read the generator log file using the 'nx_read_generator_log' tool -- use the information provided in the log file to answer the user's question or continue with what they were doing - - - diff --git a/core-web/.mcp.json b/core-web/.mcp.json deleted file mode 100644 index 7206148e4d3d..000000000000 --- a/core-web/.mcp.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "mcpServers": { - "nx-mcp": { - "type": "stdio", - "command": "npx", - "args": ["nx-mcp"] - } - } -} diff --git a/core-web/AGENTS.md b/core-web/AGENTS.md index ec27f8330efa..1bd62dcf741a 100644 --- a/core-web/AGENTS.md +++ b/core-web/AGENTS.md @@ -3,12 +3,21 @@ # General Guidelines for working with Nx +- For navigating/exploring the workspace, invoke the `nx-workspace` skill first - it has patterns for querying projects, targets, and dependencies - When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly +- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI - You have access to the Nx MCP server and its tools, use them to help the user -- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable. -- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies -- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration -- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors - For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable. +- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure + +## Scaffolding & Generators + +- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools + +## When to use nx_docs + +- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases +- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know +- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax <!-- nx configuration end--> diff --git a/core-web/CLAUDE.md b/core-web/CLAUDE.md index 42024325bed5..4d102d21b249 100644 --- a/core-web/CLAUDE.md +++ b/core-web/CLAUDE.md @@ -77,7 +77,7 @@ spectator.setInput('prop', value); // ALWAYS use setInput - **Prefix**: All components use `dot-` prefix - **Standalone**: All new components must be standalone - **State**: Use NgRx signals (`@ngrx/signals`) for state management -- **Styling**: Tailwind CSS + PrimeFlex utilities +- **Styling**: Tailwind CSS + PrimeNG theme (PrimeFlex deprecated/removed — use Tailwind utilities instead) - **Testing**: Jest + Spectator, use `data-testid` for selectors - **Dialogs**: All dialogs must have `closable: true` and `closeOnEscape: true` to allow closing via X button and ESC key @@ -158,13 +158,23 @@ See **[../CLAUDE.md](../CLAUDE.md)** for Java, Maven, REST API, and Git workflow <!-- nx configuration start--> <!-- Leave the start & end comments to automatically receive updates. --> -# General Guidelines for working with Nx +## General Guidelines for working with Nx -- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `yarn nx` (i.e. `yarn nx run`, `yarn nx run-many`, `yarn nx affected`) instead of using the underlying tooling directly +- For navigating/exploring the workspace, invoke the `nx-workspace` skill first - it has patterns for querying projects, targets, and dependencies +- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly +- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI - You have access to the Nx MCP server and its tools, use them to help the user -- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable. -- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies -- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration -- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors +- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable. +- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure + +## Scaffolding & Generators + +- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools + +## When to use nx_docs + +- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases +- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know +- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax <!-- nx configuration end--> diff --git a/core-web/apps/dotcms-ui/src/main.ts b/core-web/apps/dotcms-ui/src/main.ts index 61697ddb6fde..a318f5c817b0 100644 --- a/core-web/apps/dotcms-ui/src/main.ts +++ b/core-web/apps/dotcms-ui/src/main.ts @@ -1,8 +1,6 @@ import { enableProdMode } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; -import { defineCustomElements } from '@dotcms/dotcms-webcomponents/loader'; - import { AppComponent } from './app/app.component'; import { appConfig } from './app/app.config'; import { environment } from './environments/environment'; @@ -12,4 +10,3 @@ if (environment.production) { } bootstrapApplication(AppComponent, appConfig); -defineCustomElements(); diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/index.ts b/core-web/libs/portlets/dot-analytics/data-access/src/index.ts index 404fd48390a4..3c1d15b88f5b 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/index.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/index.ts @@ -6,8 +6,8 @@ export * from './lib/services/dot-analytics.service'; // Utils export * from './lib/utils/data/analytics-data.utils'; +export * from './lib/utils/data/engagement-data.utils'; export * from './lib/utils/filters.utils'; -export * from './lib/utils/mock-engagement-data'; // Constants export * from './lib/constants'; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts index 5c4f796b88cb..f7a6de667662 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/constants/dot-analytics.constants.ts @@ -76,6 +76,12 @@ export const AnalyticsChartColors = { line: '#E5E7EB', fill: 'rgba(229, 231, 235, 0.15)', bar: 'rgba(229, 231, 235, 0.6)' + }, + // Neutral dark: Gray for secondary/compare lines + neutralDark: { + line: '#9CA3AF', + fill: 'rgba(156, 163, 175, 0.15)', + bar: 'rgba(156, 163, 175, 0.6)' } } as const; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/dot-analytics-dashboard.store.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/dot-analytics-dashboard.store.ts index 6a6383fb6ee4..aa3c442c8faa 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/dot-analytics-dashboard.store.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/dot-analytics-dashboard.store.ts @@ -1,5 +1,6 @@ import { patchState, signalStore, withHooks, withMethods } from '@ngrx/signals'; +import { Location } from '@angular/common'; import { effect, inject } from '@angular/core'; import { ActivatedRoute, Params, Router } from '@angular/router'; @@ -35,71 +36,76 @@ export const DotAnalyticsDashboardStore = signalStore( withConversions(), withEngagement(), // Coordinator methods that work across features - withMethods((store, route = inject(ActivatedRoute), router = inject(Router)) => ({ - /** - * Sets current tab and syncs URL. - */ - setCurrentTabAndNavigate(tab: DashboardTab): void { - store.setCurrentTab(tab); - - // Update URL with tab query param - // TODO: Find a better way to update the URL with the tab query param. - router.navigate([], { - relativeTo: route, - queryParams: { tab: tab }, - queryParamsHandling: 'merge', - replaceUrl: true - }); - }, - - /** - * Refreshes all currently loaded data based on the current tab. - */ - refreshAllData(): void { - const currentTab = store.currentTab(); - - switch (currentTab) { - case DASHBOARD_TABS.pageview: - store.loadAllPageviewData(); - break; - case DASHBOARD_TABS.engagement: - store.loadEngagementData(); - break; - case DASHBOARD_TABS.conversions: - store.loadConversionsData(); - break; - } - }, - - /** - * Updates time range and syncs URL with query params. - */ - updateTimeRange(timeRange: TimeRangeInput): void { - store.setTimeRange(timeRange); - - // Build query params from time range - const queryParams: Params = {}; - - if (Array.isArray(timeRange)) { - // Custom date range - queryParams['time_range'] = TIME_RANGE_OPTIONS.custom; - queryParams['from'] = timeRange[0]; - queryParams['to'] = timeRange[1]; - } else { - // Predefined range - queryParams['time_range'] = timeRange; - } + withMethods( + ( + store, + route = inject(ActivatedRoute), + router = inject(Router), + location = inject(Location) + ) => ({ + /** + * Sets current tab and syncs URL without triggering Angular router navigation. + */ + setCurrentTabAndNavigate(tab: DashboardTab): void { + store.setCurrentTab(tab); + + const urlTree = router.createUrlTree([], { + relativeTo: route, + queryParams: { tab }, + queryParamsHandling: 'merge' + }); + location.replaceState(router.serializeUrl(urlTree)); + }, + + /** + * Refreshes all currently loaded data based on the current tab. + */ + refreshAllData(): void { + const currentTab = store.currentTab(); - // Update URL - // TODO: Find a better way to update the URL with the time range query params. - router.navigate([], { - relativeTo: route, - queryParams: queryParams, - queryParamsHandling: 'merge', - replaceUrl: true - }); - } - })), + switch (currentTab) { + case DASHBOARD_TABS.pageview: + store.loadAllPageviewData(); + break; + case DASHBOARD_TABS.engagement: + store.loadEngagementData(); + break; + case DASHBOARD_TABS.conversions: + store.loadConversionsData(); + break; + } + }, + + /** + * Updates time range and syncs URL with query params. + */ + updateTimeRange(timeRange: TimeRangeInput): void { + store.setTimeRange(timeRange); + + // Build query params from time range + const queryParams: Params = {}; + + if (Array.isArray(timeRange)) { + // Custom date range + queryParams['time_range'] = TIME_RANGE_OPTIONS.custom; + queryParams['from'] = timeRange[0]; + queryParams['to'] = timeRange[1]; + } else { + // Predefined range + queryParams['time_range'] = timeRange; + } + + // Update URL + // TODO: Find a better way to update the URL with the time range query params. + router.navigate([], { + relativeTo: route, + queryParams: queryParams, + queryParamsHandling: 'merge', + replaceUrl: true + }); + } + }) + ), withHooks({ onInit(store) { const route = inject(ActivatedRoute); diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-engagement.feature.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-engagement.feature.ts index 3c798053004b..f3c4dc1b50a7 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-engagement.feature.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/store/features/with-engagement.feature.ts @@ -1,63 +1,440 @@ +import { tapResponse } from '@ngrx/operators'; import { patchState, signalStoreFeature, type, withMethods, withState } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { pipe } from 'rxjs'; +import { forkJoin, of, pipe } from 'rxjs'; -import { delay, tap } from 'rxjs/operators'; +import { HttpErrorResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; + +import { DotMessageService } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { FiltersState } from './with-filters.feature'; -import { RequestState } from '../../types'; -import { EngagementData } from '../../types/engagement.types'; -import { createInitialRequestState } from '../../utils/data/analytics-data.utils'; -import { MOCK_ENGAGEMENT_DATA } from '../../utils/mock-engagement-data'; +import { DotAnalyticsService } from '../../services/dot-analytics.service'; +import { createCubeQuery } from '../../utils/cube/cube-query-builder.util'; +import { + createInitialRequestState, + getPreviousPeriod, + toTimeRangeCubeJS +} from '../../utils/data/analytics-data.utils'; +import { + toEngagementBreakdownChartData, + toEngagementKPIs, + toEngagementPlatforms, + toEngagementSparklineData +} from '../../utils/data/engagement-data.utils'; + +import type { + ChartData, + DimensionField, + EngagementDailyEntity, + EngagementKPIs, + EngagementPlatforms, + EngagementSparklineData, + RequestState, + SessionsByBrowserDailyEntity, + SessionsByDeviceDailyEntity, + TimeRangeInput +} from '../../types'; + +const ENGAGEMENT_DAILY_MEASURES = [ + 'totalSessions', + 'engagedSessions', + 'engagedConversionSessions', + 'engagementRate', + 'avgInteractionsPerEngagedSession', + 'avgSessionTimeSeconds', + 'avgEngagedSessionTimeSeconds' +]; + +const ENGAGEMENT_TREND_MEASURES = ['totalSessions', 'engagedSessions', 'engagementRate']; +/** Sparkline uses conversion rate so the trend varies day-to-day (engagement rate is often 100% when engaged_sessions === total_sessions). */ +const ENGAGEMENT_SPARKLINE_MEASURES = ['conversionRate']; + +const SESSIONS_BY_MEASURES = ['engagedSessions', 'totalSessions', 'avgEngagedSessionTimeSeconds']; +const SESSIONS_BY_DEVICE_DIMENSIONS: DimensionField[] = ['deviceCategory']; +const SESSIONS_BY_BROWSER_DIMENSIONS: DimensionField[] = ['browserFamily']; /** * State interface for the Engagement feature. + * Multiple slices for independent loading per block. */ export interface EngagementState { - engagementData: RequestState<EngagementData>; + engagementKpis: RequestState<EngagementKPIs>; + engagementSparkline: RequestState<EngagementSparklineData>; + engagementBreakdown: RequestState<ChartData>; + engagementPlatforms: RequestState<EngagementPlatforms>; } -/** - * Initial state for the Engagement feature. - */ const initialEngagementState: EngagementState = { - engagementData: createInitialRequestState() + engagementKpis: createInitialRequestState(), + engagementSparkline: createInitialRequestState(), + engagementBreakdown: createInitialRequestState(), + engagementPlatforms: createInitialRequestState() }; /** * Signal Store Feature for managing engagement analytics data. + * Each slice (KPIs, trend chart, breakdown, platforms) loads independently and has its own loading/error state. */ export function withEngagement() { return signalStoreFeature( { state: type<FiltersState>() }, withState(initialEngagementState), - withMethods((store) => ({ - loadEngagementData: rxMethod<void>( - pipe( - tap(() => - patchState(store, { - engagementData: { - status: ComponentStatus.LOADING, - data: null, - error: null - } - }) + withMethods( + ( + store, + globalStore = inject(GlobalStore), + analyticsService = inject(DotAnalyticsService), + dotMessageService = inject(DotMessageService) + ) => { + const getErrorMessage = (key: string, fallback: string) => + dotMessageService.get(key) || fallback; + + return { + /** + * Loads KPIs: current + previous period totals for trend calculation. + */ + _loadEngagementKpis: rxMethod<{ + timeRange: TimeRangeInput; + currentSiteId: string; + }>( + pipe( + tap(() => + patchState(store, { + engagementKpis: { + status: ComponentStatus.LOADING, + data: null, + error: null + } + }) + ), + switchMap(({ timeRange, currentSiteId }) => { + const dateRange = toTimeRangeCubeJS(timeRange); + const previousRange = getPreviousPeriod(timeRange); + + const currentQuery = createCubeQuery() + .fromCube('EngagementDaily') + .siteId(currentSiteId) + .measures(ENGAGEMENT_DAILY_MEASURES) + .timeRange('day', dateRange) + .build(); + + const previousQuery = createCubeQuery() + .fromCube('EngagementDaily') + .siteId(currentSiteId) + .measures(ENGAGEMENT_DAILY_MEASURES) + .timeRange('day', previousRange) + .build(); + + return forkJoin({ + current: analyticsService + .cubeQuery<EngagementDailyEntity>(currentQuery) + .pipe( + map((rows) => rows[0] ?? null), + catchError(() => of(null)) + ), + previous: analyticsService + .cubeQuery<EngagementDailyEntity>(previousQuery) + .pipe( + map((rows) => rows[0] ?? null), + catchError(() => of(null)) + ) + }).pipe( + tapResponse( + ({ current, previous }) => { + patchState(store, { + engagementKpis: { + status: ComponentStatus.LOADED, + data: toEngagementKPIs(current, previous), + error: null + } + }); + }, + (error: HttpErrorResponse) => { + patchState(store, { + engagementKpis: { + status: ComponentStatus.ERROR, + data: null, + error: + error?.message || + getErrorMessage( + 'analytics.error.loading.engagement', + 'Failed to load engagement KPIs' + ) + } + }); + } + ) + ); + }) + ) + ), + + /** + * Loads breakdown (engaged vs bounced doughnut) from current period totals. + */ + _loadEngagementBreakdown: rxMethod<{ + timeRange: TimeRangeInput; + currentSiteId: string; + }>( + pipe( + tap(() => + patchState(store, { + engagementBreakdown: { + status: ComponentStatus.LOADING, + data: null, + error: null + } + }) + ), + switchMap(({ timeRange, currentSiteId }) => { + const dateRange = toTimeRangeCubeJS(timeRange); + + const query = createCubeQuery() + .fromCube('EngagementDaily') + .siteId(currentSiteId) + .measures(['totalSessions', 'engagedSessions']) + .timeRange('day', dateRange) + .build(); + + return analyticsService + .cubeQuery<EngagementDailyEntity>(query) + .pipe( + tapResponse( + (rows) => { + const row = rows?.[0]; + const total = row + ? Number( + row['EngagementDaily.totalSessions'] ?? 0 + ) + : 0; + const engaged = row + ? Number( + row['EngagementDaily.engagedSessions'] ?? + 0 + ) + : 0; + patchState(store, { + engagementBreakdown: { + status: ComponentStatus.LOADED, + data: toEngagementBreakdownChartData( + total, + engaged + ), + error: null + } + }); + }, + (error: HttpErrorResponse) => { + patchState(store, { + engagementBreakdown: { + status: ComponentStatus.ERROR, + data: null, + error: + error?.message || + getErrorMessage( + 'analytics.error.loading.engagement', + 'Failed to load breakdown data' + ) + } + }); + } + ) + ); + }) + ) + ), + + /** + * Loads sparkline data (engagement/conversion rate per day) for current and previous period. + */ + _loadEngagementSparkline: rxMethod<{ + timeRange: TimeRangeInput; + currentSiteId: string; + }>( + pipe( + tap(() => + patchState(store, { + engagementSparkline: { + status: ComponentStatus.LOADING, + data: null, + error: null + } + }) + ), + switchMap(({ timeRange, currentSiteId }) => { + const dateRange = toTimeRangeCubeJS(timeRange); + const previousRange = getPreviousPeriod(timeRange); + + const currentQuery = createCubeQuery() + .fromCube('EngagementDaily') + .siteId(currentSiteId) + .measures([ + ...ENGAGEMENT_TREND_MEASURES, + ...ENGAGEMENT_SPARKLINE_MEASURES + ]) + .timeRange('day', dateRange, 'day') + .build(); + + const previousQuery = createCubeQuery() + .fromCube('EngagementDaily') + .siteId(currentSiteId) + .measures([ + ...ENGAGEMENT_TREND_MEASURES, + ...ENGAGEMENT_SPARKLINE_MEASURES + ]) + .timeRange('day', previousRange, 'day') + .build(); + + return forkJoin({ + current: + analyticsService.cubeQuery<EngagementDailyEntity>( + currentQuery + ), + previous: analyticsService + .cubeQuery<EngagementDailyEntity>(previousQuery) + .pipe(catchError(() => of([]))) + }).pipe( + tapResponse( + ({ current, previous }) => { + patchState(store, { + engagementSparkline: { + status: ComponentStatus.LOADED, + data: { + current: toEngagementSparklineData( + current ?? [] + ), + previous: + previous?.length > 0 + ? toEngagementSparklineData( + previous + ) + : null + }, + error: null + } + }); + }, + (error: HttpErrorResponse) => { + patchState(store, { + engagementSparkline: { + status: ComponentStatus.ERROR, + data: null, + error: + error?.message || + getErrorMessage( + 'analytics.error.loading.engagement', + 'Failed to load sparkline data' + ) + } + }); + } + ) + ); + }) + ) + ), + + /** + * Loads platforms (device, browser) in parallel. + */ + _loadEngagementPlatforms: rxMethod<{ + timeRange: TimeRangeInput; + currentSiteId: string; + }>( + pipe( + tap(() => + patchState(store, { + engagementPlatforms: { + status: ComponentStatus.LOADING, + data: null, + error: null + } + }) + ), + switchMap(({ timeRange, currentSiteId }) => { + const dateRange = toTimeRangeCubeJS(timeRange); + + const deviceQuery = createCubeQuery() + .fromCube('SessionsByDeviceDaily') + .siteId(currentSiteId) + .measures(SESSIONS_BY_MEASURES) + .dimensions(SESSIONS_BY_DEVICE_DIMENSIONS) + .timeRange('day', dateRange) + .build(); + + const browserQuery = createCubeQuery() + .fromCube('SessionsByBrowserDaily') + .siteId(currentSiteId) + .measures(SESSIONS_BY_MEASURES) + .dimensions(SESSIONS_BY_BROWSER_DIMENSIONS) + .timeRange('day', dateRange) + .build(); + + return forkJoin({ + device: analyticsService.cubeQuery<SessionsByDeviceDailyEntity>( + deviceQuery + ), + browser: + analyticsService.cubeQuery<SessionsByBrowserDailyEntity>( + browserQuery + ) + }).pipe( + map(({ device, browser }) => + toEngagementPlatforms(device, browser) + ), + tapResponse( + (platforms) => + patchState(store, { + engagementPlatforms: { + status: ComponentStatus.LOADED, + data: platforms, + error: null + } + }), + (error: HttpErrorResponse) => + patchState(store, { + engagementPlatforms: { + status: ComponentStatus.ERROR, + data: null, + error: + error?.message || + getErrorMessage( + 'analytics.error.loading.engagement', + 'Failed to load platforms data' + ) + } + }) + ) + ); + }) + ) ), - delay(500), // Simulate network delay - tap(() => { - patchState(store, { - engagementData: { - status: ComponentStatus.LOADED, - data: MOCK_ENGAGEMENT_DATA, - error: null - } - }); - }) - ) - ) - })) + + /** + * Loads all engagement data. Dispatches independent requests per block. + */ + loadEngagementData(): void { + const currentSiteId = globalStore.currentSiteId(); + const timeRange = store.timeRange(); + + if (!currentSiteId) { + return; + } + + const payload = { timeRange, currentSiteId }; + this._loadEngagementKpis(payload); + this._loadEngagementSparkline(payload); + this._loadEngagementBreakdown(payload); + this._loadEngagementPlatforms(payload); + } + }; + } + ) ); } diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/common.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/common.types.ts index 6f224d914765..3ec8e99df376 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/common.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/common.types.ts @@ -57,7 +57,7 @@ export interface RequestState<T = unknown> { */ export interface MetricData { name: string; - value: number | string; + value: number | string | null; subtitle: string; icon: string; status: ComponentStatus; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/cubequery.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/cubequery.types.ts index 88d0f8be9007..7f3790ed7868 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/cubequery.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/cubequery.types.ts @@ -8,8 +8,17 @@ * - 'EventSummary': Conversion and event analytics * - 'ContentAttribution': Content attribution for conversions * - 'Conversion': Conversions overview data + * - 'EngagementDaily' and SessionsBy*: Engagement dashboard cubes */ -export type CubePrefix = 'request' | 'EventSummary' | 'ContentAttribution' | 'Conversion'; +export type CubePrefix = + | 'request' + | 'EventSummary' + | 'ContentAttribution' + | 'Conversion' + | 'EngagementDaily' + | 'SessionsByDeviceDaily' + | 'SessionsByBrowserDaily' + | 'SessionsByLanguageDaily'; /** * Sort direction options for ordering queries. @@ -72,7 +81,10 @@ const DimensionField = { CONVERSION_NAME: 'conversionName', TOTAL_CONVERSION: 'totalConversion', CONV_RATE: 'convRate', - TOP_ATTRIBUTED_CONTENT: 'topAttributedContent' + TOP_ATTRIBUTED_CONTENT: 'topAttributedContent', + DEVICE_CATEGORY: 'deviceCategory', + BROWSER_FAMILY: 'browserFamily', + LANGUAGE_ID: 'languageId' } as const; export type DimensionField = (typeof DimensionField)[keyof typeof DimensionField]; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/engagement.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/engagement.types.ts index 6cf28d15d18b..7ecfd26722d9 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/engagement.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/engagement.types.ts @@ -6,17 +6,22 @@ export interface SparklineDataPoint { value: number; } +/** Current and previous period data for sparkline comparison */ +export interface EngagementSparklineData { + current: SparklineDataPoint[]; + previous: SparklineDataPoint[] | null; +} + export interface EngagementKPI { value: number | string; trend: number; label: string; /** Optional subtitle text */ subtitle?: string; - /** Optional sparkline data points for trend visualization */ - sparklineData?: SparklineDataPoint[]; } export interface EngagementKPIs { + totalSessions: EngagementKPI; engagementRate: EngagementKPI; avgInteractions: EngagementKPI; avgSessionTime: EngagementKPI; @@ -33,7 +38,6 @@ export interface EngagementPlatformMetrics { export interface EngagementPlatforms { device: EngagementPlatformMetrics[]; browser: EngagementPlatformMetrics[]; - language: EngagementPlatformMetrics[]; } export interface EngagementData { diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts index 45484e15042b..2320c14dbfba 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/types/entities.types.ts @@ -195,3 +195,50 @@ export type AnalyticsKeys = (typeof AnalyticsKeys)[keyof typeof AnalyticsKeys]; * Default count limit for analytics queries. */ export const DEFAULT_COUNT_LIMIT = 50; + +/** + * EngagementDaily cube entity (totals or by-day row). + * Keys match Cube response format: cubeName.measureOrDimension. + */ +export interface EngagementDailyEntity { + 'EngagementDaily.totalSessions'?: string; + 'EngagementDaily.engagedSessions'?: string; + 'EngagementDaily.engagedConversionSessions'?: string; + 'EngagementDaily.engagementRate'?: string; + 'EngagementDaily.conversionRate'?: string; + 'EngagementDaily.avgInteractionsPerEngagedSession'?: string; + 'EngagementDaily.avgSessionTimeSeconds'?: string; + 'EngagementDaily.avgEngagedSessionTimeSeconds'?: string; + 'EngagementDaily.day'?: string; + 'EngagementDaily.day.day'?: string; +} + +/** + * SessionsByDeviceDaily cube entity (one row per device category). + */ +export interface SessionsByDeviceDailyEntity { + 'SessionsByDeviceDaily.deviceCategory'?: string; + 'SessionsByDeviceDaily.engagedSessions'?: string; + 'SessionsByDeviceDaily.totalSessions'?: string; + 'SessionsByDeviceDaily.avgEngagedSessionTimeSeconds'?: string; +} + +/** + * SessionsByBrowserDaily cube entity (one row per browser family). + */ +export interface SessionsByBrowserDailyEntity { + 'SessionsByBrowserDaily.browserFamily'?: string; + 'SessionsByBrowserDaily.engagedSessions'?: string; + 'SessionsByBrowserDaily.totalSessions'?: string; + 'SessionsByBrowserDaily.avgEngagedSessionTimeSeconds'?: string; +} + +/** + * SessionsByLanguageDaily cube entity (one row per language). + */ +export interface SessionsByLanguageDailyEntity { + 'SessionsByLanguageDaily.languageId'?: string; + 'SessionsByLanguageDaily.engagedSessions'?: string; + 'SessionsByLanguageDaily.totalSessions'?: string; + 'SessionsByLanguageDaily.avgEngagedSessionTimeSeconds'?: string; +} diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts index 60de4579ae3d..050548208382 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.spec.ts @@ -14,6 +14,7 @@ import { extractTopPageValue, fillMissingDates, getDateRange, + getPreviousPeriod, transformDeviceBrowsersData, transformPageViewTimeLineData, transformTopPagesTableData @@ -71,16 +72,16 @@ describe('Analytics Data Utils', () => { expect(result).toBe(1250); }); - it('should return 0 when data is null', () => { + it('should return null when data is null', () => { const result = extractPageViews(null); - expect(result).toBe(0); + expect(result).toBeNull(); }); - it('should return 0 when totalRequest is missing', () => { + it('should return null when totalRequest is missing', () => { const mockData: Partial<TotalPageViewsEntity> = {}; const result = extractPageViews(mockData as TotalPageViewsEntity); - expect(result).toBe(0); + expect(result).toBeNull(); }); it('should handle string numbers correctly', () => { @@ -103,9 +104,9 @@ describe('Analytics Data Utils', () => { expect(result).toBe(342); }); - it('should return 0 when data is null', () => { + it('should return null when data is null', () => { const result = extractSessions(null); - expect(result).toBe(0); + expect(result).toBeNull(); }); it('should return NaN when totalUsers is missing', () => { @@ -128,9 +129,9 @@ describe('Analytics Data Utils', () => { expect(result).toBe(890); }); - it('should return 0 when data is null', () => { + it('should return null when data is null', () => { const result = extractTopPageValue(null); - expect(result).toBe(0); + expect(result).toBeNull(); }); it('should return NaN when totalRequest is missing', () => { @@ -955,6 +956,28 @@ describe('Analytics Data Utils', () => { }); }); + describe('getPreviousPeriod', () => { + it('should return previous period of same length for custom date range', () => { + const result = getPreviousPeriod(['2026-02-01', '2026-02-06']); + expect(result).toEqual(['2026-01-26', '2026-01-31']); + }); + + it('should return previous period for single-day custom range', () => { + const result = getPreviousPeriod(['2026-02-01', '2026-02-01']); + expect(result).toEqual(['2026-01-31', '2026-01-31']); + }); + + it('should return previous period for predefined last7days', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); + const result = getPreviousPeriod('last7days'); + jest.useRealTimers(); + // last7days: Jan 9 - Jan 15 (7 days). Previous: Jan 2 - Jan 8 + expect(result[0]).toBe('2024-01-02'); + expect(result[1]).toBe('2024-01-08'); + }); + }); + describe('fillMissingDates', () => { describe('with PageViewTimeLineEntity', () => { it('should return empty array when data is null', () => { diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts index 7f652ec86d13..aea84eae6d21 100644 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/analytics-data.utils.ts @@ -1,6 +1,7 @@ import { addDays, addHours, + differenceInDays, endOfDay, format, isSameDay, @@ -141,20 +142,32 @@ export function toTimeRangeCubeJS(timeRange: TimeRangeInput): TimeRangeCubeJS { /** * Extracts page views count from TotalPageViewsEntity */ -export const extractPageViews = (data: TotalPageViewsEntity | null): number => - data ? Number(data['EventSummary.totalEvents'] ?? 0) : 0; +export const extractPageViews = (data: TotalPageViewsEntity | null): number | null => { + if (!data) return null; + const value = Number(data['EventSummary.totalEvents'] ?? 0); + + return value === 0 ? null : value; +}; /** * Extracts unique sessions from UniqueVisitorsEntity */ -export const extractSessions = (data: UniqueVisitorsEntity | null): number => - data ? Number(data['EventSummary.uniqueVisitors']) : 0; +export const extractSessions = (data: UniqueVisitorsEntity | null): number | null => { + if (!data) return null; + const value = Number(data['EventSummary.uniqueVisitors']); + + return value === 0 ? null : value; +}; /** * Extracts top page performance value from TopPagePerformanceEntity */ -export const extractTopPageValue = (data: TopPagePerformanceEntity | null): number => - data ? Number(data['EventSummary.totalEvents']) : 0; +export const extractTopPageValue = (data: TopPagePerformanceEntity | null): number | null => { + if (!data) return null; + const value = Number(data['EventSummary.totalEvents']); + + return value === 0 ? null : value; +}; /** * Extracts page title from TopPagePerformanceEntity @@ -600,7 +613,9 @@ type EmptyEntityFactory<T> = (date: Date, dateKey: string) => T; * Generic factory for TimelineEntity types. * Used for PageViewTimeLineEntity and ConversionTrendEntity which share the same structure. */ -export const createEmptyAnalyticsEntity = <T extends TimelineEntity>( +export const createEmptyAnalyticsEntity = < + T extends TimelineEntity & { 'EventSummary.totalEvents': string } +>( date: Date, dateKey: string ): T => @@ -608,7 +623,7 @@ export const createEmptyAnalyticsEntity = <T extends TimelineEntity>( 'EventSummary.day': dateKey, 'EventSummary.day.day': format(date, 'yyyy-MM-dd'), 'EventSummary.totalEvents': '0' - }) as unknown as T; + }) as T; /** * Factory for TrafficVsConversionsEntity @@ -709,3 +724,19 @@ export const getDateRange = (timeRange: TimeRangeInput): [Date, Date] => { return [startOfDay(today), endOfDay(today)]; } }; + +/** + * Get the previous period of the same length as the given time range, ending the day before the current range starts. + * Used for engagement trend comparison (current vs previous period). + * + * @param timeRange - The current time range (predefined or custom [from, to]) + * @returns The previous period as [from, to] date strings (yyyy-MM-dd) for Cube queries + */ +export const getPreviousPeriod = (timeRange: TimeRangeInput): [string, string] => { + const [startDate, endDate] = getDateRange(timeRange); + const days = differenceInDays(endDate, startDate) + 1; + const previousEnd = subDays(startDate, 1); + const previousStart = subDays(previousEnd, days - 1); + + return [format(previousStart, 'yyyy-MM-dd'), format(previousEnd, 'yyyy-MM-dd')]; +}; diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/engagement-data.utils.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/engagement-data.utils.ts new file mode 100644 index 000000000000..c04729fc6009 --- /dev/null +++ b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/data/engagement-data.utils.ts @@ -0,0 +1,303 @@ +import { AnalyticsChartColors, BAR_CHART_STYLE } from '../../constants'; + +import type { + ChartData, + EngagementDailyEntity, + EngagementKPIs, + EngagementPlatformMetrics, + EngagementPlatforms, + SessionsByBrowserDailyEntity, + SessionsByDeviceDailyEntity, + SparklineDataPoint +} from '../../types'; + +const EMPTY_KPIS: EngagementKPIs = { + totalSessions: { value: 0, trend: 0, label: 'Total Sessions' }, + engagementRate: { + value: 0, + trend: 0, + label: 'Engagement Rate', + subtitle: '0 Engaged Sessions' + }, + avgInteractions: { value: 0, trend: 0, label: 'Avg Interactions (Engaged)' }, + avgSessionTime: { value: '0m 0s', trend: 0, label: 'Average Session Time' }, + conversionRate: { value: '0%', trend: 0, label: 'Conversion Rate' } +}; + +function parseNum(s: string | undefined | null): number { + if (!s) return 0; + const n = Number(s); + return Number.isFinite(n) ? n : 0; +} + +/** + * Format seconds to "Xm Ys" for display. + */ +export function formatSecondsToTime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return '0m 0s'; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}m ${s}s`; +} + +/** + * Compute trend percentage: ((current - previous) / previous) * 100. + * - When previous is 0 or missing and current > 0: returns 100 (+100%, "subió desde cero"). + * - When both 0: returns 0. + * - Otherwise: normal percentage change. + */ +export function computeTrendPercent(current: number, previous: number | null | undefined): number { + const prev = previous ?? 0; + if (prev === 0) { + return current > 0 ? 100 : 0; + } + return Math.round(((current - prev) / prev) * 1000) / 10; +} + +/** + * Map EngagementDaily totals (current + previous) to EngagementKPIs. + * Handles empty/insufficient data with defaults. + */ +export function toEngagementKPIs( + currentRow: EngagementDailyEntity | null, + previousRow: EngagementDailyEntity | null +): EngagementKPIs | null { + const current = currentRow ?? {}; + const previous = previousRow ?? {}; + + const totalSessionsCur = parseNum(current['EngagementDaily.totalSessions']); + + if (totalSessionsCur === 0) { + return null; + } + + const totalSessionsPrev = parseNum(previous['EngagementDaily.totalSessions']); + const engagedSessionsCur = parseNum(current['EngagementDaily.engagedSessions']); + const engagementRateCur = parseNum(current['EngagementDaily.engagementRate']); + const conversionRateCur = parseNum(current['EngagementDaily.conversionRate']); + const avgInteractionsCur = parseNum( + current['EngagementDaily.avgInteractionsPerEngagedSession'] + ); + const avgSessionTimeCur = parseNum(current['EngagementDaily.avgSessionTimeSeconds']); + + const engagementRatePrev = parseNum(previous['EngagementDaily.engagementRate']); + const conversionRatePrev = parseNum(previous['EngagementDaily.conversionRate']); + const avgInteractionsPrev = parseNum( + previous['EngagementDaily.avgInteractionsPerEngagedSession'] + ); + const avgSessionTimePrev = parseNum(previous['EngagementDaily.avgSessionTimeSeconds']); + + const engagementRateTrend = computeTrendPercent(engagementRateCur, engagementRatePrev); + const totalSessionsTrend = computeTrendPercent(totalSessionsCur, totalSessionsPrev); + const conversionRateTrend = computeTrendPercent(conversionRateCur, conversionRatePrev); + const avgInteractionsTrend = computeTrendPercent(avgInteractionsCur, avgInteractionsPrev); + const avgSessionTimeTrend = computeTrendPercent(avgSessionTimeCur, avgSessionTimePrev); + + const engagementRateValue = Math.round(engagementRateCur * 10000) / 100; + const conversionRateValue = `${Math.round(conversionRateCur * 1000) / 10}%`; + + return { + totalSessions: { + value: totalSessionsCur, + trend: totalSessionsTrend, + label: 'Total Sessions' + }, + engagementRate: { + value: engagementRateValue, + trend: engagementRateTrend, + label: 'Engagement Rate', + subtitle: `${engagedSessionsCur.toLocaleString()} Engaged Sessions` + }, + avgInteractions: { + value: avgInteractionsCur, + trend: avgInteractionsTrend, + label: 'Avg Interactions (Engaged)' + }, + avgSessionTime: { + value: formatSecondsToTime(avgSessionTimeCur), + trend: avgSessionTimeTrend, + label: 'Average Session Time' + }, + conversionRate: { + value: conversionRateValue, + trend: conversionRateTrend, + label: 'Conversion Rate' + } + }; +} + +/** + * Map EngagementDaily trend-by-day rows to SparklineDataPoint[]. + * Uses conversion rate (engaged_conversion_sessions / total_sessions) per day so the trend + * varies meaningfully; engagement rate per day is often 100% when engaged_sessions === total_sessions. + * When only 1 point exists, prepends a synthetic point at 0 so Chart.js draws a line. + */ +export function toEngagementSparklineData(rows: EngagementDailyEntity[]): SparklineDataPoint[] { + if (!rows?.length) return []; + + const points = rows.map((row) => { + const day = row['EngagementDaily.day.day'] ?? row['EngagementDaily.day'] ?? ''; + const rate = parseNum(row['EngagementDaily.conversionRate']); + return { + date: typeof day === 'string' ? day.slice(0, 10) : '', + value: Math.round(rate * 100) + }; + }); + + if (points.length === 1) { + const only = points[0]; + const prevDate = getPreviousDay(only.date); + points.unshift({ date: prevDate, value: 0 }); + } + + return points; +} + +function getPreviousDay(dateStr: string): string { + const date = new Date(dateStr + 'T00:00:00'); + date.setDate(date.getDate() - 1); + return date.toISOString().slice(0, 10); +} + +/** + * Map EngagementDaily by-day rows to ChartData for the trend chart. + * Handles empty array with default empty ChartData. + */ +export function toEngagementTrendChartData(rows: EngagementDailyEntity[]): ChartData { + if (!rows?.length) { + return { labels: [], datasets: [] }; + } + + const labels = rows.map((row) => { + const day = row['EngagementDaily.day.day'] ?? row['EngagementDaily.day']; + if (typeof day === 'string') return day.slice(0, 10); + return ''; + }); + const data = rows.map((row) => parseNum(row['EngagementDaily.engagedSessions'])); + + return { + labels, + datasets: [ + { + label: 'Trend', + data, + backgroundColor: AnalyticsChartColors.primary.line, + ...BAR_CHART_STYLE + } + ] + }; +} + +/** + * Map totalSessions and engagedSessions to doughnut ChartData (Engaged vs Bounced). + * Returns empty ChartData when totalSessions is 0 so the chart shows empty state. + */ +export function toEngagementBreakdownChartData( + totalSessions: number, + engagedSessions: number +): ChartData { + if (totalSessions === 0) { + return { labels: [], datasets: [] }; + } + const bounced = Math.max(0, totalSessions - engagedSessions); + const engagedPct = Math.round((engagedSessions / totalSessions) * 100); + const bouncedPct = 100 - engagedPct; + + return { + labels: [`Engaged Sessions (${engagedPct}%)`, `Bounced Sessions (${bouncedPct}%)`], + datasets: [ + { + label: 'Engagement Breakdown', + data: [engagedSessions, bounced], + backgroundColor: [AnalyticsChartColors.primary.line, '#000000'] + } + ] + }; +} + +function toPlatformMetrics( + name: string, + views: number, + totalViews: number, + avgTimeSeconds: number +): EngagementPlatformMetrics { + const percentage = totalViews > 0 ? Math.round((views / totalViews) * 100) : 0; + return { + name, + views, + percentage, + time: formatSecondsToTime(avgTimeSeconds) + }; +} + +/** + * Map SessionsByDeviceDaily rows to device platform metrics. + */ +export function toEngagementPlatformsFromDevice( + rows: SessionsByDeviceDailyEntity[] | null +): EngagementPlatformMetrics[] { + if (!rows?.length) return []; + const total = rows.reduce( + (sum, r) => sum + parseNum(r['SessionsByDeviceDaily.engagedSessions']), + 0 + ); + return rows.map((row) => { + const views = parseNum(row['SessionsByDeviceDaily.engagedSessions']); + const avgSec = parseNum(row['SessionsByDeviceDaily.avgEngagedSessionTimeSeconds']); + const name = row['SessionsByDeviceDaily.deviceCategory'] ?? 'Other'; + return toPlatformMetrics(name, views, total, avgSec); + }); +} + +/** + * Map SessionsByBrowserDaily rows to browser platform metrics. + */ +export function toEngagementPlatformsFromBrowser( + rows: SessionsByBrowserDailyEntity[] | null +): EngagementPlatformMetrics[] { + if (!rows?.length) return []; + const total = rows.reduce( + (sum, r) => sum + parseNum(r['SessionsByBrowserDaily.engagedSessions']), + 0 + ); + return rows.map((row) => { + const views = parseNum(row['SessionsByBrowserDaily.engagedSessions']); + const avgSec = parseNum(row['SessionsByBrowserDaily.avgEngagedSessionTimeSeconds']); + const name = row['SessionsByBrowserDaily.browserFamily'] ?? 'Other'; + return toPlatformMetrics(name, views, total, avgSec); + }); +} + +/** + * Build full EngagementPlatforms from device and browser arrays. + */ +export function toEngagementPlatforms( + deviceRows: SessionsByDeviceDailyEntity[] | null, + browserRows: SessionsByBrowserDailyEntity[] | null +): EngagementPlatforms { + return { + device: toEngagementPlatformsFromDevice(deviceRows), + browser: toEngagementPlatformsFromBrowser(browserRows) + }; +} + +/** + * Default empty KPIs when request fails or has no data. + */ +export function getEmptyEngagementKPIs(): EngagementKPIs { + return { ...EMPTY_KPIS }; +} + +/** + * Default empty ChartData for trend or breakdown when no data. + */ +export function getEmptyEngagementChartData(): ChartData { + return { labels: [], datasets: [] }; +} + +/** + * Default empty EngagementPlatforms when no data. + */ +export function getEmptyEngagementPlatforms(): EngagementPlatforms { + return { device: [], browser: [] }; +} diff --git a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/mock-engagement-data.ts b/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/mock-engagement-data.ts deleted file mode 100644 index cb611bfc7656..000000000000 --- a/core-web/libs/portlets/dot-analytics/data-access/src/lib/utils/mock-engagement-data.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { AnalyticsChartColors, BAR_CHART_STYLE } from '../constants/dot-analytics.constants'; -import { ChartData } from '../types/entities.types'; - -export const MOCK_ENGAGEMENT_DATA = { - kpis: { - engagementRate: { - value: 45, - trend: 8, - subtitle: '29,203 Engaged Sessions', - label: 'Engagement Rate', - sparklineData: [ - { date: 'Oct 1', value: 32 }, - { date: 'Oct 2', value: 35 }, - { date: 'Oct 3', value: 38 }, - { date: 'Oct 4', value: 36 }, - { date: 'Oct 5', value: 40 }, - { date: 'Oct 6', value: 42 }, - { date: 'Oct 7', value: 39 }, - { date: 'Oct 8', value: 43 }, - { date: 'Oct 9', value: 41 }, - { date: 'Oct 10', value: 44 }, - { date: 'Oct 11', value: 42 }, - { date: 'Oct 12', value: 45 } - ] - }, - avgInteractions: { value: 6.4, trend: 18, label: 'Avg Interactions (Engaged)' }, - avgSessionTime: { value: '2m 34s', trend: 12, label: 'Average Session Time' }, - conversionRate: { value: '3.2%', trend: -0.3, label: 'Conversion Rate' } - }, - trend: { - labels: ['Oct1', 'Oct2', 'Oct3', 'Oct4', 'Oct5', 'Nov1', 'Nov2', 'Nov3'], - datasets: [ - { - label: 'Trend', - data: [40, 35, 45, 30, 50, 45, 48, 48], - backgroundColor: AnalyticsChartColors.primary.line, - ...BAR_CHART_STYLE - } - ] - } as ChartData, - breakdown: { - labels: ['Engaged Sessions (65%)', 'Bounced Sessions (35%)'], - datasets: [ - { - label: 'Engagement Breakdown', - data: [65, 35], - backgroundColor: [AnalyticsChartColors.primary.line, '#000000'] - } - ] - } as ChartData, - platforms: { - device: [ - { name: 'Desktop', views: 77053, percentage: 72, time: '2m 45s' }, - { name: 'Mobile', views: 16071, percentage: 20, time: '1m 47s' }, - { name: 'Tablet', views: 2531, percentage: 8, time: '2m 00s' } - ], - browser: [ - { name: 'Chrome', views: 60000, percentage: 65, time: '2m 50s' }, - { name: 'Safari', views: 20000, percentage: 25, time: '2m 30s' }, - { name: 'Firefox', views: 10000, percentage: 10, time: '2m 40s' } - ], - language: [ - { name: 'English', views: 80000, percentage: 80, time: '2m 55s' }, - { name: 'Spanish', views: 10000, percentage: 10, time: '2m 20s' }, - { name: 'French', views: 5000, percentage: 5, time: '2m 10s' } - ] - } -}; diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.html index 334ae44a1283..9c893c4f1942 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.html @@ -1,19 +1,21 @@ <div class="flex flex-col portlet-wrapper" data-testid="analytics-dashboard"> <div class="header-container p-4 py-6"> @if ($showMessage()) { - <p-message - severity="info" - [closable]="true" - (onClose)="onCloseMessage()" - data-testid="analytics-message"> - <div class="flex items-center gap-2"> - <i class="pi pi-info-circle"></i> - <span data-testid="message-content"> - {{ 'analytics.feature.state' | dm }} - <b>{{ 'development' | dm }}</b> - </span> - </div> - </p-message> + <div class="pb-3"> + <p-message + severity="info" + [closable]="true" + (onClose)="onCloseMessage()" + data-testid="analytics-message"> + <div class="flex items-center gap-2"> + <i class="pi pi-info-circle"></i> + <span data-testid="message-content"> + {{ 'analytics.feature.state' | dm }} + <b>{{ 'development' | dm }}</b> + </span> + </div> + </p-message> + </div> } <div class="flex justify-between items-center"> @@ -30,7 +32,7 @@ </div> </div> - <div class="content-container px-4"> + <div class="px-4"> <!-- Tabs --> <p-tabs [value]="store.currentTab()" (valueChange)="onTabChange($event)"> <p-tablist> diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.scss index f1e54c492e3e..4ab2de4ea25c 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.scss +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.scss @@ -2,5 +2,5 @@ .header-container { background: $color-palette-gray-100; - border-bottom: 1.5px solid $color-palette-gray-300; + border-bottom: $field-border-size solid $color-palette-gray-300; } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.ts index 27eca6bec160..5dfe56db9468 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/dot-analytics-dashboard.component.ts @@ -25,6 +25,7 @@ import { isValidTab, TimeRangeInput } from '@dotcms/portlets/dot-analytics/data-access'; +import { GlobalStore } from '@dotcms/store'; import { DotMessagePipe } from '@dotcms/ui'; import DotAnalyticsConversionsReportComponent from './reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component'; @@ -35,7 +36,7 @@ import { DotAnalyticsFiltersComponent } from './shared/components/dot-analytics- const HIDE_ANALYTICS_MESSAGE_BANNER_KEY = 'analytics-dashboard-hide-message-banner'; @Component({ - selector: 'lib-dot-analytics-dashboard', + selector: 'dot-analytics-dashboard', imports: [ CommonModule, ButtonModule, @@ -56,8 +57,9 @@ const HIDE_ANALYTICS_MESSAGE_BANNER_KEY = 'analytics-dashboard-hide-message-bann * and feature-flag-gated visibility of the Engagement tab. */ export default class DotAnalyticsDashboardComponent { + readonly #globalStore = inject(GlobalStore); /** Analytics dashboard store providing data and actions */ - readonly store = inject(DotAnalyticsDashboardStore); + protected readonly store = inject(DotAnalyticsDashboardStore); readonly #activatedRoute = inject(ActivatedRoute); readonly #localStorageService = inject(DotLocalstorageService); @@ -89,7 +91,10 @@ export default class DotAnalyticsDashboardComponent { const params = this.#activatedRoute.snapshot.queryParamMap; if (enabled && !params.has('tab')) { - this.store.setCurrentTabAndNavigate(DASHBOARD_TABS.engagement); + this.#globalStore.addNewBreadcrumb({ + label: DASHBOARD_TABS.engagement, + url: '/analytics/dashboard?tab=engagement' + }); } }); } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.html index 0ce65a833bce..823f87ae2935 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.html @@ -5,64 +5,36 @@ @let eventTypeOptions = $eventTypeOptions(); @if (isLoading) { - <!-- Loading State --> - <div class="flex flex-col gap-3 p-4" data-testid="content-conversions-loading"> - @for (i of [1, 2, 3]; track i) { - <div class="flex gap-3"> - <div - class="skeleton-cell flex-1" - style=" - height: 1.5rem; - background: var(--surface-200); - border-radius: 4px; - "></div> - <div - class="skeleton-cell flex-1" - style=" - height: 1.5rem; - background: var(--surface-200); - border-radius: 4px; - "></div> - <div - class="skeleton-cell" - style=" - width: 80px; - height: 1.5rem; - background: var(--surface-200); - border-radius: 4px; - "></div> - <div - class="skeleton-cell" - style=" - width: 80px; - height: 1.5rem; - background: var(--surface-200); - border-radius: 4px; - "></div> - <div - class="skeleton-cell" - style=" - width: 80px; - height: 1.5rem; - background: var(--surface-200); - border-radius: 4px; - "></div> + <div class="skeleton-table" data-testid="content-conversions-loading"> + <div class="skeleton-table__header"> + <div class="flex-1"><p-skeleton height="1rem" width="60%" /></div> + <div class="flex-[2]"><p-skeleton height="1rem" width="50%" /></div> + <div class="w-20"><p-skeleton height="1rem" width="80%" /></div> + <div class="w-24"><p-skeleton height="1rem" width="80%" /></div> + <div class="w-[5.5rem]"><p-skeleton height="1rem" width="80%" /></div> + </div> + @for (i of [1, 2, 3, 4, 5]; track i) { + <div class="skeleton-table__row"> + <div class="flex-1"><p-skeleton height="1rem" width="70%" /></div> + <div class="flex-[2]"><p-skeleton height="1rem" width="60%" /></div> + <div class="w-20"><p-skeleton height="1rem" width="50%" /></div> + <div class="w-24"><p-skeleton height="1rem" width="50%" /></div> + <div class="w-[5.5rem]"><p-skeleton height="1rem" width="60%" /></div> </div> } + <div class="skeleton-table__pagination"> + <p-skeleton height="1.5rem" width="15rem" /> + </div> </div> } @else if (isError) { - <!-- Error State --> - <div class="p-4" style="min-height: 200px" data-testid="content-conversions-error"> + <div class="table-state" data-testid="content-conversions-error"> <dot-analytics-state-message message="analytics.error.loading.content-conversions" icon="pi-exclamation-triangle" /> </div> } @else if (isEmpty) { - <!-- Empty State --> - <div class="p-4" style="min-height: 200px" data-testid="content-conversions-empty"> - <dot-analytics-state-message - message="analytics.table.content-conversions.empty" - icon="pi-info-circle" /> + <div class="table-state" data-testid="content-conversions-empty"> + <dot-analytics-empty-state /> </div> } @else { <!-- Data Table --> @@ -148,8 +120,8 @@ [styleClass]="'event-type-badge ' + row.eventType" /> </td> <td> - <div class="content-title-cell"> - <div class="content-title">{{ row.title }}</div> + <div> + <div>{{ row.title }}</div> <div class="content-identifier">{{ row.identifier }}</div> </div> </td> diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.scss index 281f68f103b9..dd3345102f2f 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.scss +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.scss @@ -1,6 +1,40 @@ @use "variables" as *; -::ng-deep { +$table-min-height: 20rem; + +.table-state { + display: flex; + align-items: center; + justify-content: center; + min-height: $table-min-height; +} + +.skeleton-table { + display: flex; + flex-direction: column; + + &__header { + display: flex; + gap: $spacing-3; + padding: $spacing-3 $spacing-4; + border-bottom: 1px solid var(--surface-200); + } + + &__row { + display: flex; + gap: $spacing-3; + padding: $spacing-3 $spacing-4; + border-bottom: 1px solid var(--surface-100); + } + + &__pagination { + display: flex; + justify-content: center; + padding: $spacing-3 $spacing-4; + } +} + +:host ::ng-deep { .p-tag.event-type-badge { border-radius: $border-radius-sm; font-size: $font-size-xs; diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.spec.ts index b46949d75ff4..d3e7540436d9 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.spec.ts @@ -37,17 +37,15 @@ describe('DotAnalyticsContentConversionsTableComponent', () => { describe('Loading State', () => { beforeEach(() => { - spectator = createComponent({ - props: { - data: [], - status: ComponentStatus.LOADING - } as unknown - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput('data', []); + spectator.setInput('status', ComponentStatus.LOADING); + spectator.detectChanges(); }); it('should display loading skeleton when isLoading is true', () => { expect(spectator.query(byTestId('content-conversions-loading'))).toBeTruthy(); - expect(spectator.queryAll('.skeleton-cell').length).toBeGreaterThan(0); + expect(spectator.queryAll('.skeleton-table__row').length).toBe(5); }); it('should not display table when loading', () => { @@ -57,12 +55,10 @@ describe('DotAnalyticsContentConversionsTableComponent', () => { describe('Error State', () => { beforeEach(() => { - spectator = createComponent({ - props: { - data: [], - status: ComponentStatus.ERROR - } as unknown - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput('data', []); + spectator.setInput('status', ComponentStatus.ERROR); + spectator.detectChanges(); }); it('should display error message when isError is true', () => { @@ -77,17 +73,15 @@ describe('DotAnalyticsContentConversionsTableComponent', () => { describe('Empty State', () => { beforeEach(() => { - spectator = createComponent({ - props: { - data: [], - status: ComponentStatus.LOADED - } as unknown - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput('data', []); + spectator.setInput('status', ComponentStatus.LOADED); + spectator.detectChanges(); }); it('should display empty message when isEmpty is true', () => { expect(spectator.query(byTestId('content-conversions-empty'))).toBeTruthy(); - expect(spectator.query('dot-analytics-state-message')).toBeTruthy(); + expect(spectator.query('dot-analytics-empty-state')).toBeTruthy(); }); it('should not display table when empty', () => { @@ -97,12 +91,10 @@ describe('DotAnalyticsContentConversionsTableComponent', () => { describe('Data Display', () => { beforeEach(() => { - spectator = createComponent({ - props: { - data: mockData, - status: ComponentStatus.LOADED - } as unknown - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput('data', mockData); + spectator.setInput('status', ComponentStatus.LOADED); + spectator.detectChanges(); }); it('should display table with data', () => { diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.ts index 88f9a420d940..9d09f9dbb280 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-content-conversions-table/dot-analytics-content-conversions-table.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, computed, input, linkedSignal } fro import { FormsModule } from '@angular/forms'; import { MultiSelectModule } from 'primeng/multiselect'; +import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; import { TagModule } from 'primeng/tag'; @@ -10,6 +11,7 @@ import { ComponentStatus } from '@dotcms/dotcms-models'; import { ContentConversionRow } from '@dotcms/portlets/dot-analytics/data-access'; import { DotMessagePipe } from '@dotcms/ui'; +import { DotAnalyticsEmptyStateComponent } from '../../../shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component'; import { DotAnalyticsStateMessageComponent } from '../../../shared/components/dot-analytics-state-message/dot-analytics-state-message.component'; /** @@ -23,8 +25,10 @@ import { DotAnalyticsStateMessageComponent } from '../../../shared/components/do CommonModule, FormsModule, MultiSelectModule, + SkeletonModule, TableModule, TagModule, + DotAnalyticsEmptyStateComponent, DotAnalyticsStateMessageComponent, DotMessagePipe ], diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.html index 47950a106f3c..259f0f18671f 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.html @@ -4,63 +4,34 @@ @let data = $data(); @if (isLoading) { - <!-- Loading State --> - <div class="flex flex-col gap-3 p-4" data-testid="conversions-overview-loading"> - @for (i of [1, 2, 3]; track i) { - <div class="flex gap-3"> - <div - class="skeleton-cell flex-1" - style=" - height: 1.5rem; - background: var(--surface-200); - border-radius: 4px; - "></div> - <div - class="skeleton-cell flex-1" - style=" - height: 1.5rem; - background: var(--surface-200); - border-radius: 4px; - "></div> - <div - class="skeleton-cell" - style=" - width: 100px; - height: 1.5rem; - background: var(--surface-200); - border-radius: 4px; - "></div> - <div - class="skeleton-cell" - style=" - width: 100px; - height: 1.5rem; - background: var(--surface-200); - border-radius: 4px; - "></div> - <div - class="skeleton-cell flex-2" - style=" - height: 1.5rem; - background: var(--surface-200); - border-radius: 4px; - "></div> + <div class="skeleton-table" data-testid="conversions-overview-loading"> + <div class="skeleton-table__header"> + <div class="flex-[2]"><p-skeleton height="1rem" width="60%" /></div> + <div class="w-20"><p-skeleton height="1rem" width="80%" /></div> + <div class="w-[5.5rem]"><p-skeleton height="1rem" width="80%" /></div> + <div class="flex-[2]"><p-skeleton height="1rem" width="50%" /></div> + </div> + @for (i of [1, 2, 3, 4, 5]; track i) { + <div class="skeleton-table__row"> + <div class="flex-[2]"><p-skeleton height="1rem" width="70%" /></div> + <div class="w-20"><p-skeleton height="1rem" width="50%" /></div> + <div class="w-[5.5rem]"><p-skeleton height="1rem" width="60%" /></div> + <div class="flex-[2]"><p-skeleton height="1rem" width="55%" /></div> </div> } + <div class="skeleton-table__pagination"> + <p-skeleton height="1.5rem" width="15rem" /> + </div> </div> } @else if (isError) { - <!-- Error State --> - <div class="p-4" style="min-height: 200px" data-testid="conversions-overview-error"> + <div class="table-state" data-testid="conversions-overview-error"> <dot-analytics-state-message message="analytics.error.loading.conversions-overview" icon="pi-exclamation-triangle" /> </div> } @else if (isEmpty) { - <!-- Empty State --> - <div class="p-4" style="min-height: 200px" data-testid="conversions-overview-empty"> - <dot-analytics-state-message - message="analytics.table.conversions-overview.empty" - icon="pi-info-circle" /> + <div class="table-state" data-testid="conversions-overview-empty"> + <dot-analytics-empty-state /> </div> } @else { <!-- Data Table --> diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.scss index 1d490fc5c444..94f0ff2bfad4 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.scss +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.scss @@ -1,17 +1,41 @@ @use "variables" as *; -.table-title { - font-size: $font-size-lmd; - font-weight: $font-weight-bold; - color: $black; - line-height: $line-height-relative; - margin: 0; +$table-min-height: 20rem; + +.table-state { display: flex; align-items: center; + justify-content: center; + min-height: $table-min-height; } -// // Custom conversion name badges -::ng-deep { +.skeleton-table { + display: flex; + flex-direction: column; + + &__header { + display: flex; + gap: $spacing-3; + padding: $spacing-3 $spacing-4; + border-bottom: 1px solid var(--surface-200); + } + + &__row { + display: flex; + gap: $spacing-3; + padding: $spacing-3 $spacing-4; + border-bottom: 1px solid var(--surface-100); + } + + &__pagination { + display: flex; + justify-content: center; + padding: $spacing-3 $spacing-4; + } +} + +// Custom PrimeNG tag overrides +:host ::ng-deep { .p-tag.conversion-name-badge { background-color: $color-palette-blue-tint; border: 1px solid $color-palette-blue-op-20; @@ -21,6 +45,45 @@ font-weight: $font-weight-semi-bold; padding: $spacing-0 $spacing-2; } + + // Event type badge styling + .p-tag.event-type-badge { + border-radius: $border-radius-sm; + font-size: $font-size-xs; + font-weight: $font-weight-semi-bold; + padding: $spacing-0 $spacing-2; + border-width: 1px; + border-style: solid; + } + + // page_view / pageview - Gray neutral + .p-tag.event-type-badge.page_view, + .p-tag.event-type-badge.pageview { + background-color: $color-palette-gray-200; + color: $color-palette-gray-700; + border-color: $color-palette-gray-300; + } + + // content_click - Verde + .p-tag.event-type-badge.content_click { + background-color: $color-palette-green-tint; + color: $color-palette-green-shade; + border-color: $color-palette-green-op-20; + } + + // content_impression - Amarillo/Naranja + .p-tag.event-type-badge.content_impression { + background-color: $color-palette-yellow-tint; + color: $color-palette-yellow-shade; + border-color: $color-palette-yellow-op-20; + } + + // conversion - Purple + .p-tag.event-type-badge.conversion { + background-color: $color-palette-purple-tint; + color: $color-palette-purple-shade; + border-color: $color-palette-purple-op-20; + } } // // Top Attributed Content - single row, compact @@ -60,51 +123,10 @@ text-align: right; } -// .more-indicator { -// font-size: $font-size-xs; -// color: $color-palette-gray-500; -// background: $color-palette-gray-100; -// border-radius: $border-radius-sm; -// padding: 0 $spacing-1; -// } - -// // Event type badge styling -::ng-deep { - .p-tag.event-type-badge { - border-radius: $border-radius-sm; - font-size: $font-size-xs; - font-weight: $font-weight-semi-bold; - padding: $spacing-0 $spacing-2; - border-width: 1px; - border-style: solid; - } - - // page_view / pageview - Gray neutral - .p-tag.event-type-badge.page_view, - .p-tag.event-type-badge.pageview { - background-color: $color-palette-gray-200; - color: $color-palette-gray-700; - border-color: $color-palette-gray-300; - } - - // content_click - Verde - .p-tag.event-type-badge.content_click { - background-color: $color-palette-green-tint; - color: $color-palette-green-shade; - border-color: $color-palette-green-op-20; - } - - // content_impression - Amarillo/Naranja - .p-tag.event-type-badge.content_impression { - background-color: $color-palette-yellow-tint; - color: $color-palette-yellow-shade; - border-color: $color-palette-yellow-op-20; - } - - // conversion - Purple - .p-tag.event-type-badge.conversion { - background-color: $color-palette-purple-tint; - color: $color-palette-purple-shade; - border-color: $color-palette-purple-op-20; - } +.more-indicator { + font-size: $font-size-xs; + color: $color-palette-gray-500; + background: $color-palette-gray-100; + border-radius: $border-radius-sm; + padding: 0 $spacing-1; } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.spec.ts index 74b49dd6fc0a..1cd116fc0f01 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.spec.ts @@ -56,17 +56,13 @@ describe('DotAnalyticsConversionsOverviewTableComponent', () => { describe('Loading State', () => { beforeEach(() => { - spectator = createComponent({ - props: { - data: [], - status: ComponentStatus.LOADING - } as unknown - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput({ data: [], status: ComponentStatus.LOADING }); }); it('should display loading skeleton when isLoading is true', () => { expect(spectator.query(byTestId('conversions-overview-loading'))).toBeTruthy(); - expect(spectator.queryAll('.skeleton-cell').length).toBeGreaterThan(0); + expect(spectator.queryAll('.skeleton-table__row').length).toBe(5); }); it('should not display table when loading', () => { @@ -76,12 +72,8 @@ describe('DotAnalyticsConversionsOverviewTableComponent', () => { describe('Error State', () => { beforeEach(() => { - spectator = createComponent({ - props: { - data: [], - status: ComponentStatus.ERROR - } as unknown - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput({ data: [], status: ComponentStatus.ERROR }); }); it('should display error message when isError is true', () => { @@ -96,17 +88,13 @@ describe('DotAnalyticsConversionsOverviewTableComponent', () => { describe('Empty State', () => { beforeEach(() => { - spectator = createComponent({ - props: { - data: [], - status: ComponentStatus.LOADED - } as unknown - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput({ data: [], status: ComponentStatus.LOADED }); }); it('should display empty message when isEmpty is true', () => { expect(spectator.query(byTestId('conversions-overview-empty'))).toBeTruthy(); - expect(spectator.query('dot-analytics-state-message')).toBeTruthy(); + expect(spectator.query('dot-analytics-empty-state')).toBeTruthy(); }); it('should not display table when empty', () => { @@ -116,12 +104,8 @@ describe('DotAnalyticsConversionsOverviewTableComponent', () => { describe('Data Display', () => { beforeEach(() => { - spectator = createComponent({ - props: { - data: mockData, - status: ComponentStatus.LOADED - } as unknown - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput({ data: mockData, status: ComponentStatus.LOADED }); }); it('should display table with data', () => { @@ -144,16 +128,15 @@ describe('DotAnalyticsConversionsOverviewTableComponent', () => { ); }); - it('should display top attributed content items', () => { + it('should display the first attributed content item', () => { const firstRow = spectator.queryAll('p-table tbody tr')[0]; - const lastCell = firstRow.querySelectorAll('td')[3]; // Top Attributed Content column - const contentItems = lastCell.querySelectorAll('.attributed-content-item'); - - // Template shows one .attributed-content-item per row (first item only); "+N" for the rest - expect(contentItems.length).toBe(1); - const firstItem = mockData[0]['Conversion.topAttributedContent'][0]; - expect(lastCell.textContent).toContain(firstItem.title); - expect(lastCell.querySelector('.more-indicator')?.textContent?.trim()).toBe('+1'); + const lastCell = firstRow.querySelectorAll('td')[3]; + const contentItem = lastCell.querySelector('.attributed-content-item'); + + expect(contentItem).toExist(); + expect(contentItem.textContent).toContain( + mockData[0]['Conversion.topAttributedContent'][0].title + ); }); it('should display header columns', () => { diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.ts index 8913cd551cc4..77e8656e105e 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-overview-table/dot-analytics-conversions-overview-table.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; import { TagModule } from 'primeng/tag'; @@ -8,6 +9,7 @@ import { ComponentStatus } from '@dotcms/dotcms-models'; import { ConversionsOverviewEntity } from '@dotcms/portlets/dot-analytics/data-access'; import { DotMessagePipe } from '@dotcms/ui'; +import { DotAnalyticsEmptyStateComponent } from '../../../shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component'; import { DotAnalyticsStateMessageComponent } from '../../../shared/components/dot-analytics-state-message/dot-analytics-state-message.component'; /** @@ -19,8 +21,10 @@ import { DotAnalyticsStateMessageComponent } from '../../../shared/components/do selector: 'dot-analytics-conversions-overview-table', imports: [ CommonModule, + SkeletonModule, TableModule, TagModule, + DotAnalyticsEmptyStateComponent, DotAnalyticsStateMessageComponent, DotMessagePipe ], diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.html index d6a690a90c81..f0ce31f440ba 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.html @@ -1,50 +1,56 @@ -<!-- Metrics --> -<div class="flex flex-wrap gap-3"> - @for (metric of $metricsData(); track metric.name) { - <div class="flex-1"> - <dot-analytics-metric - [title]="metric.name" - [value]="metric.value || 0" - [subtitle]="metric.subtitle || ''" - [icon]="metric.icon || ''" - [status]="metric.status" - data-testid="analytics-metric-card" /> - </div> - } -</div> - -<!-- Charts: Conversion Trend + Traffic vs Conversions --> -<div class="flex flex-col lg:flex-row gap-3"> - <div class="flex-1 min-w-0"> - <dot-analytics-chart - [title]="'analytics.charts.conversion-trend.title'" - type="line" - [data]="$conversionTrendData()" - [status]="$conversionTrendStatus()" - data-testid="analytics-conversion-trend-chart" /> +<div class="flex flex-col gap-10"> + <!-- Metrics --> + <div class="flex flex-wrap gap-6"> + @for (metric of $metricsData(); track metric.name) { + <div class="flex-1"> + <dot-analytics-metric + [title]="metric.name" + [value]="metric.value" + [subtitle]="metric.subtitle" + [icon]="metric.icon" + [status]="metric.status" + data-testid="analytics-metric-card" /> + </div> + } </div> - <div class="flex-1 min-w-0"> - <dot-analytics-chart - [title]="$trafficVsConversionsTitle()" - type="bar" - [data]="$trafficVsConversionsData()" - [status]="$trafficVsConversionsStatus()" - data-testid="analytics-traffic-conversions-chart" /> + + <!-- Charts: Conversion Trend + Traffic vs Conversions --> + <div class="flex flex-col lg:flex-row gap-6"> + <div class="flex-1 min-w-0"> + <dot-analytics-chart + [title]="'analytics.charts.conversion-trend.title'" + type="line" + [data]="$conversionTrendData()" + [status]="$conversionTrendStatus()" + data-testid="analytics-conversion-trend-chart" /> + </div> + <div class="flex-1 min-w-0"> + <dot-analytics-chart + [title]="$trafficVsConversionsTitle()" + type="bar" + [data]="$trafficVsConversionsData()" + [status]="$trafficVsConversionsStatus()" + data-testid="analytics-traffic-conversions-chart" /> + </div> </div> -</div> -<!-- Content Conversions Table --> -<div> - <h3 class="text-lg font-bold mb-2">{{ 'analytics.table.content-conversions.title' | dm }}</h3> - <dot-analytics-content-conversions-table - [data]="$contentConversionsData()" - [status]="$contentConversionsStatus()" /> -</div> + <!-- Content Conversions Table --> + <div> + <h3 class="text-lg font-bold mb-2"> + {{ 'analytics.table.content-conversions.title' | dm }} + </h3> + <dot-analytics-content-conversions-table + [data]="$contentConversionsData()" + [status]="$contentConversionsStatus()" /> + </div> -<!-- Conversions Overview Table --> -<div> - <h3 class="text-lg font-bold mb-2">{{ 'analytics.table.conversions-overview.title' | dm }}</h3> - <dot-analytics-conversions-overview-table - [data]="$conversionsOverviewData()" - [status]="$conversionsOverviewStatus()" /> + <!-- Conversions Overview Table --> + <div> + <h3 class="text-lg font-bold mb-2"> + {{ 'analytics.table.conversions-overview.title' | dm }} + </h3> + <dot-analytics-conversions-overview-table + [data]="$conversionsOverviewData()" + [status]="$conversionsOverviewStatus()" /> + </div> </div> diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.ts index 56a27c0eb62d..9ac2f311b4e7 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/conversions/dot-analytics-conversions-report/dot-analytics-conversions-report.component.ts @@ -50,7 +50,7 @@ import DotAnalyticsConversionsOverviewTableComponent from '../dot-analytics-conv }) export default class DotAnalyticsConversionsReportComponent implements OnInit { /** Analytics dashboard store providing conversions data and actions */ - readonly store = inject(DotAnalyticsDashboardStore); + protected readonly store = inject(DotAnalyticsDashboardStore); readonly #globalStore = inject(GlobalStore); readonly #messageService = inject(DotMessageService); @@ -108,23 +108,28 @@ export default class DotAnalyticsConversionsReportComponent implements OnInit { const totalConversions = this.store.totalConversions(); const convertingVisitors = this.store.convertingVisitors(); - const totalConversionsValue = totalConversions.data + const totalConversionsRaw = totalConversions.data ? parseInt(totalConversions.data['EventSummary.totalEvents'], 10) - : 0; + : null; + const totalConversionsValue = totalConversionsRaw === 0 ? null : totalConversionsRaw; const uniqueVisitors = convertingVisitors.data ? parseInt(convertingVisitors.data['EventSummary.uniqueVisitors'], 10) - : 0; + : null; const uniqueConvertingVisitors = convertingVisitors.data ? parseInt(convertingVisitors.data['EventSummary.uniqueConvertingVisitors'], 10) - : 0; + : null; - // Site Conversion Rate = (uniqueConvertingVisitors / uniqueVisitors) * 100 - const conversionRate = - uniqueVisitors > 0 - ? Math.round((uniqueConvertingVisitors / uniqueVisitors) * 10000) / 100 - : 0; + const hasVisitorData = uniqueVisitors != null && uniqueVisitors > 0; + + const conversionRate = hasVisitorData + ? `${Math.round(((uniqueConvertingVisitors ?? 0) / uniqueVisitors) * 10000) / 100}%` + : null; + + const convertingVisitorsValue = hasVisitorData + ? `${uniqueConvertingVisitors ?? 0}/${uniqueVisitors}` + : null; return [ { @@ -137,7 +142,7 @@ export default class DotAnalyticsConversionsReportComponent implements OnInit { }, { name: 'analytics.metrics.converting-visitors', - value: `${uniqueConvertingVisitors}/${uniqueVisitors}`, + value: convertingVisitorsValue, subtitle: 'analytics.metrics.converting-visitors.subtitle', icon: 'pi-users', status: convertingVisitors.status, @@ -145,7 +150,7 @@ export default class DotAnalyticsConversionsReportComponent implements OnInit { }, { name: 'analytics.metrics.site-conversion-rate', - value: `${conversionRate}%`, + value: conversionRate, subtitle: 'analytics.metrics.site-conversion-rate.subtitle', icon: 'pi-chart-line', status: convertingVisitors.status, diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.html index 5144244130d7..025ed96be080 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.html @@ -1,67 +1,75 @@ -@let kpis = $kpis(); -@let status = $status(); +<div class="engagement-dashboard flex flex-col gap-10"> + @let kpis = $kpis(); + @let kpisStatus = $kpisStatus(); -<!-- Engagement Rate (full width) --> -<dot-analytics-metric - [title]="'analytics.engagement.metrics.engagement-rate'" - [value]="(kpis?.engagementRate?.value ?? '') + (kpis?.engagementRate?.value ? '%' : '')" - [trend]="kpis?.engagementRate?.trend ?? 0" - [subtitle]="kpis?.engagementRate?.subtitle ?? ''" - [status]="status"> - <i - titleAction - class="pi pi-info-circle ml-2 cursor-pointer text-color-secondary" - (click)="$showCalculationDialog.set(true)"></i> - <dot-analytics-sparkline - [data]="kpis?.engagementRate?.sparklineData ?? []" - [valueLabel]="'analytics.engagement.sparkline.value-label' | dm" - valueSuffix="%" - [status]="status" /> -</dot-analytics-metric> - -<!-- KPI Metrics Row --> -<div class="grid grid-cols-1 md:grid-cols-3 gap-3"> - <dot-analytics-metric - [title]="'analytics.engagement.metrics.avg-interactions'" - [value]="kpis?.avgInteractions?.value ?? ''" - [trend]="kpis?.avgInteractions?.trend ?? 0" - icon="pi-bullseye" - [status]="status" /> - <dot-analytics-metric - [title]="'analytics.engagement.metrics.avg-session-time'" - [value]="kpis?.avgSessionTime?.value ?? ''" - [trend]="kpis?.avgSessionTime?.trend ?? 0" - icon="pi-clock" - [status]="status" /> - <dot-analytics-metric - [title]="'analytics.engagement.metrics.total-sessions'" - [value]="kpis?.conversionRate?.value ?? ''" - [trend]="kpis?.conversionRate?.trend ?? 0" - icon="pi-bullseye" - [status]="status" /> -</div> - -<!-- Breakdown Chart + Platforms Table --> -<div class="flex flex-col lg:flex-row gap-3 items-stretch"> - <div class="flex flex-col lg:w-5/12 shrink-0"> - <dot-analytics-chart - [title]="'analytics.engagement.charts.breakdown.title'" - [type]="'doughnut'" - [data]="$breakdown()!" - [status]="status" /> + <div class="engagement-dashboard__top-row"> + <dot-analytics-metric + class="h-full" + [title]="'analytics.engagement.metrics.engagement-rate'" + [value]="(kpis?.engagementRate?.value ?? '') + (kpis?.engagementRate?.value ? '%' : '')" + [trend]="kpis?.engagementRate?.trend ?? 0" + [subtitle]="kpis?.engagementRate?.subtitle ?? ''" + [status]="kpisStatus"> + <i + titleAction + data-testid="engagement-info-icon" + class="pi pi-info-circle ml-2 cursor-pointer text-color-secondary" + (click)="$showCalculationDialog.set(true)"></i> + <dot-analytics-sparkline + [datasets]="$sparklineDatasets()" + [valueLabel]="'analytics.engagement.sparkline.value-label' | dm" + valueSuffix="%" + [status]="$sparklineStatus()" /> + </dot-analytics-metric> </div> - <div class="flex flex-col flex-1 min-w-0"> - <dot-analytics-platforms-table - class="flex-1" - [platforms]="$platforms()" - [status]="status" - [totalSessions]="$totalSessions()" /> + + <div class="engagement-dashboard__metrics-row grid grid-cols-1 md:grid-cols-3 gap-6"> + <dot-analytics-metric + [title]="'analytics.engagement.metrics.avg-interactions'" + [value]="kpis?.avgInteractions?.value ?? ''" + [trend]="kpis?.avgInteractions?.trend ?? 0" + icon="pi-bullseye" + [status]="kpisStatus" /> + <dot-analytics-metric + [title]="'analytics.engagement.metrics.avg-session-time'" + [value]="kpis?.avgSessionTime?.value ?? ''" + [trend]="kpis?.avgSessionTime?.trend ?? 0" + icon="pi-clock" + [status]="kpisStatus" /> + <dot-analytics-metric + [title]="'analytics.engagement.metrics.total-sessions'" + [value]="kpis?.totalSessions?.value ?? ''" + [trend]="kpis?.totalSessions?.trend ?? 0" + icon="pi-bullseye" + [status]="kpisStatus" /> </div> + + @defer (on viewport) { + <div class="engagement-dashboard__bottom-row flex flex-col lg:flex-row gap-6 items-stretch"> + <div class="flex flex-col lg:w-5/12 shrink-0"> + <dot-analytics-chart + [title]="'analytics.engagement.charts.breakdown.title'" + [type]="'doughnut'" + [data]="$breakdown() ?? { labels: [], datasets: [] }" + [status]="$breakdownStatus()" /> + </div> + <div class="flex flex-col flex-1 min-w-0"> + <dot-analytics-platforms-table + class="flex-1" + [platforms]="$platforms() ?? { device: [], browser: [] }" + [status]="$platformsStatus()" + [totalSessions]="$totalSessions()" /> + </div> + </div> + } @placeholder { + <div data-testid="deferred-placeholder" class="min-h-96"></div> + } </div> <p-dialog [header]="'analytics.engagement.dialog.how-calculated.title' | dm" - [(visible)]="$showCalculationDialog" + [visible]="$showCalculationDialog()" + (visibleChange)="$showCalculationDialog.set($event)" [modal]="true" [dismissableMask]="true" [closable]="true" @@ -84,4 +92,9 @@ <p class="text-color-secondary mt-3 mb-0"> {{ 'analytics.engagement.dialog.how-calculated.conclusion' | dm }} </p> + <ng-template pTemplate="footer"> + <p-button + [label]="'analytics.engagement.dialog.how-calculated.got-it' | dm" + (onClick)="$showCalculationDialog.set(false)" /> + </ng-template> </p-dialog> diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.spec.ts index bb2e6875257b..ddee024abad3 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.spec.ts @@ -1,17 +1,22 @@ -import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; import { MockComponent } from 'ng-mocks'; import { signal } from '@angular/core'; +import { DeferBlockState } from '@angular/core/testing'; import { ButtonModule } from 'primeng/button'; import { DialogModule } from 'primeng/dialog'; import { DotMessageService } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; -import { - DotAnalyticsDashboardStore, - MOCK_ENGAGEMENT_DATA +import type { + ChartData, + EngagementKPIs, + EngagementPlatforms, + SparklineDataPoint } from '@dotcms/portlets/dot-analytics/data-access'; +// eslint-disable-next-line no-duplicate-imports +import { DotAnalyticsDashboardStore } from '@dotcms/portlets/dot-analytics/data-access'; import { GlobalStore } from '@dotcms/store'; import { DotMessagePipe } from '@dotcms/ui'; @@ -22,12 +27,60 @@ import { DotAnalyticsMetricComponent } from '../../../shared/components/dot-anal import { DotAnalyticsSparklineComponent } from '../../../shared/components/dot-analytics-sparkline/dot-analytics-sparkline.component'; import { DotAnalyticsPlatformsTableComponent } from '../dot-analytics-platforms-table/dot-analytics-platforms-table.component'; +const MOCK_KPIS: EngagementKPIs = { + totalSessions: { value: 45000, trend: 5, label: 'Total Sessions' }, + engagementRate: { + value: 45, + trend: 8, + subtitle: '29,203 Engaged Sessions', + label: 'Engagement Rate' + }, + avgInteractions: { value: 6.4, trend: 18, label: 'Avg Interactions (Engaged)' }, + avgSessionTime: { value: '2m 34s', trend: 12, label: 'Average Session Time' }, + conversionRate: { value: '3.2%', trend: -0.3, label: 'Conversion Rate' } +}; + +const MOCK_BREAKDOWN: ChartData = { + labels: ['Engaged Sessions (65%)', 'Bounced Sessions (35%)'], + datasets: [ + { label: 'Engagement Breakdown', data: [65, 35], backgroundColor: ['#6366F1', '#000000'] } + ] +}; + +const MOCK_PLATFORMS: EngagementPlatforms = { + device: [ + { name: 'Desktop', views: 77053, percentage: 72, time: '2m 45s' }, + { name: 'Mobile', views: 16071, percentage: 20, time: '1m 47s' }, + { name: 'Tablet', views: 2531, percentage: 8, time: '2m 00s' } + ], + browser: [ + { name: 'Chrome', views: 60000, percentage: 65, time: '2m 50s' }, + { name: 'Safari', views: 20000, percentage: 25, time: '2m 30s' }, + { name: 'Firefox', views: 10000, percentage: 10, time: '2m 40s' } + ] +}; + describe('DotAnalyticsEngagementReportComponent', () => { let spectator: Spectator<DotAnalyticsEngagementReportComponent>; - const mockEngagementData = signal({ + const mockKpis = signal({ + status: ComponentStatus.LOADED, + data: MOCK_KPIS, + error: null + }); + const mockBreakdown = signal({ status: ComponentStatus.LOADED, - data: MOCK_ENGAGEMENT_DATA, + data: MOCK_BREAKDOWN, + error: null + }); + const mockPlatforms = signal({ + status: ComponentStatus.LOADED, + data: MOCK_PLATFORMS, + error: null + }); + const mockSparkline = signal({ + status: ComponentStatus.LOADED, + data: { current: [], previous: null as SparklineDataPoint[] | null }, error: null }); @@ -37,8 +90,10 @@ describe('DotAnalyticsEngagementReportComponent', () => { const createComponent = createComponentFactory({ component: DotAnalyticsEngagementReportComponent, - imports: [ButtonModule, DialogModule, DotMessagePipe], - declarations: [ + imports: [ + ButtonModule, + DialogModule, + DotMessagePipe, MockComponent(DotAnalyticsMetricComponent), MockComponent(DotAnalyticsChartComponent), MockComponent(DotAnalyticsPlatformsTableComponent), @@ -48,7 +103,10 @@ describe('DotAnalyticsEngagementReportComponent', () => { { provide: DotAnalyticsDashboardStore, useValue: { - engagementData: mockEngagementData + engagementKpis: mockKpis, + engagementBreakdown: mockBreakdown, + engagementPlatforms: mockPlatforms, + engagementSparkline: mockSparkline } }, { @@ -63,9 +121,24 @@ describe('DotAnalyticsEngagementReportComponent', () => { beforeEach(() => { jest.clearAllMocks(); - mockEngagementData.set({ + mockKpis.set({ status: ComponentStatus.LOADED, - data: MOCK_ENGAGEMENT_DATA, + data: MOCK_KPIS, + error: null + }); + mockBreakdown.set({ + status: ComponentStatus.LOADED, + data: MOCK_BREAKDOWN, + error: null + }); + mockPlatforms.set({ + status: ComponentStatus.LOADED, + data: MOCK_PLATFORMS, + error: null + }); + mockSparkline.set({ + status: ComponentStatus.LOADED, + data: { current: [], previous: null }, error: null }); }); @@ -95,9 +168,12 @@ describe('DotAnalyticsEngagementReportComponent', () => { expect(metrics.length).toBe(4); }); - it('should display 1 chart (breakdown doughnut)', () => { + it('should display 1 chart (breakdown doughnut) in deferred content', async () => { spectator = createComponent(); spectator.detectChanges(); + const deferBlocks = await spectator.fixture.getDeferBlocks(); + await deferBlocks[0].render(DeferBlockState.Complete); + spectator.detectChanges(); const charts = spectator.queryAll(DotAnalyticsChartComponent); expect(charts.length).toBe(1); }); @@ -109,9 +185,12 @@ describe('DotAnalyticsEngagementReportComponent', () => { expect(sparklines.length).toBe(1); }); - it('should display platforms table in breakdown section', () => { + it('should display platforms table in deferred content', async () => { spectator = createComponent(); spectator.detectChanges(); + const deferBlocks = await spectator.fixture.getDeferBlocks(); + await deferBlocks[0].render(DeferBlockState.Complete); + spectator.detectChanges(); const platformsTable = spectator.query(DotAnalyticsPlatformsTableComponent); expect(platformsTable).toBeTruthy(); }); @@ -127,7 +206,7 @@ describe('DotAnalyticsEngagementReportComponent', () => { it('should show info icon in engagement rate metric', () => { spectator = createComponent(); spectator.detectChanges(); - const infoIcon = spectator.query('.pi-info-circle'); + const infoIcon = spectator.query(byTestId('engagement-info-icon')); expect(infoIcon).toBeTruthy(); }); @@ -136,15 +215,15 @@ describe('DotAnalyticsEngagementReportComponent', () => { spectator.detectChanges(); expect(spectator.component.$showCalculationDialog()).toBe(false); - spectator.click('.pi-info-circle'); + spectator.click(byTestId('engagement-info-icon')); spectator.detectChanges(); expect(spectator.component.$showCalculationDialog()).toBe(true); }); }); describe('Loading State', () => { - it('should have $isLoaded as false when loading', () => { - mockEngagementData.set({ + it('should have $isKpisLoaded as false when KPIs are loading', () => { + mockKpis.set({ status: ComponentStatus.LOADING, data: null, error: null @@ -152,19 +231,19 @@ describe('DotAnalyticsEngagementReportComponent', () => { spectator = createComponent(); spectator.detectChanges(); - expect(spectator.component.$isLoaded()).toBe(false); + expect(spectator.component.$isKpisLoaded()).toBe(false); }); - it('should have $isLoaded as true when loaded', () => { - mockEngagementData.set({ + it('should have $isKpisLoaded as true when KPIs are loaded', () => { + mockKpis.set({ status: ComponentStatus.LOADED, - data: MOCK_ENGAGEMENT_DATA, + data: MOCK_KPIS, error: null }); spectator = createComponent(); spectator.detectChanges(); - expect(spectator.component.$isLoaded()).toBe(true); + expect(spectator.component.$isKpisLoaded()).toBe(true); }); }); }); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.ts index 267c0232cc19..64f8e978036f 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-engagement-report/dot-analytics-engagement-report.component.ts @@ -13,18 +13,25 @@ import { DialogModule } from 'primeng/dialog'; import { DotMessageService } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; -import { DotAnalyticsDashboardStore } from '@dotcms/portlets/dot-analytics/data-access'; +import { + AnalyticsChartColors, + DotAnalyticsDashboardStore +} from '@dotcms/portlets/dot-analytics/data-access'; import { GlobalStore } from '@dotcms/store'; import { DotMessagePipe } from '@dotcms/ui'; import { DotAnalyticsChartComponent } from '../../../shared/components/dot-analytics-chart/dot-analytics-chart.component'; import { DotAnalyticsMetricComponent } from '../../../shared/components/dot-analytics-metric/dot-analytics-metric.component'; -import { DotAnalyticsSparklineComponent } from '../../../shared/components/dot-analytics-sparkline/dot-analytics-sparkline.component'; +import { + DotAnalyticsSparklineComponent, + SparklineDataset +} from '../../../shared/components/dot-analytics-sparkline/dot-analytics-sparkline.component'; import { DotAnalyticsPlatformsTableComponent } from '../dot-analytics-platforms-table/dot-analytics-platforms-table.component'; /** * DotAnalyticsEngagementReportComponent displays the engagement dashboard. * It includes the engagement rate, trend chart, and platforms table. + * Each block (KPIs, breakdown, platforms) has independent loading and error state. */ @Component({ selector: 'dot-analytics-engagement-report', @@ -47,13 +54,10 @@ import { DotAnalyticsPlatformsTableComponent } from '../dot-analytics-platforms- }) export default class DotAnalyticsEngagementReportComponent implements OnInit { /** Analytics dashboard store providing engagement data and actions */ - readonly store = inject(DotAnalyticsDashboardStore); + protected readonly store = inject(DotAnalyticsDashboardStore); readonly #globalStore = inject(GlobalStore); readonly #messageService = inject(DotMessageService); - /** Raw engagement data slice from the store */ - readonly engagementData = this.store.engagementData; - /** Controls visibility of the "How it's calculated" dialog */ readonly $showCalculationDialog = signal(false); @@ -64,18 +68,64 @@ export default class DotAnalyticsEngagementReportComponent implements OnInit { }); } - /** Key performance indicators (engagement rate, avg session time, etc.) */ - readonly $kpis = computed(() => this.engagementData().data?.kpis); - /** Engagement trend data for the sparkline/trend chart */ - readonly $trend = computed(() => this.engagementData().data?.trend); - /** Engagement breakdown data for the doughnut chart */ - readonly $breakdown = computed(() => this.engagementData().data?.breakdown); - /** Platform analytics data (device, browser, language) */ - readonly $platforms = computed(() => this.engagementData().data?.platforms); - /** Current component status derived from store data */ - readonly $status = computed(() => this.engagementData().status ?? ComponentStatus.INIT); - /** Whether data has finished loading successfully */ - readonly $isLoaded = computed(() => this.$status() === ComponentStatus.LOADED); + /** KPIs slice: data and status for the metric cards */ + readonly $kpis = computed(() => this.store.engagementKpis().data); + readonly $kpisStatus = computed( + () => this.store.engagementKpis().status ?? ComponentStatus.INIT + ); + + /** Breakdown slice: doughnut chart data and status */ + readonly $breakdown = computed(() => this.store.engagementBreakdown().data); + readonly $breakdownStatus = computed( + () => this.store.engagementBreakdown().status ?? ComponentStatus.INIT + ); + + /** Platforms slice: device/browser/language and status */ + readonly $platforms = computed(() => this.store.engagementPlatforms().data); + readonly $platformsStatus = computed( + () => this.store.engagementPlatforms().status ?? ComponentStatus.INIT + ); + + /** Sparkline: current + optional previous period as datasets for the sparkline component */ + readonly $sparklineDatasets = computed<SparklineDataset[]>(() => { + const slice = this.store.engagementSparkline().data; + if (!slice?.current?.length) return []; + + const current: SparklineDataset = { + data: slice.current, + label: + this.#messageService.get('analytics.engagement.sparkline.period-current') ?? + 'This period', + color: AnalyticsChartColors.primary.line, + dashed: false + }; + + if (slice.previous?.length) { + const len = slice.current.length; + const previousData = + slice.previous.length >= len ? slice.previous.slice(0, len) : slice.previous; + const previous: SparklineDataset = { + data: previousData, + label: + this.#messageService.get('analytics.engagement.sparkline.period-previous') ?? + 'Previous period', + color: AnalyticsChartColors.neutralDark.line, + dashed: false, + borderWidth: 1, + fillOpacity: 0.35 + }; + return [current, previous]; + } + + return [current]; + }); + + readonly $sparklineStatus = computed( + () => this.store.engagementSparkline().status ?? ComponentStatus.INIT + ); + + /** Whether KPIs have finished loading successfully */ + readonly $isKpisLoaded = computed(() => this.$kpisStatus() === ComponentStatus.LOADED); /** Calculate total sessions from platforms data */ readonly $totalSessions = computed(() => { @@ -84,4 +134,15 @@ export default class DotAnalyticsEngagementReportComponent implements OnInit { return platforms.device.reduce((sum, item) => sum + item.views, 0); }); + + /** + * True when data is loaded but there are no sessions (empty state). + * Used to show a clear "no data" banner and avoid showing raw zeros everywhere. + */ + readonly $hasNoData = computed(() => { + if (this.$kpisStatus() !== ComponentStatus.LOADED) return false; + if (this.$breakdownStatus() !== ComponentStatus.LOADED) return false; + const breakdown = this.$breakdown(); + return !breakdown?.labels?.length && !breakdown?.datasets?.length; + }); } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.html index eeda0aca5da8..28225feda1d5 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.html @@ -1,176 +1,147 @@ @let isLoading = $isLoading(); +@let isAllEmpty = $isAllEmpty(); @let deviceData = $deviceData(); @let browserData = $browserData(); -@let languageData = $languageData(); -@let totalSessions = $totalSessions(); <h3 class="text-lg font-bold mb-2"> {{ 'analytics.engagement.platforms.title' | dm }} </h3> <p-card class="platforms-card h-full"> - <p-tabs value="device"> - <p-tablist> - <p-tab value="device">{{ 'analytics.engagement.platforms.device' | dm }}</p-tab> - <p-tab value="browser">{{ 'analytics.engagement.platforms.browser' | dm }}</p-tab> - <p-tab value="language">{{ 'analytics.engagement.platforms.language' | dm }}</p-tab> - </p-tablist> + @if (isAllEmpty) { + <dot-analytics-empty-state class="h-full" data-testid="platforms-empty-all" /> + } @else { + <p-tabs value="device"> + <p-tablist> + <p-tab value="device">{{ 'analytics.engagement.platforms.device' | dm }}</p-tab> + <p-tab value="browser">{{ 'analytics.engagement.platforms.browser' | dm }}</p-tab> + </p-tablist> - <p-tabpanels> - <!-- Device Tab --> - <p-tabpanel value="device"> - @if (isLoading) { - <ng-container [ngTemplateOutlet]="skeletonTable" /> - } @else { - <p-table [value]="deviceData" styleClass="p-datatable-sm platforms-table"> - <ng-template pTemplate="header"> - <tr> - <th>{{ 'analytics.engagement.table.headers.device' | dm }}</th> - <th>{{ 'analytics.engagement.table.headers.views' | dm }}</th> - <th class="text-right"> - {{ 'analytics.engagement.table.headers.time' | dm }} - </th> - </tr> - </ng-template> - <ng-template pTemplate="body" let-device> - <tr> - <td class="font-medium">{{ device.name }}</td> - <td> - <div class="views-cell"> - <div class="views-bar-row"> - <p-progressBar - [value]="device.percentage" - [showValue]="false" - styleClass="views-progress" /> - <span class="views-percentage"> - {{ device.percentage }}% - </span> - </div> - <span class="views-count text-color-secondary"> - {{ device.views | number }}/{{ totalSessions | number }} - {{ 'analytics.engagement.table.sessions-suffix' | dm }} - </span> + <p-tabpanels> + <!-- Device Tab --> + <p-tabpanel value="device"> + @if (isLoading) { + <div class="skeleton-table" data-testid="platforms-skeleton"> + <div class="skeleton-row skeleton-header"> + <p-skeleton width="5rem" height="1rem" /> + <p-skeleton width="6rem" height="1rem" /> + <div class="flex justify-end"> + <p-skeleton width="4rem" height="1rem" /> + </div> + </div> + @for (i of [1, 2, 3]; track i) { + <div class="skeleton-row"> + <p-skeleton width="5rem" height="1rem" /> + <div class="flex flex-col gap-1"> + <p-skeleton width="6rem" height="1rem" /> + <p-skeleton width="100%" height="0.5rem" /> </div> - </td> - <td class="text-right">{{ device.time }}</td> - </tr> - </ng-template> - </p-table> - } - </p-tabpanel> - - <!-- Browser Tab --> - <p-tabpanel value="browser"> - @if (isLoading) { - <ng-container [ngTemplateOutlet]="skeletonTable" /> - } @else { - <p-table [value]="browserData" styleClass="p-datatable-sm platforms-table"> - <ng-template pTemplate="header"> - <tr> - <th>{{ 'analytics.engagement.table.headers.browser' | dm }}</th> - <th>{{ 'analytics.engagement.table.headers.views' | dm }}</th> - <th class="text-right"> - {{ 'analytics.engagement.table.headers.time' | dm }} - </th> - </tr> - </ng-template> - <ng-template pTemplate="body" let-browser> - <tr> - <td class="font-medium">{{ browser.name }}</td> - <td> - <div class="views-cell"> - <div class="views-bar-row"> - <p-progressBar - [value]="browser.percentage" - [showValue]="false" - styleClass="views-progress" /> - <span class="views-percentage"> - {{ browser.percentage }}% - </span> - </div> - <span class="views-count text-color-secondary"> - {{ browser.views | number }}/{{ - totalSessions | number - }} - {{ 'analytics.engagement.table.sessions-suffix' | dm }} - </span> + <div class="flex justify-end"> + <p-skeleton width="4rem" height="1rem" /> </div> - </td> - <td class="text-right">{{ browser.time }}</td> - </tr> - </ng-template> - </p-table> - } - </p-tabpanel> - - <!-- Language Tab --> - <p-tabpanel value="language"> - @if (isLoading) { - <ng-container [ngTemplateOutlet]="skeletonTable" /> - } @else { - <p-table [value]="languageData" styleClass="p-datatable-sm platforms-table"> - <ng-template pTemplate="header"> - <tr> - <th>{{ 'analytics.engagement.table.headers.language' | dm }}</th> - <th>{{ 'analytics.engagement.table.headers.views' | dm }}</th> - <th class="text-right"> - {{ 'analytics.engagement.table.headers.time' | dm }} - </th> - </tr> - </ng-template> - <ng-template pTemplate="body" let-lang> - <tr> - <td class="font-medium">{{ lang.name }}</td> - <td> - <div class="views-cell"> - <div class="views-bar-row"> - <p-progressBar - [value]="lang.percentage" - [showValue]="false" - styleClass="views-progress" /> - <span class="views-percentage"> - {{ lang.percentage }}% - </span> + </div> + } + </div> + } @else if (deviceData.length === 0) { + <dot-analytics-empty-state + data-testid="platforms-empty-device" + class="min-h-48" /> + } @else { + <p-table [value]="deviceData" styleClass="platforms-table"> + <ng-template pTemplate="header"> + <tr> + <th>{{ 'analytics.engagement.table.headers.device' | dm }}</th> + <th>{{ 'analytics.engagement.table.headers.views' | dm }}</th> + <th class="text-right"> + {{ 'analytics.engagement.table.headers.time' | dm }} + </th> + </tr> + </ng-template> + <ng-template pTemplate="body" let-device> + <tr> + <td class="font-medium">{{ device.name }}</td> + <td> + <div class="views-cell"> + <div class="views-bar-row"> + <p-progressBar + [value]="device.percentage" + [showValue]="false" + styleClass="views-progress" /> + <span class="views-percentage"> + {{ device.percentage }}% + </span> + </div> </div> - <span class="views-count text-color-secondary"> - {{ lang.views | number }}/{{ totalSessions | number }} - {{ 'analytics.engagement.table.sessions-suffix' | dm }} - </span> + </td> + <td class="text-right">{{ device.time }}</td> + </tr> + </ng-template> + </p-table> + } + </p-tabpanel> + + <!-- Browser Tab --> + <p-tabpanel value="browser"> + @if (isLoading) { + <div class="skeleton-table" data-testid="platforms-skeleton"> + <div class="skeleton-row skeleton-header"> + <p-skeleton width="5rem" height="1rem" /> + <p-skeleton width="6rem" height="1rem" /> + <div class="flex justify-end"> + <p-skeleton width="4rem" height="1rem" /> + </div> + </div> + @for (i of [1, 2, 3]; track i) { + <div class="skeleton-row"> + <p-skeleton width="5rem" height="1rem" /> + <div class="flex flex-col gap-1"> + <p-skeleton width="6rem" height="1rem" /> + <p-skeleton width="100%" height="0.5rem" /> + </div> + <div class="flex justify-end"> + <p-skeleton width="4rem" height="1rem" /> </div> - </td> - <td class="text-right">{{ lang.time }}</td> - </tr> - </ng-template> - </p-table> - } - </p-tabpanel> - </p-tabpanels> - </p-tabs> + </div> + } + </div> + } @else if (browserData.length === 0) { + <dot-analytics-empty-state + data-testid="platforms-empty-browser" + class="min-h-48" /> + } @else { + <p-table [value]="browserData" styleClass="platforms-table"> + <ng-template pTemplate="header"> + <tr> + <th>{{ 'analytics.engagement.table.headers.browser' | dm }}</th> + <th>{{ 'analytics.engagement.table.headers.views' | dm }}</th> + <th class="text-right"> + {{ 'analytics.engagement.table.headers.time' | dm }} + </th> + </tr> + </ng-template> + <ng-template pTemplate="body" let-browser> + <tr> + <td class="font-medium">{{ browser.name }}</td> + <td> + <div class="views-cell"> + <div class="views-bar-row"> + <p-progressBar + [value]="browser.percentage" + [showValue]="false" + styleClass="views-progress" /> + <span class="views-percentage"> + {{ browser.percentage }}% + </span> + </div> + </div> + </td> + <td class="text-right">{{ browser.time }}</td> + </tr> + </ng-template> + </p-table> + } + </p-tabpanel> + </p-tabpanels> + </p-tabs> + } </p-card> - -<!-- Skeleton Table Template --> -<ng-template #skeletonTable> - <div class="skeleton-table"> - <!-- Header skeleton --> - <div class="skeleton-row skeleton-header"> - <p-skeleton width="5rem" height="1rem" /> - <p-skeleton width="6rem" height="1rem" /> - <div class="flex justify-end"> - <p-skeleton width="4rem" height="1rem" /> - </div> - </div> - <!-- Body rows skeleton --> - @for (i of [1, 2, 3]; track i) { - <div class="skeleton-row"> - <p-skeleton width="5rem" height="1rem" /> - <div class="flex flex-col gap-1"> - <p-skeleton width="6rem" height="1rem" /> - <p-skeleton width="100%" height="0.5rem" /> - </div> - <div class="flex justify-end"> - <p-skeleton width="4rem" height="1rem" /> - </div> - </div> - } - </div> -</ng-template> diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.scss index a957a4644ce8..476e6b6ac905 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.scss +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.scss @@ -1,68 +1,94 @@ @use "variables" as *; -// Table styles -::ng-deep .platforms-table { - // Header styles - match other tables (use PrimeNG defaults) - .p-datatable-thead > tr > th { - color: $color-palette-gray-700; - font-weight: $font-weight-semi-bold; - } +:host { + display: flex; + flex-direction: column; + height: 100%; +} - // Body row styles - .p-datatable-tbody > tr > td { - padding: $spacing-3; - border-bottom: $field-border-size solid $color-palette-gray-200; - vertical-align: middle; - } +.platforms-card { + flex: 1; + display: flex; + flex-direction: column; + + ::ng-deep { + .p-card-body { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + } - // Last row without bottom border - .p-datatable-tbody > tr:last-child > td { - border-bottom: none; + .p-card-content { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + } } +} - // Row background - .p-datatable-tbody > tr { - background: transparent; +// Table styles +:host { + ::ng-deep .platforms-table { + // Header styles - match other tables (use PrimeNG defaults) + .p-datatable-thead > tr > th { + color: $color-palette-gray-700; + font-weight: $font-weight-semi-bold; + } - &:hover { - background: $color-palette-gray-100; + // Body row styles + .p-datatable-tbody > tr > td { + padding: $spacing-3; + border-bottom: $field-border-size solid $color-palette-gray-200; + vertical-align: middle; } - } - // Views cell layout - .views-cell { - display: flex; - flex-direction: column; - gap: $spacing-0; - } + // Last row without bottom border + .p-datatable-tbody > tr:last-child > td { + border-bottom: none; + } - .views-bar-row { - display: flex; - align-items: center; - gap: $spacing-2; - } + // Row background + .p-datatable-tbody > tr { + background: transparent; - .views-percentage { - font-size: $font-size-sm; - font-weight: $font-weight-regular-bold; - color: $color-palette-gray-700; - min-width: 2.5rem; - } + &:hover { + background: $color-palette-gray-100; + } + } - .views-count { - font-size: $font-size-sm; - } + // Views cell layout + .views-cell { + display: flex; + flex-direction: column; + gap: $spacing-0; + } + + .views-bar-row { + display: flex; + align-items: center; + gap: $spacing-2; + } - // Progress bar custom styles - .views-progress { - height: 0.5rem !important; - border-radius: $border-radius-md; - width: 10rem; - min-width: 6rem; + .views-percentage { + font-size: $font-size-sm; + font-weight: $font-weight-regular-bold; + color: $color-palette-gray-700; + min-width: 2.5rem; + } - .p-progressbar-value { - background: $color-palette-primary-500; + // Progress bar custom styles + .views-progress { + height: 0.5rem !important; // Override PrimeNG default progressbar height border-radius: $border-radius-md; + width: 10rem; + min-width: 6rem; + + .p-progressbar-value { + background: $color-palette-primary-500; + border-radius: $border-radius-md; + } } } } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.spec.ts index 58db3774c939..6b4d968fb61c 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.spec.ts @@ -1,4 +1,4 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; import { DotMessageService } from '@dotcms/data-access'; import { ComponentStatus } from '@dotcms/dotcms-models'; @@ -22,35 +22,19 @@ describe('DotAnalyticsPlatformsTableComponent', () => { { name: 'Chrome', views: 55000, percentage: 57, time: '2m 30s' }, { name: 'Safari', views: 25000, percentage: 26, time: '2m 15s' }, { name: 'Firefox', views: 15655, percentage: 17, time: '2m 00s' } - ], - language: [ - { name: 'English', views: 70000, percentage: 73, time: '2m 40s' }, - { name: 'Spanish', views: 20000, percentage: 21, time: '2m 20s' }, - { name: 'French', views: 5655, percentage: 6, time: '1m 50s' } ] }; const createComponent = createComponentFactory({ component: DotAnalyticsPlatformsTableComponent, imports: [DotMessagePipe], - providers: [ - { - provide: DotMessageService, - useValue: { - get: (key: string) => key - } - } - ] + providers: [mockProvider(DotMessageService, { get: jest.fn((key: string) => key) })] }); beforeEach(() => { - spectator = createComponent({ - props: { - platforms: mockPlatformsData, - status: ComponentStatus.LOADED - } as unknown, - detectChanges: false - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput('platforms', mockPlatformsData); + spectator.setInput('status', ComponentStatus.LOADED); }); describe('Component Initialization', () => { @@ -64,10 +48,10 @@ describe('DotAnalyticsPlatformsTableComponent', () => { expect(spectator.query('p-card')).toExist(); }); - it('should display p-tabs with 3 tabs', () => { + it('should display p-tabs with 2 tab panels', () => { spectator.detectChanges(); expect(spectator.query('p-tabs')).toExist(); - expect(spectator.queryAll('p-tab').length).toBe(3); + expect(spectator.queryAll('p-tabpanel').length).toBe(2); }); }); @@ -110,20 +94,12 @@ describe('DotAnalyticsPlatformsTableComponent', () => { expect(browserData[0].name).toBe('Chrome'); }); - it('should compute language data from platforms input', () => { - spectator.detectChanges(); - const languageData = spectator.component.$languageData(); - expect(languageData.length).toBe(3); - expect(languageData[0].name).toBe('English'); - }); - it('should return empty arrays when platforms is null', () => { spectator.setInput('platforms', null); spectator.detectChanges(); expect(spectator.component.$deviceData()).toEqual([]); expect(spectator.component.$browserData()).toEqual([]); - expect(spectator.component.$languageData()).toEqual([]); }); }); }); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.ts index ea4e3e0730d1..16a7bf4b1b1b 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/engagement/dot-analytics-platforms-table/dot-analytics-platforms-table.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { CardModule } from 'primeng/card'; @@ -10,6 +9,8 @@ import { TabsModule } from 'primeng/tabs'; import { ComponentStatus } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; +import { DotAnalyticsEmptyStateComponent } from '../../../shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component'; + /** Platform data item structure */ export interface PlatformDataItem { name: string; @@ -22,7 +23,6 @@ export interface PlatformDataItem { export interface PlatformsData { device: PlatformDataItem[]; browser: PlatformDataItem[]; - language: PlatformDataItem[]; } /** @@ -32,13 +32,13 @@ export interface PlatformsData { @Component({ selector: 'dot-analytics-platforms-table', imports: [ - CommonModule, CardModule, TabsModule, TableModule, ProgressBarModule, SkeletonModule, - DotMessagePipe + DotMessagePipe, + DotAnalyticsEmptyStateComponent ], templateUrl: './dot-analytics-platforms-table.component.html', styleUrl: './dot-analytics-platforms-table.component.scss', @@ -60,12 +60,16 @@ export class DotAnalyticsPlatformsTableComponent { /** Derived browser rows from platforms data */ readonly $browserData = computed(() => this.$platforms()?.browser ?? []); - /** Derived language rows from platforms data */ - readonly $languageData = computed(() => this.$platforms()?.language ?? []); - /** Whether the component is in a loading or init state */ readonly $isLoading = computed(() => { const status = this.$status(); return status === ComponentStatus.INIT || status === ComponentStatus.LOADING; }); + + /** Whether all platform data is empty (hide tabs, show single empty state) */ + readonly $isAllEmpty = computed(() => { + if (this.$isLoading()) return false; + + return this.$deviceData().length === 0 && this.$browserData().length === 0; + }); } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-pageview-report/dot-analytics-pageview-report.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-pageview-report/dot-analytics-pageview-report.component.html index 695e3ba3a7b3..9497290a612b 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-pageview-report/dot-analytics-pageview-report.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-pageview-report/dot-analytics-pageview-report.component.html @@ -1,42 +1,45 @@ -<!-- Metrics --> -<div class="flex flex-wrap gap-3"> - @for (metric of $metricsData(); track metric.name) { - <div class="flex-1"> - <dot-analytics-metric - [title]="metric.name" - [value]="metric.value || 0" - [subtitle]="metric.subtitle || ''" - [icon]="metric.icon || ''" - [status]="metric.status" - data-testid="analytics-metric-card" /> - </div> - } -</div> +<div class="flex flex-col gap-10"> + <!-- Metrics --> + <div class="flex flex-wrap gap-6"> + @for (metric of $metricsData(); track metric.name) { + <div class="flex-1"> + <dot-analytics-metric + [title]="metric.name" + [value]="metric.value" + [subtitle]="metric.subtitle" + [icon]="metric.icon" + [status]="metric.status" + data-testid="analytics-metric-card" /> + </div> + } + </div> -<!-- Timeline Chart --> -<dot-analytics-chart - [title]="'analytics.charts.pageviews-timeline.title'" - type="line" - [data]="$pageViewTimeLineData()" - [status]="$pageViewTimeLineStatus()" - data-testid="analytics-timeline-chart" /> + <!-- Timeline Chart --> + <dot-analytics-chart + [title]="'analytics.charts.pageviews-timeline.title'" + type="line" + [data]="$pageViewTimeLineData()" + [status]="$pageViewTimeLineStatus()" + data-testid="analytics-timeline-chart" /> -<!-- Device Chart + Top Pages Table --> -<div class="flex flex-col lg:flex-row gap-3 items-stretch"> - <div class="lg:w-5/12 shrink-0"> - <dot-analytics-chart - [title]="'analytics.charts.device-breakdown.title'" - type="pie" - [data]="$pageViewDeviceBrowsersData()" - [status]="$pageViewDeviceBrowsersStatus()" - data-testid="analytics-device-chart" /> - </div> - <div class="flex-1 min-w-0"> - <h3 class="text-lg font-bold mb-2"> - {{ 'analytics.dashboard.table.top-pages.title' | dm }} - </h3> - <dot-analytics-top-pages-table - [tableState]="$topPagesTable()" - data-testid="analytics-table" /> + <!-- Device Chart + Top Pages Table --> + <div class="flex flex-col lg:flex-row gap-6 items-stretch"> + <div class="lg:w-5/12 shrink-0 flex flex-col justify-center"> + <dot-analytics-chart + [title]="'analytics.charts.device-breakdown.title'" + type="pie" + [data]="$pageViewDeviceBrowsersData()" + [status]="$pageViewDeviceBrowsersStatus()" + data-testid="analytics-device-chart" /> + </div> + <div class="flex-1 min-w-0 flex flex-col"> + <h3 class="text-lg font-bold mb-2"> + {{ 'analytics.dashboard.table.top-pages.title' | dm }} + </h3> + <dot-analytics-top-pages-table + class="flex-1" + [tableState]="$topPagesTable()" + data-testid="analytics-table" /> + </div> </div> </div> diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-pageview-report/dot-analytics-pageview-report.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-pageview-report/dot-analytics-pageview-report.component.ts index 66b49396cd56..c4efbca1f528 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-pageview-report/dot-analytics-pageview-report.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-pageview-report/dot-analytics-pageview-report.component.ts @@ -33,7 +33,7 @@ import { DotAnalyticsTopPagesTableComponent } from '../dot-analytics-top-pages-t styleUrl: './dot-analytics-pageview-report.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { - class: 'flex flex-col gap-6 w-full pt-0 pb-4 px-4' + class: 'flex flex-col gap-6 w-full pt-0 pb-4' } }) /** diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.html index e72ad1d134eb..25fe1ee27293 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.html @@ -8,136 +8,92 @@ @let showRealTable = !isLoadingState && !isErrorState && !isEmptyState && hasData; @if (isLoadingState) { - <div class="table-loading-state h-full"> - <p-card class="h-full"> - <!-- Loading State --> - <div class="flex flex-col gap-3 h-full"> - <!-- Custom Table Skeleton --> - <div class="p-datatable p-datatable-sm"> - <!-- Skeleton Header --> - <div class="p-datatable-header-row flex p-3"> - @for ( - columnConfig of columnsConfig; - track trackByField($index, columnConfig) - ) { - <div - [class]="columnConfig.cssClass" - class="flex-1" - [style.width]="columnConfig.width"> - <p-skeleton height="1.2rem" width="80%" /> - </div> - } + <div class="flex flex-col gap-3"> + <div class="p-datatable p-datatable-sm"> + <div class="p-datatable-header-row flex p-3"> + @for (columnConfig of columnsConfig; track columnConfig.field) { + <div + [class]="columnConfig.cssClass" + class="flex-1" + [style.width]="columnConfig.width"> + <p-skeleton height="1.2rem" width="80%" /> </div> + } + </div> - <!-- Skeleton Rows --> - @for (row of skeletonRows; track trackByIndex($index)) { - <div class="flex p-3 hover:bg-gray-50"> - @for ( - columnConfig of columnsConfig; - track trackByField($index, columnConfig) - ) { - <div - [class]="columnConfig.cssClass" - class="flex-1" - [style.width]="columnConfig.width"> - <p-skeleton - height="1rem" - [width]="columnConfig.skeletonWidth" /> - </div> - } + @for (row of skeletonRows; track $index) { + <div class="flex p-3"> + @for (columnConfig of columnsConfig; track columnConfig.field) { + <div + [class]="columnConfig.cssClass" + class="flex-1" + [style.width]="columnConfig.width"> + <p-skeleton height="1rem" [width]="columnConfig.skeletonWidth" /> </div> } - - <!-- Skeleton Pagination --> - <div class="flex justify-center items-center p-3"> - <p-skeleton height="1.5rem" width="15.625rem" /> - </div> </div> - </div> - </p-card> + } + </div> </div> } @else if (isErrorState) { - <div class="table-error-state h-full"> - <p-card class="h-full"> - <!-- Error State --> - <div class="flex flex-col gap-3 h-full"> - <dot-analytics-state-message - message="analytics.error.loading.top-pages-table" - icon="pi-exclamation-triangle" /> - </div> - </p-card> - </div> + <dot-analytics-state-message + message="analytics.error.loading.top-pages-table" + icon="pi-exclamation-triangle" /> } @else if (isEmptyState) { - <div class="table-empty-state h-full" data-testid="empty-table-state"> - <p-card class="h-full"> - <!-- Empty State --> - <div class="flex flex-col gap-3 h-full"> - <dot-analytics-state-message - message="analytics.table.empty.description" - icon="pi-info-circle" /> - </div> - </p-card> - </div> + <dot-analytics-empty-state data-testid="empty-table-state" /> } @else if (showRealTable) { - <p-card> - <!-- Loaded State --> - <p-table - [value]="tableData" - [columns]="columnsConfig" - [attr.data-testid]="'analytics-top-pages-table'" - styleClass="p-datatable-sm" - [scrollable]="true" - [scrollHeight]="tableConfig.SCROLL_HEIGHT" - [virtualScroll]="true" - [virtualScrollItemSize]="tableConfig.VIRTUAL_SCROLL_ITEM_SIZE" - [dataKey]="tableConfig.DATA_KEY" - [sortMode]="tableConfig.SORT_MODE"> - <ng-template pTemplate="header" let-columns> - <tr> - @for (columnConfig of columnsConfig; track trackByField($index, columnConfig)) { - <th - [class]="columnConfig.cssClass" - [pSortableColumn]=" - columnConfig.sortable ? columnConfig.field : undefined - " - [style.width]="columnConfig.width"> - {{ columnConfig.header | dm }} - @if (columnConfig.sortable) { - <p-sortIcon [field]="columnConfig.field" /> - } - </th> - } - </tr> - </ng-template> + <p-table + [value]="tableData" + [columns]="columnsConfig" + [attr.data-testid]="'analytics-top-pages-table'" + styleClass="" + [paginator]="true" + [rows]="tableConfig.ROWS_PER_PAGE" + [dataKey]="tableConfig.DATA_KEY" + [sortMode]="tableConfig.SORT_MODE"> + <ng-template pTemplate="header" let-columns> + <tr> + @for (columnConfig of columnsConfig; track columnConfig.field) { + <th + [class]="columnConfig.cssClass" + [pSortableColumn]="columnConfig.sortable ? columnConfig.field : undefined" + [style.width]="columnConfig.width"> + {{ columnConfig.header | dm }} + @if (columnConfig.sortable) { + <p-sortIcon [field]="columnConfig.field" /> + } + </th> + } + </tr> + </ng-template> - <ng-template pTemplate="body" let-row let-rowIndex="rowIndex" let-columns="columns"> - <tr [style.height.px]="tableConfig.VIRTUAL_SCROLL_ITEM_SIZE"> - @for (columnConfig of columnsConfig; track trackByField($index, columnConfig)) { - <td [class]="columnConfig.cssClass"> - @switch (columnConfig.type) { - @case ('link') { - <span> - {{ row[columnConfig.field] }} - </span> - } - @case ('number') { - <span class="font-semibold"> - {{ row[columnConfig.field] | number }} - </span> - } - @case ('percentage') { - <span class="text-600 font-medium"> - {{ row[columnConfig.field] }}% - </span> - } - @default { + <ng-template pTemplate="body" let-row let-rowIndex="rowIndex" let-columns="columns"> + <tr> + @for (columnConfig of columnsConfig; track columnConfig.field) { + <td [class]="columnConfig.cssClass"> + @switch (columnConfig.type) { + @case ('link') { + <span> {{ row[columnConfig.field] }} - } + </span> } - </td> - } - </tr> - </ng-template> - </p-table> - </p-card> + @case ('number') { + <span class="font-semibold"> + {{ row[columnConfig.field] | number }} + </span> + } + @case ('percentage') { + <span class="text-zinc-600 font-medium"> + {{ row[columnConfig.field] }}% + </span> + } + @default { + {{ row[columnConfig.field] }} + } + } + </td> + } + </tr> + </ng-template> + </p-table> } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.scss index 38902337e1ce..2ae26e71219b 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.scss +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.scss @@ -1,51 +1,4 @@ -@use "variables" as *; - -// Table wrapper to ensure full height -.table-wrapper { - display: block; - width: 100%; - height: 100%; -} - -// Full height for loading, empty and error states -.table-loading-state, -.table-empty-state, -.table-error-state { - height: 100%; - - ::ng-deep .p-card { - height: 100%; - - .p-card-body { - height: 100%; - display: flex; - flex-direction: column; - } - - .p-card-content { - flex: 1; - display: flex; - flex-direction: column; - } - } -} - -// Virtual scroll specific styles -::ng-deep .p-datatable { - &.p-datatable-scrollable { - .p-datatable-scrollable-wrapper { - border-radius: $border-radius-md; - } - - .p-datatable-scrollable-body { - border-radius: 0 0 $border-radius-md $border-radius-md; - } - } - - // Ensure consistent row height for virtual scroll - .p-datatable-tbody > tr { - height: 46px; - box-sizing: border-box; - cursor: default; - } +:host { + display: flex; + flex-direction: column; } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts index 5b6fc3ef5cf4..f330244078ab 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.spec.ts @@ -1,7 +1,6 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; import { MockModule } from 'ng-mocks'; -import { CardModule } from 'primeng/card'; import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; @@ -50,13 +49,9 @@ describe('DotAnalyticsTopPagesTableComponent', () => { [ DotAnalyticsTopPagesTableComponent, { - remove: { imports: [CardModule, SkeletonModule, TableModule] }, + remove: { imports: [SkeletonModule, TableModule] }, add: { - imports: [ - MockModule(CardModule), - MockModule(SkeletonModule), - MockModule(TableModule) - ] + imports: [MockModule(SkeletonModule), MockModule(TableModule)] } } ] @@ -72,12 +67,8 @@ describe('DotAnalyticsTopPagesTableComponent', () => { }); beforeEach(() => { - spectator = createComponent({ - props: { - tableState: createMockTableState() - } as unknown, - detectChanges: false // Prevent automatic change detection - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput('tableState', createMockTableState()); }); describe('Component Initialization', () => { @@ -106,27 +97,14 @@ describe('DotAnalyticsTopPagesTableComponent', () => { 'EventSummary.totalEvents': '100' } ]; - - // Create new component with different data - spectator = createComponent({ - props: { - tableState: createMockTableState(newData, ComponentStatus.LOADED) - } as unknown, - detectChanges: false - }); + spectator.setInput('tableState', createMockTableState(newData, ComponentStatus.LOADED)); expect(spectator.component.$tableState().data).toEqual(newData); expect(spectator.component['$data']()).toHaveLength(1); }); it('should handle empty data array', () => { - // Create new component with empty data - spectator = createComponent({ - props: { - tableState: createMockTableState([], ComponentStatus.LOADED) - } as unknown, - detectChanges: false - }); + spectator.setInput('tableState', createMockTableState([], ComponentStatus.LOADED)); expect(spectator.component.$tableState().data).toEqual([]); expect(spectator.component['$data']()).toHaveLength(0); @@ -135,119 +113,78 @@ describe('DotAnalyticsTopPagesTableComponent', () => { describe('Status Changes', () => { it('should handle different status values', () => { - // Test LOADING status - spectator = createComponent({ - props: { - tableState: createMockTableState(mockTableData, ComponentStatus.LOADING) - } as unknown, - detectChanges: false - }); + spectator.setInput( + 'tableState', + createMockTableState(mockTableData, ComponentStatus.LOADING) + ); expect(spectator.component.$tableState().status).toBe(ComponentStatus.LOADING); - // Test ERROR status - spectator = createComponent({ - props: { - tableState: createMockTableState(mockTableData, ComponentStatus.ERROR) - } as unknown, - detectChanges: false - }); + spectator.setInput( + 'tableState', + createMockTableState(mockTableData, ComponentStatus.ERROR) + ); expect(spectator.component.$tableState().status).toBe(ComponentStatus.ERROR); - // Test INIT status - spectator = createComponent({ - props: { - tableState: createMockTableState(mockTableData, ComponentStatus.INIT) - } as unknown, - detectChanges: false - }); + spectator.setInput( + 'tableState', + createMockTableState(mockTableData, ComponentStatus.INIT) + ); expect(spectator.component.$tableState().status).toBe(ComponentStatus.INIT); }); }); describe('Computed Properties', () => { it('should correctly identify loading state', () => { - // Test LOADING state - spectator = createComponent({ - props: { - tableState: createMockTableState(mockTableData, ComponentStatus.LOADING) - } as unknown, - detectChanges: false - }); + spectator.setInput( + 'tableState', + createMockTableState(mockTableData, ComponentStatus.LOADING) + ); expect(spectator.component['$isLoading']()).toBe(true); - // Test INIT state - spectator = createComponent({ - props: { - tableState: createMockTableState(mockTableData, ComponentStatus.INIT) - } as unknown, - detectChanges: false - }); + spectator.setInput( + 'tableState', + createMockTableState(mockTableData, ComponentStatus.INIT) + ); expect(spectator.component['$isLoading']()).toBe(true); - // Test LOADED state - spectator = createComponent({ - props: { - tableState: createMockTableState(mockTableData, ComponentStatus.LOADED) - } as unknown, - detectChanges: false - }); + spectator.setInput( + 'tableState', + createMockTableState(mockTableData, ComponentStatus.LOADED) + ); expect(spectator.component['$isLoading']()).toBe(false); }); it('should correctly identify empty state', () => { - // Test with empty array - spectator = createComponent({ - props: { - tableState: createMockTableState([], ComponentStatus.LOADED) - } as unknown, - detectChanges: false - }); + spectator.setInput('tableState', createMockTableState([], ComponentStatus.LOADED)); expect(spectator.component['$isEmpty']()).toBe(true); - // Test with null data - spectator = createComponent({ - props: { - tableState: createMockTableState(null, ComponentStatus.LOADED) - } as unknown, - detectChanges: false - }); + spectator.setInput('tableState', createMockTableState(null, ComponentStatus.LOADED)); expect(spectator.component['$isEmpty']()).toBe(true); - // Test with valid data - spectator = createComponent({ - props: { - tableState: createMockTableState(mockTableData, ComponentStatus.LOADED) - } as unknown, - detectChanges: false - }); + spectator.setInput( + 'tableState', + createMockTableState(mockTableData, ComponentStatus.LOADED) + ); expect(spectator.component['$isEmpty']()).toBe(false); }); it('should correctly identify error state', () => { - // Test ERROR state - spectator = createComponent({ - props: { - tableState: createMockTableState(mockTableData, ComponentStatus.ERROR) - } as unknown, - detectChanges: false - }); + spectator.setInput( + 'tableState', + createMockTableState(mockTableData, ComponentStatus.ERROR) + ); expect(spectator.component['$isError']()).toBe(true); - // Test non-error states - spectator = createComponent({ - props: { - tableState: createMockTableState(mockTableData, ComponentStatus.LOADED) - } as unknown, - detectChanges: false - }); + spectator.setInput( + 'tableState', + createMockTableState(mockTableData, ComponentStatus.LOADED) + ); expect(spectator.component['$isError']()).toBe(false); - spectator = createComponent({ - props: { - tableState: createMockTableState(mockTableData, ComponentStatus.LOADING) - } as unknown, - detectChanges: false - }); + spectator.setInput( + 'tableState', + createMockTableState(mockTableData, ComponentStatus.LOADING) + ); expect(spectator.component['$isError']()).toBe(false); }); }); @@ -271,8 +208,8 @@ describe('DotAnalyticsTopPagesTableComponent', () => { } as unknown }); - const stateMessage = spectator.query('dot-analytics-state-message'); - expect(stateMessage).toExist(); + const emptyState = spectator.query('dot-analytics-empty-state'); + expect(emptyState).toExist(); }); it('should not show empty state when data is available', () => { @@ -295,8 +232,8 @@ describe('DotAnalyticsTopPagesTableComponent', () => { } as unknown }); - const errorState = spectator.query('.table-error-state'); - expect(errorState).toExist(); + const stateMessage = spectator.query('dot-analytics-state-message'); + expect(stateMessage).toExist(); }); it('should not show error state when status is not ERROR', () => { @@ -306,19 +243,8 @@ describe('DotAnalyticsTopPagesTableComponent', () => { } as unknown }); - const errorState = spectator.query('.table-error-state'); - expect(errorState).not.toExist(); - }); - - it('should show error state message component', () => { - spectator = createComponent({ - props: { - tableState: createMockTableState(mockTableData, ComponentStatus.ERROR) - } as unknown - }); - const stateMessage = spectator.query('dot-analytics-state-message'); - expect(stateMessage).toExist(); + expect(stateMessage).not.toExist(); }); }); }); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts index b67bbf04f38d..0025de682716 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/reports/pageview/dot-analytics-top-pages-table/dot-analytics-top-pages-table.component.ts @@ -1,7 +1,6 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; -import { CardModule } from 'primeng/card'; import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; @@ -13,6 +12,7 @@ import { } from '@dotcms/portlets/dot-analytics/data-access'; import { DotMessagePipe } from '@dotcms/ui'; +import { DotAnalyticsEmptyStateComponent } from '../../../shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component'; import { DotAnalyticsStateMessageComponent } from '../../../shared/components/dot-analytics-state-message/dot-analytics-state-message.component'; import { TABLE_CONFIG, TOP_PAGES_TABLE_COLUMNS } from '../../../shared/constants'; import { TableColumn } from '../../../shared/types'; @@ -34,10 +34,10 @@ const SKELETON_WIDTH_MAP = { selector: 'dot-analytics-top-pages-table', imports: [ CommonModule, - CardModule, SkeletonModule, TableModule, DotMessagePipe, + DotAnalyticsEmptyStateComponent, DotAnalyticsStateMessageComponent ], templateUrl: './dot-analytics-top-pages-table.component.html', @@ -91,14 +91,4 @@ export class DotAnalyticsTopPagesTableComponent { SKELETON_WIDTH_MAP.text })); }); - - /** - * Track function for skeleton rows to improve performance - */ - protected trackByIndex = (index: number): number => index; - - /** - * Track function for table columns - */ - protected trackByField = (index: number, column: TableColumn): string => column.field; } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.html index bc475a5ec3d9..9902c00de183 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.html @@ -16,22 +16,19 @@ <h3 class="text-lg font-bold mb-2" data-testid="chart-title"> <p-card class="chart-card" - [class.chart-card--loading]="isLoading" - [class.chart-card--empty]="isEmpty" - [class.chart-card--error]="isError" [class.flex]="shouldUsePieLayout" [class.w-full]="shouldUsePieLayout" data-testid="analytics-chart"> @if ($isError()) { <!-- Error state --> - <div class="chart-error flex items-center justify-center" [style.height]="height"> + <div class="flex flex-1 items-center justify-center" [style.min-height]="height"> <dot-analytics-state-message message="analytics.charts.error.loading" icon="pi-exclamation-triangle" /> </div> } @else if (isLoading) { <!-- Loading state with skeletons --> - <div class="chart-loading flex items-center justify-center w-full" [style.height]="height"> + <div class="flex items-center justify-center w-full" [style.height]="height"> @if (type === 'line') { <!-- Line chart skeleton --> <div class="chart-skeleton chart-skeleton--line"> @@ -89,14 +86,7 @@ <h3 class="text-lg font-bold mb-2" data-testid="chart-title"> } </div> } @else if (isEmpty) { - <!-- Empty state --> - <div - class="chart-container chart-empty w-full flex items-center justify-center" - [style.height]="height"> - <dot-analytics-state-message - message="analytics.charts.empty.description" - icon="pi-info-circle" /> - </div> + <dot-analytics-empty-state [style.height]="height" /> } @else { <!-- Normal chart content --> <div class="chart-container" [style.height]="height"> diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.scss index 5d9589985d60..1aa898a5c9ce 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.scss +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.scss @@ -11,34 +11,32 @@ $chart-grid-opacity: 0.6; display: flex; flex-direction: column; width: 100%; - flex: 1; + height: 100%; } .chart-card { flex: 1; width: 100%; + display: flex; + flex-direction: column; - &.chart-card--loading, - &.chart-card--empty, - &.chart-card--error { - height: 100%; - - ::ng-deep .p-card { - height: 100%; + ::ng-deep .p-card { + flex: 1; + display: flex; + flex-direction: column; - .p-card-body { - height: 100%; - width: 100%; - display: flex; - flex-direction: column; - } + .p-card-body { + flex: 1; + width: 100%; + display: flex; + flex-direction: column; + } - .p-card-content { - flex: 1; - width: 100%; - display: flex; - flex-direction: column; - } + .p-card-content { + flex: 1; + width: 100%; + display: flex; + flex-direction: column; } } } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.spec.ts index da4872f47eb5..1fa47f1b0cba 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.spec.ts @@ -51,13 +51,12 @@ describe('DotAnalyticsChartComponent', () => { }); beforeEach(() => { - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - status: ComponentStatus.LOADED, - options: {} - } as unknown + spectator = createComponent({ detectChanges: false }); + spectator.setInput({ + type: 'line' as ChartType, + data: createMockChartData(), + status: ComponentStatus.LOADED, + options: {} }); }); @@ -131,15 +130,8 @@ describe('DotAnalyticsChartComponent', () => { describe('Loading State', () => { it('should show loading skeleton when status is LOADING', () => { - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADING, - options: {} - } as unknown - }); + spectator.setInput('status', ComponentStatus.LOADING); + spectator.detectChanges(); const skeleton = spectator.query('.chart-skeleton'); expect(skeleton).toExist(); @@ -147,15 +139,8 @@ describe('DotAnalyticsChartComponent', () => { }); it('should show loading skeleton when status is INIT', () => { - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.INIT, - options: {} - } as unknown - }); + spectator.setInput('status', ComponentStatus.INIT); + spectator.detectChanges(); const skeleton = spectator.query('.chart-skeleton'); expect(skeleton).toExist(); @@ -163,75 +148,43 @@ describe('DotAnalyticsChartComponent', () => { }); it('should show line chart skeleton for line chart type', () => { - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADING, - options: {} - } as unknown - }); + spectator.setInput('status', ComponentStatus.LOADING); + spectator.detectChanges(); const lineSkeleton = spectator.query('.chart-skeleton--line'); expect(lineSkeleton).toExist(); }); it('should show pie chart skeleton for pie chart type', () => { - spectator = createComponent({ - props: { - type: 'pie' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADING, - options: {} - } as unknown - }); + spectator.setInput('type', 'pie' as ChartType); + spectator.setInput('status', ComponentStatus.LOADING); + spectator.detectChanges(); const pieSkeleton = spectator.query('.chart-skeleton--pie'); expect(pieSkeleton).toExist(); }); it('should show pie chart skeleton for doughnut chart type', () => { - spectator = createComponent({ - props: { - type: 'doughnut' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADING, - options: {} - } as unknown - }); + spectator.setInput('type', 'doughnut' as ChartType); + spectator.setInput('status', ComponentStatus.LOADING); + spectator.detectChanges(); const pieSkeleton = spectator.query('.chart-skeleton--pie'); expect(pieSkeleton).toExist(); }); it('should show default skeleton for other chart types', () => { - spectator = createComponent({ - props: { - type: 'bar' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADING, - options: {} - } as unknown - }); + spectator.setInput('type', 'bar' as ChartType); + spectator.setInput('status', ComponentStatus.LOADING); + spectator.detectChanges(); const defaultSkeleton = spectator.query('.chart-skeleton--default'); expect(defaultSkeleton).toExist(); }); it('should hide chart when loading', () => { - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADING, - options: {} - } as unknown - }); + spectator.setInput('status', ComponentStatus.LOADING); + spectator.detectChanges(); expect(spectator.query(UIChart)).not.toExist(); }); @@ -239,15 +192,8 @@ describe('DotAnalyticsChartComponent', () => { describe('Error State', () => { it('should show error message when status is ERROR', () => { - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.ERROR, - options: {} - } as unknown - }); + spectator.setInput('status', ComponentStatus.ERROR); + spectator.detectChanges(); const errorElement = spectator.query('.chart-error'); expect(errorElement).toExist(); @@ -256,15 +202,8 @@ describe('DotAnalyticsChartComponent', () => { }); it('should show error icon when in error state', () => { - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.ERROR, - options: {} - } as unknown - }); + spectator.setInput('status', ComponentStatus.ERROR); + spectator.detectChanges(); const errorIcon = spectator.query('.pi-exclamation-triangle'); expect(errorIcon).toExist(); @@ -273,86 +212,37 @@ describe('DotAnalyticsChartComponent', () => { describe('Computed Properties', () => { it('should correctly identify loading state', () => { - // Test INIT state - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.INIT - } as unknown - }); - expect(spectator.component['$isLoading']()).toBe(true); - - // Test LOADING state - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADING - } as unknown - }); - expect(spectator.component['$isLoading']()).toBe(true); - - // Test LOADED state - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADED - } as unknown - }); - expect(spectator.component['$isLoading']()).toBe(false); + spectator.setInput('status', ComponentStatus.INIT); + spectator.detectChanges(); + expect(spectator.query('.chart-skeleton')).toExist(); + + spectator.setInput('status', ComponentStatus.LOADING); + spectator.detectChanges(); + expect(spectator.query('.chart-skeleton')).toExist(); + + spectator.setInput('status', ComponentStatus.LOADED); + spectator.detectChanges(); + expect(spectator.query('.chart-skeleton')).not.toExist(); }); it('should correctly identify error state', () => { - // Test ERROR state - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.ERROR - } as unknown - }); - expect(spectator.component['$isError']()).toBe(true); - - // Test non-ERROR state - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADED - } as unknown - }); - expect(spectator.component['$isError']()).toBe(false); + spectator.setInput('status', ComponentStatus.ERROR); + spectator.detectChanges(); + expect(spectator.query('.chart-error')).toExist(); + + spectator.setInput('status', ComponentStatus.LOADED); + spectator.detectChanges(); + expect(spectator.query('.chart-error')).not.toExist(); }); it('should correctly identify empty state', () => { - // Test with empty data - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: { labels: [], datasets: [] }, - - status: ComponentStatus.LOADED - } as unknown - }); - expect(spectator.component['$isEmpty']()).toBe(true); - - // Test with valid data - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADED - } as unknown - }); - expect(spectator.component['$isEmpty']()).toBe(false); + spectator.setInput('data', { labels: [], datasets: [] }); + spectator.detectChanges(); + expect(spectator.query('dot-analytics-empty-state')).toExist(); + + spectator.setInput('data', createMockChartData()); + spectator.detectChanges(); + expect(spectator.query('dot-analytics-empty-state')).not.toExist(); }); }); @@ -386,28 +276,12 @@ describe('DotAnalyticsChartComponent', () => { describe('Custom Dimensions', () => { it('should calculate height automatically based on chart type', () => { - // Test line chart height - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADED - } as unknown - }); - expect(spectator.component['$height']()).toBeDefined(); expect(typeof spectator.component['$height']()).toBe('string'); // Test pie chart height - spectator = createComponent({ - props: { - type: 'pie' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADED - } as unknown - }); + spectator.setInput('type', 'pie' as ChartType); + spectator.detectChanges(); expect(spectator.component['$height']()).toBeDefined(); expect(typeof spectator.component['$height']()).toBe('string'); @@ -416,45 +290,24 @@ describe('DotAnalyticsChartComponent', () => { describe('Empty State', () => { it('should show empty state when data is empty', () => { - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: { labels: [], datasets: [] }, - - status: ComponentStatus.LOADED - } as unknown - }); + spectator.setInput('data', { labels: [], datasets: [] }); + spectator.detectChanges(); - const emptyState = spectator.query('.chart-empty'); + const emptyState = spectator.query('dot-analytics-empty-state'); expect(emptyState).toExist(); expect(spectator.query(UIChart)).not.toExist(); }); it('should show empty state icon and messages', () => { - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: { labels: [], datasets: [] }, - - status: ComponentStatus.LOADED - } as unknown - }); + spectator.setInput('data', { labels: [], datasets: [] }); + spectator.detectChanges(); - const stateMessage = spectator.query('dot-analytics-state-message'); - expect(stateMessage).toExist(); + const emptyState = spectator.query('dot-analytics-empty-state'); + expect(emptyState).toExist(); }); it('should not show empty state when data is available', () => { - spectator = createComponent({ - props: { - type: 'line' as ChartType, - data: createMockChartData(), - - status: ComponentStatus.LOADED - } as unknown - }); - - const emptyState = spectator.query('.chart-empty'); + const emptyState = spectator.query('dot-analytics-empty-state'); expect(emptyState).not.toExist(); expect(spectator.query(UIChart)).toExist(); }); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.ts index f2ee62f9c610..12ad8c2812e2 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-chart/dot-analytics-chart.component.ts @@ -38,6 +38,7 @@ import { ComboChartDataset, getAnalyticsChartColors } from '../../types'; +import { DotAnalyticsEmptyStateComponent } from '../dot-analytics-empty-state/dot-analytics-empty-state.component'; import { DotAnalyticsStateMessageComponent } from '../dot-analytics-state-message/dot-analytics-state-message.component'; /** @@ -69,6 +70,7 @@ const CHART_TYPE_HEIGHTS = { ChartModule, SkeletonModule, DotMessagePipe, + DotAnalyticsEmptyStateComponent, DotAnalyticsStateMessageComponent ], changeDetection: ChangeDetectionStrategy.OnPush, @@ -328,7 +330,7 @@ export class DotAnalyticsChartComponent { (dataset) => !dataset.data || dataset.data.length === 0 || - dataset.data.every((value) => value === null || value === undefined) + dataset.data.every((value) => !value) ) ); }); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.html new file mode 100644 index 000000000000..f6a4ee01b0c7 --- /dev/null +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.html @@ -0,0 +1,4 @@ +<div class="flex flex-col flex-1 items-center justify-center h-full gap-3 text-center"> + <i class="pi text-4xl text-gray-400" [class]="$icon()"></i> + <p class="text-sm font-medium text-gray-500 m-0">{{ $message() | dm }}</p> +</div> diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.scss new file mode 100644 index 000000000000..24fe6a1681cb --- /dev/null +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex: 1; + height: 100%; +} diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.spec.ts new file mode 100644 index 000000000000..19dddef068b8 --- /dev/null +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.spec.ts @@ -0,0 +1,53 @@ +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { DotAnalyticsEmptyStateComponent } from './dot-analytics-empty-state.component'; + +describe('DotAnalyticsEmptyStateComponent', () => { + let spectator: Spectator<DotAnalyticsEmptyStateComponent>; + + const createComponent = createComponentFactory({ + component: DotAnalyticsEmptyStateComponent, + providers: [mockProvider(DotMessageService, { get: jest.fn((key: string) => key) })] + }); + + it('should create', () => { + spectator = createComponent(); + expect(spectator.component).toBeTruthy(); + }); + + it('should use default message and icon', () => { + spectator = createComponent(); + + const icon = spectator.query('i'); + expect(icon).toHaveClass('pi-info-circle'); + + const message = spectator.query('p'); + expect(message).toContainText('analytics.charts.empty.description'); + }); + + it('should accept custom message', () => { + spectator = createComponent({ detectChanges: false }); + spectator.setInput('message', 'custom.message.key'); + spectator.detectChanges(); + + const message = spectator.query('p'); + expect(message).toContainText('custom.message.key'); + }); + + it('should accept custom icon', () => { + spectator = createComponent({ detectChanges: false }); + spectator.setInput('icon', 'pi-exclamation-triangle'); + spectator.detectChanges(); + + const icon = spectator.query('i'); + expect(icon).toHaveClass('pi-exclamation-triangle'); + }); + + it('should have flex container for centering', () => { + spectator = createComponent(); + const container = spectator.query('.flex.flex-col.flex-1'); + expect(container).toExist(); + }); +}); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.ts new file mode 100644 index 000000000000..fcbfff25154a --- /dev/null +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-empty-state/dot-analytics-empty-state.component.ts @@ -0,0 +1,19 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { DotMessagePipe } from '@dotcms/ui'; + +/** + * Standardized empty state for all analytics components. + * Centers icon + message vertically and horizontally, filling the parent container. + */ +@Component({ + selector: 'dot-analytics-empty-state', + imports: [DotMessagePipe], + templateUrl: './dot-analytics-empty-state.component.html', + styleUrl: './dot-analytics-empty-state.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotAnalyticsEmptyStateComponent { + readonly $message = input('analytics.charts.empty.description', { alias: 'message' }); + readonly $icon = input('pi-info-circle', { alias: 'icon' }); +} diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.html index 7ab533b3872c..09b57094c6e2 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.html +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.html @@ -18,25 +18,27 @@ <h3 class="text-lg font-bold mb-2" data-testid="metric-title"> </h3> } -<p-card - class="h-full metric-card" - [class.metric-card--loading]="isLoadingState" - [class.metric-card--empty]="isEmptyState" - [class.metric-card--error]="isErrorState" - data-testid="metric-card"> +<p-card class="h-full" data-testid="metric-card"> @if (isErrorState) { - <!-- Error state --> - <div class="flex flex-col justify-center items-center h-full" [@fadeInContent]> + <div class="metric-fade-in flex flex-col justify-center items-center h-full"> <dot-analytics-state-message message="analytics.metrics.error.reload" icon="pi-exclamation-triangle" /> </div> } @else if (isEmptyState) { - <!-- Empty state --> - <div class="flex flex-col justify-center items-center h-full" [@fadeInContent]> - <dot-analytics-state-message - message="analytics.metrics.empty.insufficient-data" - icon="pi-info-circle" /> + <!-- Empty state: dash preserves card layout --> + <div class="metric-fade-in flex flex-col justify-between h-full gap-2"> + <div class="flex justify-between items-start gap-1"> + <div class="metric-value metric-value--empty" data-testid="metric-value"> + — + </div> + @if (hasIcon) { + <i [class]="iconClasses" class="metric-icon pi" data-testid="metric-icon"></i> + } + </div> + <div class="metric-subtitle" data-testid="metric-subtitle"> + {{ 'analytics.metrics.empty.no-activity' | dm }} + </div> </div> } @else if (isLoadingState) { <!-- Loading state with skeleton (mirrors loaded structure) --> @@ -51,14 +53,14 @@ <h3 class="text-lg font-bold mb-2" data-testid="metric-title"> } </div> <!-- Subtitle skeleton (separate row, same as loaded state) --> - <p-skeleton width="4rem" height="1.06rem" /> + <p-skeleton width="4rem" height="1.03rem" /> </div> } @else { <!-- Normal content - only shown when LOADED --> <div class="flex flex-col justify-between h-full gap-2"> @let animated = $animated(); @let duration = $animationDuration(); - <div class="flex justify-between items-start gap-1" [@fadeInContent]> + <div class="metric-fade-in flex justify-between items-start gap-1"> <div class="flex flex-col gap-1"> @if ($isFraction()) { @let fractionParts = $fractionParts(); @@ -99,16 +101,13 @@ <h3 class="text-lg font-bold mb-2" data-testid="metric-title"> } </div> @if (hasIcon) { - <i - [class]="iconClasses" - class="metric-icon pi icon-md" - data-testid="metric-icon"></i> + <i [class]="iconClasses" class="metric-icon pi" data-testid="metric-icon"></i> } </div> <!-- Subtítulo --> @if (hasSubtitle || trendData) { - <div class="metric-subtitle" data-testid="metric-subtitle" [@fadeInContent]> + <div class="metric-fade-in metric-subtitle" data-testid="metric-subtitle"> @if (trendData) { <span [class]="trendData.class"> {{ trendData.prefix }}{{ trendData.value }}% @@ -124,10 +123,10 @@ <h3 class="text-lg font-bold mb-2" data-testid="metric-title"> </div> } - <!-- Projected content - visible during loading and loaded (sparkline handles its own skeleton) --> - @if (!isErrorState && !isEmptyState) { - <div class="metric-projected-content" data-testid="metric-projected-content"> - <ng-content /> - </div> - } + <div + class="metric-projected-content" + [class.metric-projected-content--hidden]="isErrorState" + data-testid="metric-projected-content"> + <ng-content /> + </div> </p-card> diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.scss index d06b4b2a178a..c29de840134d 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.scss +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.scss @@ -1,5 +1,14 @@ @use "variables" as *; +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + :host { display: flex; flex-direction: column; @@ -7,10 +16,8 @@ width: 100%; } -.metric-card { - &--loading { - min-height: 6rem; - } +.metric-fade-in { + animation: fadeIn 200ms ease-in; } .metric-value { @@ -20,6 +27,10 @@ margin: 0; line-height: 1.1; + &--empty { + color: $color-palette-gray-400; + } + &--fraction { display: flex; align-items: baseline; @@ -66,7 +77,7 @@ i { font-size: $font-size-sm; // Override the global .pi width (20px box) that pushes layout - width: 16px; + width: 1rem; } } @@ -86,8 +97,12 @@ width: 100%; margin-top: $spacing-2; - &:empty, - &--hidden { + &:empty { display: none; } + + &--hidden { + visibility: hidden; + pointer-events: none; + } } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.spec.ts index da024b8cff1c..f9e6fa6d3db0 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.spec.ts @@ -24,14 +24,10 @@ describe('DotAnalyticsMetricComponent', () => { }); beforeEach(() => { - spectator = createComponent({ - props: { - value: 100, - status: ComponentStatus.LOADED, - animated: false - } as unknown, - detectChanges: false - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput('value', 100); + spectator.setInput('status', ComponentStatus.LOADED); + spectator.setInput('animated', false); // Setup the mock return value const messageService = spectator.inject(DotMessageService); @@ -177,82 +173,65 @@ describe('DotAnalyticsMetricComponent', () => { }); describe('Computed Properties', () => { - it('should detect loading state correctly for INIT and LOADING', () => { - // INIT - + it('should show skeleton for INIT and LOADING, hide it for LOADED', () => { spectator.setInput('value', 100); spectator.setInput('status', ComponentStatus.INIT); spectator.detectChanges(); - expect(spectator.component['$isLoading']()).toBe(true); + expect(spectator.queryAll('p-skeleton').length).toBeGreaterThan(0); - // LOADING spectator.setInput('status', ComponentStatus.LOADING); spectator.detectChanges(); - expect(spectator.component['$isLoading']()).toBe(true); + expect(spectator.queryAll('p-skeleton').length).toBeGreaterThan(0); - // LOADED spectator.setInput('status', ComponentStatus.LOADED); spectator.detectChanges(); - expect(spectator.component['$isLoading']()).toBe(false); + expect(spectator.queryAll('p-skeleton').length).toBe(0); }); - it('should detect error state correctly', () => { - // ERROR - + it('should show error icon for ERROR, hide it for LOADED', () => { spectator.setInput('value', 100); spectator.setInput('status', ComponentStatus.ERROR); spectator.detectChanges(); - expect(spectator.component['$isError']()).toBe(true); + expect(spectator.query('.pi.pi-exclamation-triangle')).toBeTruthy(); - // LOADED spectator.setInput('status', ComponentStatus.LOADED); spectator.detectChanges(); - expect(spectator.component['$isError']()).toBe(false); + expect(spectator.query('.pi.pi-exclamation-triangle')).toBeFalsy(); }); - it('should detect empty state correctly', () => { - // Not empty when value is 0 (zero is a valid metric) - + it('should apply empty class for null/undefined/empty-string values but not for 0', () => { spectator.setInput('value', 0); spectator.setInput('status', ComponentStatus.LOADED); spectator.detectChanges(); - expect(spectator.component['$isEmpty']()).toBe(false); + expect(spectator.query(byTestId('metric-value'))).not.toHaveClass( + 'metric-value--empty' + ); - // Empty when value is null spectator.setInput('value', null); spectator.detectChanges(); - expect(spectator.component['$isEmpty']()).toBe(true); + expect(spectator.query(byTestId('metric-value'))).toHaveClass('metric-value--empty'); - // Empty when value is undefined - spectator.setInput('value', undefined); + spectator.setInput('value', ''); spectator.detectChanges(); - expect(spectator.component['$isEmpty']()).toBe(true); + expect(spectator.query(byTestId('metric-value'))).toHaveClass('metric-value--empty'); - // Not empty when value is valid spectator.setInput('value', 100); spectator.detectChanges(); - expect(spectator.component['$isEmpty']()).toBe(false); - - // Not empty when loading (even if value is 0) - spectator.setInput('value', 0); - spectator.setInput('status', ComponentStatus.LOADING); - spectator.detectChanges(); - expect(spectator.component['$isEmpty']()).toBe(false); - - // Not empty when error (even if value is 0) - spectator.setInput('status', ComponentStatus.ERROR); - spectator.detectChanges(); - expect(spectator.component['$isEmpty']()).toBe(false); + expect(spectator.query(byTestId('metric-value'))).not.toHaveClass( + 'metric-value--empty' + ); }); - it('should generate correct icon classes', () => { + it('should render icon element with correct classes when icon is provided', () => { spectator.setInput('value', 100); spectator.setInput('icon', 'pi-eye'); spectator.setInput('status', ComponentStatus.LOADED); spectator.detectChanges(); - const iconClasses = spectator.component['$iconClasses'](); - expect(iconClasses).toBe('pi pi-eye'); + const icon = spectator.query(byTestId('metric-icon')); + expect(icon).toBeTruthy(); + expect(icon).toHaveClass('pi'); + expect(icon).toHaveClass('pi-eye'); }); }); @@ -263,33 +242,50 @@ describe('DotAnalyticsMetricComponent', () => { spectator.setInput('animated', false); spectator.detectChanges(); - const emptyIcon = spectator.query('.pi.pi-info-circle'); const value = spectator.query(byTestId('metric-value')); - expect(emptyIcon).not.toExist(); expect(value).toExist(); - expect(value).toHaveText('0'); // 0 should be displayed as a valid value + expect(value).toHaveText('0'); }); - it('should show empty state icon and message when value is null', () => { + it('should show dash and subtitle when value is null', () => { spectator.setInput('value', null); spectator.setInput('status', ComponentStatus.LOADED); spectator.detectChanges(); - const emptyIcon = spectator.query('.pi.pi-info-circle'); - const emptyMessage = spectator.query('.state-message'); + const value = spectator.query(byTestId('metric-value')); + const subtitle = spectator.query(byTestId('metric-subtitle')); + + expect(value).toExist(); + expect(value).toHaveText('—'); + expect(value).toHaveClass('metric-value--empty'); + expect(subtitle).toExist(); + }); + + it('should show dash and subtitle when value is empty string', () => { + spectator.setInput('value', ''); + spectator.setInput('status', ComponentStatus.LOADED); + spectator.detectChanges(); + + const value = spectator.query(byTestId('metric-value')); + const subtitle = spectator.query(byTestId('metric-subtitle')); - expect(emptyIcon).toExist(); - expect(emptyMessage).toExist(); + expect(value).toExist(); + expect(value).toHaveText('—'); + expect(value).toHaveClass('metric-value--empty'); + expect(subtitle).toExist(); }); it('should not show empty state when value is valid', () => { spectator.setInput('value', 100); spectator.setInput('status', ComponentStatus.LOADED); + spectator.setInput('animated', false); spectator.detectChanges(); - const emptyIcon = spectator.query('.pi.pi-info-circle'); - expect(emptyIcon).not.toExist(); + const value = spectator.query(byTestId('metric-value')); + + expect(value).toExist(); + expect(value).not.toHaveClass('metric-value--empty'); }); }); @@ -446,11 +442,11 @@ describe('DotAnalyticsMetricComponent - Content Projection', () => { expect(projectedContent).toExist(); }); - it('should not show projected content in error state', () => { + it('should hide projected content visually in error state', () => { spectator.hostComponent.status = ComponentStatus.ERROR; spectator.detectChanges(); - const projectedContent = spectator.query(byTestId('projected-content')); - expect(projectedContent).not.toExist(); + const container = spectator.query(byTestId('metric-projected-content')); + expect(container).toHaveClass('metric-projected-content--hidden'); }); }); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.ts index c3d6180cc8d6..857006be739b 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-metric/dot-analytics-metric.component.ts @@ -5,7 +5,7 @@ import { CardModule } from 'primeng/card'; import { SkeletonModule } from 'primeng/skeleton'; import { ComponentStatus } from '@dotcms/dotcms-models'; -import { DotMessagePipe, fadeInContent } from '@dotcms/ui'; +import { DotMessagePipe } from '@dotcms/ui'; import { DotCountUpDirective } from '../../directives'; import { DotAnalyticsStateMessageComponent } from '../dot-analytics-state-message/dot-analytics-state-message.component'; @@ -27,16 +27,15 @@ import { DotAnalyticsStateMessageComponent } from '../dot-analytics-state-messag ], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './dot-analytics-metric.component.html', - styleUrl: './dot-analytics-metric.component.scss', - animations: [fadeInContent] + styleUrl: './dot-analytics-metric.component.scss' }) export class DotAnalyticsMetricComponent { // Inputs /** Optional title displayed above the card */ readonly $title = input<string>('', { alias: 'title' }); - /** Metric value (number will be formatted with separators, or string for special formats like "2/3") */ - readonly $value = input.required<number | string>({ alias: 'value' }); + /** Metric value (number will be formatted with separators, string for special formats like "2/3", null for empty) */ + readonly $value = input.required<number | string | null>({ alias: 'value' }); /** Optional secondary text below the metric value */ readonly $subtitle = input<string>('', { alias: 'subtitle' }); @@ -48,7 +47,9 @@ export class DotAnalyticsMetricComponent { readonly $trend = input<number | undefined>(undefined, { alias: 'trend' }); /** Component status for loading/error states */ - readonly $status = input<ComponentStatus>(ComponentStatus.INIT, { alias: 'status' }); + readonly $status = input<keyof typeof ComponentStatus>(ComponentStatus.INIT, { + alias: 'status' + }); /** Whether to animate numeric values with count-up effect */ readonly $animated = input<boolean>(true, { alias: 'animated' }); @@ -174,8 +175,8 @@ export class DotAnalyticsMetricComponent { return false; } - // Show empty state only when we have NO data (null/undefined) + // Show empty state only when we have NO data (null/undefined/empty string) // 0 is a valid metric value and should NOT be considered empty - return value === null || value === undefined; + return value === null || value === undefined || value === ''; }); } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-sparkline/dot-analytics-sparkline.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-sparkline/dot-analytics-sparkline.component.spec.ts index 73b76275f97c..201f4e8a31b7 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-sparkline/dot-analytics-sparkline.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-sparkline/dot-analytics-sparkline.component.spec.ts @@ -1,10 +1,11 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; import { ComponentStatus } from '@dotcms/dotcms-models'; +import type { SparklineDataPoint } from '@dotcms/portlets/dot-analytics/data-access'; import { DotAnalyticsSparklineComponent, - SparklineDataPoint + SparklineDataset } from './dot-analytics-sparkline.component'; import { AnalyticsChartColors } from '../../types'; @@ -26,14 +27,11 @@ describe('DotAnalyticsSparklineComponent', () => { { date: 'Oct 7', value: 28 } ]; + const mockDatasets: SparklineDataset[] = [{ data: mockData }]; + beforeEach(() => { - spectator = createComponent({ - props: { - data: mockData, - status: ComponentStatus.LOADED - } as unknown, - detectChanges: false - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput({ datasets: mockDatasets, status: ComponentStatus.LOADED }); }); describe('Component Initialization', () => { @@ -80,15 +78,16 @@ describe('DotAnalyticsSparklineComponent', () => { it('should have plugins configured', () => { spectator.detectChanges(); expect(spectator.component.chartPlugins).toBeDefined(); - expect(spectator.component.chartPlugins.length).toBe(2); + expect(spectator.component.chartPlugins.length).toBe(3); expect(spectator.component.chartPlugins[0].id).toBe('gradientFill'); - expect(spectator.component.chartPlugins[1].id).toBe('lineDrawAnimation'); + expect(spectator.component.chartPlugins[1].id).toBe('sparklineCrosshair'); + expect(spectator.component.chartPlugins[2].id).toBe('lineDrawAnimation'); }); }); describe('Custom Inputs', () => { it('should apply custom color', () => { - spectator.setInput('color', '#FF0000'); + spectator.setInput('datasets', [{ data: mockData, color: '#FF0000' }]); spectator.detectChanges(); const chartData = spectator.component['$chartData'](); @@ -120,6 +119,26 @@ describe('DotAnalyticsSparklineComponent', () => { expect(chartData.labels).toEqual(mockData.map((d) => d.date)); }); + it('should render two datasets when given current and previous period', () => { + const previousData: SparklineDataPoint[] = [ + { date: 'Sep 24', value: 8 }, + { date: 'Sep 25', value: 20 }, + { date: 'Sep 26', value: 12 } + ]; + spectator.setInput('datasets', [ + { data: mockData.slice(0, 3), label: 'This period', color: '#1243e3' }, + { data: previousData, label: 'Previous period', color: '#E5E7EB', dashed: true } + ]); + spectator.detectChanges(); + + const chartData = spectator.component['$chartData'](); + expect(chartData.datasets.length).toBe(2); + expect(chartData.datasets[0].borderDash).toBeUndefined(); + expect(chartData.datasets[0].fill).toBe(true); + expect(chartData.datasets[1].borderDash).toEqual([4, 2]); + expect(chartData.datasets[1].fill).toBe(false); + }); + it('should have fill enabled', () => { spectator.detectChanges(); @@ -144,19 +163,12 @@ describe('DotAnalyticsSparklineComponent', () => { expect(options.plugins?.legend?.display).toBe(false); }); - it('should have tooltip enabled by default (interactive)', () => { - spectator.detectChanges(); - - const options = spectator.component['$chartOptions'](); - expect(options.plugins?.tooltip?.enabled).toBe(true); - }); - - it('should have tooltip disabled when not interactive', () => { - spectator.setInput('interactive', false); + it('should have tooltip disabled with external handler', () => { spectator.detectChanges(); const options = spectator.component['$chartOptions'](); expect(options.plugins?.tooltip?.enabled).toBe(false); + expect(options.plugins?.tooltip?.external).toBeInstanceOf(Function); }); it('should have axes hidden', () => { @@ -176,11 +188,11 @@ describe('DotAnalyticsSparklineComponent', () => { expect(options.maintainAspectRatio).toBe(false); }); - it('should have hover radius when interactive', () => { + it('should have hover radius disabled (drawn by plugin)', () => { spectator.detectChanges(); const options = spectator.component['$chartOptions'](); - expect(options.elements?.point?.hoverRadius).toBe(6); + expect(options.elements?.point?.hoverRadius).toBe(0); }); it('should have no hover radius when not interactive', () => { @@ -210,12 +222,22 @@ describe('DotAnalyticsSparklineComponent', () => { expect(spectator.query('p-chart')).not.toExist(); }); - it('should show chart when status is LOADED', () => { + it('should show chart when status is LOADED and datasets have data', () => { + spectator.setInput('datasets', mockDatasets); spectator.setInput('status', ComponentStatus.LOADED); spectator.detectChanges(); expect(spectator.query('p-chart')).toExist(); expect(spectator.query('.sparkline-skeleton')).not.toExist(); }); + + it('should show empty state when datasets are empty', () => { + spectator.setInput('datasets', []); + spectator.setInput('status', ComponentStatus.LOADED); + spectator.detectChanges(); + + expect(spectator.query('.sparkline-empty')).toExist(); + expect(spectator.query('p-chart')).not.toExist(); + }); }); }); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-sparkline/dot-analytics-sparkline.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-sparkline/dot-analytics-sparkline.component.ts index b32268338d8f..c1ed9c389dfb 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-sparkline/dot-analytics-sparkline.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-sparkline/dot-analytics-sparkline.component.ts @@ -6,7 +6,8 @@ import { ElementRef, inject, input, - NgZone + NgZone, + signal } from '@angular/core'; import { ChartModule } from 'primeng/chart'; @@ -19,25 +20,53 @@ import { createGradientFillPlugin, createInteractionConfig, createLineDrawAnimationPlugin, - createTooltipConfig, + createSparklineCrosshairPlugin, SPARKLINE_ANIMATION_DURATION } from '../../plugins'; import { AnalyticsChartColors, ChartData } from '../../types'; +import type { SparklineDataPoint } from '@dotcms/portlets/dot-analytics/data-access'; import { hexToRgba } from '../../utils/dot-analytics.utils'; -/** Data point for sparkline with date and value */ -export interface SparklineDataPoint { - date: string; - value: number; +/** Context object passed to the Chart.js external tooltip callback */ +interface SparklineTooltipContext { + chart: { width: number }; + tooltip: { + opacity: number; + caretX: number; + caretY: number; + title?: string[]; + dataPoints?: { datasetIndex: number; parsed: { y: number } }[]; + }; +} + +/** Single series for sparkline (e.g. current or previous period) */ +export interface SparklineDataset { + /** Display label for tooltip/legend */ + label?: string; + /** Data points (date + value) */ + data: SparklineDataPoint[]; + /** Line color (hex or rgb) */ + color?: string; + /** Whether to draw line as dashed */ + dashed?: boolean; + /** Line width in pixels (default 2) */ + borderWidth?: number; + /** Gradient fill opacity multiplier (0-1, default 1). Lower values produce a more subtle gradient. */ + fillOpacity?: number; } /** * Sparkline component for displaying mini trend charts within metric cards. - * A lightweight chart with gradient fill and optional interactions. + * Supports multiple series (e.g. current vs previous period) with optional fill and dashed lines. * * @example * ```html - * <dot-analytics-sparkline [data]="[{ date: 'Oct 1', value: 10 }, { date: 'Oct 2', value: 25 }]" /> + * <dot-analytics-sparkline + * [datasets]="[ + * { data: currentPoints, label: 'This period', color: '#1243e3' }, + * { data: previousPoints, label: 'Previous period', color: '#E5E7EB', dashed: true } + * ]" + * valueSuffix="%" /> * ``` */ @Component({ @@ -49,13 +78,36 @@ export interface SparklineDataPoint { <div class="sparkline-skeleton"> <p-skeleton width="100%" height="100%" /> </div> + } @else if ($isEmpty()) { + <div class="sparkline-empty"> + <div class="sparkline-empty__line"></div> + </div> } @else { - <p-chart - type="line" - [data]="$chartData()" - [options]="$chartOptions()" - height="100%" - [plugins]="chartPlugins" /> + <div class="sparkline-container"> + <p-chart + type="line" + [data]="$chartData()" + [options]="$chartOptions()" + height="100%" + [plugins]="chartPlugins" /> + @if ($hoverInfo(); as info) { + <div + class="sparkline-tooltip" + [class.sparkline-tooltip--left]="info.alignLeft" + [style.left.px]="info.left" + [style.top.px]="info.top"> + <div class="sparkline-tooltip__date">{{ info.date }}</div> + @for (item of info.items; track item.label) { + <div class="sparkline-tooltip__row"> + <span + class="sparkline-tooltip__dot" + [style.background]="item.color"></span> + <span>{{ item.label }}: {{ item.value }}</span> + </div> + } + </div> + } + </div> } `, styles: ` @@ -73,14 +125,71 @@ export interface SparklineDataPoint { border-radius: 0.5rem; } } + + .sparkline-empty { + width: 100%; + height: 100%; + display: flex; + align-items: center; + } + + .sparkline-empty__line { + width: 100%; + border-top: 2px dashed var(--p-gray-300, #d1d5db); + } + + .sparkline-container { + position: relative; + width: 100%; + height: 100%; + overflow: visible; + } + + .sparkline-tooltip { + position: absolute; + z-index: 10; + transform: translateY(-50%); + background: rgba(30, 30, 30, 0.92); + color: white; + border-radius: 6px; + padding: 5px 8px; + font-size: 0.625rem; + line-height: 1.4; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + } + + .sparkline-tooltip--left { + transform: translate(-100%, -50%); + } + + .sparkline-tooltip__row { + display: flex; + align-items: center; + gap: 4px; + } + + .sparkline-tooltip__date { + opacity: 0.7; + margin-bottom: 2px; + } + + .sparkline-tooltip__dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + } ` }) export class DotAnalyticsSparklineComponent { readonly #ngZone = inject(NgZone); readonly #el = inject(ElementRef); - /** Data points for the sparkline (array of date/value objects) */ - readonly $data = input.required<SparklineDataPoint[]>({ alias: 'data' }); + /** Series to display (each with data, optional label, color, dashed). Labels from first series used for X axis. */ + readonly $datasets = input.required<SparklineDataset[]>({ alias: 'datasets' }); /** Label for the value in tooltip (e.g., "Rate", "Sessions") */ readonly $valueLabel = input<string>('Value', { alias: 'valueLabel' }); @@ -88,7 +197,7 @@ export class DotAnalyticsSparklineComponent { /** Unit suffix for the value (e.g., "%", "ms") */ readonly $valueSuffix = input<string>('', { alias: 'valueSuffix' }); - /** Line color (defaults to primary indigo) */ + /** Line color for first series when not set per dataset (defaults to primary indigo) */ readonly $color = input<string>(AnalyticsChartColors.primary.line, { alias: 'color' }); /** Height of the sparkline (sets CSS --sparkline-height variable on the host) */ @@ -111,12 +220,29 @@ export class DotAnalyticsSparklineComponent { /** Component status for loading state */ readonly $status = input<ComponentStatus>(ComponentStatus.INIT, { alias: 'status' }); + /** Hover info populated by Chart.js external tooltip callback */ + protected readonly $hoverInfo = signal<{ + date: string; + items: { label: string; value: string; color: string }[]; + left: number; + top: number; + alignLeft: boolean; + } | null>(null); + /** Whether the component is in loading state */ protected readonly $isLoading = computed(() => { const status = this.$status(); return status === ComponentStatus.INIT || status === ComponentStatus.LOADING; }); + /** Whether there is no data to display */ + protected readonly $isEmpty = computed(() => { + if (this.$isLoading()) return false; + const datasets = this.$datasets(); + if (!datasets?.length) return true; + return datasets.every((ds) => !ds.data?.length); + }); + constructor() { effect(() => { this.#el.nativeElement.style.setProperty('--sparkline-height', this.$height()); @@ -131,14 +257,16 @@ export class DotAnalyticsSparklineComponent { * Animation runs outside Angular's zone to avoid triggering change detection. */ readonly chartPlugins = [ - // Gradient fill plugin (reusable) + // Gradient fill plugin (applies to all filled datasets using each dataset's borderColor) createGradientFillPlugin( () => ({ - enabled: this.$filled(), + enabled: this.$filled() && this.$datasets().length > 0, color: this.$color() }), hexToRgba ), + // Vertical crosshair when hovering (tooltip active) + createSparklineCrosshairPlugin(), // Line drawing animation plugin (reusable, runs outside zone) createLineDrawAnimationPlugin( () => ({ @@ -152,41 +280,83 @@ export class DotAnalyticsSparklineComponent { /** Chart data formatted for Chart.js */ protected readonly $chartData = computed<ChartData>(() => { - const dataPoints = this.$data(); - const color = this.$color(); + const datasets = this.$datasets(); const filled = this.$filled(); + if (!datasets?.length) { + return { labels: [], datasets: [] }; + } + const first = datasets[0]; + const labels = first.data?.map((p) => p.date) ?? []; - return { - labels: dataPoints.map((point) => point.date), - datasets: [ - { - data: dataPoints.map((point) => point.value), - borderColor: color, - borderWidth: 2, - backgroundColor: hexToRgba(color, 0.15), // Fallback color - fill: filled - } - ] - }; + const chartDatasets = datasets.map((ds) => { + const color = ds.color ?? this.$color(); + const values = ds.data?.map((p) => p.value) ?? []; + return { + data: values, + borderColor: color, + borderWidth: ds.borderWidth ?? 2, + borderDash: ds.dashed ? [4, 2] : undefined, + backgroundColor: hexToRgba(color, 0.15), + fill: filled, + clip: false as const, + _fillOpacity: ds.fillOpacity ?? 1 + }; + }); + + return { labels, datasets: chartDatasets }; }); /** Chart options optimized for sparkline display */ protected readonly $chartOptions = computed(() => { const interactive = this.$interactive(); - const valueLabel = this.$valueLabel(); + const datasets = this.$datasets(); const valueSuffix = this.$valueSuffix(); + const pointSpace = interactive ? 4 : 0; + return { responsive: true, maintainAspectRatio: false, - // Smooth interaction animations (centralized config) + layout: { + padding: { + top: pointSpace, + bottom: pointSpace, + left: pointSpace, + right: pointSpace + } + }, ...createInteractionConfig(), plugins: { legend: { display: false }, - tooltip: createTooltipConfig({ - enabled: interactive, - labelCallback: (context) => `${valueLabel}: ${context.parsed.y}${valueSuffix}` - }) + tooltip: { + enabled: false, + external: ({ chart, tooltip }: SparklineTooltipContext) => { + if (!tooltip.opacity || !tooltip.dataPoints?.length) { + this.#ngZone.run(() => this.$hoverInfo.set(null)); + return; + } + + const gap = 14; + const alignLeft = tooltip.caretX > chart.width / 2; + const left = alignLeft ? tooltip.caretX - gap : tooltip.caretX + gap; + + const items = tooltip.dataPoints.map((dp) => ({ + label: datasets[dp.datasetIndex]?.label ?? this.$valueLabel(), + value: `${dp.parsed.y}${valueSuffix}`, + color: datasets[dp.datasetIndex]?.color ?? this.$color() + })); + + this.#ngZone.run(() => + this.$hoverInfo.set({ + date: tooltip.title?.[0] ?? '', + items, + left, + top: tooltip.caretY, + alignLeft + }) + ); + } + } }, scales: { x: { display: false }, @@ -198,10 +368,7 @@ export class DotAnalyticsSparklineComponent { }, point: { radius: 0, - hoverRadius: interactive ? 6 : 0, - hoverBackgroundColor: AnalyticsChartColors.primary.line, - hoverBorderColor: 'white', - hoverBorderWidth: 2 + hoverRadius: 0 } }, interaction: { diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.html b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.html new file mode 100644 index 000000000000..496c13a9b1b4 --- /dev/null +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.html @@ -0,0 +1,4 @@ +<div class="flex flex-col justify-center items-center h-full text-center gap-3" [@fadeInContent]> + <i [class]="$iconClasses()"></i> + <div class="state-message">{{ $message() | dm }}</div> +</div> diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.scss b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.scss index fe1a839728ec..5297359dd484 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.scss +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.scss @@ -1,9 +1,8 @@ @use "variables" as *; + :host { flex: 1; -} -i { - color: $color-palette-gray-500; + height: 100%; } .state-message { diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.spec.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.spec.ts index 66dbc6a9e14b..bba7eb7f2fb6 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.spec.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.spec.ts @@ -13,12 +13,8 @@ describe('DotAnalyticsStateMessageComponent', () => { }); beforeEach(() => { - spectator = createComponent({ - props: { - message: 'test.message', - icon: 'pi-info-circle' - } - }); + spectator = createComponent({ detectChanges: false }); + spectator.setInput({ message: 'test.message', icon: 'pi-info-circle' }); }); it('should create', () => { @@ -44,6 +40,7 @@ describe('DotAnalyticsStateMessageComponent', () => { it('should update icon classes when inputs change', () => { spectator.setInput('icon', 'pi-exclamation-triangle'); + spectator.detectChanges(); const iconElement = spectator.query('i'); @@ -54,6 +51,7 @@ describe('DotAnalyticsStateMessageComponent', () => { it('should apply custom icon size and color', () => { spectator.setInput('iconSize', 'text-xl'); spectator.setInput('iconColor', 'text-red-500'); + spectator.detectChanges(); const iconElement = spectator.query('i'); @@ -65,6 +63,7 @@ describe('DotAnalyticsStateMessageComponent', () => { it('should apply additional icon classes', () => { spectator.setInput('iconClasses', 'custom-class'); + spectator.detectChanges(); const iconElement = spectator.query('i'); @@ -72,7 +71,7 @@ describe('DotAnalyticsStateMessageComponent', () => { }); it('should have correct component structure', () => { - const container = spectator.query('.flex.flex-column.justify-content-center'); + const container = spectator.query('.flex.flex-col.justify-center'); const iconElement = spectator.query('i'); const messageElement = spectator.query('.state-message'); diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.ts index e7c3488077fd..650bd4c0c646 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/components/dot-analytics-state-message/dot-analytics-state-message.component.ts @@ -9,53 +9,34 @@ import { DotMessagePipe, fadeInContent } from '@dotcms/ui'; @Component({ selector: 'dot-analytics-state-message', imports: [DotMessagePipe], - template: ` - <div - class="flex flex-column justify-content-center align-items-center h-full text-center gap-3" - [@fadeInContent]> - <i [class]="$iconClasses()"></i> - <div class="state-message">{{ message() | dm }}</div> - </div> - `, + templateUrl: './dot-analytics-state-message.component.html', styleUrl: './dot-analytics-state-message.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeInContent] }) export class DotAnalyticsStateMessageComponent { - /** - * The message key to display (will be translated) - */ - message = input.required<string>(); + /** The message key to display (will be translated) */ + readonly $message = input.required<string>({ alias: 'message' }); - /** - * The PrimeNG icon class name (e.g., 'pi-info-circle', 'pi-exclamation-triangle') - */ - icon = input.required<string>(); + /** The PrimeNG icon class name (e.g., 'pi-info-circle', 'pi-exclamation-triangle') */ + readonly $icon = input.required<string>({ alias: 'icon' }); - /** - * Icon size class (default: 'text-2xl') - */ - iconSize = input<string>('text-4xl'); + /** Icon size class (default: 'text-4xl') */ + readonly $iconSize = input<string>('text-4xl', { alias: 'iconSize' }); - /** - * Icon color class (default: 'text-gray-400') - */ - iconColor = input<string>('text-gray-400'); + /** Icon color class (default: 'text-gray-400') */ + readonly $iconColor = input<string>('text-gray-400', { alias: 'iconColor' }); - /** - * Additional icon CSS classes - */ - iconClasses = input<string>(''); + /** Additional icon CSS classes */ + readonly $extraClasses = input<string>('', { alias: 'iconClasses' }); - /** - * Computed signal for complete icon classes combining all icon-related inputs - */ + /** Computed signal for complete icon classes combining all icon-related inputs */ protected readonly $iconClasses = computed(() => { const baseClasses = 'pi'; - const iconName = this.icon(); - const size = this.iconSize(); - const color = this.iconColor(); - const additional = this.iconClasses(); + const iconName = this.$icon(); + const size = this.$iconSize(); + const color = this.$iconColor(); + const additional = this.$extraClasses(); const classes = [baseClasses, iconName, size, color]; diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/constants/dot-analytics.constants.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/constants/dot-analytics.constants.ts index 0a5aa43bdcea..6d708ba49bd7 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/constants/dot-analytics.constants.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/constants/dot-analytics.constants.ts @@ -25,9 +25,7 @@ export const ANALYTICS_KEYS = { export const TABLE_CONFIG = { SORT_MODE: 'multiple', DATA_KEY: 'path', - // Virtual scroll configuration - VIRTUAL_SCROLL_ITEM_SIZE: 46, - SCROLL_HEIGHT: '23.125rem' + ROWS_PER_PAGE: 10 } as const; /** Table column configuration for top pages analytics table */ @@ -52,7 +50,7 @@ export const TOP_PAGES_TABLE_COLUMNS = [ field: 'views', header: 'analytics.table.headers.pageviews', type: 'number', - alignment: 'center', + alignment: 'right', sortable: true, width: '15%' } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/plugins/gradient-fill.plugin.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/plugins/gradient-fill.plugin.ts index 8a65035cfec6..3944b6433e53 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/plugins/gradient-fill.plugin.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/plugins/gradient-fill.plugin.ts @@ -52,26 +52,38 @@ export function createGradientFillPlugin( const { ctx, chartArea, data } = chart; if (!chartArea) return; - const dataset = data?.datasets?.[0]; - if (!dataset) return; - - // Only create gradient if not already a CanvasGradient - if (dataset.backgroundColor instanceof CanvasGradient) return; - const { - color, + color: fallbackColor, topOpacity = DEFAULT_TOP_OPACITY, middleOpacity = DEFAULT_MIDDLE_OPACITY, bottomOpacity = DEFAULT_BOTTOM_OPACITY } = options; - const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); + /** Extended dataset shape that includes the non-standard _fillOpacity property */ + interface ExtendedDataset { + fill?: boolean | string; + /** Per-dataset fill opacity multiplier injected by sparkline component */ + _fillOpacity?: number; + borderColor?: string | CanvasGradient | CanvasPattern; + backgroundColor?: string | CanvasGradient; + } + + for (const dataset of data?.datasets ?? []) { + const ds = dataset as ExtendedDataset; + if (ds.fill === false || ds.fill === undefined) continue; + if (dataset.backgroundColor instanceof CanvasGradient) continue; + + const opacity = ds._fillOpacity ?? 1; + const rawColor = dataset.borderColor; + const color = typeof rawColor === 'string' ? rawColor : fallbackColor; + const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); - gradient.addColorStop(0, hexToRgba(color, topOpacity)); - gradient.addColorStop(0.5, hexToRgba(color, middleOpacity)); - gradient.addColorStop(1, hexToRgba(color, bottomOpacity)); + gradient.addColorStop(0, hexToRgba(color, topOpacity * opacity)); + gradient.addColorStop(0.5, hexToRgba(color, middleOpacity * opacity)); + gradient.addColorStop(1, hexToRgba(color, bottomOpacity * opacity)); - dataset.backgroundColor = gradient; + dataset.backgroundColor = gradient; + } } }; } diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/plugins/index.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/plugins/index.ts index 39e714a42247..7d460bf317ca 100644 --- a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/plugins/index.ts +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/plugins/index.ts @@ -15,3 +15,4 @@ export { TOOLTIP_STYLE, TooltipOptions } from './tooltip-config.plugin'; +export { createSparklineCrosshairPlugin } from './sparkline-crosshair.plugin'; diff --git a/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/plugins/sparkline-crosshair.plugin.ts b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/plugins/sparkline-crosshair.plugin.ts new file mode 100644 index 000000000000..f55575f1ef6a --- /dev/null +++ b/core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/shared/plugins/sparkline-crosshair.plugin.ts @@ -0,0 +1,56 @@ +import { Plugin } from 'chart.js'; + +const CROSSHAIR_COLOR = 'rgba(52, 211, 153, 0.4)'; +const CROSSHAIR_WIDTH = 1; +const POINT_RADIUS = 4; +const POINT_BORDER = 2; +const POINT_BORDER_COLOR = 'white'; + +/** + * Chart.js plugin that draws a vertical crosshair line and hover points + * at the active index. Hover points are drawn by the plugin (not by Chart.js) + * to avoid chart-area clipping at the edges. + */ +export function createSparklineCrosshairPlugin(): Plugin { + return { + id: 'sparklineCrosshair', + afterDraw(chart) { + const activeElements = chart.getActiveElements(); + if (!activeElements?.length) return; + + const { ctx, chartArea } = chart; + const x = activeElements[0].element.x; + if (x == null || !chartArea) return; + + ctx.save(); + + // Vertical crosshair line + ctx.beginPath(); + ctx.strokeStyle = CROSSHAIR_COLOR; + ctx.lineWidth = CROSSHAIR_WIDTH; + ctx.setLineDash([]); + ctx.moveTo(x, chartArea.top); + ctx.lineTo(x, chartArea.bottom); + ctx.stroke(); + + // Hover points (drawn without clip so edge points are fully visible) + ctx.setLineDash([]); + for (const active of activeElements) { + const { x: px, y: py } = active.element; + const ds = chart.data.datasets[active.datasetIndex]; + const rawColor = ds.borderColor; + const color = typeof rawColor === 'string' ? rawColor : '#000'; + + ctx.beginPath(); + ctx.arc(px, py, POINT_RADIUS, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = POINT_BORDER_COLOR; + ctx.lineWidth = POINT_BORDER; + ctx.stroke(); + } + + ctx.restore(); + } + }; +} diff --git a/docs/frontend/ANGULAR_STANDARDS.md b/docs/frontend/ANGULAR_STANDARDS.md index 7052b09f9793..c3c3a1ff49e9 100644 --- a/docs/frontend/ANGULAR_STANDARDS.md +++ b/docs/frontend/ANGULAR_STANDARDS.md @@ -4,7 +4,7 @@ This document is the single source of truth for Angular development in the dotCM ## Tech Stack Configuration - **Angular**: 20.3.9 standalone components -- **UI**: PrimeNG 17.18.11, PrimeFlex 3.3.1 +- **UI**: PrimeNG 17.18.11, Tailwind CSS 4.x (PrimeFlex deprecated/removed) - **State**: NgRx Signals, Component Store - **Build**: Nx 20.5.1 - **Testing**: Jest + Spectator (REQUIRED) @@ -297,6 +297,6 @@ yarn install # NOT npm install ## See also - [COMPONENT_ARCHITECTURE.md](./COMPONENT_ARCHITECTURE.md) — Structure, file layout, data flow - [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md) — NgRx Signal Store for feature state -- [STYLING_STANDARDS.md](./STYLING_STANDARDS.md) — PrimeFlex, BEM, SCSS +- [STYLING_STANDARDS.md](./STYLING_STANDARDS.md) — Tailwind CSS, PrimeNG theme, BEM, SCSS - [TESTING_FRONTEND.md](./TESTING_FRONTEND.md) — Spectator, byTestId, setInput - [TYPESCRIPT_STANDARDS.md](./TYPESCRIPT_STANDARDS.md) — Strict types, as const, # private \ No newline at end of file diff --git a/docs/frontend/README.md b/docs/frontend/README.md index 92f9ebdf8dbb..31cdb339c07b 100644 --- a/docs/frontend/README.md +++ b/docs/frontend/README.md @@ -10,7 +10,7 @@ Index for Angular/TypeScript frontend standards in `core-web`. **Cursor** uses ` | [BREADCRUMBS.md](./BREADCRUMBS.md) | GlobalStore breadcrumbs: addNewBreadcrumb, setBreadcrumbs, id/url for tabs, duplicate prevention | | [COMPONENT_ARCHITECTURE.md](./COMPONENT_ARCHITECTURE.md) | Component structure, file layout, data flow, parent-child | | [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md) | NgRx Signal Store, rxMethod, patchState — **prefer over manual state** | -| [STYLING_STANDARDS.md](./STYLING_STANDARDS.md) | PrimeFlex, PrimeNG, BEM, SCSS variables | +| [STYLING_STANDARDS.md](./STYLING_STANDARDS.md) | Tailwind CSS, PrimeNG theme, BEM (when needed), SCSS variables | | [TESTING_FRONTEND.md](./TESTING_FRONTEND.md) | Spectator, Jest/Vitest, byTestId, setInput, data-testid | | [TYPESCRIPT_STANDARDS.md](./TYPESCRIPT_STANDARDS.md) | Strict types, inference, unknown, as const, # private | @@ -20,4 +20,4 @@ Index for Angular/TypeScript frontend standards in `core-web`. **Cursor** uses ` - **State**: Use NgRx Signal Store for feature state; avoid manual signal soup — STATE_MANAGEMENT, COMPONENT_ARCHITECTURE. - **Breadcrumbs**: GlobalStore only; use `addNewBreadcrumb` with `id` or `url` for tabs/sub-routes to avoid duplicates — BREADCRUMBS. - **Testing**: Spectator, `byTestId`, `setInput`, `detectChanges`, `click` — TESTING_FRONTEND, ANGULAR_STANDARDS. -- **TypeScript**: Strict, no `any`, `as const`, `#` private — TYPESCRIPT_STANDARDS, referenced from others. \ No newline at end of file +- **TypeScript**: Strict, no `any`, `as const`, `#` private — TYPESCRIPT_STANDARDS, referenced from others. diff --git a/docs/frontend/STYLING_STANDARDS.md b/docs/frontend/STYLING_STANDARDS.md index 21ae69eb0639..6c5ad4dd0f2a 100644 --- a/docs/frontend/STYLING_STANDARDS.md +++ b/docs/frontend/STYLING_STANDARDS.md @@ -1,43 +1,106 @@ # Styling Standards -## Priority: PrimeFlex & PrimeNG First +## Priority: Tailwind CSS + PrimeNG Theme -- **Prefer PrimeFlex utility classes** for layout, spacing, typography, and colors. Avoid creating custom SCSS when a utility exists (e.g. `p-flex`, `p-m-3`, `p-text-primary`, `p-shadow-2`). -- **Use PrimeNG components** in most cases instead of building custom UI from scratch (e.g. `p-button`, `p-inputText`, `p-card`, `p-dialog`, `p-table`). Custom styles should be the exception, not the default. -- When you do need custom styles, follow BEM and the rules below. +- **Use Tailwind utility classes** for layout, spacing, typography, colors, sizing, flexbox, and grid. Avoid custom SCSS when a Tailwind class exists. +- **Use PrimeNG components** instead of building custom UI (e.g. `p-button`, `p-inputText`, `p-card`, `p-dialog`, `p-table`). PrimeNG theme tokens handle component styling automatically. +- **Minimize custom CSS** — component `.scss` files should be the exception, not the default. Most components should need zero or near-zero custom styles. +- **PrimeFlex is deprecated and uninstalled** — do NOT use PrimeFlex classes (`flex`, `grid`, `col-*`, `p-m-*`, `gap-*`, `align-items-*`, `justify-content-*`). Replace with Tailwind equivalents. + +## Tailwind Usage + +```html +<!-- ✅ Layout with Tailwind --> +<div class="flex items-center gap-4 p-4"> + <span class="text-sm font-semibold text-color">Title</span> + <p-button label="Save" /> +</div> + +<!-- ✅ Responsive grid --> +<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + <p-card *ngFor="..." /> +</div> + +<!-- ❌ NEVER: PrimeFlex (deprecated) --> +<div class="flex align-items-center gap-3 p-3">...</div> + +<!-- ❌ NEVER: custom CSS for what Tailwind handles --> +<div class="my-custom-flex-container">...</div> +``` + +## When Custom SCSS Is Acceptable + +Custom SCSS is only allowed for: +- **PrimeNG component overrides** (via `::ng-deep` inside `:host`) +- **Complex animations** or pseudo-element styles Tailwind cannot express +- **Third-party library integration** overrides + +```scss +// ✅ PrimeNG override (scoped) +:host { + ::ng-deep .p-datatable .p-datatable-header { + border-radius: var(--border-radius); + } +} + +// ❌ NEVER: unscoped ::ng-deep +::ng-deep .p-button { color: red; } + +// ❌ NEVER: custom SCSS for layout/spacing/colors +.my-container { + display: flex; + gap: 1rem; + padding: 16px; + color: #333; +} +``` + +## SCSS Variables (when custom styles are needed) -## BEM Methodology (when custom styles are needed) ```scss -// ALWAYS import variables first @use "variables" as *; -// Use global variables, NEVER hardcoded values -.feature-list { - padding: $spacing-3; - color: $color-palette-primary; - background: $color-palette-gray-100; +.feature-overlay { box-shadow: $shadow-m; + border: 1px solid $color-palette-gray-200; } - -// BEM with flat structure (no nesting) -.feature-list { } -.feature-list__header { } -.feature-list__item { } -.feature-list__item--active { } ``` -## Required Variables -- **Spacing**: `$spacing-1` through `$spacing-5` -- **Colors**: `$color-palette-primary`, `$color-palette-gray-100` +### Available Variables +- **Spacing**: `$spacing-0` through `$spacing-9` +- **Colors**: `$color-palette-primary`, `$color-palette-gray-*`, etc. - **Shadows**: `$shadow-s`, `$shadow-m`, `$shadow-l` ## Rules -- **Prefer PrimeFlex utilities and PrimeNG components** over custom SCSS; use BEM only when utilities/components are insufficient. -- **NEVER hardcode**: colors, spacing, shadows (use variables or PrimeFlex tokens). -- **BEM naming**: Block__Element--Modifier (for custom blocks only). -- **Flat structure**: No nested SCSS selectors. -- **Component scoping**: Use component-specific classes when writing custom styles. + +- **Tailwind first** — use utility classes for layout, spacing, colors, typography, sizing. +- **PrimeNG theme** — rely on the theme for component styling; avoid overriding PrimeNG styles unless necessary. +- **No PrimeFlex** — it is deprecated and uninstalled. Replace any remaining usage with Tailwind. +- **NEVER hardcode** colors, spacing, or shadows in SCSS — use SCSS variables or Tailwind classes. +- **`::ng-deep` must be scoped** inside `:host` — never bare. +- **BEM naming** (`Block__Element--Modifier`) only when custom SCSS is truly needed. +- **Flat SCSS structure** — no deeply nested selectors (max 3 levels). +- **No `!important`** unless justified with a comment. + +## PrimeFlex → Tailwind Migration Reference + +| PrimeFlex (deprecated) | Tailwind | +|---|---| +| `flex` | `flex` | +| `align-items-center` | `items-center` | +| `justify-content-between` | `justify-between` | +| `gap-3` | `gap-3` | +| `p-3` (padding) | `p-3` | +| `m-2` (margin) | `m-2` | +| `col-6` | `w-1/2` or `grid grid-cols-2` | +| `text-center` | `text-center` | +| `font-bold` | `font-bold` | +| `w-full` | `w-full` | +| `hidden` | `hidden` | +| `grid` | `grid` | +| `flex-column` | `flex-col` | +| `flex-wrap` | `flex-wrap` | ## See also - [ANGULAR_STANDARDS.md](./ANGULAR_STANDARDS.md) — Component rules, templates -- [docs/frontend/README.md](./README.md) — Index of all frontend docs \ No newline at end of file +- [docs/frontend/README.md](./README.md) — Index of all frontend docs diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 3994caccc63e..170564d4f9a4 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6252,6 +6252,7 @@ analytics.dashboard.refresh.button=Refresh Data analytics.metrics.error.loading=Error loading data analytics.metrics.error.reload=Error loading the data, please reload the page. analytics.metrics.empty.insufficient-data=Results will be displayed once we collect sufficient data. +analytics.metrics.empty.no-activity=No activity yet analytics.table.data.not-available=N/A analytics.device.mobile=Mobile analytics.device.tablet=Tablet @@ -6378,8 +6379,13 @@ analytics.engagement.dialog.how-calculated.criteria-time=Session duration is lon analytics.engagement.dialog.how-calculated.criteria-pageviews=Session contains 2 or more pageview events analytics.engagement.dialog.how-calculated.criteria-conversion=Session contains at least 1 conversion event analytics.engagement.dialog.how-calculated.conclusion=Meeting any one of these counts as an engaged session. +analytics.engagement.dialog.how-calculated.got-it=Got it analytics.engagement.sparkline.value-label=Rate +analytics.engagement.sparkline.period-current=This period +analytics.engagement.sparkline.period-previous=Previous period +analytics.engagement.empty.period=No engagement data for the selected period. +analytics.engagement.platforms.empty=No data for the selected period. com.dotcms.repackage.javax.portlet.title.analytics-dashboard=Analytics Dashboard edit.content.form.field.calendar.never.expires=Never Expires