Skip to content

feat(content-drive): merge base + content type filters into one chip#35503

Open
zJaaal wants to merge 36 commits intomainfrom
35490-task-migrate-content-drive-filters-to-chip-pattern
Open

feat(content-drive): merge base + content type filters into one chip#35503
zJaaal wants to merge 36 commits intomainfrom
35490-task-migrate-content-drive-filters-to-chip-pattern

Conversation

@zJaaal
Copy link
Copy Markdown
Member

@zJaaal zJaaal commented Apr 29, 2026

Summary

  • Merges the separate Base Type and Content Type filters in the Content Drive toolbar into a single chip-style filter built on dot-chip-filter + <p-popover> + <p-listbox> (per [TASK] Content Drive: Migrate existent filters to chip + popover + listbox pattern #35490).
  • Two-column popover: focus a base type on the left to filter the content-type list on the right; the right column also has a built-in search and lazy-loaded virtual scroll.
  • Selected content types are pinned to the top of the right list so they remain visible across base-type focus changes.

Behavior

Selection cascade:

  • Selecting a content type selects its parent base type.
  • Unselecting the last content type of a base type drops the base type.
  • Unselecting a base type drops all of its content types.
  • Selecting a base type alone never auto-selects content types.

Chip label rules:

  • Name (All) for a base type selected without narrowing.
  • Specific content type names when the selection is narrowed.
  • Mixed states combine both, e.g. Content (All), Blog Author.

Implementation notes

  • Panel content is rendered inside @if ($popoverOpen()) so the listbox is recreated on each open. Without this, primeng's virtual scroll computes 0 visible items behind the hidden overlay and the right list never renders until clicked.
  • Right list eagerly clears + immediately refetches on focus change (no debounce), which avoids stale items from the previous focus while the new fetch is in flight.
  • A monotonically growing in-component cache lets $selectedContentTypes resolve full objects from the store even when those items are no longer in the visible page.
  • Reverts the chip-filter clicked output to output<Event>() so consumers can pass the event into popover.toggle($event) for positioning.
  • Tests drive every interaction through events on rendered elements (ngModelChange, onChange, onLazyLoad, onFilter, popover onHide, chip removed/clicked) — no protected-method bracket access.

Test plan

  • Open the chip — popover renders, base types load on the left, content types on the right.
  • Click a base type row (no checkbox) — right list refetches for that base type only.
  • Click an empty checkbox in the left list — base type is added to selection; the chip shows Name (All).
  • Pick a content type — both baseType and contentType are persisted to the store; the chip shows the content type name (no (All)).
  • Unselect the last content type of a base — base type is dropped; the chip clears that segment.
  • Select content types under different base types, then change focus — selected items remain pinned at the top of the right list.
  • Type into the right search input — debounced server-side filter; (All) rows on the right respect the focused base type.
  • Scroll the right list — lazy load fires, more pages are appended.
  • Click the chip's X — all selections clear; both baseType and contentType are removed from the store.
  • Click the toolbar "Clear all" — the chip empties.

🤖 Generated with Claude Code

zJaaal and others added 25 commits April 23, 2026 16:26
Chip/pill filter trigger button with:
- title + selections[] inputs; label computed from selection count
  (none → title, 1-2 → joined, 3+ → "first and N more..." via i18n)
- active state derived from selections length (primary colors vs neutral)
- clicked / removed outputs; close icon stops propagation so removed
  does not also fire clicked
- Host-element styling via flex justify-between so chevron always sits
  at the right edge of the chip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Chip filter updates:
- Split title and values into separate spans so the title can be
  accented with font-semibold while values stay in regular weight
- Shift active/hover colors from brand-specific blues to the primary
  palette tints (primary-100/200 + primary-400/500/700/800) so 4+
  active chips no longer visually overwhelm the toolbar
- Drop component SCSS: all state colors now live in a Tailwind class
  string toggled by the computed active() signal
- Enforce min-w-[140px] on the host so chips never shrink below the
  design floor; line-height set to 150% (leading-normal)
- i18n the "and N more" fallback via DotMessageService

Toolbar updates:
- Swap the filter row from a fixed grid (repeat(3, minmax(...)))
  to flex-wrap so chips wrap onto a second line instead of being
  clipped at narrow widths
- Replace grid-rows-2 (1fr 1fr, which forced row 1 to match the
  wrapped filter row height on responsive) with grid-rows-[auto_auto]
  so each row hugs its content
- Bump inter-chip gap to gap-2 (8px) and vertical row gap to gap-y-4
  (16px)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Restore the 14rem (min-w-56) floor on the three filter fields that
got lost when the toolbar filter row switched from a fixed grid to
flex-wrap. Without it, flex items shrink freely and the multiselects
collapsed below their PrimeNG theme-default min-width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drop the class-based assertions (active/inactive color classes) and
replace with a presence test for the values span so coverage now
reflects what the component does, not how it's styled. Tailwind class
names are an implementation detail that should stay testable via
visual review, not brittle string comparisons in unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Make onRemove protected — it is template-only, not part of the
  public component API (review #1)
- Replace per-token i18n string assembly with a single parameterized
  key 'content-drive.chip-filter.overflow-label={0} and {1} more' so
  word order is owned by the translation, not the JS template, and
  RTL/non-English locales get correct grammar (review #2)
- Drop the 'border' static class + 'border-none' active class mix;
  border now lives only on the inactive variant (review #5)

Skipping review #3 (host class assertions) per established preference
for presence/behavior tests over CSS class assertions, and #4 (chip
not wired into the toolbar) which is intentional staging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tions

Address PR review feedback (#35465):
- Close icon is now a real <button> with aria-label; keyboard users can
  actually remove a filter.
- Chip host gets role=button plus Enter/Space keydown so the primary
  activator is keyboard-reachable.
- tabIndex exposed as an input so a future roving-tabindex toolbar can
  manage focus across many chips.
- Active and inactive states now share a border, removing the 1px
  state-switch layout jump; transition timing eased for smoother feel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rive-filters' of https://github.com/dotCMS/core into 33524-task-create-new-chip-like-component-for-content-drive-filters
For #35490. Initial work-in-progress on the migration:

- Language field migrated off p-multiSelect onto DotChipFilterComponent +
  p-popover + p-listbox + DotFilterListItemComponent. Filter input,
  reset-on-close, and debounced store updates preserved.
- New DotFilterListItemComponent in @dotcms/portlets/content-drive/ui to
  standardize row markup (label + optional secondary, palette colors).
- New shared CHIP_FILTER_POPOVER_PT and CHIP_FILTER_LISTBOX_PT constants
  so every filter chip uses the same chrome (rounded popover, no arrow,
  borderless listbox, palette-driven hover/selected colors, 16x16
  checkboxes, py-3 rows).

Still TODO on this ticket: merge Base Type + Content Type into a single
filter with a master/detail panel as described in #35490.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… Clear all

For #35490. Three related changes to filter behavior:

- setGlobalSearch no longer wipes other filters. It strips/sets the
  search title in place, so applying a search keeps your active
  base/content-type/language filters, and clearing the input removes
  the title key without disturbing the rest.
- setPath is now strictly navigation: it only updates path and resets
  pagination. Folder navigation no longer touches filters at all
  (previously it leaked title:undefined into the filter map, which made
  the filter-state computed look truthy on every folder click).
- New clearFilters store action and a "Clear all" p-button in the
  toolbar (text variant, sits inline next to the last filter chip)
  that calls it. Visible only when at least one filter is active. The
  filter row is now items-center so chips and the button align.

Adds the content-drive.filters.clear-all i18n key and tests covering:
search-preserves-filters, search-empty-strips-title, clearFilters
action, setPath leaves filters untouched, Clear all button visibility
and click delegation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix bug where Enter on the close button emitted both `removed` and
  `clicked` (keydown bubbled to host even though the synthesized click
  was stopped). Single host keydown handler now ignores keydowns that
  bubbled from a descendant.
- Drop unnecessary `computed()` wrapper around the static `removeLabel`
  string; resolve via the `dm` pipe in the template instead.
- Narrow `clicked` output from `Event` to `void` to avoid leaking DOM
  internals to consumers.
- Remove 32 lines of commented-out demo `<dot-chip-filter>` usages and
  the unused `DotChipFilterComponent` import from the toolbar.
- Add regression tests for Enter/Space on the close button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…chip

Replaces the two separate Base Type and Content Type filters in the
content drive toolbar with a single merged chip-style filter built on
the new `dot-chip-filter` + `<p-popover>` + `<p-listbox>` pattern.

Layout:
- Two-column popover (Base Type / Content Type)
- Left listbox uses single-select for the focused base type plus an
  inline checkbox per row for the actual filter selection. An
  "All Content" row sits at the top to browse every type without
  narrowing.
- Right listbox is multi-select with checkbox + filter input + virtual
  scroll + lazy load. Filtered by the focused base type only — never
  by all selected ones.
- Selected content types are pinned to the top of the right list so
  they remain visible across focus changes.
- Panel content is mounted with @if while the popover is open so the
  listbox's virtual scroll measures real dimensions on each open
  (otherwise it computes 0 visible items behind the hidden overlay).

Selection cascade rules:
- Selecting a content type selects its parent base type.
- Unselecting the last content type of a base type drops the base type.
- Unselecting a base type drops all its content types.
- Selecting a base type alone never auto-selects content types.

Chip label rules (per ticket):
- `Name (All)` for a base type selected without narrowing.
- Specific content type names when narrowed.
- Mixed states combine both.

Other:
- Reverts the chip-filter `clicked` output back to `output<Event>()`
  so consumers can pass it to `popover.toggle($event)` for positioning.
- Removes the old base-type-selector and content-type-field components
  and updates the toolbar to render the merged filter.
- Adds i18n keys for the new filter (title, columns, all-content, all).
- Tests drive every interaction through events on rendered elements
  rather than calling protected methods.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Apr 29, 2026
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 29, 2026

Claude finished @zJaaal's task in 5m 55s —— View job


PR Review

  • Gather context
  • Read main component files
  • Read store and shared changes
  • Post review findings

Issues Found

1. Panel-reopen data corruption after an active search (Bug)

onPanelHide resets contentTypeFilter to '' in state but leaves $state.contentTypes populated with whatever search results were loaded before closing. On the next open, @if ($popoverOpen()) recreates the DOM, virtual scroll fires onLazyLoad, which calls #loadContentTypes with the now-empty contentTypeFilter — appending unfiltered page-2 results onto stale filtered page-1 results.

dot-content-drive-content-type-filter.component.ts:359-362

protected onPanelHide(): void {
    this.$popoverOpen.set(false);
    patchState(this.$state, { contentTypeFilter: '' });  // data not cleared
}

Fix: also reset contentTypes: [], currentPage: 1, canLoadMore: true, loading: true on hide, and let the virtual-scroll onLazyLoad (which fires immediately on each open) drive the initial refetch. Fix this →


2. Spinner clears prematurely during concurrent search-debounce + focus-change (Bug)

If the user types (setting loading: true via the tap), then within 500 ms changes focus, the stale-guard branch in #setupFilterSubscription runs after the debounce and calls patchState(this.$state, { loading: false }) — while the focus-change HTTP call is still in-flight.

dot-content-drive-content-type-filter.component.ts:487-495

if (req.baseType !== currentType) {
    patchState(this.$state, { loading: false });  // ← fires while focus-fetch is in flight
    return EMPTY;
}

The spinner disappears before results arrive. Fix: only clear loading in the stale guard if no other fetch is in flight (e.g., by tracking an #isFetchingFocus flag). Fix this →


3. #hasMorePages divergence can trigger a spurious empty fetch (Minor)

Server-side totalEntries counts FORM and system content types before client-side filtering. When the last server page consists entirely of FORMs/system types, canLoadMore stays true after that page loads. The lazy loader fires again, gets an empty response from #filterContentTypes, and onLazyLoad then sets canLoadMore: false. This is a single extra API call per focus-change, not a loop — but it's a predictable extra round-trip.

dot-content-drive-content-type-filter.component.ts:550-554

#hasMorePages(pagination: DotPagination): boolean {
    const perPage = pagination.perPage || ITEMS_PER_PAGE;
    const totalPages = Math.ceil((pagination.totalEntries ?? 0) / perPage);
    return pagination.currentPage < totalPages;
}

The PR description acknowledges this but calls it "better than skewing the count client-side." That's true — just noting it triggers real extra traffic, not just a count mismatch.


4. #contentTypeCache grows without bound (Design)

#cacheContentTypes only appends, never evicts. In a large dotCMS installation, scrolling through all content types over a long session accumulates the full catalog in memory for the component's lifetime.

dot-content-drive-content-type-filter.component.ts:556-563

The PR description explicitly notes this as intentional to keep selected items resolvable. Acceptable as-is, but worth a comment about the trade-off and whether a max-size eviction policy (while preserving selected items) would be worthwhile.


5. Cascade-down on deselect-last-content-type drops the base entirely (Design concern)

When a user selects CONTENT in "all" mode, then adds Blog to narrow it, then removes Blog — the code drops CONTENT entirely rather than reverting to "all" mode. Compare with the checkbox path (onBaseTypeToggle partial→all): clicking a partial checkbox keeps the base in "all" mode. The two paths are asymmetric.

dot-content-drive-content-type-filter.component.ts:329-346

The PR description specifies this behavior explicitly. Flag it as a UX decision to verify with design: is "deselect last → drop base" really the intent, given the checkbox takes the opposite approach?


Minor observations (no action required)

  • take(1) ordering: take(1) before takeUntilDestroyed in #loadContentTypes means the destroy-cleanup is effectively unreachable in the happy path (the observable completes after one value). Swapping the order (takeUntilDestroyedtake(1)) makes the intent clearer and ensures destroy correctly cancels in-flight calls. Currently it still works because takeUntilDestroyed wrapping take(1) propagates unsubscription upstream — but the intent is obscured.

  • openPopover() test helper: relies on PrimeNG's popover.toggle() firing onShow synchronously in JSDOM. This works today but is brittle against PrimeNG upgrades that change overlay lifecycle timing. Directly setting spectator.component.$popoverOpen.set(true) would be more reliable.

  • No retry UX on fetch failure: all catchError paths return of({ contentTypes: [], ... }) and silently show an empty list. A transient network blip leaves the user staring at an empty panel with no affordance to retry.

zJaaal and others added 9 commits April 29, 2026 16:59
Content-type filter:
- Race conditions: a #cancelFetch$ subject is fired on focus change so any
  in-flight focus or lazy-load fetch is cancelled before the new one runs.
  Late responses from a stale focus can no longer overwrite the right list.
- canLoadMore: use server-side pagination math (currentPage / totalEntries
  / perPage) instead of comparing the client-filtered count against
  totalEntries. The client-side FORM/system filter would otherwise leave
  canLoadMore stuck at true and trigger a wasted empty fetch every load.
- Initial load: drop the misplaced tap(loading=true) from after the http
  call. The initial state already has loading: true and the tap was firing
  on the response (the opposite of intent).
- Chip flash: $chipSelections returns [] until the base-type catalog has
  loaded, so the chip never renders raw enum names like 'CONTENT (All)'
  before falling back to 'Content (All)'.

Language field:
- Drop the of(...).pipe(debounceTime).subscribe() pattern from onChange.
  It wasn't actually debouncing (each call built a fresh pipeline), and
  the 500ms wait felt broken to users selecting a language. Now updates
  the store immediately on every change.

Tests:
- openPopover() helper asserts $popoverOpen is true after the click so a
  silently inert helper would fail loud rather than passing assertions
  against an empty DOM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s' banner

Base-type checkbox is now tri-state:
- empty       → click to select the base (no narrowing).
- pi-check    → click to drop the base + its content types.
- pi-minus    → some content types of this base are selected (partial).
                Clicking it drops the narrowing and keeps the base — i.e.
                switches to "all content types of this base type".

The pi-minus checkbox is styled identically to the checked state (white
icon on the primary background). PrimeNG v21 doesn't apply any host class
for the indeterminate state, so we tag the host with `.dot-checkbox-partial`
ourselves and redirect the base checkbox CSS tokens to their checked
equivalents from inside the component's styles.

A green banner reading "All content types selected." is now rendered
between the search input and the list whenever the focused base type is
selected with no narrowing content types — i.e. the right list is acting
as "all of this base type". This covers both:
- selecting a base type alone, and
- clicking a partial checkbox to drop its narrowing.

To make the banner sit between filter and items, the right column's
built-in `[filter]` is replaced with a custom `pInputText` search bar
above the listbox; everything still flows through the same fetch /
debounce / cancel pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d of CSS

Replaces the ::ng-deep CSS workaround with PrimeNG's native passthrough
API. A `partialCheckboxPt` object pumps the checked-state design tokens
straight into the checkbox's box and icon slots when the row is in the
partial state — same visual outcome (white pi-minus on a primary
background) but no global selectors and no scoped-style escapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the absolute-positioned <i class="pi pi-search"> with PrimeNG's
p-iconfield + p-inputicon wrapper for the content type search input,
matching the standard pattern used elsewhere in the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…re logic

- Search input switched from manual icon positioning to PrimeNG's
  <p-iconfield> + <p-inputicon> so the magnifying glass aligns and
  the placeholder padding is handled by the design system.
- Indeterminate checkbox styling moved from a CSS workaround to
  PrimeNG's [pt] passthrough — a `partialCheckboxPt` object pumps
  the checked-state tokens directly into the box and icon slots.
- Popover height is now derived from the LEFT listbox's natural
  height (LISTBOX_SCROLL_HEIGHT + column header) instead of a
  hardcoded 30rem, so the popover always matches the base-type
  list with no leftover space at the bottom.
- When the "All content types selected" banner appears, the right
  listbox's scroll height shrinks by exactly one item slot — banner
  takes that row's place — so the popover height stays constant.
- Removed the "pin selected at top of right list" behavior; user
  testing showed it was confusing. The cache stays so selected
  items can still be resolved across focus changes.
- `#ensureParam` now only includes selected content types whose
  baseType matches the focused base type (or all selections when
  focus is ALL_CONTENT). Avoids asking the API to ensure items
  the current focus would never legitimately return.
- Renamed i18n key `all-content` → `all-content-types`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- #loadBaseTypes now has a catchError that falls back to []. A failed
  base-types fetch used to leave the left column showing only "All
  Content Types" with no user feedback.
- Initial content-type fetch reads `ensure` straight from the store
  instead of from #ensureParam. The cache is empty at this point so
  #ensureParam was always returning undefined, which meant restoring
  contentType filters from the URL silently dropped any item that
  wasn't on page 1 (the chip would render empty).
- Lazy-load empty-response branch now also sets loading: false. It
  was leaving the loading flag stuck at true, which the empty-state
  template reads.
- Replaced the O(n²) Object.entries(MAP_NUMBERS_TO_BASE_TYPES).find
  reverse lookup in #syncStore with a precomputed
  MAP_BASE_TYPES_TO_NUMBERS constant.
- Language field now declares `implements OnInit` so a typo in the
  hook name would actually fail compilation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Race: a buffered debounced search whose focus changed mid-window used
  to slip past `takeUntil(#cancelFetch$)` because the cancel had already
  fired before switchMap created its inner observable. The switchMap
  callback now re-reads `$focusedBaseType()` and returns EMPTY (plus
  resets `loading: false`) when the buffered request's `baseType` no
  longer matches the live focus, so the focus-change path stays the
  sole owner of the right list.
- Removed `$fetchingItems` — never referenced in the template.
- Removed the dead `pTemplate="emptyfilter"` block — the right listbox
  no longer has `[filter]="true"` (we render our own search input).
- `$chipSelections` now falls back to the raw enum name (e.g.
  `CONTENT (All)`) when the base-type catalog isn't loaded yet, instead
  of returning [] and silently hiding active filters when the catalog
  API fails.
- `$selectedBaseTypes`'s lookup uses `MAP_NUMBERS_TO_BASE_TYPES[Number(k)]`
  to match how the store does the same lookup — keeps the index types
  consistent.
- Bound `isBaseTypePartial(item.name)` once via `@let` in the left
  listbox row template instead of calling it twice (once for
  `[indeterminate]`, once for `[pt]`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[TASK] Content Drive: Migrate existent filters to chip + popover + listbox pattern

1 participant