diff --git a/core-web/UVE_REFACTOR_HANDOFF.md b/core-web/UVE_REFACTOR_HANDOFF.md
new file mode 100644
index 000000000000..97cd23da3ebb
--- /dev/null
+++ b/core-web/UVE_REFACTOR_HANDOFF.md
@@ -0,0 +1,753 @@
+# UVE Refactor - Team Handoff
+
+**Branch:** `uve-experiment`
+**Base:** `main`
+**Status:** 🟡 Ready for Testing & Completion
+**Changes:** 44 files, +3,378 / -1,623 lines
+
+**Recent Updates:**
+- ✅ Resolved inline editing vs contentlet selection conflict with dual overlay system
+- ✅ Implemented hover/selected state management in contentlet tools
+- ✅ Relocated toolbar buttons (palette toggle, copy URL, right sidebar toggle)
+- ✅ Added right sidebar with toggle and empty state
+- ✅ Fixed responsive preview broken by zoom implementation
+- ✅ Fixed missing "Edit All Pages vs This Page" dialog in contentlet form submission
+
+---
+
+## What Was Done
+
+### Architecture Refactor
+Extracted business logic from the monolithic `EditEmaEditor` component into specialized services, reducing component complexity from ~2000 to ~1000 lines.
+
+**Main Component:**
+- [`edit-ema-editor.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts) - Refactored to use new service architecture
+
+### New Services (No Tests Yet - May become 3 after consolidation)
+
+| Service | Purpose | Location |
+|---------|---------|----------|
+| **DotUveActionsHandlerService** | Centralized action handling (edit, delete, reorder) | [`dot-uve-actions-handler.service.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.ts) |
+| **DotUveBridgeService** | PostMessage communication with iframe | [`dot-uve-bridge.service.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-bridge/dot-uve-bridge.service.ts) |
+| **DotUveDragDropService** | Drag-and-drop logic | [`dot-uve-drag-drop.service.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-drag-drop/dot-uve-drag-drop.service.ts) |
+| **DotUveZoomService** | Zoom controls (25%-150%) - **Should be moved to UVEStore** | [`dot-uve-zoom.service.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-zoom/dot-uve-zoom.service.ts) |
+
+### New Components (No Tests Yet)
+
+| Component | Purpose | Location |
+|-----------|---------|----------|
+| **DotUveIframeComponent** | Iframe with zoom support | [`dot-uve-iframe/`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/) |
+| **DotUveZoomControlsComponent** | Zoom UI controls | [`dot-uve-zoom-controls/`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/) |
+| **DotRowReorderComponent** | Row/column drag-and-drop reordering | [`dot-row-reorder/`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-row-reorder/) |
+
+### State Management
+- [`withSave.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts) - Enhanced to re-fetch page content after save
+- [`dot-uve.store.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts) - Improved integration with new services
+
+### UI/UX Improvements
+
+#### Contentlet Tools - Dual State Management
+**Component:** [`dot-uve-contentlet-tools.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts)
+
+**Implemented:**
+- **Hover state**: Blue overlay with opacity, clickable, allows page interaction
+- **Selected state**: Border outline, transparent background, tools visible, `pointer-events: none` on bounds to allow iframe interaction
+- **Dual overlays**: Both hover and selected can be visible simultaneously
+- **Click-to-select**: Clicking hover overlay selects contentlet and shows action buttons
+- **Computed signals**: `isHoveredDifferentFromSelected`, `showHoverOverlay`, `showSelectedOverlay`, `selectedContentContext`, `isSelectedContainerEmpty`, `selectedHasVtlFiles`
+- **Signal methods**: `handleClick()` sets selection, `signalMethod()` resets selection when contentlet changes
+
+**User Benefit:** Hovering shows subtle preview without blocking interaction; clicking selects and shows tools while still allowing page interaction.
+
+#### Editor Component - Toolbar Button Relocations
+**Component:** [`edit-ema-editor.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts)
+
+**Implemented:**
+- **Right sidebar toggle**: Added `$rightSidebarOpen` signal with width-based animation
+- **Button relocations**:
+ - Palette toggle moved from `dot-uve-toolbar` to `browser-toolbar` (start)
+ - Copy URL button moved from `dot-uve-toolbar` to `browser-url-bar-container` (left of URL bar)
+ - Right sidebar toggle added to `browser-toolbar` (end) with flipped icon
+- **Empty state**: "Select a contentlet" message when sidebar is empty
+- **Image drag prevention**: `draggable="false"` on toggle button images
+- **Grid layout**: Updated to `grid-template-columns: min-content 1fr min-content` for three-column layout
+
+**User Benefit:** More intuitive placement of controls near content area, consistent animations, clear empty states.
+
+#### Toolbar Component - Cleanup
+**Component:** [`dot-uve-toolbar.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.ts)
+
+**Removed:**
+- Palette toggle button and functionality
+- Copy URL button and overlay panel
+- Related imports (`ClipboardModule`, `OverlayPanelModule`)
+- `$pageURLS` computed signal and `triggerCopyToast()` method
+
+**User Benefit:** Cleaner toolbar component, functionality moved to more appropriate locations.
+
+---
+
+## Design Decisions Needed
+
+### 1. Contentlet Controls at Zoom
+
+### Problem
+The contentlet-level controls in [`dot-uve-contentlet-tools`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts) become too small to use when zoomed out.
+
+### Proposed Solution
+**Remove the overlay buttons and replace with:**
+
+1. **Move/Drag** - Make entire contentlet draggable (no button needed)
+2. **Code Button** - Move to right panel alongside edit content
+3. **Delete** - Keyboard action (Delete/Backspace) and/or button in right panel
+4. **Edit** - Button in right panel with two modes:
+ - **Quick Edit** (default) - Edit simple fields inline in right panel:
+ - Text, Textarea, Select, Multi-select, Radio, Checkbox
+ - **Full Editor** - Button to open complete editor for other field types
+
+**Impact:** Requires refactoring [`dot-uve-contentlet-tools.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts) and enhancing right panel UI.
+
+### 2. Layout Tree Completeness
+
+**Current State:** Layout tab shows: Rows > Columns
+
+**Missing Levels:** Containers > Contentlets (actual content pieces)
+
+**Proposed Enhancement (Optional):**
+- **Containers:** Add to tree with double-click to edit:
+ - Add/remove allowed content types
+ - Set max contentlets per container
+ - Quick container configuration without leaving editor
+
+- **Contentlets:** Show actual content pieces in tree for better visibility
+
+**Considerations:**
+- **Permissions:** Not all users have permission to create/edit containers - need to understand and respect permission layer
+- **Scope Creep:** Could be deferred to post-MVP
+- **Complexity:** Additional tree levels add UI complexity
+
+**Decision Required:**
+- [ ] Ship as-is with Rows/Columns only (simpler, faster to market)
+- [ ] Add Containers level with editing capability
+- [ ] Add full tree: Rows > Columns > Containers > Contentlets
+- [ ] Defer to future iteration based on user feedback
+
+**Impact:** [`dot-row-reorder.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-row-reorder/dot-row-reorder.component.ts) would need significant expansion to support deeper tree levels and permission checks.
+
+### 3. Inline Editing vs Contentlet Selection Conflict ✅ RESOLVED
+
+**Component:** [`dot-uve-contentlet-tools.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts)
+
+**Solution Implemented:**
+- **Dual overlay system**: Separate hover (blue, clickable) and selected (border, tools visible) overlays
+- **Selected overlay**: Uses `pointer-events: none` on bounds, allowing iframe interaction while tools remain visible
+- **Click-to-select**: Clicking hover overlay selects contentlet and shows action buttons
+- **Visual feedback**: Clear distinction between hover (blue highlight) and selected (border outline) states
+- **Non-blocking interaction**: Selected state allows page interaction while maintaining tool visibility
+
+**Result:** Users can hover to preview, click to select and access tools, and continue interacting with the page without overlay interference. Both states can be visible simultaneously for better context.
+
+---
+
+## Known Issues to Address
+
+#### 1. Viewport Units with Zoom
+**Issue:** The zoom feature sets the iframe width to the full extent of the loaded page, which breaks viewport height CSS units (`vh`, `vw`, `vmin`, `vmax`) used by customer developers.
+
+**Solution:** Customers need to add `max-height` to elements using viewport units.
+
+**Action Needed:**
+- [ ] Document this requirement in user-facing documentation
+- [ ] Consider adding a warning/notice in the editor UI when zoom is active
+- [ ] Update developer best practices guide
+
+#### 2. Layout Tab Styling & Column Offset Handling
+**Component:** [`dot-row-reorder.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-row-reorder/dot-row-reorder.component.ts)
+
+**Issues:**
+- **Needs styling** - Layout tab UI requires polish and refinement
+- **Column offset wiped on reorder** - When moving columns, their offset values are lost. Current logic only calculates based on column width, not preserving offset configuration
+
+**Action Needed:**
+- [ ] Design and implement proper styling for layout tab
+- [ ] Fix column offset preservation logic when reordering
+- [ ] Test edge cases (columns with various offset values)
+- [ ] Document expected behavior for offset handling during reorder
+
+#### 2b. Palette UI Visual Grouping
+**Components:** [`dot-uve-palette.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/dot-uve-palette.component.ts) and related palette components
+
+**Issue:** Card and list items in the palette are too visually separated, creating poor logical grouping.
+
+**Action Needed:**
+- [ ] Review spacing/margins between palette items
+- [ ] Improve visual grouping to show relationships
+- [ ] Consider: Group cards, list sections more clearly
+- [ ] Ensure consistent spacing throughout palette tabs
+- [ ] Test with actual content to verify readability
+
+#### 3. Headless Pages Not Supported
+**Issue:** The new content editing form and row/column reordering features don't work with headless pages.
+
+**Action Needed:**
+- [ ] Decide: Should headless pages support these features?
+- [ ] If yes: Implement support for headless page architecture
+- [ ] If no: Disable/hide these features when editing headless pages
+- [ ] Add clear messaging to users when features are unavailable for headless pages
+
+#### 4. Row/Column Names Using Wrong Property
+**Issue:** Currently using `styleClass` property to store row/column names, but this property is intended for CSS classes, not labels.
+
+**Solution:** Backend schema changes needed to add proper metadata fields.
+
+**Action Needed:**
+- [ ] Backend: Add `name` field to row/column data model
+- [ ] Backend: Add `description` field to row/column data model
+- [ ] Backend: Update API endpoints to support new fields
+- [ ] Frontend: Update [`dot-row-reorder.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-row-reorder/dot-row-reorder.component.ts) to use new fields
+- [ ] Frontend: Keep `styleClass` for CSS classes only
+- [ ] Migration: Consider data migration for existing styleClass "names"
+
+#### 5. "Rules" Feature Not Loading
+**Issue:** "Rules" feature is not loading in the editor. Unknown if this is a regression from refactor or an existing issue with Angular dev server (port 4200).
+
+**Action Needed:**
+- [ ] Investigate: Does "rules" work in production build?
+- [ ] Investigate: Did refactor break rules functionality?
+- [ ] Investigate: Is this a dev server proxy/routing issue?
+- [ ] Test rules in full dotCMS environment (port 8080)
+- [ ] Fix or document known limitation if dev-server only issue
+
+#### 6. Missing "Edit All Pages vs This Page" Dialog
+**Issue:** The new palette form to edit content doesn't ask users whether to edit content globally or just for this page.
+
+**Current Flow (Exists):** Dialog with two options:
+- **"All Pages"** - Edit the original/global content (changes appear on all pages using this content)
+- **"This Page"** - Copy the content, add copy to this page, then edit (changes only affect current page)
+
+**New Flow (Missing):** Goes directly to edit without asking, potentially editing global content unintentionally.
+
+**Impact:** Users could accidentally modify content globally when they only intended to change it on one page.
+
+**Action Needed:**
+- [ ] Add "All Pages vs This Page" dialog before opening edit form
+- [ ] Implement copy logic when "This Page" is selected
+- [ ] Handle workflow: Check if content is shared across pages → Show dialog
+- [ ] Handle workflow: If content only on current page → Skip dialog, go straight to edit
+- [ ] Reuse existing dialog UI/logic from current edit flow
+
+#### 7. Responsive Preview Broken by Zoom Implementation ✅ RESOLVED
+**Issue:** Responsive preview (device viewport switching) stopped working after adding zoom-in-out functionality to the iframe.
+
+**Component:** [`dot-uve-iframe.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/dot-uve-iframe.component.ts) and [`dot-uve-zoom.service.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-zoom/dot-uve-zoom.service.ts)
+
+**Solution Implemented:**
+- Fixed zoom transform/scaling conflicts with responsive preview viewport resizing
+- Zoom and responsive preview now work together correctly
+- Iframe sizing calculations updated to account for both features
+
+**Result:** Responsive preview (device viewport switching) now works correctly with zoom functionality enabled.
+
+#### 8. Feature Flags Breaking Panels and Contentlet Selection
+**Issue:** When feature flags for lock and show style editor are turned on, the panels and "select a contentlet" functionality is not working.
+
+**Action Needed:**
+- [ ] Investigate which feature flags are causing the issue (lock flag, show style editor flag)
+- [ ] Check if panels are properly initialized when feature flags are enabled
+- [ ] Verify contentlet selection state management with feature flags active
+- [ ] Test interaction between feature flags and panel visibility/functionality
+- [ ] Fix panel rendering and contentlet selection when flags are enabled
+
+#### 9. Quick Editor Form Not Showing on Contentlet Click
+**Issue:** When clicking a contentlet, the quick editor form doesn't show. This requires a UX decision on the expected behavior.
+
+**Action Needed:**
+- [ ] **UX Decision Required:** Determine expected behavior when clicking a contentlet
+ - Should quick editor form auto-open on click?
+ - Should it open in the right sidebar or as an overlay?
+ - What triggers the quick editor vs full editor?
+- [ ] Investigate why quick editor form is not triggering on contentlet click
+- [ ] Check if form opening logic is properly wired to contentlet selection event
+- [ ] Verify right sidebar state management when contentlet is selected
+- [ ] Implement quick editor form display based on UX decision
+- [ ] Test user flow: click contentlet → see form → edit → save/cancel
+
+---
+
+## What's Missing
+
+### 1. Backend Changes Required
+- [ ] **Row/Column name fields** (see "Known Issues to Address" section above)
+ - Add `name` and `description` fields to row/column data model
+ - Update API endpoints
+ - Plan data migration if needed
+
+### 2. Testing
+
+**⚠️ Critical:** This is a major refactor - comprehensive smoke and regression testing required before release.
+
+#### Unit Tests
+- [ ] Unit tests for 3 new services (after zoom consolidation) or 4 if keeping zoom service
+- [ ] Unit tests for 3 new components
+- [ ] Unit tests for zoom logic in UVEStore (after consolidation)
+- [ ] Update existing tests for refactored `EditEmaEditor`
+
+#### Smoke Testing (New Features)
+- [ ] Zoom controls work at all levels (25%, 50%, 75%, 100%, 125%, 150%)
+- [ ] Row reordering via drag-and-drop
+- [ ] Column reordering within rows
+- [ ] Row/column style class editing
+- [ ] Content editing form (quick edit mode)
+- [ ] All new services communicate correctly
+
+#### Regression Testing (Existing Features - Don't Break These!)
+- [ ] Page loading and rendering
+- [ ] Content drag-and-drop from palette
+- [ ] Inline editing (text, WYSIWYG)
+- [ ] Add/remove contentlets
+- [ ] Container operations
+- [ ] Delete contentlets
+- [ ] Undo/redo (if implemented)
+- [ ] Page saving
+- [ ] Publish/unpublish workflows
+- [x] **Responsive preview/device switching** ✅ **FIXED - See Known Issues #7**
+- [ ] Page preview at different viewports
+- [ ] Multi-language support
+- [ ] Permissions enforcement
+- [ ] SEO tools integration
+- [ ] Block editor integration
+- [ ] Form editing
+- [ ] Template changes
+- [ ] Device/persona switching
+- [ ] **Feature flags with lock and style editor** - Test panels and contentlet selection (see Known Issues #8)
+- [ ] **Quick editor form on contentlet click** - Verify form opens correctly (see Known Issues #9)
+
+### 3. Documentation
+- [ ] JSDoc comments for service methods
+- [ ] Component input/output documentation
+- [ ] Architecture diagram showing service relationships
+
+### 4. Accessibility
+- [ ] Keyboard navigation for drag-and-drop
+- [ ] ARIA labels for interactive elements
+- [ ] Screen reader testing
+
+### 5. UX Improvements
+- [ ] **Decide on layout tree completeness** (see "Design Decisions Needed" section above)
+ - Ship as-is or add Container/Contentlet levels?
+ - Research permission layer requirements if proceeding
+ - Estimate effort for container editing dialog
+- [ ] **Implement "All Pages vs This Page" dialog** (see "Known Issues to Address" section above)
+ - Add dialog before opening edit form
+ - Implement content copy logic for "This Page" option
+ - Check if content is shared across pages
+ - Reuse existing dialog from current flow
+- [x] **Resolve inline editing vs selection conflict** ✅ **COMPLETED**
+ - Implemented dual overlay system (hover + selected states)
+ - Selected overlay uses `pointer-events: none` to allow iframe interaction
+ - Clear visual distinction between hover and selected states
+ - Both states can be visible simultaneously
+- [ ] **Implement contentlet controls redesign** (see "Design Decisions Needed" section above)
+ - Make entire contentlet draggable
+ - Move code/delete/edit actions to right panel
+ - Implement quick edit mode for simple fields
+ - Add keyboard shortcuts for delete action
+- [ ] **Fix layout tab issues** (see "Known Issues to Address" section above)
+ - Style the row/column reorder UI
+ - Preserve column offset values when reordering
+ - Test and document offset behavior
+- [ ] **Improve palette visual grouping** (see "Known Issues to Address" section above)
+ - Fix spacing/margins between items
+ - Improve visual relationships and grouping
+ - Ensure consistency across all palette tabs
+- [x] **Fix responsive preview with zoom** ✅ **COMPLETED**
+ - Fixed zoom/responsive preview conflict
+ - Zoom and responsive preview now work together
+ - Iframe sizing calculations updated to account for both features
+- [ ] **Handle headless pages** (see "Known Issues to Address" section above)
+ - Decide on headless page support strategy
+ - Implement or disable features accordingly
+ - Add user messaging for unsupported scenarios
+- [ ] **Fix feature flags breaking panels** (see "Known Issues to Address" section above)
+ - Fix panel initialization when lock and style editor flags are enabled
+ - Fix contentlet selection with feature flags active
+ - Test all feature flag combinations
+- [ ] **Fix quick editor form on contentlet click** (see "Known Issues to Address" section above)
+ - Make UX decision on expected behavior
+ - Implement quick editor form display
+ - Verify right sidebar integration
+
+### 6. Polish
+- [ ] Error handling in services
+- [ ] Loading states during operations
+- [ ] Browser compatibility testing (Chrome, Firefox, Safari, Edge)
+
+### 7. Internationalization
+- [ ] **No translation in new UI** - All new components/services need i18n support
+- [ ] Add message keys to `webapp/WEB-INF/messages/Language.properties`
+- [ ] Test with different locales
+
+### 8. Code Quality & Refactoring
+
+**⚠️ Important:** This code "works" but needs proper cleanup before release. Expect to find quick fixes and shortcuts that need refactoring.
+
+- [ ] **Consolidate zoom service into UVEStore**
+ - Move [`dot-uve-zoom.service.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-zoom/dot-uve-zoom.service.ts) logic into [`dot-uve.store.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts)
+ - Remove unnecessary service abstraction
+ - Update components to use store directly
+ - Clean up service references
+
+- [ ] **Move `updateRows` method to proper location**
+ - Currently in [`withSave.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts) but doesn't belong there
+ - **Should move to:** [`withLayout.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/layout/withLayout.ts) - already exists!
+ - Proper separation: layout operations belong in layout feature, not save feature
+ - Update all references to use the layout feature
+
+- [ ] **Comprehensive code review and cleanup**
+ - Review all new services for proper error handling
+ - Check for memory leaks (subscriptions, event listeners)
+ - Look for "TODO" or "FIXME" comments
+ - Identify hardcoded values that should be constants
+ - Review conditional logic for edge cases
+ - Ensure proper use of RxJS operators (no nested subscribes, proper cleanup)
+ - Check for race conditions in async operations
+
+- [ ] **Specific files to scrutinize:**
+ - [`edit-ema-editor.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts) - Large refactor, likely has shortcuts
+ - [`dot-uve-actions-handler.service.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.ts) - Complex logic, review flow
+ - [`dot-row-reorder.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-row-reorder/dot-row-reorder.component.ts) - New component, review patterns
+ - [`withSave.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts) - Added `updateRows` method - **May not belong here, needs review**
+
+- [ ] Fix any ESLint warnings
+- [ ] Remove `any` types where possible
+- [ ] Clean up commented code
+- [ ] Remove console.log/debug statements
+
+---
+
+## How to Review
+
+### Compare Changes
+```bash
+# View all changes
+git diff origin/main...uve-experiment
+
+# View specific file
+git diff origin/main...uve-experiment -- core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts
+
+# View stats
+git diff origin/main...uve-experiment --stat
+
+# See commits
+git log origin/main..uve-experiment --oneline
+```
+
+### Test Manually
+1. **Row/Column Reordering** - Drag rows, expand rows, reorder columns, edit style classes
+2. **Zoom** - Zoom in/out, test scroll position, verify drag-and-drop at different zooms
+3. **Contentlet Editing** - Open form, validate, save, cancel
+4. **Contentlet Hover/Selection** - Hover to see blue overlay, click to select (border + tools), verify page interaction still works when selected
+5. **Toolbar Buttons** - Test palette toggle, right sidebar toggle, copy URL button in new locations
+6. **Right Sidebar** - Toggle open/closed, verify empty state message, verify contentlet selection updates sidebar
+7. **General** - Ensure all existing features work, no console errors
+
+---
+
+## Key Files to Understand
+
+| Type | Key Files |
+|------|-----------|
+| **Services** | [`services/`](core-web/libs/portlets/edit-ema/portlet/src/lib/services/) |
+| **Main Component** | [`edit-ema-editor.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts) |
+| **New Components** | [`components/dot-uve-iframe/`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-iframe/), [`components/dot-uve-zoom-controls/`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-zoom-controls/), [`components/dot-row-reorder/`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-row-reorder/) |
+| **Store** | [`store/dot-uve.store.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts), [`store/features/editor/save/withSave.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts) |
+
+---
+
+## Architecture
+
+```
+EditEmaEditor (Main Component)
+ ├── UVEStore (State + Zoom logic to be consolidated)
+ └── Services
+ ├── DotUveActionsHandlerService (Actions)
+ ├── DotUveBridgeService (PostMessage)
+ ├── DotUveDragDropService (Drag & Drop)
+ └── DotUveZoomService (Zoom - TO BE REMOVED, move to store)
+```
+
+---
+
+## Recent Commits
+
+```
+de10f910e1 clean up state management
+def181c3c1 Update styles in EditEmaEditor and TemplateBuilder
+0ebaca4867 Refactor DotEmaShellComponent and update routing
+22fdb6705c Enhance EditEmaEditor: add cancel functionality
+a2787aa462 Enhance DotRowReorderComponent: add column drag/sort animations
+```
+
+---
+
+## Definition of Done
+
+- [ ] All tests written and passing
+- [ ] Accessibility audit complete
+- [ ] Code review approved
+- [ ] QA sign-off
+- [ ] Documentation complete
+
+---
+
+## Production Readiness Plan
+
+### 🔴 CRITICAL (Block Production Release)
+
+These issues **must** be fixed before production:
+
+#### 1. Missing "Edit All Pages vs This Page" Dialog ✅ **FIXED**
+**Location:** [`edit-ema-editor.component.ts:onFormSubmit`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts)
+
+**Problem:** Users could accidentally edit global content when they only intended to change it on one page.
+
+**Solution Implemented:** Added logic in `onFormSubmit` method to check `contentlet.onNumberOfPages`:
+- If contentlet is on only one page (`onNumberOfPages === 1`), save directly
+- If contentlet exists on multiple pages (`onNumberOfPages > 1`), show `DotCopyContentModalService` dialog
+- If user selects "This Page", copy content before editing
+- After copying, update the selected contentlet with the new inode and set `onNumberOfPages` to 1
+
+**Status:** ✅ Fixed - The form submission now properly checks `onNumberOfPages` and shows the copy content modal when content exists on multiple pages, preventing accidental global content modifications.
+
+---
+
+#### 2. "Rules" Feature Not Loading
+**Issue:** Unknown if regression from refactor or dev server issue.
+
+**Action Items:**
+- [ ] Test in production build (not just dev server on port 4200)
+- [ ] Test in full dotCMS environment (port 8080)
+- [ ] Check if refactor broke rules functionality
+- [ ] Verify proxy/routing configuration
+- [ ] Fix or document known limitation
+
+**Impact:** Feature may be completely broken in production.
+
+---
+
+#### 3. Column Offset Preservation Bug
+**Location:** [`dot-row-reorder.component.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-row-reorder/dot-row-reorder.component.ts)
+
+**Issue:** When reordering columns, offset values are lost. Logic only calculates based on width, not preserving offset configuration.
+
+**Action Items:**
+- [ ] Review column reorder logic
+- [ ] Preserve offset values during reorder
+- [ ] Test edge cases (columns with various offset values)
+- [ ] Document expected behavior
+
+**Impact:** User configuration lost during reordering operations.
+
+---
+
+#### 4. Comprehensive Regression Testing
+**Issue:** Major refactor requires full testing before release.
+
+**Critical Test Areas:**
+- [ ] Page loading and rendering
+- [ ] Content drag-and-drop from palette
+- [ ] Inline editing (text, WYSIWYG)
+- [ ] Add/remove contentlets
+- [ ] Container operations
+- [ ] Delete contentlets
+- [ ] Page saving
+- [ ] Publish/unpublish workflows
+- [ ] Multi-language support
+- [ ] Permissions enforcement
+- [ ] SEO tools integration
+- [ ] Block editor integration
+- [ ] Form editing
+- [ ] Template changes
+- [ ] Device/persona switching
+
+**Impact:** Risk of breaking existing functionality.
+
+---
+
+#### 5. Feature Flags Breaking Panels and Contentlet Selection
+**Issue:** When feature flags for lock and show style editor are turned on, the panels and "select a contentlet" functionality is not working.
+
+**Action Items:**
+- [ ] Investigate which feature flags are causing the issue (lock flag, show style editor flag)
+- [ ] Check if panels are properly initialized when feature flags are enabled
+- [ ] Verify contentlet selection state management with feature flags active
+- [ ] Test interaction between feature flags and panel visibility/functionality
+- [ ] Fix panel rendering and contentlet selection when flags are enabled
+
+**Impact:** Core functionality broken when feature flags are enabled - blocking release if these flags are required.
+
+---
+
+### 🟠 HIGH PRIORITY (Should Fix Before Release)
+
+#### 5. Code Architecture Cleanup
+
+**5a. Consolidate Zoom Service into UVEStore**
+- **Location:** [`dot-uve-zoom.service.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-zoom/dot-uve-zoom.service.ts)
+- **Action:** Move logic into [`dot-uve.store.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts), remove service abstraction
+- **Why:** Reduces unnecessary service layer, aligns with store pattern
+
+**5b. Move `updateRows` Method**
+- **Current:** [`withSave.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts)
+- **Should be:** [`withLayout.ts`](core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/layout/withLayout.ts)
+- **Why:** Proper separation of concerns - layout operations don't belong in save feature
+
+---
+
+#### 6. Unit Testing (Zero Coverage Currently)
+
+**Services (3 after zoom consolidation):**
+- [ ] `DotUveActionsHandlerService` - Complex action routing logic
+- [ ] `DotUveBridgeService` - PostMessage communication
+- [ ] `DotUveDragDropService` - Drag-and-drop logic
+
+**Components:**
+- [ ] `DotUveIframeComponent` - Iframe with zoom support
+- [ ] `DotUveZoomControlsComponent` - Zoom UI controls
+- [ ] `DotRowReorderComponent` - Row/column reordering
+
+**Store:**
+- [ ] Zoom logic in UVEStore (after consolidation)
+- [ ] Update existing tests for refactored `EditEmaEditor`
+
+**Impact:** No test coverage means high risk of regressions.
+
+---
+
+#### 7. Internationalization (i18n)
+**Issue:** All new UI components have hardcoded English text.
+
+**Action Items:**
+- [ ] Add message keys to `webapp/WEB-INF/messages/Language.properties`
+- [ ] Replace all hardcoded strings in new components
+- [ ] Test with different locales
+- [ ] Verify all user-facing text is translatable
+
+**Affected Components:**
+- `DotUveZoomControlsComponent`
+- `DotRowReorderComponent`
+- `DotUveIframeComponent`
+- Right sidebar empty state
+- All new service error messages
+
+**Impact:** Product not usable in non-English locales.
+
+---
+
+#### 8. Quick Editor Form Not Showing on Contentlet Click
+**Issue:** When clicking a contentlet, the quick editor form doesn't show. This requires a UX decision on the expected behavior.
+
+**Action Items:**
+- [ ] **UX Decision Required:** Determine expected behavior when clicking a contentlet
+ - Should quick editor form auto-open on click?
+ - Should it open in the right sidebar or as an overlay?
+ - What triggers the quick editor vs full editor?
+- [ ] Investigate why quick editor form is not triggering on contentlet click
+- [ ] Check if form opening logic is properly wired to contentlet selection event
+- [ ] Verify right sidebar state management when contentlet is selected
+- [ ] Implement quick editor form display based on UX decision
+- [ ] Test user flow: click contentlet → see form → edit → save/cancel
+
+**Impact:** Primary content editing flow broken - users cannot edit contentlets via quick editor.
+
+---
+
+### 🟡 MEDIUM PRIORITY (Polish & UX)
+
+#### 8. Code Cleanup
+**Found Issues:**
+- **Console statements:** 23 `console.*` calls found (most are `console.error` for error handling - acceptable, but some `console.warn` should be removed)
+- **TODO comments:** 3 TODOs found:
+ - `dot-uve.store.ts:39` - "TODO: remove when the unit tests are fixed"
+ - `inline-edit.service.ts:237` - Format handling TODO
+ - `dot-ema-dialog.component.ts:408` - Emit function TODO
+- **Linting:** No current linting errors ✅
+
+**Action Items:**
+- [ ] Remove `console.log`/`console.debug` statements (keep `console.error` for error handling)
+- [ ] Remove `any` types where possible
+- [ ] Clean up commented code
+- [ ] Address or remove TODO comments
+
+---
+
+#### 9. Error Handling & Loading States
+- [ ] Review all new services for proper error handling
+- [ ] Add loading states during operations
+- [ ] Ensure proper error messages to users
+- [ ] Check for memory leaks (subscriptions, event listeners)
+
+---
+
+### 🟢 LOW PRIORITY (Nice to Have)
+
+#### 10. Documentation
+- [ ] JSDoc comments for service methods
+- [ ] Component input/output documentation
+- [ ] Architecture diagram showing service relationships
+
+#### 11. Accessibility
+- [ ] Keyboard navigation for drag-and-drop
+- [ ] ARIA labels for interactive elements
+- [ ] Screen reader testing
+
+#### 12. Browser Compatibility
+- [ ] Test in Chrome, Firefox, Safari, Edge
+- [ ] Verify zoom functionality across browsers
+- [ ] Test drag-and-drop across browsers
+
+---
+
+## Implementation Priority
+
+### Phase 1: Critical Fixes (Week 1)
+1. Fix "All Pages vs This Page" dialog
+2. Investigate and fix "Rules" feature
+3. Fix column offset preservation
+4. Begin regression testing
+
+### Phase 2: Architecture & Testing (Week 2)
+1. Consolidate zoom service into store
+2. Move `updateRows` to `withLayout.ts`
+3. Write unit tests for services
+4. Write unit tests for components
+
+### Phase 3: Polish & i18n (Week 3)
+1. Add i18n support
+2. UI/UX improvements
+3. Code cleanup
+4. Error handling improvements
+
+### Phase 4: Final QA (Week 4)
+1. Complete regression testing
+2. Accessibility audit
+3. Browser compatibility testing
+4. Documentation
+
+---
+
+## Risk Assessment
+
+| Risk | Severity | Likelihood | Mitigation |
+|------|----------|------------|------------|
+| Missing dialog causes data loss | 🔴 High | Medium | Fix in Phase 1 |
+| Rules feature broken in production | 🔴 High | Unknown | Test immediately |
+| Column offset bug loses user config | 🔴 High | High | Fix in Phase 1 |
+| Regression in existing features | 🔴 High | Medium | Comprehensive testing |
+| No test coverage | 🟠 Medium | High | Add tests in Phase 2 |
+| i18n missing | 🟠 Medium | High | Add in Phase 3 |
+| Code quality issues | 🟡 Low | High | Cleanup in Phase 3 |
+
+---
+
+**Last Updated:** 2025-01-28
diff --git a/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.ts b/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.ts
index 3985e90b6b96..9a2f8b619ac4 100644
--- a/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.ts
+++ b/core-web/libs/data-access/src/lib/dot-usage/dot-usage.service.ts
@@ -129,3 +129,4 @@ export class DotUsageService {
return 'usage.dashboard.error.generic';
}
}
+
diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tree.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tree.scss
index 1461a513433d..477dc3a18540 100644
--- a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tree.scss
+++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_tree.scss
@@ -10,16 +10,16 @@
.p-tree-container {
.p-treenode {
padding: 0;
- margin: 0;
+ margin: 2px 0;
outline: 0;
.p-treenode-content {
border-radius: $border-radius-xs;
transition: none;
- padding: $spacing-0 $spacing-1;
+ padding: 0 $spacing-0;
.p-tree-toggler {
- margin-right: $spacing-1;
+ margin-right: $spacing-0;
width: $spacing-5;
height: $spacing-5;
color: $black;
diff --git a/core-web/libs/dotcms-scss/shared/_spacing.scss b/core-web/libs/dotcms-scss/shared/_spacing.scss
index a3fca1e2abbf..d21812e3d42a 100644
--- a/core-web/libs/dotcms-scss/shared/_spacing.scss
+++ b/core-web/libs/dotcms-scss/shared/_spacing.scss
@@ -11,6 +11,3 @@ $spacing-8: 4rem; // 64px
$spacing-9: 4.5rem; // 72px
$spacing-10: 7rem; // 112px
$spacing-11: 11rem; // 176px
-
-// New Spacing Variables
-$spacing-width-m: 0.875rem; // 14px
diff --git a/core-web/libs/portlets/edit-ema/portlet/README.md b/core-web/libs/portlets/edit-ema/portlet/README.md
index c02f871fc7ba..642ea4230aa7 100644
--- a/core-web/libs/portlets/edit-ema/portlet/README.md
+++ b/core-web/libs/portlets/edit-ema/portlet/README.md
@@ -1,7 +1,252 @@
-# portlets-edit-ema-portlet
+# Edit EMA Portlet
-This library was generated with [Nx](https://nx.dev).
+Universal Visual Editor (UVE) portlet for dotCMS - enables in-context editing of pages with drag-and-drop content management.
-## Running unit tests
+## Architecture
-Run `nx test portlets-edit-ema-portlet` to execute the unit tests.
+This portlet follows a **Container/Presentational Component Pattern** to maintain clear separation of concerns:
+
+- **Smart Containers**: Inject `UVEStore`, manage state, pass data down via `@Input`
+- **Presentational Components**: No store injection, receive all data via `@Input`, emit events via `@Output`
+
+### Component Tree
+
+```
+DotEmaShellComponent (Smart Container) [UVEStore]
+├─ EditEmaNavigationBarComponent (Smart Container) [UVEStore]
+│ └─ Receives navigation data via @Input + reads from UVEStore
+│
+├─ Route → EditEmaEditorComponent (Smart Container) [UVEStore]
+ ├─ DotUveToolbarComponent (Smart Container) [UVEStore]
+ │ ├─ DotToggleLockButtonComponent (Presentational) [NO STORE]
+ │ │ └─ @Input: toggleLockOptions | @Output: toggleLockClick
+ │ ├─ DotEmaInfoDisplayComponent (Presentational) [NO STORE]
+ │ │ └─ @Input: options | @Output: actionClicked
+ │ ├─ DotUveDeviceSelectorComponent (Presentational) [NO STORE]
+ │ │ └─ @Input: state, devices | @Output: stateChange
+ │ ├─ EditEmaLanguageSelectorComponent (Presentational) [NO STORE]
+ │ │ └─ @Input: contentLanguageId, pageLanguageId | @Output: change
+ │ └─ ... (most toolbar children are presentational)
+ │
+ ├─ DotUvePaletteComponent (Smart Container) [UVEStore]
+ │ ├─ Uses local signalState for tab management (not global store)
+ │ └─ DotUvePaletteListComponent (Smart Container) [DotPaletteListStore + GlobalStore]
+ │ └─ @Input: listType, languageId, pagePath, variantId
+ │ └─ Uses feature store for palette-specific state management
+ │
+ ├─ DotUveContentletQuickEditComponent (Presentational) [NO STORE]
+ │ └─ @Input: data (ContentletEditData), loading | @Output: submit, cancel
+ │ └─ Dynamic form generation based on field types
+ │
+ ├─ EmaPageDropzoneComponent (Presentational) [NO STORE]
+ │ └─ @Input: containers, dragItem, zoomLevel
+ │ └─ Pure canvas for drag-and-drop operations
+ │
+ └─ DotUveLockOverlayComponent (Smart Container) [UVEStore]
+ └─ Reads toggle lock state directly from UVEStore
+```
+
+## Key Patterns
+
+### Local vs Global State
+
+**Local Component State** (use `signalState()`):
+- UI-specific state (tab selection, form validation, etc.)
+- State that doesn't need cross-component coordination
+- Example: `DotUvePaletteComponent` tab management
+
+**Global Store State** (use `UVEStore`):
+- Shared feature state (page data, contentlet selection, etc.)
+- Cross-component coordination required
+- Example: `activeContentlet`, `pageAPIResponse`
+
+**Feature Store State** (use component-specific store):
+- Domain-specific state for a feature (palette list data, search params, pagination)
+- Encapsulated within a feature boundary
+- Example: `DotPaletteListStore` for palette list management
+
+### Container Component Pattern (with UVEStore)
+
+```typescript
+// Smart Container (reads from global store, manages state)
+@Component({ selector: 'dot-uve-palette' })
+export class DotUvePaletteComponent {
+ protected readonly uveStore = inject(UVEStore);
+
+ // Read from global store
+ readonly $languageId = computed(() => this.uveStore.$languageId());
+ readonly $pagePath = computed(() => this.uveStore.$pageURI());
+
+ // Local UI state
+ readonly #localState = signalState({ currentTab: 0 });
+}
+```
+
+### Container Component Pattern (with Feature Store)
+
+```typescript
+// Smart Container with feature store
+@Component({ selector: 'dot-uve-palette-list' })
+export class DotUvePaletteListComponent {
+ readonly #paletteListStore = inject(DotPaletteListStore);
+ readonly #globalStore = inject(GlobalStore);
+
+ // Inputs from parent (for initialization)
+ $type = input.required({ alias: 'listType' });
+ $languageId = input.required({ alias: 'languageId' });
+
+ // Read from feature store
+ protected readonly $contenttypes = this.#paletteListStore.contenttypes;
+ protected readonly $pagination = this.#paletteListStore.pagination;
+}
+```
+
+### Presentational Component Pattern
+
+```typescript
+// Presentational (receives props, emits events, NO store injection)
+@Component({ selector: 'dot-uve-contentlet-quick-edit' })
+export class DotUveContentletQuickEditComponent {
+ // NO store injection!
+
+ // Inputs (data down from parent container)
+ data = input.required({ alias: 'data' });
+ loading = input(false, { alias: 'loading' });
+
+ // Outputs (events up to parent container)
+ submit = output>();
+ cancel = output();
+}
+```
+
+## Benefits
+
+### Testability
+- **Presentational components**: Easier to test (no mock store needed, pure input/output testing)
+- **Container components**: Clear boundaries (test store interactions separately)
+- **Feature stores**: Isolated testing of domain logic separate from UI
+
+### Reusability
+- **Presentational components**: Can be reused in different contexts without store coupling
+- **Feature stores**: Domain logic can be shared across multiple components
+- **Clear interfaces**: Well-defined @Input/@Output contracts
+
+### Maintainability
+- **Clear separation of concerns**: Smart containers vs presentational components vs feature stores
+- **Localized changes**:
+ - Global store changes affect only global state consumers
+ - Feature store changes affect only feature-specific components
+ - Presentational component changes are isolated
+- **Easier refactoring**: Components can be converted between patterns as needs evolve
+
+## State Management Architecture
+
+### UVEStore State Structure
+
+The UVEStore uses a **nested state structure** to clearly separate concerns:
+
+```typescript
+interface UVEState {
+ // ============ DOMAIN STATE (Source of Truth) ============
+ languages: DotLanguage[];
+ isEnterprise: boolean;
+ currentUser?: CurrentUser;
+ experiment?: DotExperiment;
+ pageAPIResponse?: DotCMSPageAsset;
+
+ // ============ UI STATE (Transient) ============
+ // Nested editor state
+ editor: {
+ // Functional editor state
+ dragItem: EmaDragItem | null;
+ bounds: Container[];
+ state: EDITOR_STATE;
+ activeContentlet: ContentletPayload | null;
+ contentArea: ContentletArea | null;
+
+ // UI panel preferences (user-configurable)
+ panels: {
+ palette: { open: boolean };
+ rightSidebar: { open: boolean };
+ };
+
+ // Editor-specific data
+ ogTags: any | null;
+ styleSchemas: StyleEditorFormSchema[];
+ };
+
+ // Nested toolbar state
+ toolbar: {
+ device: DotDeviceListItem | null;
+ orientation: Orientation | null;
+ socialMedia: string | null;
+ isEditState: boolean;
+ isPreviewModeActive: boolean;
+ ogTagsResults: SeoMetaTagsResult[] | null;
+ };
+}
+```
+
+### Accessing Nested State in Components
+
+**Container components** access nested state directly:
+
+```typescript
+// Access editor functional state
+const dragItem = this.uveStore.editor().dragItem;
+const editorState = this.uveStore.editor().state;
+
+// Access panel preferences
+const isPaletteOpen = this.uveStore.editor().panels.palette.open;
+const isSidebarOpen = this.uveStore.editor().panels.rightSidebar.open;
+
+// Access toolbar state
+const device = this.uveStore.toolbar().device;
+const socialMedia = this.uveStore.toolbar().socialMedia;
+```
+
+**Updating nested state** requires spreading the parent object:
+
+```typescript
+// Update panel state
+setPaletteOpen(open: boolean) {
+ const editor = store.editor();
+ patchState(store, {
+ editor: {
+ ...editor,
+ panels: {
+ ...editor.panels,
+ palette: { open }
+ }
+ }
+ });
+}
+```
+
+### Benefits of Nested State
+
+- **Clear Grouping**: Related state is grouped together (editor.panels groups all panel preferences)
+- **Easier to Reason About**: The structure mirrors the UI hierarchy
+- **Better Type Safety**: TypeScript can catch errors at deeper levels
+- **Reduced Prop Drilling**: Components get logical state chunks instead of individual properties
+
+## Running Tests
+
+Run unit tests for this portlet:
+```bash
+nx test portlets-edit-ema-portlet
+```
+
+Run specific test file:
+```bash
+nx test portlets-edit-ema-portlet --testFile=path/to/test.spec.ts
+```
+
+## Development
+
+This library uses:
+- **Angular 19+** with standalone components
+- **NgRx Signals** for state management
+- **Modern Angular syntax**: `@if`, `@for`, `input()`, `output()`
+- **PrimeNG** for UI components
+- **Jest + Spectator** for testing
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/components/edit-ema-navigation-bar/edit-ema-navigation-bar.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/components/edit-ema-navigation-bar/edit-ema-navigation-bar.component.spec.ts
index e44d725739f6..496af9a03892 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/components/edit-ema-navigation-bar/edit-ema-navigation-bar.component.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/components/edit-ema-navigation-bar/edit-ema-navigation-bar.component.spec.ts
@@ -22,11 +22,6 @@ const messages = {
const store = {
paletteOpen: () => false,
setPaletteOpen: jest.fn(),
- $editorProps: () => ({
- palette: {
- paletteClass: 'palette-class'
- }
- }),
pageParams: () => ({
language_id: '3',
personaId: '123'
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/components/edit-ema-navigation-bar/edit-ema-navigation-bar.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/components/edit-ema-navigation-bar/edit-ema-navigation-bar.component.ts
index 465eddbec864..08a90d6f7366 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/components/edit-ema-navigation-bar/edit-ema-navigation-bar.component.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/components/edit-ema-navigation-bar/edit-ema-navigation-bar.component.ts
@@ -42,8 +42,6 @@ export class EditEmaNavigationBarComponent {
uveStore = inject(UVEStore);
- $editorProps = this.uveStore.$editorProps;
-
$params = this.uveStore.pageParams;
/**
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.html
index 87e38b46c94f..ccf6bd1c288b 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.html
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.html
@@ -1,4 +1,4 @@
-@if ($shellProps()?.canRead) {
+@if ($canRead()) {
@if ($toggleLockOptions()?.showBanner && $showBanner()) {
@@ -38,16 +38,18 @@
-
-
+ data-testId="ema-nav-bar">
+
+
} @else {
- @if ($shellProps().error?.code === 401) {
+ @if ($errorDisplay()?.code === 401) {
- } @else if ($shellProps()?.error?.pageInfo) {
-
+ } @else if ($errorDisplay()?.pageInfo) {
+
}
}
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts
index 6f7b7d50a249..c8408fe4c111 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts
@@ -27,6 +27,7 @@ import {
DotLanguagesService,
DotLicenseService,
DotMessageService,
+ DotPageLayoutService,
DotPropertiesService,
DotSiteService,
DotSystemConfigService,
@@ -308,6 +309,12 @@ describe('DotEmaShellComponent', () => {
track: jest.fn()
}
},
+ {
+ provide: DotPageLayoutService,
+ useValue: {
+ save: jest.fn().mockReturnValue(of({}))
+ }
+ },
{
provide: WINDOW,
useValue: window
@@ -452,7 +459,6 @@ describe('DotEmaShellComponent', () => {
});
it('should patch viewParams with empty object when the mode is edit', () => {
- const patchViewParamsSpy = jest.spyOn(store, 'patchViewParams');
const params = {
...INITIAL_PAGE_PARAMS,
mode: UVE_MODE.EDIT
@@ -465,12 +471,10 @@ describe('DotEmaShellComponent', () => {
spectator.detectChanges();
- expect(patchViewParamsSpy).toHaveBeenCalledWith({});
+ expect(store.view().viewParams).toEqual({});
});
it('should patch viewParams with empty params on init', () => {
- const patchViewParamsSpy = jest.spyOn(store, 'patchViewParams');
-
const params = {
...INITIAL_PAGE_PARAMS,
mode: UVE_MODE.PREVIEW
@@ -483,7 +487,7 @@ describe('DotEmaShellComponent', () => {
spectator.detectChanges();
- expect(patchViewParamsSpy).toHaveBeenCalledWith({
+ expect(store.view().viewParams).toEqual({
orientation: undefined,
seo: undefined,
device: undefined
@@ -491,8 +495,6 @@ describe('DotEmaShellComponent', () => {
});
it('should patch viewParams with the correct params on init', () => {
- const patchViewParamsSpy = jest.spyOn(store, 'patchViewParams');
-
const withViewParams = {
device: 'mobile',
orientation: 'landscape',
@@ -507,7 +509,7 @@ describe('DotEmaShellComponent', () => {
spectator.detectChanges();
- expect(patchViewParamsSpy).toHaveBeenCalledWith({
+ expect(store.view().viewParams).toEqual({
orientation: 'landscape',
seo: undefined,
device: 'mobile'
@@ -515,8 +517,6 @@ describe('DotEmaShellComponent', () => {
});
it('should patch viewParams with the correct params on init with live mode', () => {
- const patchViewParamsSpy = jest.spyOn(store, 'patchViewParams');
-
const withViewParams = {
device: 'mobile',
orientation: 'landscape',
@@ -531,7 +531,7 @@ describe('DotEmaShellComponent', () => {
spectator.detectChanges();
- expect(patchViewParamsSpy).toHaveBeenCalledWith({
+ expect(store.view().viewParams).toEqual({
orientation: 'landscape',
seo: undefined,
device: 'mobile'
@@ -982,7 +982,9 @@ describe('DotEmaShellComponent', () => {
it('should trigger a store reload if the URL from urlContentMap is the same as the current URL', () => {
const reloadSpy = jest.spyOn(store, 'reloadCurrentPage');
- jest.spyOn(store, 'pageAPIResponse').mockReturnValue(PAGE_RESPONSE_URL_CONTENT_MAP);
+ // Spy on normalized properties instead of pageAPIResponse
+ jest.spyOn(store, 'page').mockReturnValue(PAGE_RESPONSE_URL_CONTENT_MAP.page);
+ jest.spyOn(store, 'urlContentMap').mockReturnValue(PAGE_RESPONSE_URL_CONTENT_MAP.urlContentMap);
store.loadPageAsset({
url: '/test-url',
language_id: '1',
@@ -1041,15 +1043,167 @@ describe('DotEmaShellComponent', () => {
});
});
+ describe('Phase 2.1: Local View Models', () => {
+ describe('$menuItems computed property', () => {
+ it('should build menu items with correct structure', () => {
+ const menuItems = spectator.component['$menuItems']();
+
+ expect(menuItems).toHaveLength(6);
+ expect(menuItems[0]).toEqual({
+ icon: 'pi-file',
+ label: 'editema.editor.navbar.content',
+ href: 'content',
+ id: 'content'
+ });
+ });
+
+ it('should disable layout when page cannot be edited', () => {
+ jest.spyOn(dotPageApiService, 'get').mockReturnValue(
+ of({
+ ...MOCK_RESPONSE_HEADLESS,
+ page: {
+ ...MOCK_RESPONSE_HEADLESS.page,
+ canEdit: false
+ }
+ })
+ );
+ spectator.detectChanges();
+
+ const menuItems = spectator.component['$menuItems']();
+ const layoutItem = menuItems.find((item) => item.id === 'layout');
+
+ expect(layoutItem.isDisabled).toBe(true);
+ });
+
+ it('should disable layout for non-enterprise license', () => {
+ jest.spyOn(dotLicenseService, 'isEnterprise').mockReturnValue(of(false));
+ spectator.detectChanges();
+
+ const menuItems = spectator.component['$menuItems']();
+ const layoutItem = menuItems.find((item) => item.id === 'layout');
+
+ expect(layoutItem.isDisabled).toBe(true);
+ });
+
+ it('should show tooltip for advanced templates', () => {
+ jest.spyOn(dotPageApiService, 'get').mockReturnValue(
+ of({
+ ...MOCK_RESPONSE_HEADLESS,
+ template: {
+ ...MOCK_RESPONSE_HEADLESS.template,
+ drawed: false
+ }
+ })
+ );
+ spectator.detectChanges();
+
+ const menuItems = spectator.component['$menuItems']();
+ const layoutItem = menuItems.find((item) => item.id === 'layout');
+
+ expect(layoutItem.tooltip).toBe(
+ 'editema.editor.navbar.layout.tooltip.cannot.edit.advanced.template'
+ );
+ });
+ });
+
+ describe('$seoParams computed property', () => {
+ beforeEach(() => {
+ spectator.detectChanges();
+ });
+
+ it('should build SEO params with correct structure', () => {
+ const seoParams = spectator.component['$seoParams']();
+
+ expect(seoParams).toEqual({
+ siteId: MOCK_RESPONSE_HEADLESS.site.identifier,
+ languageId: MOCK_RESPONSE_HEADLESS.viewAs.language.id,
+ currentUrl: expect.stringContaining('/'),
+ requestHostName: expect.any(String)
+ });
+ });
+
+ it('should sanitize and format page URI correctly', () => {
+ const seoParams = spectator.component['$seoParams']();
+ const currentUrl = seoParams.currentUrl;
+
+ expect(currentUrl).toMatch(/^\//);
+ });
+ });
+
+ describe('$errorDisplay computed property', () => {
+ it('should return null when no error code', () => {
+ const errorDisplay = spectator.component['$errorDisplay']();
+
+ expect(errorDisplay).toBeNull();
+ });
+
+ it('should return error payload when error code exists', () => {
+ spectator.component['uveStore'].setUveStatus = jest.fn();
+ // Simulate an error by directly patching the store
+ const store = spectator.component['uveStore'];
+ (store as any).errorCode = jest.fn().mockReturnValue(401);
+
+ spectator.detectChanges();
+
+ const errorDisplay = spectator.component['$errorDisplay']();
+
+ expect(errorDisplay).not.toBeNull();
+ expect(errorDisplay?.code).toBe(401);
+ });
+ });
+
+ describe('$canRead computed property', () => {
+ it('should return true when page can be read', () => {
+ spectator.detectChanges();
+ const canRead = spectator.component['$canRead']();
+
+ expect(canRead).toBe(true);
+ });
+
+ it('should return false when page cannot be read', () => {
+ jest.spyOn(dotPageApiService, 'get').mockReturnValue(
+ of({
+ ...MOCK_RESPONSE_HEADLESS,
+ page: {
+ ...MOCK_RESPONSE_HEADLESS.page,
+ canRead: false
+ }
+ })
+ );
+ spectator.detectChanges();
+
+ const canRead = spectator.component['$canRead']();
+
+ expect(canRead).toBe(false);
+ });
+
+ it('should return false when page is undefined', () => {
+ jest.spyOn(dotPageApiService, 'get').mockReturnValue(
+ of({
+ ...MOCK_RESPONSE_HEADLESS,
+ page: undefined
+ })
+ );
+ spectator.detectChanges();
+
+ const canRead = spectator.component['$canRead']();
+
+ expect(canRead).toBe(false);
+ });
+ });
+ });
+
afterEach(() => {
// Restoring the snapshot to the default
- overrideRouteSnashot(
- activatedRoute,
- SNAPSHOT_MOCK({
- queryParams: INITIAL_PAGE_PARAMS,
- data: UVE_CONFIG_MOCK(BASIC_OPTIONS)
- })
- );
+ if (activatedRoute && typeof activatedRoute === 'object') {
+ overrideRouteSnashot(
+ activatedRoute,
+ SNAPSHOT_MOCK({
+ queryParams: INITIAL_PAGE_PARAMS,
+ data: UVE_CONFIG_MOCK(BASIC_OPTIONS)
+ })
+ );
+ }
jest.clearAllMocks();
});
});
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts
index d3dcfc5df4c7..c43804325de5 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts
@@ -1,46 +1,33 @@
+import { patchState } from '@ngrx/signals';
+
import { Location } from '@angular/common';
-import { Component, DestroyRef, effect, inject, OnInit, signal, ViewChild } from '@angular/core';
+import { Component, computed, DestroyRef, effect, inject, OnInit, signal, ViewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Params, Router, RouterModule } from '@angular/router';
-import { ConfirmationService, MessageService } from 'primeng/api';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
-import { DialogService } from 'primeng/dynamicdialog';
import { MessagesModule } from 'primeng/messages';
import { ToastModule } from 'primeng/toast';
-import {
- DotAnalyticsTrackerService,
- DotContentletService,
- DotESContentService,
- DotExperimentsService,
- DotFavoritePageService,
- DotLanguagesService,
- DotPageLayoutService,
- DotPageRenderService,
- DotSeoMetaTagsService,
- DotSeoMetaTagsUtilService,
- DotWorkflowsActionsService
-} from '@dotcms/data-access';
import { SiteService } from '@dotcms/dotcms-js';
+import { DotPageToolUrlParams } from '@dotcms/dotcms-models';
import { DotPageToolsSeoComponent } from '@dotcms/portlets/dot-ema/ui';
import { GlobalStore } from '@dotcms/store';
import { UVE_MODE } from '@dotcms/types';
-import { DotInfoPageComponent, DotMessagePipe, DotNotLicenseComponent } from '@dotcms/ui';
-import { WINDOW } from '@dotcms/utils';
+import { DotInfoPageComponent, DotMessagePipe, DotNotLicenseComponent, InfoPage } from '@dotcms/ui';
import { EditEmaNavigationBarComponent } from './components/edit-ema-navigation-bar/edit-ema-navigation-bar.component';
import { DotEmaDialogComponent } from '../components/dot-ema-dialog/dot-ema-dialog.component';
-import { DotActionUrlService } from '../services/dot-action-url/dot-action-url.service';
-import { DotPageApiService } from '../services/dot-page-api.service';
import { PERSONA_KEY } from '../shared/consts';
-import { NG_CUSTOM_EVENTS } from '../shared/enums';
-import { DialogAction, DotPageAssetParams } from '../shared/models';
+import { NG_CUSTOM_EVENTS, UVE_STATUS } from '../shared/enums';
+import { DialogAction, DotPageAssetParams, NavigationBarItem } from '../shared/models';
import { UVEStore } from '../store/dot-uve.store';
import { DotUveViewParams } from '../store/models';
import {
checkClientHostAccess,
+ getErrorPayload,
+ getRequestHostName,
getTargetUrl,
normalizeQueryParams,
sanitizeURL,
@@ -49,29 +36,6 @@ import {
@Component({
selector: 'dot-ema-shell',
- providers: [
- UVEStore,
- DotPageApiService,
- DotActionUrlService,
- DotLanguagesService,
- MessageService,
- DotPageLayoutService,
- ConfirmationService,
- DotFavoritePageService,
- DotESContentService,
- DialogService,
- DotPageRenderService,
- DotSeoMetaTagsService,
- DotSeoMetaTagsUtilService,
- DotWorkflowsActionsService,
- DotContentletService,
- {
- provide: WINDOW,
- useValue: window
- },
- DotExperimentsService,
- DotAnalyticsTrackerService
- ],
templateUrl: './dot-ema-shell.component.html',
styleUrls: ['./dot-ema-shell.component.scss'],
imports: [
@@ -98,11 +62,97 @@ export class DotEmaShellComponent implements OnInit {
readonly #siteService = inject(SiteService);
readonly #location = inject(Location);
readonly #globalStore = inject(GlobalStore);
- protected readonly $shellProps = this.uveStore.$shellProps;
protected readonly $toggleLockOptions = this.uveStore.$toggleLockOptions;
protected readonly $showBanner = signal(true);
+ // Component builds its own menu items (Phase 2.1: Move view models from store to components)
+ protected readonly $menuItems = computed(() => {
+ const page = this.uveStore.page();
+ const template = this.uveStore.template();
+ const isLoading = this.uveStore.status() === UVE_STATUS.LOADING;
+ const isEnterpriseLicense = this.uveStore.isEnterprise();
+ const templateDrawed = template?.drawed;
+ const isLayoutDisabled = !page?.canEdit || !templateDrawed;
+ const canSeeRulesExists = page && 'canSeeRules' in page;
+
+ return [
+ {
+ icon: 'pi-file',
+ label: 'editema.editor.navbar.content',
+ href: 'content',
+ id: 'content'
+ },
+ {
+ icon: 'pi-table',
+ label: 'editema.editor.navbar.layout',
+ href: 'layout',
+ id: 'layout',
+ isDisabled: isLayoutDisabled || !isEnterpriseLicense,
+ tooltip: templateDrawed
+ ? null
+ : 'editema.editor.navbar.layout.tooltip.cannot.edit.advanced.template'
+ },
+ {
+ icon: 'pi-sliders-h',
+ label: 'editema.editor.navbar.rules',
+ id: 'rules',
+ href: `rules/${page?.identifier}`,
+ isDisabled:
+ (canSeeRulesExists && !page.canSeeRules) ||
+ !page?.canEdit ||
+ !isEnterpriseLicense
+ },
+ {
+ iconURL: 'experiments',
+ label: 'editema.editor.navbar.experiments',
+ href: `experiments/${page?.identifier}`,
+ id: 'experiments',
+ isDisabled: !page?.canEdit || !isEnterpriseLicense
+ },
+ {
+ icon: 'pi-th-large',
+ label: 'editema.editor.navbar.page-tools',
+ id: 'page-tools'
+ },
+ {
+ icon: 'pi-ellipsis-v',
+ label: 'editema.editor.navbar.properties',
+ id: 'properties',
+ isDisabled: isLoading
+ }
+ ];
+ });
+
+ // Component builds SEO params locally
+ protected readonly $seoParams = computed(() => {
+ // Removed pageAPIResponse - use normalized accessors
+ const url = sanitizeURL(this.uveStore.page().pageURI);
+ const currentUrl = url.startsWith('/') ? url : '/' + url;
+ const requestHostName = getRequestHostName(this.uveStore.pageParams());
+
+ return {
+ siteId: this.uveStore.site()?.identifier,
+ languageId: this.uveStore.viewAs()?.language.id,
+ currentUrl,
+ requestHostName
+ };
+ });
+
+ // Component builds error display locally
+ protected readonly $errorDisplay = computed<{ code: number; pageInfo: InfoPage } | null>(() => {
+ const errorCode = this.uveStore.errorCode();
+ if (!errorCode) return null;
+
+ return getErrorPayload(errorCode);
+ });
+
+ // Component determines read permissions locally
+ protected readonly $canRead = computed(() => {
+ // Removed pageAPIResponse - use normalized accessors
+ return this.uveStore.page()?.canRead ?? false;
+ });
+
/**
* Handle the update of the page params
* When the page params change, we update the location
@@ -122,11 +172,11 @@ export class DotEmaShellComponent implements OnInit {
});
readonly $updateBreadcrumbEffect = effect(() => {
- const pageAPIResponse = this.uveStore.pageAPIResponse();
+ const page = this.uveStore.page();
- if (pageAPIResponse) {
+ if (page) {
this.#globalStore.addNewBreadcrumb({
- label: pageAPIResponse?.page.title,
+ label: page?.title,
url: this.uveStore.pageParams().url
});
}
@@ -136,8 +186,28 @@ export class DotEmaShellComponent implements OnInit {
const params = this.#getPageParams();
const viewParams = this.#getViewParams(params.mode);
- this.uveStore.patchViewParams(viewParams);
- this.uveStore.loadPageAsset(params);
+ // Initialize view viewParams from query parameters
+ const view = this.uveStore.view();
+ patchState(this.uveStore, {
+ view: {
+ ...view,
+ viewParams
+ }
+ });
+
+ // Check if we already have page data loaded with matching params
+ // This prevents reloading when navigating between child routes (content <-> layout)
+ const currentPageParams = this.uveStore.pageParams();
+ const hasPageData = !!this.uveStore.page();
+ const paramsMatch = currentPageParams &&
+ currentPageParams.url === params.url &&
+ currentPageParams.language_id === params.language_id &&
+ currentPageParams.mode === params.mode;
+
+ // Only load if we don't have data or params have changed
+ if (!hasPageData || !paramsMatch) {
+ this.uveStore.loadPageAsset(params);
+ }
this.#siteService.switchSite$
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -147,8 +217,8 @@ export class DotEmaShellComponent implements OnInit {
handleNgEvent({ event }: DialogAction) {
switch (event.detail.name) {
case NG_CUSTOM_EVENTS.UPDATE_WORKFLOW_ACTION: {
- const pageAPIResponse = this.uveStore.pageAPIResponse();
- this.uveStore.getWorkflowActions(pageAPIResponse.page.inode);
+ // Removed pageAPIResponse - use normalized accessors
+ this.uveStore.getWorkflowActions(this.uveStore.page().inode);
break;
}
@@ -168,7 +238,7 @@ export class DotEmaShellComponent implements OnInit {
private handleSavePageEvent(event: CustomEvent): void {
const htmlPageReferer = event.detail.payload?.htmlPageReferer;
const url = new URL(htmlPageReferer, window.location.origin); // Add base for relative URLs
- const targetUrl = getTargetUrl(url.pathname, this.uveStore.pageAPIResponse().urlContentMap);
+ const targetUrl = getTargetUrl(url.pathname, this.uveStore.urlContentMap());
if (shouldNavigate(targetUrl, this.uveStore.pageParams().url)) {
// Navigate to the new URL if it's different from the current one
@@ -190,7 +260,7 @@ export class DotEmaShellComponent implements OnInit {
if (itemId === 'page-tools') {
this.pageTools.toggleDialog();
} else if (itemId === 'properties') {
- const page = this.uveStore.pageAPIResponse().page;
+ const page = this.uveStore.page();
this.dialog.editContentlet({
inode: page.inode,
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.html
new file mode 100644
index 000000000000..82a30a13bd9d
--- /dev/null
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.html
@@ -0,0 +1,103 @@
+@if ($contentletForm() && data().fields.length > 0) {
+
+} @else {
+
+}
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.scss b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.scss
new file mode 100644
index 000000000000..7acda9d70423
--- /dev/null
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.scss
@@ -0,0 +1,25 @@
+@use "variables" as *;
+
+:host {
+ background-color: $color-palette-gray-100;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border-left: 1px solid $color-palette-gray-200;
+ height: 100%;
+ width: 25rem; // 400px equivalent
+}
+
+.empty-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 100%;
+
+ .empty-message {
+ color: $color-palette-gray-500;
+ font-size: 1rem;
+ margin: 0;
+ }
+}
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.spec.ts
new file mode 100644
index 000000000000..eeec3102074a
--- /dev/null
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.spec.ts
@@ -0,0 +1,185 @@
+import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
+
+import { ComponentFixture } from '@angular/core/testing';
+
+import { DotCMSClazzes, DotCMSContentlet } from '@dotcms/dotcms-models';
+
+import {
+ ContentletEditData,
+ DotUveContentletQuickEditComponent
+} from './dot-uve-contentlet-quick-edit.component';
+
+describe('DotUveContentletQuickEditComponent', () => {
+ let spectator: Spectator;
+ let fixture: ComponentFixture;
+
+ const createComponent = createComponentFactory({
+ component: DotUveContentletQuickEditComponent
+ });
+
+ const mockContentletEditData: ContentletEditData = {
+ container: {
+ identifier: 'container-123',
+ uuid: 'uuid-123',
+ acceptTypes: 'test',
+ maxContentlets: 1,
+ variantId: 'DEFAULT'
+ },
+ contentlet: {
+ identifier: 'contentlet-123',
+ inode: 'inode-123',
+ title: 'Test Contentlet',
+ contentType: 'TestType',
+ baseType: 'CONTENT',
+ archived: false,
+ folder: 'folder-123',
+ hasTitleImage: false,
+ host: 'host-123',
+ locked: false,
+ modDate: '2024-01-01',
+ sortOrder: 0,
+ stInode: 'stInode-123',
+ titleField: 'Test Title',
+ hostName: 'demo.dotcms.com',
+ languageId: 1,
+ live: true,
+ modUser: 'admin',
+ working: true,
+ owner: 'admin',
+ modUserName: 'Admin User',
+ titleImage: 'test',
+ url: '/test-contentlet'
+ } as DotCMSContentlet,
+ fields: [
+ {
+ name: 'Test Field',
+ variable: 'testField',
+ clazz: DotCMSClazzes.TEXT,
+ required: true,
+ readOnly: false,
+ dataType: 'TEXT'
+ }
+ ]
+ };
+
+ beforeEach(() => {
+ spectator = createComponent({
+ props: {
+ data: mockContentletEditData,
+ loading: false
+ }
+ });
+ fixture = spectator.fixture;
+ spectator.detectChanges(); // Trigger effect to build form
+ });
+
+ it('should create', () => {
+ expect(spectator.component).toBeTruthy();
+ });
+
+ it('should build form when data is provided', () => {
+ spectator.detectChanges(); // Ensure form is built and rendered
+ const formElement = spectator.query('form');
+ expect(formElement).toBeTruthy();
+
+ const testFieldInput = spectator.query('input[formcontrolname="testField"]') || spectator.query('#testField');
+ expect(testFieldInput).toBeTruthy();
+ });
+
+ it('should display form fields', () => {
+ const label = spectator.query('label');
+ expect(label).toHaveText('Test Field');
+ });
+
+ it('should emit submit event when form is valid and submitted', () => {
+ spectator.detectChanges(); // Ensure form is built and rendered
+ let emittedData: Record | undefined;
+ spectator.component.submit.subscribe((data) => (emittedData = data));
+
+ const input = (spectator.query('input[formcontrolname="testField"]') || spectator.query('#testField')) as HTMLInputElement;
+ expect(input).toBeTruthy();
+ spectator.typeInElement('test value', input);
+ spectator.detectChanges();
+
+ spectator.click('button[type="submit"]');
+ spectator.detectChanges();
+
+ expect(emittedData).toBeDefined();
+ expect(emittedData?.['testField']).toBe('test value');
+ });
+
+ it('should emit cancel event when cancel button is clicked', () => {
+ let cancelEmitted = false;
+ spectator.component.cancel.subscribe(() => (cancelEmitted = true));
+
+ const cancelButton = spectator.query('button[type="button"]');
+ expect(cancelButton).toBeTruthy();
+
+ if (cancelButton) {
+ spectator.click(cancelButton);
+ }
+
+ expect(cancelEmitted).toBe(true);
+ });
+
+ it('should disable buttons when loading', () => {
+ fixture.componentRef.setInput('loading', true);
+ spectator.detectChanges();
+
+ const cancelButton = spectator.query('button[type="button"]') as HTMLButtonElement;
+ const submitButton = spectator.query('button[type="submit"]') as HTMLButtonElement;
+
+ expect(cancelButton.disabled).toBe(true);
+ expect(submitButton.disabled).toBe(true);
+ });
+
+ it('should display empty state when no fields', () => {
+ fixture.componentRef.setInput('data', {
+ ...mockContentletEditData,
+ fields: []
+ });
+ spectator.detectChanges();
+
+ expect(spectator.query('.empty-state')).toExist();
+ expect(spectator.query('.empty-message')).toHaveText('Select a contentlet');
+ });
+
+ it('should mark required fields with CSS class', () => {
+ const label = spectator.query('label');
+ expect(label).toHaveClass('p-label-input-required');
+ });
+
+ it('should emit cancel event on Escape key', () => {
+ let cancelEmitted = false;
+ spectator.component.cancel.subscribe(() => (cancelEmitted = true));
+
+ const form = spectator.query('form');
+ expect(form).toBeTruthy();
+
+ if (form) {
+ form.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ spectator.detectChanges();
+ }
+
+ expect(cancelEmitted).toBe(true);
+ });
+
+ it('should include inode in form if contentlet has inode', () => {
+ const inodeInput = spectator.query('input[formcontrolname="inode"]');
+ expect(inodeInput).toBeTruthy();
+ expect((inodeInput as HTMLInputElement).value).toBe('inode-123');
+ });
+
+ it('should not submit form when invalid', () => {
+ let emittedData: Record | undefined;
+ spectator.component.submit.subscribe((data) => (emittedData = data));
+
+ const input = spectator.query('input[formcontrolname="testField"]') as HTMLInputElement;
+ spectator.typeInElement('', input); // Clear required field to make form invalid
+
+ const submitButton = spectator.query('button[type="submit"]') as HTMLButtonElement;
+ expect(submitButton.disabled).toBe(true); // Should be disabled when form is invalid
+
+ expect(emittedData).toBeUndefined();
+ });
+});
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.ts
new file mode 100644
index 000000000000..7f932d964140
--- /dev/null
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-quick-edit/dot-uve-contentlet-quick-edit.component.ts
@@ -0,0 +1,168 @@
+import { Component, input, output, inject, effect, signal, computed } from '@angular/core';
+import { FormGroup, FormBuilder, ReactiveFormsModule, Validators, AbstractControl } from '@angular/forms';
+
+import { ButtonModule } from 'primeng/button';
+import { CheckboxModule } from 'primeng/checkbox';
+import { DropdownModule } from 'primeng/dropdown';
+import { InputTextModule } from 'primeng/inputtext';
+import { InputTextareaModule } from 'primeng/inputtextarea';
+import { MultiSelectModule } from 'primeng/multiselect';
+import { RadioButtonModule } from 'primeng/radiobutton';
+
+import { DotCMSClazzes, DotCMSContentTypeField, DotCMSContentlet } from '@dotcms/dotcms-models';
+
+import { ContainerPayload } from '../../../shared/models';
+
+/**
+ * Pick only the fields needed for the quick-edit form from DotCMSContentTypeField.
+ * Extends with options property for dropdown/checkbox/radio rendering.
+ */
+export type ContentletField = Pick<
+ DotCMSContentTypeField,
+ 'name' | 'variable' | 'clazz' | 'required' | 'readOnly' | 'regexCheck' | 'dataType'
+> & {
+ options?: Array<{ label: string; value: string }>;
+};
+
+export interface ContentletEditData {
+ container: ContainerPayload;
+ contentlet: DotCMSContentlet;
+ fields: ContentletField[];
+}
+
+/**
+ * Presentational component for quick-editing contentlet form fields in the right sidebar.
+ * NO store injection - receives all data via @Input, emits events via @Output.
+ * Container controls visibility with @if directive.
+ *
+ * @example
+ * ```html
+ * @if ($rightSidebarOpen()) {
+ *
+ * }
+ * ```
+ */
+@Component({
+ selector: 'dot-uve-contentlet-quick-edit',
+ standalone: true,
+ imports: [
+ ReactiveFormsModule,
+ ButtonModule,
+ CheckboxModule,
+ DropdownModule,
+ InputTextModule,
+ InputTextareaModule,
+ MultiSelectModule,
+ RadioButtonModule
+ ],
+ templateUrl: './dot-uve-contentlet-quick-edit.component.html',
+ styleUrl: './dot-uve-contentlet-quick-edit.component.scss'
+})
+export class DotUveContentletQuickEditComponent {
+ private readonly fb = inject(FormBuilder);
+
+ // Inputs (data down from parent container)
+ data = input.required({ alias: 'data' });
+ loading = input(false, { alias: 'loading' });
+
+ // Outputs (events up to parent container)
+ submit = output>();
+ cancel = output();
+
+ // Internal form state
+ private readonly contentletForm = signal(null);
+ protected readonly $contentletForm = computed(() => this.contentletForm());
+
+ protected readonly DotCMSClazzes = DotCMSClazzes;
+
+ constructor() {
+ // Build form when data changes
+ effect(() => {
+ const { fields, contentlet } = this.data();
+
+ if (!fields || fields.length === 0) {
+ this.contentletForm.set(null);
+ return;
+ }
+
+ this.buildForm(fields, contentlet);
+ });
+ }
+
+ private buildForm(fields: ContentletField[], contentlet: DotCMSContentlet): void {
+ const formControls: Record = {};
+
+ // Add hidden inode field
+ if (contentlet?.inode) {
+ formControls['inode'] = this.fb.control(contentlet.inode);
+ }
+
+ fields.forEach((field) => {
+ let fieldValue: string | string[] | boolean = contentlet?.[field.variable] ?? '';
+ const validators = [];
+
+ // Handle checkbox with multiple options - value should be an array
+ if (field.clazz === DotCMSClazzes.CHECKBOX && field.options && field.options.length > 0) {
+ // Convert string value to array if needed
+ if (typeof fieldValue === 'string' && fieldValue) {
+ fieldValue = fieldValue.split(',').map((v) => v.trim());
+ } else if (!Array.isArray(fieldValue)) {
+ fieldValue = [];
+ }
+ }
+
+ // Handle multi-select - value should be an array
+ if (field.clazz === DotCMSClazzes.MULTI_SELECT) {
+ if (typeof fieldValue === 'string' && fieldValue) {
+ fieldValue = fieldValue.split(',').map((v) => v.trim());
+ } else if (!Array.isArray(fieldValue)) {
+ fieldValue = [];
+ }
+ }
+
+ if (field.required) {
+ validators.push(Validators.required);
+ }
+
+ if (field.regexCheck) {
+ try {
+ // Validate the regex pattern before using it
+ new RegExp(field.regexCheck);
+ validators.push(Validators.pattern(field.regexCheck));
+ } catch (error) {
+ // Skip invalid regex patterns
+ console.warn(
+ `Invalid regex pattern for field ${field.variable}: ${field.regexCheck}`,
+ error
+ );
+ }
+ }
+
+ formControls[field.variable] = this.fb.control(
+ fieldValue,
+ validators.length > 0 ? validators : null
+ );
+
+ if (field.readOnly) {
+ formControls[field.variable].disable();
+ }
+ });
+
+ this.contentletForm.set(this.fb.group(formControls));
+ }
+
+ protected handleSubmit(): void {
+ const form = this.$contentletForm();
+ if (form?.valid) {
+ this.submit.emit(form.value);
+ }
+ }
+
+ protected handleCancel(): void {
+ this.cancel.emit();
+ }
+}
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html
index 90f0484099e9..ef0d17bd1cc1 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html
@@ -1,69 +1,77 @@
-@let context = contentContext();
-@let dragItem = dragPayload();
-@let isEmpty = isContainerEmpty();
+
+@if (showHoverOverlay()) {
+
+}
-
-
- @if (!isEmpty) {
+
+@if (showSelectedOverlay()) {
+ @let selectedContext = selectedContentContext();
+ @let selectedDragItem = dragPayload();
+ @let selectedIsEmpty = isSelectedContainerEmpty();
+
+
- }
+ @if (!selectedIsEmpty) {
+
+ }
- @if (!isEmpty) {
-
- @if (hasVtlFiles()) {
+ @if (!selectedIsEmpty) {
+
+ @if (selectedHasVtlFiles()) {
+
+
+ }
-
- }
-
-
-
-
+ icon="pi pi-arrows-alt" />
+
- @if (showStyleEditorOption()) {
- }
-
- }
+ icon="pi pi-pencil" />
+
+ @if (showStyleEditorOption()) {
+
+ }
+
+ }
-
-
+
+
+}
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.scss b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.scss
index f58da024d3f0..add1f6fc4a0b 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.scss
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.scss
@@ -35,9 +35,6 @@
.bounds {
position: absolute;
- pointer-events: none;
- outline: solid 2px $color-palette-primary-500;
- background-color: transparent;
container-type: inline-size;
.add-button-top,
@@ -73,6 +70,27 @@
transform: translate(0, -50%);
}
}
+
+ // Hover state: Blue background with opacity, no border, no tools, pointer-events: all
+ &.hover {
+ pointer-events: all;
+ background-color: $color-palette-primary-op-20;
+ outline: none;
+ }
+
+ // Selected state: Border, transparent background, pointer-events: none on bounds, tools visible
+ &.selected {
+ pointer-events: none;
+ outline: solid 2px $color-palette-primary-500;
+ background-color: transparent;
+
+ // Buttons and actions remain interactive
+ .add-button-top,
+ .add-button-bottom,
+ .actions {
+ pointer-events: all;
+ }
+ }
}
.actions {
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.spec.ts
index f531d7ca0d73..03398c05a67f 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.spec.ts
@@ -128,6 +128,13 @@ describe('DotUveContentletToolsComponent', () => {
hostComponent = spectator.component;
component = spectator.query(DotUveContentletToolsComponent);
spectator.detectChanges();
+
+ // Select the contentlet by clicking the hover bounds
+ const hoverBounds = spectator.query(byTestId('bounds-hover'));
+ if (hoverBounds) {
+ spectator.click(hoverBounds);
+ spectator.detectChanges();
+ }
});
describe('Rendering', () => {
@@ -136,7 +143,7 @@ describe('DotUveContentletToolsComponent', () => {
});
it('should render bounds container with correct styles', () => {
- const bounds = spectator.query(byTestId('bounds'));
+ const bounds = spectator.query(byTestId('bounds-selected'));
expect(bounds).toBeTruthy();
const styles = (bounds as HTMLElement).style;
@@ -163,6 +170,13 @@ describe('DotUveContentletToolsComponent', () => {
hostComponent.contentletArea = MOCK_EMPTY_CONTENTLET_AREA;
spectator.detectChanges();
+ // Re-select after changing contentletArea
+ const hoverBounds = spectator.query(byTestId('bounds-hover'));
+ if (hoverBounds) {
+ spectator.click(hoverBounds);
+ spectator.detectChanges();
+ }
+
const actions = spectator.query(byTestId('actions'));
expect(actions).toBeFalsy();
});
@@ -171,6 +185,13 @@ describe('DotUveContentletToolsComponent', () => {
hostComponent.contentletArea = MOCK_EMPTY_CONTENTLET_AREA;
spectator.detectChanges();
+ // Re-select after changing contentletArea
+ const hoverBounds = spectator.query(byTestId('bounds-hover'));
+ if (hoverBounds) {
+ spectator.click(hoverBounds);
+ spectator.detectChanges();
+ }
+
const addBottomButton = spectator.query(byTestId('add-bottom-button'));
expect(addBottomButton).toBeFalsy();
});
@@ -185,11 +206,25 @@ describe('DotUveContentletToolsComponent', () => {
it('should NOT render edit VTL button when no vtl files', () => {
const areaWithoutVtl = {
...MOCK_CONTENTLET_AREA,
- payload: { ...MOCK_CONTENTLET_AREA.payload, vtlFiles: undefined }
+ x: MOCK_CONTENTLET_AREA.x + 1, // Change position to make it different
+ payload: {
+ ...MOCK_CONTENTLET_AREA.payload,
+ contentlet: {
+ ...MOCK_CONTENTLET_AREA.payload.contentlet,
+ identifier: 'different-contentlet-id'
+ },
+ vtlFiles: undefined
+ }
};
hostComponent.contentletArea = areaWithoutVtl;
spectator.detectChanges();
+ // Re-select after changing contentletArea
+ const hoverBounds = spectator.query(byTestId('bounds-hover'));
+ expect(hoverBounds).toBeTruthy();
+ spectator.click(hoverBounds);
+ spectator.detectChanges();
+
const editVtlButton = spectator.query(byTestId('edit-vtl-button'));
expect(editVtlButton).toBeFalsy();
});
@@ -483,18 +518,32 @@ describe('DotUveContentletToolsComponent', () => {
it('should return undefined when no vtl files', () => {
const areaWithoutVtl = {
...MOCK_CONTENTLET_AREA,
- payload: { ...MOCK_CONTENTLET_AREA.payload, vtlFiles: undefined }
+ x: MOCK_CONTENTLET_AREA.x + 1, // Change position to make it different
+ payload: {
+ ...MOCK_CONTENTLET_AREA.payload,
+ contentlet: {
+ ...MOCK_CONTENTLET_AREA.payload.contentlet,
+ identifier: 'different-contentlet-id-2'
+ },
+ vtlFiles: undefined
+ }
};
hostComponent.contentletArea = areaWithoutVtl;
spectator.detectChanges();
+ // Re-select after changing contentletArea
+ const hoverBounds = spectator.query(byTestId('bounds-hover'));
+ expect(hoverBounds).toBeTruthy();
+ spectator.click(hoverBounds);
+ spectator.detectChanges();
+
expect(component.vtlMenuItems()).toBeUndefined();
});
});
describe('boundsStyles', () => {
it('should apply correct inline styles from contentletArea dimensions', () => {
- const bounds = spectator.query(byTestId('bounds')) as HTMLElement;
+ const bounds = spectator.query(byTestId('bounds-selected')) as HTMLElement;
expect(bounds.style.left).toBe('100px');
expect(bounds.style.top).toBe('200px');
@@ -503,12 +552,31 @@ describe('DotUveContentletToolsComponent', () => {
});
it('should default to 0px when contentletArea values are undefined', () => {
- const areaWithUndefined = { ...MOCK_CONTENTLET_AREA, x: undefined };
- hostComponent.contentletArea = areaWithUndefined as ContentletArea;
+ // Create area without x property to test undefined handling
+ const { x, ...rest } = MOCK_CONTENTLET_AREA;
+ const areaWithUndefined = {
+ ...rest,
+ payload: {
+ ...rest.payload,
+ contentlet: {
+ ...rest.payload.contentlet,
+ identifier: 'different-contentlet-id-3'
+ }
+ }
+ } as ContentletArea;
+ hostComponent.contentletArea = areaWithUndefined;
spectator.detectChanges();
- const bounds = spectator.query(byTestId('bounds')) as HTMLElement;
- expect(bounds.style.left).toBe('0px');
+ // Re-select after changing contentletArea
+ const hoverBounds = spectator.query(byTestId('bounds-hover'));
+ expect(hoverBounds).toBeTruthy();
+ spectator.click(hoverBounds);
+ spectator.detectChanges();
+
+ const bounds = spectator.query(byTestId('bounds-selected')) as HTMLElement;
+ expect(bounds).toBeTruthy();
+ // The computed uses ?? operator, so undefined x should default to 0
+ expect(parseInt(bounds.style.left)).toBe(0);
});
});
@@ -534,6 +602,13 @@ describe('DotUveContentletToolsComponent', () => {
hostComponent.contentletArea = areaWithoutContentlet;
spectator.detectChanges();
+ // Re-select after changing contentletArea
+ const hoverBounds = spectator.query(byTestId('bounds-hover'));
+ if (hoverBounds) {
+ spectator.click(hoverBounds);
+ spectator.detectChanges();
+ }
+
const payload = component.dragPayload();
expect(payload).toEqual({
container: null,
@@ -623,6 +698,13 @@ describe('DotUveContentletToolsComponent', () => {
hostComponent.contentletArea = MOCK_EMPTY_CONTENTLET_AREA;
spectator.detectChanges();
+ // Re-select after changing contentletArea
+ const hoverBounds = spectator.query(byTestId('bounds-hover'));
+ if (hoverBounds) {
+ spectator.click(hoverBounds);
+ spectator.detectChanges();
+ }
+
const paletteButton = spectator.query(byTestId('palette-button'));
expect(paletteButton).toBeFalsy();
});
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts
index 6562884f0bb4..203e521fa3d2 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts
@@ -20,7 +20,7 @@ import { TooltipModule } from 'primeng/tooltip';
import { DotMessageService } from '@dotcms/data-access';
import { DotMessagePipe } from '@dotcms/ui';
-import { ActionPayload, ContentletPayload, VTLFile } from '../../../shared/models';
+import { ActionPayload, ClientData, ContentletPayload, VTLFile } from '../../../shared/models';
import { ContentletArea } from '../ema-page-dropzone/types';
/**
@@ -46,10 +46,16 @@ export class DotUveContentletToolsComponent {
*/
readonly isEnterprise = input(false, { alias: 'isEnterprise' });
/**
- * Positional and contextual data for the currently hovered/selected contentlet.
- * Drives the floating toolbar's placement and the payload for all actions.
+ * Positional and contextual data for the currently hovered contentlet.
+ * This comes from the iframe mouse enter events.
*/
readonly contentletArea = input.required({ alias: 'contentletArea' });
+
+ /**
+ * Internal state tracking the selected/clicked contentlet.
+ * This persists even when hovering different contentlets.
+ */
+ protected readonly selectedContentletArea = signal(null);
/**
* Controls whether the delete-content action is allowed.
* When `false`, the delete button is disabled and a tooltip explaining why is shown.
@@ -85,6 +91,9 @@ export class DotUveContentletToolsComponent {
type: 'content' | 'form' | 'widget';
payload: ActionPayload;
}>();
+
+
+ readonly outputSelectedContentlet = output>();
/**
* Emitted when the contentlet is selected from the tools (for example, via a drag handle).
*/
@@ -104,7 +113,49 @@ export class DotUveContentletToolsComponent {
protected readonly buttonPosition = signal<'after' | 'before'>('after');
/**
- * Snapshot of the area payload augmented with the current insert position.
+ * Helper function to compare two contentlets by their identifier.
+ * Returns true if they represent the same contentlet.
+ */
+ protected isSameContentlet(area1: ContentletArea | null, area2: ContentletArea | null): boolean {
+ if (!area1 || !area2) {
+ return false;
+ }
+ const id1 = area1.payload?.contentlet?.identifier;
+ const id2 = area2.payload?.contentlet?.identifier;
+ return id1 !== undefined && id1 === id2;
+ }
+
+ /**
+ * Computed property to determine if the hovered contentlet is different from the selected one.
+ */
+ readonly isHoveredDifferentFromSelected = computed(() => {
+ const hovered = this.contentletArea();
+ const selected = this.selectedContentletArea();
+ if (!hovered || !selected) {
+ return true;
+ }
+ return !this.isSameContentlet(hovered, selected);
+ });
+
+ /**
+ * Computed property to determine if we should show the hover overlay.
+ * Show when hovered exists and is different from selected.
+ */
+ readonly showHoverOverlay = computed(() => {
+ const hovered = this.contentletArea();
+ return hovered !== null && this.isHoveredDifferentFromSelected();
+ });
+
+ /**
+ * Computed property to determine if we should show the selected overlay.
+ * Show when selected exists.
+ */
+ readonly showSelectedOverlay = computed(() => {
+ return this.selectedContentletArea() !== null;
+ });
+
+ /**
+ * Snapshot of the area payload augmented with the current insert position for hovered contentlet.
* Consumers can dispatch the returned object directly.
*/
readonly contentContext = computed(() => ({
@@ -112,6 +163,17 @@ export class DotUveContentletToolsComponent {
position: this.buttonPosition()
}));
+ /**
+ * Snapshot of the area payload augmented with the current insert position for selected contentlet.
+ */
+ readonly selectedContentContext = computed(() => {
+ const selected = this.selectedContentletArea();
+ return {
+ ...selected?.payload,
+ position: this.buttonPosition()
+ };
+ });
+
/**
* Whether there is at least one VTL file associated with the current contentlet.
* Used to determine if the VTL menu should be rendered/enabled.
@@ -126,6 +188,20 @@ export class DotUveContentletToolsComponent {
return this.contentContext()?.contentlet?.identifier === 'TEMP_EMPTY_CONTENTLET';
});
+ /**
+ * Whether the selected container is represented by a temporary "empty" contentlet.
+ */
+ readonly isSelectedContainerEmpty = computed(() => {
+ return this.selectedContentContext()?.contentlet?.identifier === 'TEMP_EMPTY_CONTENTLET';
+ });
+
+ /**
+ * Whether there is at least one VTL file associated with the selected contentlet.
+ */
+ readonly selectedHasVtlFiles = computed(() => {
+ return !!this.selectedContentContext()?.vtlFiles?.length;
+ });
+
/**
* Tooltip key for the delete button.
* Returns an i18n key when delete is disabled, or `null` when the button is enabled.
@@ -141,33 +217,36 @@ export class DotUveContentletToolsComponent {
/**
* Menu items used for adding new content to the layout (content, widget, and optionally form).
* Items are localized and wired so that selecting them emits `addContent`.
+ * Uses selected context when available, otherwise falls back to hovered context.
*/
readonly menuItems = computed
');
+ expect(writtenContent.indexOf(SDK_EDITOR_SCRIPT_SOURCE)).toBeLessThan(
+ writtenContent.indexOf('')
+ );
+ });
+
+ it('should inject editor script at end if no body tag exists', () => {
+ const htmlWithoutBody = '