|
-
+ @if ($selectionMode() === 'multiple') {
+
+ }
|
@for (column of $columns(); track column.field) {
@if (column.sortable && !$readOnly()) {
@@ -67,13 +73,17 @@
data-testId="item-row"
(contextmenu)="onContextMenu($event, item)"
(dblclick)="onDoubleClick(item)"
- [draggable]="!$readOnly()"
+ [attr.draggable]="!$readOnly()"
(dragstart)="onDragStart($event, item)"
(dragend)="onDragEnd()"
(dragover)="onDragOver($event, item)"
(drop)="onDrop($event, item)">
-
+ @if ($selectionMode() === 'multiple') {
+
+ } @else {
+
+ }
|
@if ($visibleColumnSet().has('title')) {
+ (click)="onTitleClick($event, item)">
@if (!isFolder) {
+ resolves through titleImage / the default /dA path. Passing the
+ contentType here (legacy quirk) 404s the /contentAsset-based
+ types (SVG, PDF) — that endpoint needs the real field variable. -->
}
-
+ (click)="onTitleClick($event, item)">
{{ isFolder ? item.name : item.title }}
@if (!isFolder) {
@if (item.locked) {
-
@if ($lockedByOthersSet().has(item.inode)) {
}
-
@if ($visibleColumnSet().has('contentType')) {
|
{{ (isFolder ? 'Folder' : item.contentType) | dm }}
diff --git a/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.scss b/core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.scss
similarity index 91%
rename from core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.scss
rename to core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.scss
index 8351a232f098..060e657fc05e 100644
--- a/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.scss
+++ b/core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.scss
@@ -1,7 +1,7 @@
-@use "../../../../../../dotcms-scss/shared/colors";
-@use "../../../../../../dotcms-scss/shared/common";
-@use "../../../../../../dotcms-scss/shared/fonts";
-@use "../../../../../../dotcms-scss/shared/spacing";
+@use "../../../../../dotcms-scss/shared/colors";
+@use "../../../../../dotcms-scss/shared/common";
+@use "../../../../../dotcms-scss/shared/fonts";
+@use "../../../../../dotcms-scss/shared/spacing";
@use "variables" as *;
// Drag image styles (dynamically created, appended to body)
diff --git a/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.spec.ts
similarity index 91%
rename from core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.spec.ts
rename to core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.spec.ts
index 2ed6196c30e7..d0e73f0c0541 100644
--- a/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.spec.ts
+++ b/core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.spec.ts
@@ -13,10 +13,9 @@ import { DotcmsConfigService } from '@dotcms/dotcms-js';
import { DotContentDriveItem, DotLanguage } from '@dotcms/dotcms-models';
import { DotcmsConfigServiceMock, MockDotMessageService } from '@dotcms/utils-testing';
+import { DOT_DRAG_ITEM, HEADER_COLUMNS } from './constants';
import { DotFolderListViewComponent } from './dot-folder-list-view.component';
-
-import { DOT_DRAG_ITEM, HEADER_COLUMNS } from '../shared/constants';
-import { mockItems } from '../shared/mocks';
+import { mockItems } from './mocks';
// Mock DragEvent since it's not available in Jest environment
class DragEventMock extends Event {
@@ -656,39 +655,47 @@ describe('DotFolderListViewComponent', () => {
expect(spectator.query(byTestId('item-checkbox'))).toBeTruthy();
});
- it('should rescale a subset so the columns still fill the table', () => {
- // `table-layout: fixed` shares leftover width across *every* column, including the 3rem
- // checkbox one. A subset adding up to 57% therefore opens a gutter between the checkbox
- // and the first heading instead of widening the title.
+ it('should leave the title column unsized so it absorbs the leftover width', () => {
+ // The one column without a width is what makes every other case below work: whatever
+ // the sized columns and the 3rem checkbox one do not claim lands here.
spectator.setInput('visibleColumns', ['title', 'live', 'contentType']);
spectator.detectChanges();
- const total = headerCells()
- .map((cell) => Number.parseFloat(cell.style.width))
- .reduce((sum, width) => sum + width, 0);
+ const [title, status, type] = headerCells();
- expect(total).toBeCloseTo(100);
+ expect(title.style.width).toBe('');
+ expect(status.style.width).toBe('10%');
+ expect(type.style.width).toBe('15%');
});
- it('should keep the relative proportions of the columns it keeps', () => {
- // 32 : 10 : 15 in HEADER_COLUMNS — rescaling must not flatten them to equal thirds.
- spectator.setInput('visibleColumns', ['title', 'live', 'contentType']);
+ it('should keep the sized columns at their authored widths whatever is hidden', () => {
+ // No rescaling: dropping a column hands its share to the title, not to everyone.
spectator.detectChanges();
- const [title, status, type] = headerCells().map((cell) =>
- Number.parseFloat(cell.style.width)
+ expect(headerCells().map((cell) => cell.style.width)).toEqual(
+ HEADER_COLUMNS.map((column) => column.width ?? '')
);
-
- expect(title / status).toBeCloseTo(3.2, 1);
- expect(type / status).toBeCloseTo(1.5, 1);
});
- it('should leave the widths of the full column set untouched', () => {
- spectator.detectChanges();
+ it('should leave room for the checkbox column instead of overflowing the table', () => {
+ // Regression guard: percentages adding up to a full 100% put the `table-layout: fixed`
+ // table 3rem past its container, which reads as a horizontal scrollbar that scrolls
+ // nothing. Every subset has to stay under 100% so the checkbox column fits inside it.
+ for (const columns of [
+ [],
+ ['title', 'live', 'contentType'],
+ ['title', 'modUser', 'modDate']
+ ]) {
+ spectator.setInput('visibleColumns', columns);
+ spectator.setInput('showActions', columns.length === 0);
+ spectator.detectChanges();
- expect(headerCells().map((cell) => cell.style.width)).toEqual(
- HEADER_COLUMNS.map((column) => column.width)
- );
+ const total = headerCells()
+ .map((cell) => Number.parseFloat(cell.style.width) || 0)
+ .reduce((sum, width) => sum + width, 0);
+
+ expect(total).toBeLessThan(100);
+ }
});
it('should keep extra columns under their own heading when Type is hidden', () => {
@@ -1151,6 +1158,15 @@ describe('DotFolderListViewComponent', () => {
);
});
+ it('should not emit dragStart when a drag is started anyway', () => {
+ // The attribute only stops the user; the handler has to stop everything else.
+ const dragStartSpy = jest.spyOn(spectator.component.dragStart, 'emit');
+
+ spectator.component.onDragStart(createDragStartEvent(), mockItems[0]);
+
+ expect(dragStartSpy).not.toHaveBeenCalled();
+ });
+
it('should not render the kebab menu button', () => {
expect(spectator.query(byTestId('kebab-menu-button'))).toBeFalsy();
});
@@ -1376,8 +1392,8 @@ describe('DotFolderListViewComponent', () => {
spectator.detectChanges();
// Set some selected items
- spectator.component.onSelectionChange([firstItem, secondItem]);
- expect(spectator.component.selectedItems.length).toBe(2);
+ spectator.component.selectedItems = [firstItem, secondItem];
+ expect(spectator.component.selectedItems).toEqual([firstItem, secondItem]);
// Change items input
const newItems = [mockItems[2], mockItems[3]];
@@ -1395,8 +1411,8 @@ describe('DotFolderListViewComponent', () => {
spectator.detectChanges();
// Set some selected items
- spectator.component.onSelectionChange([firstItem]);
- expect(spectator.component.selectedItems.length).toBe(1);
+ spectator.component.selectedItems = [firstItem];
+ expect(spectator.component.selectedItems).toEqual([firstItem]);
// Change to empty items
spectator.setInput('items', []);
@@ -1537,6 +1553,100 @@ describe('DotFolderListViewComponent', () => {
});
});
+ describe('selectionMode', () => {
+ it('should default to multiple and show header checkbox', () => {
+ expect(spectator.component.$selectionMode()).toBe('multiple');
+ expect(spectator.query(byTestId('header-checkbox'))).toBeTruthy();
+ expect(spectator.query(byTestId('item-radio'))).toBeFalsy();
+ });
+
+ it('should hide header checkbox and show radios in single mode', () => {
+ spectator.setInput('selectionMode', 'single');
+ spectator.setInput('items', mockItems);
+ spectator.setInput('loading', false);
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('header-checkbox'))).toBeFalsy();
+ expect(spectator.queryAll(byTestId('item-radio')).length).toBeGreaterThan(0);
+ });
+
+ it('should emit a one-item array when selection changes in single mode', () => {
+ spectator.setInput('selectionMode', 'single');
+ spectator.setInput('items', mockItems);
+ spectator.detectChanges();
+
+ const selectionChangeSpy = jest.spyOn(spectator.component.selectionChange, 'emit');
+ const table = spectator.debugElement.query(By.css('[data-testId="table"]'));
+
+ spectator.triggerEventHandler(table, 'selectionChange', mockItems[0]);
+
+ expect(selectionChangeSpy).toHaveBeenCalledWith([mockItems[0]]);
+ expect(spectator.component.selectedItems).toEqual(mockItems[0]);
+ });
+
+ it('should clear selection to null when items change in single mode', () => {
+ spectator.setInput('selectionMode', 'single');
+ spectator.setInput('items', mockItems);
+ spectator.detectChanges();
+
+ spectator.component.selectedItems = mockItems[0];
+ spectator.setInput('items', [mockItems[1]]);
+ spectator.detectChanges();
+
+ expect(spectator.component.selectedItems).toBeNull();
+ });
+ });
+
+ describe('showActions', () => {
+ beforeEach(() => {
+ spectator.setInput('items', mockItems);
+ spectator.setInput('loading', false);
+ spectator.detectChanges();
+ });
+
+ it('should default to showing row actions so Content Drive is unchanged', () => {
+ expect(spectator.component.$showActions()).toBe(true);
+ expect(spectator.query(byTestId('kebab-menu-button'))).toBeTruthy();
+ });
+
+ it('should drop the kebab column when actions are off', () => {
+ // The AssetPicker's rows are things you pick, not things you manage.
+ spectator.setInput('showActions', false);
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('kebab-menu-button'))).toBeFalsy();
+ expect(spectator.query(byTestId('item-actions'))).toBeFalsy();
+ });
+
+ it('should drop the actions header cell too, so the columns stay aligned', () => {
+ const withActions = spectator.queryAll(
+ '[data-testId="header-column-sortable"], [data-testId="header-column-not-sortable"]'
+ ).length;
+
+ spectator.setInput('showActions', false);
+ spectator.detectChanges();
+
+ expect(
+ spectator.queryAll(
+ '[data-testId="header-column-sortable"], [data-testId="header-column-not-sortable"]'
+ ).length
+ ).toBe(withActions - 1);
+ });
+
+ it('should leave the browser context menu alone when actions are off', () => {
+ spectator.setInput('showActions', false);
+ spectator.detectChanges();
+
+ const rightClickSpy = jest.spyOn(spectator.component.rightClick, 'emit');
+ const event = new MouseEvent('contextmenu', { cancelable: true });
+
+ spectator.component.onContextMenu(event, mockItems[0]);
+
+ expect(rightClickSpy).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(false);
+ });
+ });
+
describe('Drag Events', () => {
const firstItem = mockItems[0];
const secondItem = mockItems[1];
@@ -2118,6 +2228,63 @@ describe('DotFolderListViewComponent', () => {
expect(emitSpy).toHaveBeenCalledWith(mockItems[0]);
});
+
+ it('should swallow the title click so the row is not selected underneath', () => {
+ // Content Drive's title is an "open" affordance, distinct from selecting the row.
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true });
+ const stopPropagation = jest.spyOn(event, 'stopPropagation');
+
+ spectator.component.onTitleClick(event, mockItems[0]);
+
+ expect(stopPropagation).toHaveBeenCalled();
+ });
+ });
+
+ describe('titleOpensItem', () => {
+ beforeEach(() => {
+ spectator.setInput('items', mockItems);
+ spectator.setInput('loading', false);
+ spectator.detectChanges();
+ });
+
+ it('should default to opening the item, leaving Content Drive untouched', () => {
+ expect(spectator.component.$titleOpensItem()).toBe(true);
+ });
+
+ it('should not open the item when turned off', () => {
+ spectator.setInput('titleOpensItem', false);
+ spectator.detectChanges();
+
+ const emitSpy = jest.spyOn(spectator.component.doubleClick, 'emit');
+ spectator.click(spectator.query(byTestId('item-title-text')));
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
+ it('should let the title click reach the row so the whole row selects', () => {
+ // The regression: the title swallowed the click, so only the cell padding selected the
+ // row and the radio fell out of step with the picker's stored selection.
+ spectator.setInput('titleOpensItem', false);
+ spectator.detectChanges();
+
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true });
+ const stopPropagation = jest.spyOn(event, 'stopPropagation');
+
+ spectator.component.onTitleClick(event, mockItems[0]);
+
+ expect(stopPropagation).not.toHaveBeenCalled();
+ });
+
+ it('should select the row when its title is clicked', () => {
+ spectator.setInput('titleOpensItem', false);
+ spectator.setInput('selectionMode', 'single');
+ spectator.detectChanges();
+
+ const selectionSpy = jest.spyOn(spectator.component.selectionChange, 'emit');
+ spectator.click(spectator.query(byTestId('item-title-text')));
+
+ expect(selectionSpy).toHaveBeenCalledWith([mockItems[0]]);
+ });
});
describe('Scroll Events', () => {
diff --git a/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.ts b/core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.ts
similarity index 82%
rename from core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.ts
rename to core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.ts
index 3a2487360bf6..6c0ffe805394 100644
--- a/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/dot-folder-list-view.component.ts
+++ b/core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.ts
@@ -2,12 +2,14 @@ import { patchState, signalState } from '@ngrx/signals';
import { DatePipe, NgTemplateOutlet } from '@angular/common';
import {
+ AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
+ OnDestroy,
OnInit,
output,
Renderer2,
@@ -30,20 +32,20 @@ import {
DotContentDrivePaginateEvent,
DotLanguage
} from '@dotcms/dotcms-models';
-import {
- DotContentletStatusBadgeComponent,
- DotContentThumbnailComponent,
- DotLocaleTagPipe,
- DotMessagePipe,
- DotRelativeDatePipe
-} from '@dotcms/ui';
-
-import { DOT_DRAG_ITEM, DotFolderListViewFixedColumn, HEADER_COLUMNS } from '../shared/constants';
+
+import { DOT_DRAG_ITEM, DotFolderListViewFixedColumn, HEADER_COLUMNS } from './constants';
import {
DOT_FOLDER_LIST_VIEW_COLUMN_TYPE,
DotFolderListViewColumn,
- DotFolderListViewColumnField
-} from '../shared/models';
+ DotFolderListViewColumnField,
+ DotFolderListViewSelectionMode
+} from './models';
+
+import { DotMessagePipe } from '../../dot-message/dot-message.pipe';
+import { DotLocaleTagPipe } from '../../pipes/dot-locale-tag/dot-locale-tag.pipe';
+import { DotRelativeDatePipe } from '../../pipes/dot-relative-date/dot-relative-date.pipe';
+import { DotContentThumbnailComponent } from '../dot-content-thumbnail/dot-content-thumbnail.component';
+import { DotContentletStatusBadgeComponent } from '../dot-contentlet-status-badge/dot-contentlet-status-badge.component';
/**
* Canonical position of the "type" column. Extra columns follow it, in the header and in the body
@@ -72,7 +74,7 @@ const TYPE_COLUMN_ORDER =
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'w-full h-full min-h-0 block' }
})
-export class DotFolderListViewComponent implements OnInit {
+export class DotFolderListViewComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly renderer = inject(Renderer2);
private readonly dotLanguagesService = inject(DotLanguagesService);
@@ -81,7 +83,6 @@ export class DotFolderListViewComponent implements OnInit {
/**
* A signal that takes an array of DotContentDriveItem objects.
*
- * @type {InputSignal}
* @alias items
*/
$items = input([], { alias: 'items' });
@@ -89,7 +90,6 @@ export class DotFolderListViewComponent implements OnInit {
/**
* A signal that takes the total number of items.
*
- * @type {InputSignal}
* @alias totalItems
*/
$totalItems = input(0, { alias: 'totalItems' });
@@ -97,7 +97,6 @@ export class DotFolderListViewComponent implements OnInit {
/**
* A signal that takes the loading state.
*
- * @type {InputSignal}
* @alias loading
*/
$loading = input(false, { alias: 'loading' });
@@ -105,7 +104,6 @@ export class DotFolderListViewComponent implements OnInit {
/**
* A signal that takes the offset.
*
- * @type {InputSignal}
* @alias offset
*/
$offset = input(0, { alias: 'offset' });
@@ -114,15 +112,50 @@ export class DotFolderListViewComponent implements OnInit {
* Extra, caller-provided columns appended after the fixed "type" column. Agnostic of how they
* are sourced (Content Drive derives them from the selected content type's "Show In List" fields).
*
- * @type {InputSignal}
* @alias extraColumns
*/
$extraColumns = input([], { alias: 'extraColumns' });
+ /**
+ * Table selection mode. Defaults to `multiple` so Content Drive behavior is unchanged.
+ * AssetPicker passes `single`.
+ *
+ * @alias selectionMode
+ */
+ $selectionMode = input('multiple', { alias: 'selectionMode' });
+
+ /**
+ * Whether rows offer per-row actions: the kebab column and the right-click menu.
+ *
+ * Defaults to `true` so Content Drive — where a row is something you manage (publish, move,
+ * delete) — is unchanged. The AssetPicker turns it off: there a row is something you *pick*, and
+ * the actions would either do nothing (nothing is listening) or take the editor out of the flow.
+ * It also gates `onContextMenu`, so right-click keeps the browser's own menu instead of being
+ * swallowed for a menu that never opens.
+ *
+ * @alias showActions
+ */
+ $showActions = input(true, { alias: 'showActions' });
+
+ /**
+ * Whether clicking a row's title or thumbnail **opens** the item rather than selecting the row.
+ *
+ * Defaults to `true` for Content Drive, where the title is a distinct affordance: it navigates
+ * to the editor, so it swallows the click to keep the row from being selected underneath.
+ *
+ * The AssetPicker turns it off. There is nothing to open there — a row exists to be picked — so
+ * swallowing the click left the row selectable only through its padding, with the radio out of
+ * step with what the store had already recorded. With this off the click bubbles to
+ * `pSelectableRow` and the whole row selects. Travels with {@link $showActions}: together they
+ * are what separates a picker from a manager.
+ *
+ * @alias titleOpensItem
+ */
+ $titleOpensItem = input(true, { alias: 'titleOpensItem' });
+
/**
* An output that emits the selected items.
*
- * @type {Output}
* @alias selectionChange
*/
selectionChange = output();
@@ -130,7 +163,6 @@ export class DotFolderListViewComponent implements OnInit {
/**
* An output that emits the pagination event.
*
- * @type {Output}
* @alias paginate
*/
paginate = output();
@@ -138,7 +170,6 @@ export class DotFolderListViewComponent implements OnInit {
/**
* An output that emits the sort event.
*
- * @type {Output}
* @alias sort
*/
sort = output();
@@ -146,7 +177,6 @@ export class DotFolderListViewComponent implements OnInit {
/**
* An output that emits the right click event.
*
- * @type {Output}
* @alias rightClick
*/
rightClick = output();
@@ -154,7 +184,6 @@ export class DotFolderListViewComponent implements OnInit {
/**
* An output that emits the double click event.
*
- * @type {Output}
* @alias doubleClick
*/
doubleClick = output();
@@ -162,7 +191,6 @@ export class DotFolderListViewComponent implements OnInit {
/**
* An output that emits the drag start event.
*
- * @type {Output}
* @alias dragStart
*/
dragStart = output();
@@ -170,15 +198,13 @@ export class DotFolderListViewComponent implements OnInit {
/**
* An output that emits the drag end event.
*
- * @type {Output}
* @alias dragEnd
*/
dragEnd = output();
/**
- * An output that emits the drop event.
+ * An output that emits the drop event, carrying the target value.
*
- * @type {Output} the target value
* @alias drop
*/
drop = output();
@@ -186,7 +212,6 @@ export class DotFolderListViewComponent implements OnInit {
/**
* An output that emits the scroll event.
*
- * @type {Output}
* @alias scroll
*/
scroll = output();
@@ -257,10 +282,12 @@ export class DotFolderListViewComponent implements OnInit {
* changes through `selectionChange`, never applying them itself. Omit for the uncontrolled
* table, which keeps its own set and clears it whenever `items` changes.
*
- * @type {InputSignal}
+ * @type {InputSignal}
* @alias selection
*/
- $selection = input(undefined, { alias: 'selection' });
+ $selection = input(undefined, {
+ alias: 'selection'
+ });
/** Checked set while uncontrolled. Ignored as long as `selection` is provided. */
readonly #internalSelection = signal([]);
@@ -272,24 +299,26 @@ export class DotFolderListViewComponent implements OnInit {
*/
readonly #selectionRevision = signal(0);
- /** What the table renders: the caller's set when provided, otherwise our own. */
- protected readonly $tableSelection = computed(
- () => this.$selection() ?? this.#internalSelection()
- );
-
/**
* The effective selection — the caller's set when one is provided, otherwise our own.
*
- * Read-only on purpose. A setter used to exist and it was a trap: while controlled it wrote the
- * internal set, which the getter then ignored in favour of the caller's, so an assignment
- * vanished without a word. The only way in is `onSelectionChange`, the same entry point the
- * table itself uses.
+ * PrimeNG binds an array in `multiple` mode and a single row (or `null`) in `single` mode.
+ * The controlled input is normalized to an array internally and projected back into whichever
+ * shape the current mode expects.
*
- * @type {DotContentDriveItem[]}
* @alias selectedItems
*/
- get selectedItems(): DotContentDriveItem[] {
- return this.$tableSelection();
+ get selectedItems(): DotContentDriveItem | DotContentDriveItem[] | null {
+ const items =
+ this.$selection() !== undefined
+ ? this.#asSelectedArray(this.$selection())
+ : this.#internalSelection();
+
+ return this.$selectionMode() === 'multiple' ? items : (items[0] ?? null);
+ }
+
+ set selectedItems(selection: DotContentDriveItem | DotContentDriveItem[] | null) {
+ this.#internalSelection.set(this.#asSelectedArray(selection));
}
readonly MIN_ROWS_PER_PAGE = 20;
@@ -357,28 +386,28 @@ export class DotFolderListViewComponent implements OnInit {
/**
* The fixed columns being rendered. Filtered off `HEADER_COLUMNS` rather than off the caller's
* list, so display order stays the table's.
+ *
+ * No width bookkeeping is needed when a column drops out: `title` is unsized (see
+ * `HEADER_COLUMNS`) and soaks up whatever the remaining sized columns leave over.
*/
protected readonly $fixedColumns = computed(() => {
const requested = this.$visibleColumns();
-
- return requested.length
- ? this.#fillWidth(HEADER_COLUMNS.filter((column) => requested.includes(column.field)))
+ const fixed = requested.length
+ ? HEADER_COLUMNS.filter((column) => requested.includes(column.field))
: HEADER_COLUMNS;
+
+ return this.$showActions() ? fixed : fixed.filter((column) => column.field !== 'actions');
});
protected readonly $columns = computed(() => {
- const extras = this.$sizedExtraColumns();
const fixed = this.$fixedColumns();
+ const extras = this.$sizedExtraColumns();
if (!extras.length) {
return fixed;
}
const columns: DotFolderListViewColumn[] = [...fixed];
- // Anchored to where the "type" column *sits in the canonical order*, not to whether it is
- // rendered — the body's extra-column loop occupies that slot either way. Keying off the
- // rendered index instead appended the extras when Type was hidden, putting every extra cell
- // one heading early.
const afterType = columns.findIndex((column) => column.order > TYPE_COLUMN_ORDER);
const insertAt = afterType === -1 ? columns.length : afterType;
columns.splice(insertAt, 0, ...extras);
@@ -394,7 +423,7 @@ export class DotFolderListViewComponent implements OnInit {
() => new Set(this.$fixedColumns().map((column) => column.field))
);
- /** Total column count including the leading checkbox column — drives colspan/skeleton span. */
+ /** Total column count including the leading checkbox/radio column — drives colspan/skeleton span. */
protected readonly $columnSpan = computed(() => this.$columns().length + 1);
/**
@@ -463,7 +492,7 @@ export class DotFolderListViewComponent implements OnInit {
* being re-asserted is the one PrimeNG just produced.
*/
protected readonly $syncTableSelection = effect(() => {
- const selection = this.$tableSelection();
+ const selection = this.selectedItems;
// Read so a click re-runs this even when the effective selection did not change — the
// decline case, where the parent sends nothing back but PrimeNG has already moved.
this.#selectionRevision();
@@ -484,13 +513,14 @@ export class DotFolderListViewComponent implements OnInit {
/**
* Effect that cleans the selected items when the items change.
*
- * Only ever touches the uncontrolled set — `$tableSelection` prefers the caller's, so this can
- * no longer discard a selection the parent owns (in the action preview the rows and the
- * selection are the same data, and clearing here would empty the payload about to be fired).
+ * Only ever touches the uncontrolled set — the `selectedItems` getter prefers the caller's
+ * `selection` input when one is present, so this can no longer discard a selection the parent
+ * owns (in the action preview the rows and the selection are the same data, and clearing here
+ * would empty the payload about to be fired).
*/
protected readonly $cleanSelectedItems = effect(() => {
this.$items();
- this.#internalSelection.set([]);
+ this.selectedItems = this.$selectionMode() === 'multiple' ? [] : null;
});
/**
@@ -499,36 +529,16 @@ export class DotFolderListViewComponent implements OnInit {
private readonly boundScrollHandler = this.scrollHandler.bind(this);
/**
- * Rescales a subset of the fixed columns so their percentage widths add back up to 100%,
- * keeping their proportions to one another.
- *
- * `HEADER_COLUMNS` is authored to fill the table exactly. Take three of seven and the rest is
- * leftover — which `table-layout: fixed` shares across *every* column, the 3rem checkbox one
- * included. The visible result is a gutter between the checkbox and the first heading rather
- * than a wider title column.
- *
- * Left alone when any visible column carries a non-percentage width: there is nothing
- * meaningful to rescale a `12rem` against a `32%`.
+ * Normalizes PrimeNG selection (array in multiple mode, object/null in single) to an array.
*/
- #fillWidth(columns: DotFolderListViewFixedColumn[]): DotFolderListViewFixedColumn[] {
- const widths = columns.map((column) =>
- column.width?.endsWith('%') ? Number.parseFloat(column.width) : NaN
- );
-
- if (widths.some((width) => !Number.isFinite(width))) {
- return columns;
+ #asSelectedArray(
+ selection: DotContentDriveItem | DotContentDriveItem[] | null | undefined
+ ): DotContentDriveItem[] {
+ if (!selection) {
+ return [];
}
- const total = widths.reduce((sum, width) => sum + width, 0);
-
- if (!total) {
- return columns;
- }
-
- return columns.map((column, index) => ({
- ...column,
- width: `${((widths[index] / total) * 100).toFixed(2)}%`
- }));
+ return Array.isArray(selection) ? selection : [selection];
}
/**
@@ -636,9 +646,7 @@ export class DotFolderListViewComponent implements OnInit {
* @param contentlet The content item that was right clicked
*/
onContextMenu(event: Event, contentlet: DotContentDriveItem) {
- // Returns before `preventDefault` so a read-only table leaves the browser's own menu alone
- // rather than suppressing it and offering nothing in its place.
- if (this.$readOnly()) {
+ if (!this.$showActions() || this.$readOnly()) {
return;
}
@@ -668,11 +676,11 @@ export class DotFolderListViewComponent implements OnInit {
* Records the set for the uncontrolled case and always reports it. A controlled table renders
* the caller's set until the caller echoes this back, so the parent can decline a change.
*
- * @param items The table's new selection
+ * @param selection The table's new selection
*/
- onSelectionChange(items: DotContentDriveItem[]) {
- this.#internalSelection.set(items ?? []);
- this.selectionChange.emit(items ?? []);
+ onSelectionChange(selection: DotContentDriveItem | DotContentDriveItem[] | null) {
+ this.selectedItems = selection;
+ this.selectionChange.emit(this.#asSelectedArray(selection));
// Runs after PrimeNG has finished mutating its own state — it drops the row's selection key
// *after* emitting, so anything restored from inside this handler would be undone.
this.#selectionRevision.update((revision) => revision + 1);
@@ -700,12 +708,37 @@ export class DotFolderListViewComponent implements OnInit {
this.doubleClick.emit(contentlet);
}
+ /**
+ * Handles a click on the row's title or thumbnail.
+ *
+ * When the title opens the item it has to swallow the click, or the row would be selected on
+ * the way out too. When it does not, the click is left alone so it reaches `pSelectableRow` and
+ * the whole row — content included, not just its padding — selects.
+ *
+ * @param event The click event
+ * @param contentlet The content item whose title was clicked
+ */
+ onTitleClick(event: Event, contentlet: DotContentDriveItem) {
+ if (!this.$titleOpensItem()) {
+ return;
+ }
+
+ this.onDoubleClick(contentlet);
+ event.stopPropagation();
+ }
+
/**
* Handles drag start on a content item
* @param event The drag start event
* @param contentlet The content item that was dragged
*/
onDragStart(event: DragEvent, contentlet: DotContentDriveItem) {
+ // The `draggable` attribute already keeps the row still, but it only covers user-initiated
+ // drags: a programmatic `dragstart` would still reach this and emit a move nothing can drop.
+ if (this.$readOnly()) {
+ return;
+ }
+
if (!event.dataTransfer) return;
event.stopPropagation();
@@ -714,7 +747,7 @@ export class DotFolderListViewComponent implements OnInit {
patchState(this.state, { isDragging: true });
// Check if the dragged item is in the current selection
- const selected = this.selectedItems;
+ const selected = this.#asSelectedArray(this.selectedItems);
const isDraggingSelectedItem = selected.some(
(item) => item.identifier === contentlet.identifier
);
diff --git a/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/mocks.ts b/core-web/libs/ui/src/lib/components/dot-folder-list-view/mocks.ts
similarity index 100%
rename from core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/mocks.ts
rename to core-web/libs/ui/src/lib/components/dot-folder-list-view/mocks.ts
diff --git a/core-web/libs/ui/src/lib/components/dot-folder-list-view/models.ts b/core-web/libs/ui/src/lib/components/dot-folder-list-view/models.ts
new file mode 100644
index 000000000000..4448250f415b
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-folder-list-view/models.ts
@@ -0,0 +1,52 @@
+/**
+ * Generic display types for a column's cell value. Kept agnostic of any domain field system so the
+ * table can format and size values (dates, booleans, numbers) without knowing where the column came
+ * from. Callers map their own field/data types onto these.
+ */
+export const DOT_FOLDER_LIST_VIEW_COLUMN_TYPE = {
+ TEXT: 'text',
+ NUMBER: 'number',
+ BOOLEAN: 'boolean',
+ DATE: 'date',
+ DATETIME: 'datetime',
+ TIME: 'time',
+ /** Image/binary/file field: renders the field's own asset as a thumbnail. */
+ IMAGE: 'image'
+} as const;
+
+export type DotFolderListViewColumnType =
+ (typeof DOT_FOLDER_LIST_VIEW_COLUMN_TYPE)[keyof typeof DOT_FOLDER_LIST_VIEW_COLUMN_TYPE];
+
+/**
+ * Column configuration for the folder list view.
+ */
+export interface DotFolderListViewColumn {
+ field: string;
+ header: string;
+ /**
+ * Explicit width (any CSS length). Optional: when omitted the column takes whatever the sized
+ * ones leave over. Caller-provided extra columns are sized per type when they omit it; among the
+ * fixed columns, `title` omits it on purpose so it can absorb the remainder.
+ */
+ width?: string;
+ sortable?: boolean;
+ order: number;
+ /** How the cell value is rendered and sized. Defaults to `text` when omitted. */
+ type?: DotFolderListViewColumnType;
+}
+
+/**
+ * The table's fixed columns, by field. A closed set so `visibleColumns` and the body's per-cell
+ * checks are compiler-checked against `HEADER_COLUMNS`.
+ */
+export type DotFolderListViewColumnField =
+ | 'title'
+ | 'live'
+ | 'languageId'
+ | 'contentType'
+ | 'modUser'
+ | 'modDate'
+ | 'actions';
+
+/** Selection mode for {@link DotFolderListViewComponent}. */
+export type DotFolderListViewSelectionMode = 'single' | 'multiple';
diff --git a/core-web/libs/ui/src/lib/components/dot-folder-tree/constants.ts b/core-web/libs/ui/src/lib/components/dot-folder-tree/constants.ts
new file mode 100644
index 000000000000..79d4e8c67f4c
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-folder-tree/constants.ts
@@ -0,0 +1,29 @@
+import { TreeNodeItem } from '@dotcms/dotcms-models';
+
+/** Identifier of the synthetic "System Host" site. Not a browsable site. */
+export const SYSTEM_HOST_ID = 'SYSTEM_HOST';
+
+/**
+ * Synthetic root node of a folder tree: selecting it means "everything on this site", not a folder.
+ *
+ * Consumers clone it per site to fill in `data.id` / `data.hostname` — see Content Drive's
+ * `withSidebar` and the AssetPicker store. Shared across Content Drive and AssetPicker.
+ *
+ * The label keeps its `content-drive.*` i18n key: the string is already translated under that key in
+ * `Language.properties`, and renaming keys is separate work.
+ */
+export const ALL_FOLDER: TreeNodeItem = {
+ key: 'ALL_FOLDER',
+ label: 'content-drive.all-folder.label',
+ loading: false,
+ data: {
+ type: 'folder',
+ path: '',
+ hostname: '',
+ id: '',
+ inode: ''
+ },
+ icon: 'pi pi-folder',
+ leaf: false,
+ expanded: true
+};
diff --git a/core-web/libs/ui/src/lib/components/dot-folder-tree/site-tree.utils.ts b/core-web/libs/ui/src/lib/components/dot-folder-tree/site-tree.utils.ts
new file mode 100644
index 000000000000..68856c12c79b
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-folder-tree/site-tree.utils.ts
@@ -0,0 +1,144 @@
+import {
+ createLoadMoreTreeNode,
+ DotPagination,
+ LOAD_MORE_NODE_TYPE,
+ TreeNodeItem
+} from '@dotcms/dotcms-models';
+
+/**
+ * Helpers for a folder tree whose roots are **sites** rather than folders of one site.
+ *
+ * Shared by the legacy Browser Selector and the AssetPicker: both render every site the user can
+ * reach as an expandable root, and both page each level (sites and folders alike) behind a
+ * "Load more" sentinel. Content Drive does not use these — its tree is scoped to the site picked in
+ * the global site switcher.
+ */
+
+/** `levelKey` used for the sites level, so its sentinel can't collide with a folder's. */
+export const SITES_LOAD_MORE_KEY = 'sites';
+
+/** Whether the paginated response has pages left after the one just returned. */
+export function hasMorePages(pagination: DotPagination): boolean {
+ return pagination.currentPage * pagination.perPage < pagination.totalEntries;
+}
+
+/** Drops the "Load more" sentinel from a level, so a new page can be appended after it. */
+export function stripLoadMore(nodes: TreeNodeItem[] | undefined): TreeNodeItem[] {
+ return (nodes ?? []).filter((node) => node.type !== LOAD_MORE_NODE_TYPE);
+}
+
+/** Appends a "Load more" sentinel to a level when the server says there is more to fetch. */
+export function withLoadMore(
+ children: TreeNodeItem[],
+ hasMore: boolean,
+ levelKey: string,
+ nextPage: number,
+ path: string,
+ hostname: string
+): TreeNodeItem[] {
+ if (!hasMore) {
+ return children;
+ }
+
+ return [
+ ...children,
+ createLoadMoreTreeNode({
+ levelKey,
+ nextPage,
+ path,
+ hostname
+ })
+ ];
+}
+
+/**
+ * Finds a tree node by key, at any depth.
+ *
+ * The way to address a node across an async gap. `p-tree` and its `p-treeNode`s are `OnPush` and
+ * track by object identity, so a level that changed has to be published as **new** objects to
+ * re-render — which strands any reference taken before the request. Look the node up again by key
+ * after each publish instead of holding on to it.
+ */
+export function findNodeByKey(nodes: TreeNodeItem[], key: string): TreeNodeItem | undefined {
+ for (const node of nodes) {
+ if (node.key === key) {
+ return node;
+ }
+
+ const found = node.children
+ ? findNodeByKey(node.children as TreeNodeItem[], key)
+ : undefined;
+
+ if (found) {
+ return found;
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Site identifier for a hostname, looked up among the tree roots.
+ *
+ * Folder nodes carry their hostname but not their site id, and `/folder/search` is scoped by site
+ * id — so expanding a folder means resolving its site through the root it hangs from.
+ */
+export function findSiteIdByHostname(hostname: string, roots: TreeNodeItem[]): string | undefined {
+ return roots.find((node) => node.data?.type === 'site' && node.data.hostname === hostname)?.data
+ ?.id;
+}
+
+/**
+ * Depth-first lookup of the node a level belongs to: the site root when the path is the site root,
+ * the matching folder otherwise. "Load more" sentinels are skipped — they are not parents.
+ */
+export function findFolderParent(
+ roots: TreeNodeItem[],
+ path: string,
+ hostname: string
+): TreeNodeItem | undefined {
+ for (const node of roots) {
+ if (node.data?.type === LOAD_MORE_NODE_TYPE) {
+ continue;
+ }
+
+ if (
+ node.data?.type === 'site' &&
+ node.data.hostname === hostname &&
+ (path === '/' || path === '')
+ ) {
+ return node;
+ }
+
+ if (
+ node.data?.type === 'folder' &&
+ node.data.hostname === hostname &&
+ node.data.path === path
+ ) {
+ return node;
+ }
+
+ const found = node.children
+ ? findFolderParent(node.children as TreeNodeItem[], path, hostname)
+ : undefined;
+
+ if (found) {
+ return found;
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Site id a node belongs to: its own id when it is a site, otherwise resolved from its hostname.
+ */
+export function resolveSiteId(node: TreeNodeItem, roots: TreeNodeItem[]): string | undefined {
+ const data = node.data;
+
+ if (!data || data.type === LOAD_MORE_NODE_TYPE) {
+ return undefined;
+ }
+
+ return data.type === 'site' ? data.id : findSiteIdByHostname(data.hostname, roots);
+}
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-language-field/dot-content-drive-language-field.component.html b/core-web/libs/ui/src/lib/components/dot-language-filter/dot-language-filter.component.html
similarity index 93%
rename from core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-language-field/dot-content-drive-language-field.component.html
rename to core-web/libs/ui/src/lib/components/dot-language-filter/dot-language-filter.component.html
index da309f061a96..c78d5eb214a0 100644
--- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-language-field/dot-content-drive-language-field.component.html
+++ b/core-web/libs/ui/src/lib/components/dot-language-filter/dot-language-filter.component.html
@@ -1,5 +1,5 @@
{
+ let spectator: Spectator;
+ let component: DotLanguageFilterComponent;
+ let languagesService: SpyObject;
+
+ const createComponent = createComponentFactory({
+ component: DotLanguageFilterComponent,
+ providers: [
+ mockProvider(DotLanguagesService, {
+ get: jest.fn().mockReturnValue(of(MOCK_LANGUAGES))
+ }),
+ {
+ provide: DotMessageService,
+ useValue: new MockDotMessageService({
+ 'content-drive.language-selector.placeholder': 'Language',
+ 'content-drive.chip-filter.overflow-label': '{0} and {1} more',
+ search: 'Search'
+ })
+ }
+ ],
+ detectChanges: false
+ });
+
+ beforeEach(() => {
+ spectator = createComponent();
+ component = spectator.component;
+ languagesService = spectator.inject(DotLanguagesService);
+ });
+
+ afterEach(() => jest.clearAllMocks());
+
+ it('should fetch languages and populate state', () => {
+ spectator.detectChanges();
+
+ expect(languagesService.get).toHaveBeenCalled();
+ expect(component.$state().languages).toEqual(MOCK_LANGUAGES);
+ });
+
+ it('should not inject any portlet store', () => {
+ // The component is instantiated with only DotLanguagesService + DotMessageService provided,
+ // so construction succeeding is the assertion.
+ expect(() => spectator.detectChanges()).not.toThrow();
+ });
+
+ describe('selectedLanguageIds input', () => {
+ it('should seed the working selection from the host', () => {
+ spectator.setInput('selectedLanguageIds', [1, 2]);
+ spectator.detectChanges();
+
+ expect(component.$selectedLanguages()).toEqual([1, 2]);
+ });
+
+ it('should re-seed when the host pushes a different selection', () => {
+ spectator.setInput('selectedLanguageIds', [1, 2]);
+ spectator.detectChanges();
+
+ spectator.setInput('selectedLanguageIds', []);
+ spectator.detectChanges();
+
+ expect(component.$selectedLanguages()).toEqual([]);
+ });
+ });
+
+ describe('selectionChange output', () => {
+ it('should emit the selected ids on change', () => {
+ spectator.detectChanges();
+
+ const handler = jest.fn();
+ spectator.output('selectionChange').subscribe(handler);
+
+ component.$selectedLanguages.set([1, 2]);
+ component.onChange();
+
+ expect(handler).toHaveBeenCalledWith([1, 2]);
+ });
+
+ it('should emit an empty array when the selection is cleared', () => {
+ spectator.setInput('selectedLanguageIds', [1]);
+ spectator.detectChanges();
+
+ const handler = jest.fn();
+ spectator.output('selectionChange').subscribe(handler);
+
+ component.$selectedLanguages.set([]);
+ component.onChange();
+
+ expect(handler).toHaveBeenCalledWith([]);
+ });
+ });
+
+ describe('Chip', () => {
+ it('should render the chip with the default title key translated', () => {
+ spectator.detectChanges();
+
+ const chip = spectator.query(byTestId('language-chip'));
+ expect(chip).toBeTruthy();
+ expect(chip?.querySelector('[data-testid="chip-title"]')?.textContent?.trim()).toBe(
+ 'Language'
+ );
+ });
+
+ it('should expose selected language names with iso codes for the chip', () => {
+ spectator.setInput('selectedLanguageIds', [1, 2]);
+ spectator.detectChanges();
+
+ expect(component['$selectedLanguageNames']()).toEqual([
+ 'English (en-US)',
+ 'Spanish (es-ES)'
+ ]);
+ });
+
+ it('should toggle popover when the chip is clicked', () => {
+ spectator.detectChanges();
+
+ const popoverDe = spectator.fixture.debugElement.query(By.directive(Popover));
+ const popover = popoverDe.componentInstance as Popover;
+ const toggleSpy = jest.spyOn(popover, 'toggle');
+
+ const chipDe = spectator.fixture.debugElement.query(
+ By.directive(DotChipFilterComponent)
+ );
+ spectator.triggerEventHandler(chipDe, 'clicked', new MouseEvent('click'));
+
+ expect(toggleSpy).toHaveBeenCalled();
+ });
+
+ it('should clear the selection and emit when the chip emits removed', () => {
+ spectator.setInput('selectedLanguageIds', [1]);
+ spectator.detectChanges();
+
+ const handler = jest.fn();
+ spectator.output('selectionChange').subscribe(handler);
+
+ const chipDe = spectator.fixture.debugElement.query(
+ By.directive(DotChipFilterComponent)
+ );
+ spectator.triggerEventHandler(chipDe, 'removed', undefined);
+
+ expect(component.$selectedLanguages()).toEqual([]);
+ expect(handler).toHaveBeenCalledWith([]);
+ });
+ });
+
+ describe('Listbox', () => {
+ it('should have correct properties configured', () => {
+ spectator.detectChanges();
+
+ // Listbox is inside a closed popover, open it via the chip
+ const chipHost = spectator.query(byTestId('language-chip'));
+ spectator.click(chipHost as Element);
+ spectator.detectChanges();
+
+ const listboxDe = spectator.fixture.debugElement.query(By.directive(Listbox));
+ const listbox = listboxDe.componentInstance as Listbox;
+
+ expect(listbox.scrollHeight).toBe('25rem');
+ expect(listbox.multiple).toBe(true);
+ expect(listbox.checkbox).toBe(true);
+ });
+ });
+});
diff --git a/core-web/libs/ui/src/lib/components/dot-language-filter/dot-language-filter.component.ts b/core-web/libs/ui/src/lib/components/dot-language-filter/dot-language-filter.component.ts
new file mode 100644
index 000000000000..0a1dfe483038
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-language-filter/dot-language-filter.component.ts
@@ -0,0 +1,112 @@
+import { patchState, signalState } from '@ngrx/signals';
+
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+ input,
+ linkedSignal,
+ OnInit,
+ output
+} from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+import { ListboxModule } from 'primeng/listbox';
+import { PopoverModule } from 'primeng/popover';
+
+import { DotLanguagesService } from '@dotcms/data-access';
+import { DotLanguage } from '@dotcms/dotcms-models';
+
+import { DotMessagePipe } from '../../dot-message/dot-message.pipe';
+import {
+ CHIP_FILTER_LISTBOX_PT,
+ CHIP_FILTER_POPOVER_PT,
+ CHIP_FILTER_SCROLL_HEIGHT
+} from '../dot-chip-filter/constants';
+import { DotChipFilterComponent } from '../dot-chip-filter/dot-chip-filter.component';
+import { DotFilterListItemComponent } from '../dot-filter-list-item/dot-filter-list-item.component';
+
+/**
+ * Locale chip filter shared across Content Drive and AssetPicker.
+ *
+ * Owns the language catalog (fetched through {@link DotLanguagesService}) but not the selection:
+ * the host passes the selected ids in and receives every change back through `selectionChange`.
+ */
+@Component({
+ selector: 'dot-language-filter',
+ imports: [
+ FormsModule,
+ ListboxModule,
+ PopoverModule,
+ DotChipFilterComponent,
+ DotFilterListItemComponent,
+ DotMessagePipe
+ ],
+ templateUrl: './dot-language-filter.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ // `contents` keeps the host out of the layout, so the chip and its popover sit directly in
+ // whatever row the consumer lays out.
+ host: { class: 'contents' }
+})
+export class DotLanguageFilterComponent implements OnInit {
+ readonly #dotLanguagesService = inject(DotLanguagesService);
+
+ /**
+ * Currently selected language ids, owned by the host.
+ * @type {number[]}
+ * @alias selectedLanguageIds
+ */
+ readonly $selectedLanguageIds = input([], { alias: 'selectedLanguageIds' });
+
+ /**
+ * i18n key for the chip title.
+ * @type {string}
+ * @alias title
+ */
+ readonly $title = input('content-drive.language-selector.placeholder', { alias: 'title' });
+
+ /** Emits the full selection on every change. An empty array means "no locale filter". */
+ readonly selectionChange = output();
+
+ /**
+ * Working copy of the selection so the listbox can two-way bind. Re-seeds whenever the host
+ * pushes a different set (URL restore, "clear all").
+ */
+ readonly $selectedLanguages = linkedSignal(() => this.$selectedLanguageIds() ?? []);
+
+ readonly $state = signalState<{ languages: DotLanguage[] }>({
+ languages: []
+ });
+
+ protected readonly LISTBOX_SCROLL_HEIGHT = CHIP_FILTER_SCROLL_HEIGHT;
+ protected readonly popoverPt = CHIP_FILTER_POPOVER_PT;
+ protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT;
+
+ protected readonly $selectedLanguageNames = computed(() => {
+ const ids = this.$selectedLanguages() ?? [];
+ const languages = this.$state.languages();
+
+ return ids
+ .map((id) => languages.find((language) => language.id === id))
+ .filter((language): language is DotLanguage => !!language)
+ .map(
+ (language) => `${language.language} (${language.isoCode ?? language.countryCode})`
+ );
+ });
+
+ ngOnInit(): void {
+ this.#dotLanguagesService.get().subscribe((languages) => {
+ patchState(this.$state, { languages });
+ });
+ }
+
+ onChange() {
+ this.selectionChange.emit(this.$selectedLanguages() ?? []);
+ }
+
+ onRemoveAll() {
+ this.$selectedLanguages.set([]);
+ this.onChange();
+ }
+}
diff --git a/core-web/libs/ui/src/lib/components/dot-search-input/constants.ts b/core-web/libs/ui/src/lib/components/dot-search-input/constants.ts
new file mode 100644
index 000000000000..01282de2ec05
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-search-input/constants.ts
@@ -0,0 +1,2 @@
+/** Default debounce (ms) applied to the search input before emitting. */
+export const DEFAULT_SEARCH_DEBOUNCE = 500;
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.html b/core-web/libs/ui/src/lib/components/dot-search-input/dot-search-input.component.html
similarity index 72%
rename from core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.html
rename to core-web/libs/ui/src/lib/components/dot-search-input/dot-search-input.component.html
index 323686aa456a..154551beec47 100644
--- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.html
+++ b/core-web/libs/ui/src/lib/components/dot-search-input/dot-search-input.component.html
@@ -4,11 +4,13 @@
pInputText
type="text"
name="search"
- placeholder="Search"
+ [placeholder]="$placeholder() | dm"
+ [attr.aria-label]="$placeholder() | dm"
autocomplete="off"
class="w-full"
+ [attr.data-testId]="$testId()"
[formControl]="searchControl" />
- @if ($title().length) {
+ @if ($text().length) {
{
+ let spectator: Spectator;
+
+ const createComponent = createComponentFactory({
+ component: DotSearchInputComponent,
+ providers: [
+ {
+ provide: DotMessageService,
+ useValue: new MockDotMessageService({
+ search: 'Search',
+ 'content-drive.search.placeholder': 'Search assets'
+ })
+ }
+ ]
+ });
+
+ const getInput = () => spectator.query(byTestId('search-input-field')) as HTMLInputElement;
+ const type = (value: string) => {
+ spectator.typeInElement(value, getInput());
+ spectator.detectChanges();
+ };
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ spectator = createComponent();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('placeholder', () => {
+ it('should translate the default placeholder key', () => {
+ expect(getInput().placeholder).toBe('Search');
+ });
+
+ it('should translate a custom placeholder key', () => {
+ spectator.setInput('placeholder', 'content-drive.search.placeholder');
+ spectator.detectChanges();
+
+ expect(getInput().placeholder).toBe('Search assets');
+ });
+ });
+
+ describe('debounced emission', () => {
+ it('should not emit before the debounce window closes', () => {
+ const handler = jest.fn();
+ spectator.output('search').subscribe(handler);
+
+ type('blog');
+ jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE - 1);
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it('should emit the term once the debounce window closes', () => {
+ const handler = jest.fn();
+ spectator.output('search').subscribe(handler);
+
+ type('blog');
+ jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+
+ expect(handler).toHaveBeenCalledWith('blog');
+ });
+
+ it('should emit only the last term typed within the window', () => {
+ const handler = jest.fn();
+ spectator.output('search').subscribe(handler);
+
+ type('b');
+ jest.advanceTimersByTime(100);
+ type('bl');
+ jest.advanceTimersByTime(100);
+ type('blog');
+ jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith('blog');
+ });
+
+ it('should honor a custom debounceTime', () => {
+ spectator.setInput('debounceTime', 50);
+ spectator.detectChanges();
+
+ const handler = jest.fn();
+ spectator.output('search').subscribe(handler);
+
+ type('blog');
+ jest.advanceTimersByTime(50);
+
+ expect(handler).toHaveBeenCalledWith('blog');
+ });
+
+ it('should trim the emitted term', () => {
+ const handler = jest.fn();
+ spectator.output('search').subscribe(handler);
+
+ type(' blog ');
+ jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+
+ expect(handler).toHaveBeenCalledWith('blog');
+ });
+
+ it('should not re-emit a term the host already has', () => {
+ const handler = jest.fn();
+ spectator.output('search').subscribe(handler);
+
+ type('blog');
+ jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+ type('blog ');
+ jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('value input', () => {
+ it('should seed the control from the host', () => {
+ spectator.setInput('value', 'blog');
+ spectator.detectChanges();
+
+ expect(getInput().value).toBe('blog');
+ });
+
+ it('should not echo an emission back when the host pushes a value', () => {
+ const handler = jest.fn();
+ spectator.output('search').subscribe(handler);
+
+ spectator.setInput('value', 'blog');
+ spectator.detectChanges();
+ jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it('should emit again after the host clears a term the user re-types', () => {
+ const handler = jest.fn();
+ spectator.output('search').subscribe(handler);
+
+ type('blog');
+ jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+
+ // Host echoes the term back, then clears the filter (e.g. "clear all")
+ spectator.setInput('value', 'blog');
+ spectator.detectChanges();
+ spectator.setInput('value', '');
+ spectator.detectChanges();
+
+ type('blog');
+ jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+
+ expect(handler).toHaveBeenNthCalledWith(1, 'blog');
+ expect(handler).toHaveBeenNthCalledWith(2, 'blog');
+ });
+ });
+
+ describe('clear icon', () => {
+ it('should be hidden while the input is empty', () => {
+ expect(spectator.query(byTestId('search-icon-clear'))).toBeNull();
+ });
+
+ it('should appear as soon as the user types, without waiting for the debounce', () => {
+ type('blog');
+
+ expect(spectator.query(byTestId('search-icon-clear'))).not.toBeNull();
+ });
+
+ it('should clear the input and emit an empty term', () => {
+ const handler = jest.fn();
+ spectator.output('search').subscribe(handler);
+
+ type('blog');
+ jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+
+ spectator.click(byTestId('search-icon-clear'));
+ spectator.detectChanges();
+ jest.advanceTimersByTime(DEFAULT_SEARCH_DEBOUNCE);
+
+ expect(getInput().value).toBe('');
+ expect(handler).toHaveBeenLastCalledWith('');
+ });
+ });
+});
diff --git a/core-web/libs/ui/src/lib/components/dot-search-input/dot-search-input.component.ts b/core-web/libs/ui/src/lib/components/dot-search-input/dot-search-input.component.ts
new file mode 100644
index 000000000000..0b205254d3ea
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-search-input/dot-search-input.component.ts
@@ -0,0 +1,105 @@
+import { timer } from 'rxjs';
+
+import { ChangeDetectionStrategy, Component, effect, input, output, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+
+import { IconField } from 'primeng/iconfield';
+import { InputIcon } from 'primeng/inputicon';
+import { InputTextModule } from 'primeng/inputtext';
+
+import { debounce, tap } from 'rxjs/operators';
+
+import { DEFAULT_SEARCH_DEBOUNCE } from './constants';
+
+import { DotMessagePipe } from '../../dot-message/dot-message.pipe';
+
+/**
+ * Presentational debounced search box shared across Content Drive and AssetPicker.
+ *
+ * The host owns the value: it flows in through `value` and back out through `search` after the
+ * debounce window. Pushing a new `value` (URL restore, "clear all") re-syncs the control without
+ * echoing an emission back to the host.
+ */
+@Component({
+ selector: 'dot-search-input',
+ templateUrl: './dot-search-input.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [IconField, InputIcon, InputTextModule, ReactiveFormsModule, DotMessagePipe],
+ host: { class: 'block w-full' }
+})
+export class DotSearchInputComponent {
+ /**
+ * Current search term, owned by the host.
+ * @alias value
+ */
+ readonly $value = input('', { alias: 'value' });
+
+ /**
+ * i18n key for the input placeholder.
+ * @alias placeholder
+ */
+ readonly $placeholder = input('search', { alias: 'placeholder' });
+
+ /**
+ * Debounce (ms) applied before emitting. Read on every keystroke, so it can change at runtime.
+ * @alias debounceTime
+ */
+ readonly $debounceTime = input(DEFAULT_SEARCH_DEBOUNCE, { alias: 'debounceTime' });
+
+ /**
+ * `data-testId` for the input, so a host that renders more than one search box can tell them
+ * apart — the AssetPicker has both an asset search and a sites-and-folders search on screen at
+ * once, and a single hardcoded id made every selector ambiguous.
+ *
+ * Defaults to the original value, so existing consumers and their tests are unaffected.
+ *
+ * @alias testId
+ */
+ readonly $testId = input('search-input-field', { alias: 'testId' });
+
+ /** Emits the trimmed term once the debounce window closes. */
+ readonly search = output();
+
+ protected readonly searchControl = new FormControl('');
+
+ /** Mirrors what the user sees, so the clear icon reacts immediately instead of after the debounce. */
+ protected readonly $text = signal('');
+
+ /**
+ * Last value handed to the host (or received from it). Guards against re-emitting a term the
+ * host already knows — e.g. typing a trailing space, which trims back to the same term.
+ */
+ #lastEmitted = '';
+
+ constructor() {
+ effect(() => {
+ const value = this.$value();
+
+ if (value === this.searchControl.value) {
+ return;
+ }
+
+ this.searchControl.setValue(value, { emitEvent: false });
+ this.$text.set(value);
+ this.#lastEmitted = value;
+ });
+
+ this.searchControl.valueChanges
+ .pipe(
+ tap((value) => this.$text.set(value ?? '')),
+ debounce(() => timer(this.$debounceTime())),
+ takeUntilDestroyed()
+ )
+ .subscribe((value) => {
+ const term = value?.trim() ?? '';
+
+ if (term === this.#lastEmitted) {
+ return;
+ }
+
+ this.#lastEmitted = term;
+ this.search.emit(term);
+ });
+ }
+}
diff --git a/core-web/libs/ui/src/lib/components/dot-upload-button/dot-upload-button.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-upload-button/dot-upload-button.component.spec.ts
new file mode 100644
index 000000000000..bf98d8f8de51
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-upload-button/dot-upload-button.component.spec.ts
@@ -0,0 +1,91 @@
+import { byTestId, createComponentFactory, Spectator } from '@openng/spectator/jest';
+
+import { DotMessageService } from '@dotcms/data-access';
+import { DotCMSBaseTypesContentTypes } from '@dotcms/dotcms-models';
+import { MockDotMessageService } from '@dotcms/utils-testing';
+
+import { DotUploadButtonComponent } from './dot-upload-button.component';
+
+describe('DotUploadButtonComponent', () => {
+ let spectator: Spectator;
+
+ const createComponent = createComponentFactory({
+ component: DotUploadButtonComponent,
+ providers: [
+ {
+ provide: DotMessageService,
+ useValue: new MockDotMessageService({
+ 'content-drive.upload': 'Upload',
+ 'content-drive.upload-asset': 'Upload Asset',
+ 'content-drive.upload-file': 'Upload File'
+ })
+ }
+ ]
+ });
+
+ const getButton = () =>
+ spectator.query(byTestId('upload-button'))?.querySelector('button') as HTMLButtonElement;
+
+ beforeEach(() => {
+ spectator = createComponent();
+ });
+
+ describe('label', () => {
+ it('should fall back to the generic label when no base type is pinned', () => {
+ expect(getButton().textContent?.trim()).toBe('Upload');
+ });
+
+ it('should read "Upload Asset" for dotAsset folders', () => {
+ spectator.setInput('defaultBaseType', DotCMSBaseTypesContentTypes.DOTASSET);
+ spectator.detectChanges();
+
+ expect(getButton().textContent?.trim()).toBe('Upload Asset');
+ });
+
+ it('should read "Upload File" for fileAsset folders', () => {
+ spectator.setInput('defaultBaseType', DotCMSBaseTypesContentTypes.FILEASSET);
+ spectator.detectChanges();
+
+ expect(getButton().textContent?.trim()).toBe('Upload File');
+ });
+
+ it('should match the base type case-insensitively', () => {
+ spectator.setInput('defaultBaseType', 'dotasset');
+ spectator.detectChanges();
+
+ expect(getButton().textContent?.trim()).toBe('Upload Asset');
+ });
+
+ it('should fall back to the generic label for an unknown base type', () => {
+ spectator.setInput('defaultBaseType', 'WIDGET');
+ spectator.detectChanges();
+
+ expect(getButton().textContent?.trim()).toBe('Upload');
+ });
+ });
+
+ describe('disabled', () => {
+ it('should be enabled by default', () => {
+ expect(getButton().disabled).toBe(false);
+ });
+
+ it('should disable the button when requested', () => {
+ spectator.setInput('disabled', true);
+ spectator.detectChanges();
+
+ expect(getButton().disabled).toBe(true);
+ });
+ });
+
+ describe('output', () => {
+ it('should emit the originating click', () => {
+ const handler = jest.fn();
+ spectator.output('upload').subscribe(handler);
+
+ spectator.click(getButton());
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler.mock.calls[0][0]).toBeInstanceOf(Event);
+ });
+ });
+});
diff --git a/core-web/libs/ui/src/lib/components/dot-upload-button/dot-upload-button.component.ts b/core-web/libs/ui/src/lib/components/dot-upload-button/dot-upload-button.component.ts
new file mode 100644
index 000000000000..3eec2f7e91d3
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-upload-button/dot-upload-button.component.ts
@@ -0,0 +1,63 @@
+import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
+
+import { ButtonModule } from 'primeng/button';
+
+import { DotCMSBaseTypesContentTypes } from '@dotcms/dotcms-models';
+
+import { DotMessagePipe } from '../../dot-message/dot-message.pipe';
+
+/** Folder-aware upload labels. Anything else falls back to the generic "Upload". */
+const UPLOAD_LABEL_KEY_BY_BASE_TYPE: Record = {
+ [DotCMSBaseTypesContentTypes.DOTASSET]: 'content-drive.upload-asset',
+ [DotCMSBaseTypesContentTypes.FILEASSET]: 'content-drive.upload-file'
+};
+
+const DEFAULT_UPLOAD_LABEL_KEY = 'content-drive.upload';
+
+/**
+ * Upload trigger shared across Content Drive and AssetPicker.
+ *
+ * Emit-only by design: it owns the folder-aware label and nothing else. The host runs the upload
+ * orchestration (type popover, file input, drag-and-drop handoff) off the emitted click.
+ */
+@Component({
+ selector: 'dot-upload-button',
+ imports: [ButtonModule, DotMessagePipe],
+ template: `
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: { class: 'inline-flex' }
+})
+export class DotUploadButtonComponent {
+ /**
+ * Base type the target folder pins uploads to, when it pins one. Drives the button label.
+ * @type {string | null}
+ * @alias defaultBaseType
+ */
+ readonly $defaultBaseType = input(null, { alias: 'defaultBaseType' });
+
+ /**
+ * @type {boolean}
+ * @alias disabled
+ */
+ readonly $disabled = input(false, { alias: 'disabled' });
+
+ /**
+ * Emits the originating click so the host can anchor an overlay to the button.
+ * Consume it synchronously — `currentTarget` is cleared once dispatch settles.
+ */
+ readonly upload = output();
+
+ protected readonly $labelKey = computed(
+ () =>
+ UPLOAD_LABEL_KEY_BY_BASE_TYPE[this.$defaultBaseType()?.toUpperCase() ?? ''] ??
+ DEFAULT_UPLOAD_LABEL_KEY
+ );
+}
diff --git a/core-web/libs/ui/src/lib/components/dot-upload-dropzone/constants.ts b/core-web/libs/ui/src/lib/components/dot-upload-dropzone/constants.ts
new file mode 100644
index 000000000000..6b1e40dd378e
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-upload-dropzone/constants.ts
@@ -0,0 +1,9 @@
+/**
+ * Dropzone drag states. `INTERNAL_DRAG` distinguishes rows being dragged inside the host (a move,
+ * not an upload) from files coming in from the OS.
+ */
+export const DROPZONE_STATE = {
+ INTERNAL_DRAG: 'internal-drag',
+ ACTIVE: 'active',
+ INACTIVE: 'inactive'
+} as const;
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.html b/core-web/libs/ui/src/lib/components/dot-upload-dropzone/dot-upload-dropzone.component.html
similarity index 100%
rename from core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.html
rename to core-web/libs/ui/src/lib/components/dot-upload-dropzone/dot-upload-dropzone.component.html
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-upload-dropzone/dot-upload-dropzone.component.spec.ts
similarity index 93%
rename from core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.spec.ts
rename to core-web/libs/ui/src/lib/components/dot-upload-dropzone/dot-upload-dropzone.component.spec.ts
index 00b0131b273b..9c07502fa304 100644
--- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.spec.ts
+++ b/core-web/libs/ui/src/lib/components/dot-upload-dropzone/dot-upload-dropzone.component.spec.ts
@@ -1,13 +1,15 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
-import { createComponentFactory, mockProvider, Spectator, SpyObject } from '@openng/spectator/jest';
+import { createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest';
import { DotMessageService } from '@dotcms/data-access';
-import { DOT_DRAG_ITEM } from '@dotcms/portlets/content-drive/ui';
+import { TreeNodeData } from '@dotcms/dotcms-models';
-import { DotContentDriveDropzoneComponent } from './dot-content-drive-dropzone.component';
+import { DROPZONE_STATE } from './constants';
+import { DotUploadDropzoneComponent } from './dot-upload-dropzone.component';
-import { DROPZONE_STATE } from '../../shared/constants';
-import { DotContentDriveStore } from '../../store/dot-content-drive.store';
+import { DOT_DRAG_ITEM } from '../dot-folder-list-view/constants';
+
+const TARGET_FOLDER = { id: 'test-id' } as TreeNodeData;
// Mock DragEvent since it's not available in Jest environment
class DragEventMock extends Event {
@@ -44,18 +46,14 @@ function createDropEvent(files?: FileList | null): DragEvent {
return event;
}
-describe('DotContentDriveDropzoneComponent', () => {
- let spectator: Spectator;
- let store: SpyObject>;
+describe('DotUploadDropzoneComponent', () => {
+ let spectator: Spectator;
+ let dragEnterSpyEmitter: ReturnType;
let elementRefSpy: ReturnType;
let uploadFilesSpyEmitter: ReturnType;
const createComponent = createComponentFactory({
- component: DotContentDriveDropzoneComponent,
+ component: DotUploadDropzoneComponent,
providers: [
- mockProvider(DotContentDriveStore, {
- resetContextMenu: jest.fn(),
- selectedNode: jest.fn().mockReturnValue({ data: { id: 'test-id' } })
- }),
mockProvider(DotMessageService, {
get: jest.fn().mockReturnValue('Drag and drop files here')
})
@@ -65,8 +63,9 @@ describe('DotContentDriveDropzoneComponent', () => {
beforeEach(() => {
spectator = createComponent();
- store = spectator.inject(DotContentDriveStore, true);
+ spectator.setInput('targetFolder', TARGET_FOLDER);
uploadFilesSpyEmitter = jest.spyOn(spectator.component.uploadFiles, 'emit');
+ dragEnterSpyEmitter = jest.spyOn(spectator.component.dragEnter, 'emit');
// Spy on the component's elementRef nativeElement.contains method
if (spectator.component.elementRef?.nativeElement) {
elementRefSpy = jest
@@ -177,7 +176,7 @@ describe('DotContentDriveDropzoneComponent', () => {
spectator.detectChanges();
expect(spectator.component.active).toBe(true);
- expect(store.resetContextMenu).toHaveBeenCalled();
+ expect(dragEnterSpyEmitter).toHaveBeenCalled();
});
it('should not activate dropzone when internal drag enters', () => {
@@ -192,7 +191,7 @@ describe('DotContentDriveDropzoneComponent', () => {
spectator.detectChanges();
expect(spectator.component.active).toBe(false);
- expect(store.resetContextMenu).not.toHaveBeenCalled();
+ expect(dragEnterSpyEmitter).not.toHaveBeenCalled();
});
it('should not activate dropzone when drag contains DOT_DRAG_ITEM type', () => {
@@ -202,7 +201,7 @@ describe('DotContentDriveDropzoneComponent', () => {
spectator.detectChanges();
expect(spectator.component.active).toBe(false);
- expect(store.resetContextMenu).not.toHaveBeenCalled();
+ expect(dragEnterSpyEmitter).not.toHaveBeenCalled();
});
it('should activate dropzone when drag contains other types but not DOT_DRAG_ITEM', () => {
@@ -212,7 +211,7 @@ describe('DotContentDriveDropzoneComponent', () => {
spectator.detectChanges();
expect(spectator.component.active).toBe(true);
- expect(store.resetContextMenu).toHaveBeenCalled();
+ expect(dragEnterSpyEmitter).toHaveBeenCalled();
});
it('should prevent default and stop propagation for external drag', () => {
@@ -393,7 +392,7 @@ describe('DotContentDriveDropzoneComponent', () => {
expect(uploadFilesSpyEmitter).toHaveBeenCalledWith({
files: mockFiles,
- targetFolder: { id: 'test-id' }
+ targetFolder: TARGET_FOLDER
});
});
@@ -471,7 +470,7 @@ describe('DotContentDriveDropzoneComponent', () => {
expect(uploadFilesSpyEmitter).toHaveBeenCalledWith({
files: mockFiles,
- targetFolder: { id: 'test-id' }
+ targetFolder: TARGET_FOLDER
});
expect(spectator.component.active).toBe(false);
@@ -566,7 +565,7 @@ describe('DotContentDriveDropzoneComponent', () => {
spectator.detectChanges();
expect(spectator.component.active).toBe(false);
- expect(store.resetContextMenu).not.toHaveBeenCalled();
+ expect(dragEnterSpyEmitter).not.toHaveBeenCalled();
// 3. Trigger window drop event to reset
const windowDrop = new DragEvent('drop');
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.ts b/core-web/libs/ui/src/lib/components/dot-upload-dropzone/dot-upload-dropzone.component.ts
similarity index 71%
rename from core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.ts
rename to core-web/libs/ui/src/lib/components/dot-upload-dropzone/dot-upload-dropzone.component.ts
index 8a1205630c94..d35eacc0ef67 100644
--- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-dropzone/dot-content-drive-dropzone.component.ts
+++ b/core-web/libs/ui/src/lib/components/dot-upload-dropzone/dot-upload-dropzone.component.ts
@@ -5,20 +5,30 @@ import {
HostBinding,
HostListener,
inject,
+ input,
output,
signal
} from '@angular/core';
-import { DotContentDriveUploadFiles, DOT_DRAG_ITEM } from '@dotcms/portlets/content-drive/ui';
-import { DotMessagePipe } from '@dotcms/ui';
+import { TreeNodeData } from '@dotcms/dotcms-models';
-import { DROPZONE_STATE } from '../../shared/constants';
-import { DotContentDriveStore } from '../../store/dot-content-drive.store';
+import { DROPZONE_STATE } from './constants';
+import { DotMessagePipe } from '../../dot-message/dot-message.pipe';
+import { DOT_DRAG_ITEM } from '../dot-folder-list-view/constants';
+import { DotUploadFiles } from '../dot-upload-type-selector/models';
+
+/**
+ * Drop target for uploading files by dragging them from the OS. Wraps whatever content it is given
+ * and paints a full-surface overlay while a file drag is over it. Shared by Content Drive and the
+ * AssetPicker.
+ *
+ * Presentational: the host owns the target folder and what an upload means.
+ */
@Component({
- selector: 'dot-content-drive-dropzone',
+ selector: 'dot-upload-dropzone',
imports: [DotMessagePipe],
- templateUrl: './dot-content-drive-dropzone.component.html',
+ templateUrl: './dot-upload-dropzone.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'relative h-full w-full max-h-full min-h-0' },
styles: `
@@ -43,15 +53,27 @@ import { DotContentDriveStore } from '../../store/dot-content-drive.store';
}
`
})
-export class DotContentDriveDropzoneComponent {
- readonly uploadFiles = output();
+export class DotUploadDropzoneComponent {
+ /**
+ * Folder the dropped files land in. Carried straight into {@link uploadFiles}.
+ * @type {TreeNodeData | undefined}
+ * @alias targetFolder
+ */
+ readonly $targetFolder = input(undefined, { alias: 'targetFolder' });
+
+ /** Emitted once files are dropped on the zone. */
+ readonly uploadFiles = output();
+
+ /**
+ * Emitted when an external file drag enters the zone. Content Drive uses it to dismiss its
+ * context menu; hosts without one can ignore it.
+ */
+ readonly dragEnter = output();
readonly elementRef = inject(ElementRef);
readonly state = signal(DROPZONE_STATE.INACTIVE);
- readonly #store = inject(DotContentDriveStore);
-
/**
* @description Get the active state of the dropzone
* @returns {boolean} - The active state of the dropzone
@@ -62,7 +84,6 @@ export class DotContentDriveDropzoneComponent {
/**
* @description Set the dropzone as internal drag
- * @memberof DotContentDriveDropzoneComponent
*/
@HostListener('window:dragstart')
onWindowDragStart() {
@@ -71,7 +92,6 @@ export class DotContentDriveDropzoneComponent {
/**
* @description Set the dropzone as not internal drag
- * @memberof DotContentDriveDropzoneComponent
*/
@HostListener('window:dragend')
@HostListener('window:drop')
@@ -88,6 +108,7 @@ export class DotContentDriveDropzoneComponent {
event.stopPropagation();
event.preventDefault();
+ // Dragging rows around inside the host is not an upload.
if (
this.state() === DROPZONE_STATE.INTERNAL_DRAG ||
event.dataTransfer?.types.includes(DOT_DRAG_ITEM)
@@ -96,9 +117,7 @@ export class DotContentDriveDropzoneComponent {
}
this.state.set(DROPZONE_STATE.ACTIVE);
-
- // Reset the context menu
- this.#store.resetContextMenu();
+ this.dragEnter.emit();
}
/**
@@ -144,7 +163,6 @@ export class DotContentDriveDropzoneComponent {
* @description Set the dropzone as inactive when the drag ends on the dropzone
* @param event - DragEvent
*/
-
@HostListener('drop', ['$event'])
onDrop(event: DragEvent) {
event.stopPropagation();
@@ -155,7 +173,7 @@ export class DotContentDriveDropzoneComponent {
this.state.set(DROPZONE_STATE.INACTIVE);
if (files?.length) {
- this.uploadFiles.emit({ files, targetFolder: this.#store.selectedNode()?.data });
+ this.uploadFiles.emit({ files, targetFolder: this.$targetFolder() });
}
}
}
diff --git a/core-web/libs/ui/src/lib/components/dot-upload-type-selector/constants.ts b/core-web/libs/ui/src/lib/components/dot-upload-type-selector/constants.ts
new file mode 100644
index 000000000000..5d3ef5f85e33
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-upload-type-selector/constants.ts
@@ -0,0 +1,22 @@
+import { DotCMSBaseTypesContentTypes } from '@dotcms/dotcms-models';
+
+/**
+ * The two ways an upload can be stored. Order is the display order; `recommended` flags the one the
+ * product steers users toward.
+ */
+export const UPLOAD_SELECTOR_OPTIONS = [
+ {
+ baseType: DotCMSBaseTypesContentTypes.DOTASSET,
+ icon: 'image',
+ labelKey: 'content-drive.dialog.upload-selector.asset',
+ descriptionKey: 'content-drive.dialog.upload-selector.asset.description',
+ recommended: true
+ },
+ {
+ baseType: DotCMSBaseTypesContentTypes.FILEASSET,
+ icon: 'code_blocks',
+ labelKey: 'content-drive.dialog.upload-selector.file',
+ descriptionKey: 'content-drive.dialog.upload-selector.file.description',
+ recommended: false
+ }
+] as const;
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.html b/core-web/libs/ui/src/lib/components/dot-upload-type-selector/dot-upload-type-selector.component.html
similarity index 100%
rename from core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.html
rename to core-web/libs/ui/src/lib/components/dot-upload-type-selector/dot-upload-type-selector.component.html
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-upload-type-selector/dot-upload-type-selector.component.spec.ts
similarity index 84%
rename from core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.spec.ts
rename to core-web/libs/ui/src/lib/components/dot-upload-type-selector/dot-upload-type-selector.component.spec.ts
index aa1766954b03..9d5cd6929e44 100644
--- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component.spec.ts
+++ b/core-web/libs/ui/src/lib/components/dot-upload-type-selector/dot-upload-type-selector.component.spec.ts
@@ -2,25 +2,24 @@ import { beforeEach, describe, expect, it } from '@jest/globals';
import { byTestId, createComponentFactory, Spectator } from '@openng/spectator/jest';
import { DotMessageService } from '@dotcms/data-access';
-import { DotFolderTreeNodeData } from '@dotcms/portlets/content-drive/ui';
+import { TreeNodeData } from '@dotcms/dotcms-models';
import { MockDotMessageService } from '@dotcms/utils-testing';
-import { DotContentDriveDialogUploadSelectorComponent } from './dot-content-drive-dialog-upload-selector.component';
-
-import { DotContentDriveUploadSelection } from '../../../shared/models';
+import { DotUploadTypeSelectorComponent } from './dot-upload-type-selector.component';
+import { DotUploadSelection } from './models';
const TARGET_FOLDER = {
id: 'folder-123',
hostname: 'localhost',
path: 'folder-123',
type: 'folder'
-} as DotFolderTreeNodeData;
+} as TreeNodeData;
-describe('DotContentDriveDialogUploadSelectorComponent', () => {
- let spectator: Spectator;
+describe('DotUploadTypeSelectorComponent', () => {
+ let spectator: Spectator;
const createComponent = createComponentFactory({
- component: DotContentDriveDialogUploadSelectorComponent,
+ component: DotUploadTypeSelectorComponent,
providers: [
{
provide: DotMessageService,
@@ -78,7 +77,7 @@ describe('DotContentDriveDialogUploadSelectorComponent', () => {
spectator.setInput('files', files);
spectator.detectChanges();
- let emitted: DotContentDriveUploadSelection | undefined;
+ let emitted: DotUploadSelection | undefined;
spectator.component.selectUploadType.subscribe((selection) => (emitted = selection));
clickOption('DOTASSET');
@@ -91,7 +90,7 @@ describe('DotContentDriveDialogUploadSelectorComponent', () => {
});
it('should emit the FILEASSET selection when File is clicked', () => {
- let emitted: DotContentDriveUploadSelection | undefined;
+ let emitted: DotUploadSelection | undefined;
spectator.component.selectUploadType.subscribe((selection) => (emitted = selection));
clickOption('FILEASSET');
diff --git a/core-web/libs/ui/src/lib/components/dot-upload-type-selector/dot-upload-type-selector.component.ts b/core-web/libs/ui/src/lib/components/dot-upload-type-selector/dot-upload-type-selector.component.ts
new file mode 100644
index 000000000000..7c7084e1dd11
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-upload-type-selector/dot-upload-type-selector.component.ts
@@ -0,0 +1,45 @@
+import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
+
+import { TreeNodeData } from '@dotcms/dotcms-models';
+
+import { UPLOAD_SELECTOR_OPTIONS } from './constants';
+import { DotUploadBaseType, DotUploadSelection } from './models';
+
+import { DotMessagePipe } from '../../dot-message/dot-message.pipe';
+
+/**
+ * Upload type prompt: lets the user pick whether an upload is created as an Asset (`DOTASSET`) or a
+ * File (`FILEASSET`). Shared by Content Drive (Upload-button popover and drag-and-drop modal) and
+ * the AssetPicker.
+ *
+ * Each option is a single click — choosing one emits the full {@link DotUploadSelection} (target
+ * folder + chosen base type + the files, when already known) so the host can trigger the upload
+ * directly. Carrying the folder forward also feeds the per-folder upload preference set in folder
+ * settings (epic #35436).
+ */
+@Component({
+ selector: 'dot-upload-type-selector',
+ imports: [DotMessagePipe],
+ templateUrl: './dot-upload-type-selector.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class DotUploadTypeSelectorComponent {
+ /** Folder the upload targets; carried through to the emitted selection (root when undefined). */
+ $targetFolder = input(undefined, { alias: 'targetFolder' });
+
+ /** Files to upload — present for the drag-and-drop flow, absent for the Upload-button flow. */
+ $files = input(undefined, { alias: 'files' });
+
+ /** Emits the chosen base type plus the upload context when the user picks an option. */
+ selectUploadType = output();
+
+ protected readonly options = UPLOAD_SELECTOR_OPTIONS;
+
+ protected onSelect(baseType: DotUploadBaseType): void {
+ this.selectUploadType.emit({
+ targetFolder: this.$targetFolder(),
+ baseType,
+ files: this.$files()
+ });
+ }
+}
diff --git a/core-web/libs/ui/src/lib/components/dot-upload-type-selector/models.ts b/core-web/libs/ui/src/lib/components/dot-upload-type-selector/models.ts
new file mode 100644
index 000000000000..1b4d644905df
--- /dev/null
+++ b/core-web/libs/ui/src/lib/components/dot-upload-type-selector/models.ts
@@ -0,0 +1,28 @@
+import { TreeNodeData } from '@dotcms/dotcms-models';
+
+import { UPLOAD_SELECTOR_OPTIONS } from './constants';
+
+/**
+ * Base type an upload is created as. Narrowed to what the selector actually offers
+ * (`DOTASSET` / `FILEASSET`) rather than the full base-type enum.
+ */
+export type DotUploadBaseType = (typeof UPLOAD_SELECTOR_OPTIONS)[number]['baseType'];
+
+/** What the Asset/File prompt needs to render. `files` is set only in the drag-and-drop flow. */
+export interface DotUploadSelectorPayload {
+ targetFolder?: TreeNodeData;
+ files?: FileList;
+}
+
+/** The user's answer to the prompt, plus the context needed to run the upload. */
+export interface DotUploadSelection {
+ baseType: DotUploadBaseType;
+ targetFolder?: TreeNodeData;
+ files?: FileList;
+}
+
+/** Files dropped on a target, emitted by the dropzone and the folder tree. */
+export interface DotUploadFiles {
+ files: FileList;
+ targetFolder?: TreeNodeData;
+}
diff --git a/core-web/libs/ui/src/lib/components/dot-workflow-push-publish/dot-workflow-push-publish.component.html b/core-web/libs/ui/src/lib/components/dot-workflow-push-publish/dot-workflow-push-publish.component.html
index 93637b3cf17e..a5fbc81caed8 100644
--- a/core-web/libs/ui/src/lib/components/dot-workflow-push-publish/dot-workflow-push-publish.component.html
+++ b/core-web/libs/ui/src/lib/components/dot-workflow-push-publish/dot-workflow-push-publish.component.html
@@ -92,7 +92,7 @@
{{ $localTimezoneLabel() }}
- {{ ($showTimezone() ? 'hide' : 'Change') | dm }}
+ {{ ($showTimezone() ? 'hide' : 'change') | dm }}
diff --git a/core-web/libs/ui/src/lib/dialog/fullscreen-dialog.ts b/core-web/libs/ui/src/lib/dialog/fullscreen-dialog.ts
new file mode 100644
index 000000000000..da9023d20225
--- /dev/null
+++ b/core-web/libs/ui/src/lib/dialog/fullscreen-dialog.ts
@@ -0,0 +1,34 @@
+/**
+ * PrimeNG's own "maximized dialog" class.
+ *
+ * The theme styles it with `width/height/top/left: … !important`, which is the only thing that beats
+ * the width/height `DialogService` writes **inline** on `.p-dialog`. That is why a dialog goes
+ * full-screen by taking this class rather than by having its inline styles rewritten.
+ *
+ * PrimeNG applies it itself when `maximizable` is on and its own maximize button is clicked. A dialog
+ * that hides PrimeNG's header (`showHeader: false`) to render its own has no such button, so it
+ * drives the class from its own control instead.
+ */
+export const MAXIMIZED_DIALOG_CLASS = 'p-dialog-maximized';
+
+/**
+ * Inline `.p-dialog` style props applied when a dialog goes full-screen and
+ * restored on exit. Overrides PrimeNG's `DynamicDialog` size (set inline via
+ * `[ngStyle]`), so it must be applied as inline styles to win — a stylesheet
+ * rule can't beat inline without `!important`.
+ *
+ * @deprecated Prefer {@link MAXIMIZED_DIALOG_CLASS}: PrimeNG's theme already declares the same sizing
+ * with `!important`, so the class needs no save/restore of the windowed values. Still used by the
+ * image editor.
+ */
+export const FULLSCREEN_DIALOG_STYLE: Record = {
+ width: '100vw',
+ height: '100vh',
+ maxWidth: '100vw',
+ maxHeight: '100vh',
+ borderRadius: '0'
+};
+
+/** Eased transition so the dialog grows/shrinks smoothly instead of snapping. */
+export const DIALOG_SIZE_TRANSITION =
+ 'width 250ms ease, height 250ms ease, border-radius 250ms ease';
diff --git a/core-web/libs/ui/src/lib/services/dot-browsing/dot-browsing.service.ts b/core-web/libs/ui/src/lib/services/dot-browsing/dot-browsing.service.ts
index 30e02d62df44..3dc11a9705e6 100644
--- a/core-web/libs/ui/src/lib/services/dot-browsing/dot-browsing.service.ts
+++ b/core-web/libs/ui/src/lib/services/dot-browsing/dot-browsing.service.ts
@@ -93,6 +93,7 @@ export class DotBrowsingService {
filter: string;
perPage?: number;
page?: number;
+ system?: boolean;
}): Observable {
return this.getSitesPage(data).pipe(map(({ sites }) => sites));
}
@@ -104,18 +105,21 @@ export class DotBrowsingService {
* @param {string} data.filter - Filter string to search sites
* @param {number} [data.perPage] - Number of items per page
* @param {number} [data.page] - Page number to fetch
+ * @param {boolean} [data.system] - Whether to include System Host. Omit to keep the API default
+ * (included) — pass `false` for a tree whose roots must all be browsable sites.
* @returns {Observable<{ sites: TreeNodeItem[]; pagination: DotPagination }>}
*/
getSitesPage(data: {
filter: string;
perPage?: number;
page?: number;
+ system?: boolean;
}): Observable<{ sites: TreeNodeItem[]; pagination: DotPagination }> {
- const { filter, perPage, page } = data;
+ const { filter, perPage, page, system } = data;
- return this.#siteService.getSites({ filter, per_page: perPage, page }).pipe(
+ return this.#siteService.getSites({ filter, per_page: perPage, page, system }).pipe(
map(({ sites, pagination }) => ({
- sites: sites.map((site) => this.#mapSiteToTreeNodeItem(site)),
+ sites: sites.map((site) => this.mapSiteToTreeNode(site)),
pagination
}))
);
@@ -143,7 +147,17 @@ export class DotBrowsingService {
);
}
- #mapSiteToTreeNodeItem(site: { identifier: string; hostname: string }): TreeNodeItem {
+ /**
+ * Maps a site to its tree-root node shape.
+ *
+ * Public so a consumer can produce a root for a site it already knows about without a round
+ * trip — the AssetPicker needs the site it is browsing present in the tree even when a filtered
+ * sites query left it out. Keeping this the single source of the node shape is the point.
+ *
+ * @param {Object} site - Site identifier and hostname
+ * @returns {TreeNodeItem} The site as an expandable tree root
+ */
+ mapSiteToTreeNode(site: { identifier: string; hostname: string }): TreeNodeItem {
return {
key: site.identifier,
label: site.hostname,
diff --git a/core-web/libs/utils-testing/src/lib/test-setup-helpers.ts b/core-web/libs/utils-testing/src/lib/test-setup-helpers.ts
index 3685db089645..cf3d8ece7fe0 100644
--- a/core-web/libs/utils-testing/src/lib/test-setup-helpers.ts
+++ b/core-web/libs/utils-testing/src/lib/test-setup-helpers.ts
@@ -60,13 +60,6 @@ export class MockDotSpinner {}
})
export class MockDotEmptyState {}
-@Component({
- selector: 'dot-dialog',
- changeDetection: ChangeDetectionStrategy.Eager,
- template: ' '
-})
-export class MockDotDialog {}
-
/**
* Collection of commonly needed mock dotCMS components
*/
@@ -75,8 +68,7 @@ export const MOCK_DOTCMS_COMPONENTS = [
MockDotPortletBase,
MockDotLoadingIndicator,
MockDotSpinner,
- MockDotEmptyState,
- MockDotDialog
+ MockDotEmptyState
];
/**
diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchAPIImpl.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchAPIImpl.java
index c21350d3fe0d..21fcdcf14199 100644
--- a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchAPIImpl.java
+++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchAPIImpl.java
@@ -260,8 +260,6 @@ public SiteSearchResult getFromIndex(final String index, final String id) {
public Optional defaultIndexName() throws DotDataException {
try {
return router.readChecked(SiteSearchAPI::defaultIndexName);
- } catch (DotDataException e) {
- throw e;
} catch (Exception e) {
throw new DotDataException(e.getMessage(), e);
}
diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
index 0a77ee91083e..bc3d3f7c6291 100644
--- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
+++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
@@ -1230,8 +1230,6 @@ dot.file.field.action.import.from.url=Import from URL
dot.file.field.action.import.from.url.error.message=The URL you requested is not valid. Please try again.
dot.file.field.action.remove=Remove
dot.file.field.action.remove.confirm=Are you sure you want to remove this file?
-dot.file.field.dialog.select.existing.file.header=Select Existing File
-dot.file.field.dialog.select.existing.image.header=Select Existing Image
dot.file.field.dialog.select.existing.file.table.emptymessage=No content found
dot.file.field.dialog.select.existing.file.table.thumbnail=Thumbnail
dot.file.field.dialog.select.existing.file.table.search=Search content
@@ -7751,3 +7749,17 @@ categories.delete.partial-success.with-descendants={0} category(s) and {1} desce
categories.delete.failed=No categories were deleted.
categories.update.success=Category updated successfully.
categories.more.actions=More Actions
+
+## Asset Picker (dialog opened from the Edit Content File / Image fields and the Story Block's
+## image / video / audio blocks)
+dot.asset.picker.header.file=Add File
+dot.asset.picker.header.image=Add Image
+dot.asset.picker.header.video=Add Video
+dot.asset.picker.header.audio=Add Audio
+dot.asset.picker.fullscreen.enter.aria=Enter full screen
+dot.asset.picker.fullscreen.exit.aria=Exit full screen
+dot.asset.picker.close.aria=Close
+dot.asset.picker.splitter.aria=Resize the folder panel
+dot.asset.picker.sidebar.search.placeholder=Search sites & folders
+dot.asset.picker.confirm.error=Couldn't add the asset
+dot.asset.picker.confirm.error.detail=We couldn't load the selected asset. It may have been deleted or you may no longer have access to it.
diff --git a/specs/36834-assetpicker-headless-browse-store/spec.md b/specs/36834-assetpicker-headless-browse-store/spec.md
new file mode 100644
index 000000000000..bb52ca82060d
--- /dev/null
+++ b/specs/36834-assetpicker-headless-browse-store/spec.md
@@ -0,0 +1,288 @@
+# Feature Specification: Headless Browse Store for AssetPicker
+
+**Feature Branch**: `issue-36702-asset-picker`
+
+**Created**: 2026-08-07
+
+**Status**: Draft
+
+**Type**: Task (enabling refactor — AssetPicker 4/7)
+
+**GitHub Issue**: [dotCMS/core#36834](https://github.com/dotCMS/core/issues/36834) (parent epic: [#36702](https://github.com/dotCMS/core/issues/36702))
+
+**Input**: User description: "Create a headless browse SignalStore for AssetPicker that builds the same style of `DotContentDriveSearchRequest` as Content Drive, but **without** `ActivatedRoute`, URL sync, or editor navigation. Store responsibilities: site/asset path; sidebar folder tree state (expand / load-more via shared helpers); items, pagination/sort, loading/error; filters (text, contentTypes, baseTypes, language, silent `mimeTypes`); always `showFolders: false` for picker browse requests; selection (single asset). Must NOT include: query-param / `Location.go` sync; `DotContentDriveNavigationService`; context menu / dragging / Add New dialogs."
+
+---
+
+## Scope Note *(read this first)*
+
+This issue delivers **state management only — no UI**. The `DotAssetPicker` shell that renders this
+store lands in [#36835](https://github.com/dotCMS/core/issues/36835) (5/7), and the File/Image entry
+points that configure it land in [#36836](https://github.com/dotCMS/core/issues/36836) (6/7).
+
+The user stories below are therefore written as **capabilities the store must provide**, and every
+"Independent Test" is a store-level unit test, not a click-through. This is deliberate: the store is
+what makes the picker's behavior correct, and it is testable in isolation *before* any UI exists. A
+user story that could only be verified through a shell that does not yet exist would be untestable
+in this issue and belongs in 5/7.
+
+---
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - A File field browses assets, never folders (Priority: P1)
+
+An editor opens the picker from a **File** field. The store requests assets for the current site and
+folder with no content-type restriction, pre-selecting only the contentlet's locale. Folders never
+appear in the result list — the list is for picking an asset, and the folder tree on the left is how
+you navigate.
+
+**Why this priority**: This is the baseline entry point. Without it there is no picker. The
+"no folders in the list" rule is a hard invariant of the epic's design and the single clearest
+behavioral difference from Content Drive, where folders and content share the list.
+
+**Independent Test**: Configure the store for a File field (site + locale, no base types) and assert
+the generated search request: `showFolders: false`, `language` set to the given locale, no
+`contentTypes`, no `baseTypes`, no `mimeTypes`.
+
+**Acceptance Scenarios**:
+
+1. **Given** the store is configured with a site and a locale, **When** the search request is built,
+ **Then** it carries that locale and no content-type or base-type restriction.
+2. **Given** any combination of filters is applied afterwards, **When** the search request is rebuilt,
+ **Then** `showFolders` is still `false`.
+3. **Given** the host supplies a starting folder path, **When** the request is built, **Then**
+ `assetPath` addresses that folder on that site.
+
+---
+
+### User Story 2 - An Image field silently narrows to images (Priority: P1)
+
+An editor opens the picker from an **Image** field. The store additionally restricts the search to
+the `dotAsset` and `File Asset` base types and applies a mimetype filter. The mimetype filter is
+**invisible to the editor** — it produces no chip and cannot be cleared, because an Image field that
+could return a PDF would be broken.
+
+**Why this priority**: Equal to User Story 1 — it is the second of the two entry points, and the
+silent-filter behavior is the requirement most likely to be implemented wrong (as a normal, visible,
+clearable filter).
+
+**Independent Test**: Configure the store for an Image field (site + locale + base types +
+`mimeTypes: ['image/*']`) and assert the generated request carries all four, then assert that the
+user-facing filter state exposed for chip rendering contains no mimetype entry.
+
+**Acceptance Scenarios**:
+
+1. **Given** an Image-field configuration, **When** the search request is built, **Then** it carries
+ `baseTypes: ['DOTASSET', 'FILEASSET']` and `mimeTypes: ['image/*']`.
+2. **Given** an Image-field configuration, **When** the user-facing filter state is read, **Then**
+ the mimetype restriction is absent from it — nothing downstream can render it as a chip.
+3. **Given** the user clears every filter they can see, **When** the search request is rebuilt,
+ **Then** the mimetype restriction is still applied.
+
+---
+
+### User Story 3 - Browsing the picker never disturbs the page behind it (Priority: P1)
+
+An editor is editing a contentlet, opens the picker, navigates folders, searches, filters and
+paginates, then closes it. The browser URL never changed, the browser history gained no entries, and
+pressing Back afterwards behaves exactly as it would have if the picker had never been opened.
+
+**Why this priority**: P1 rather than a nice-to-have because it is a **correctness** requirement, not
+a polish one. The picker opens *on top of* the Edit Contentlet screen, whose own URL state is owned
+by the editor. A store that wrote query params — as Content Drive's does — would corrupt the host
+page's history and could navigate the editor away mid-edit, losing unsaved work.
+
+**Independent Test**: Instantiate the store in a test with **no router providers at all**. If any
+part of the store injects `ActivatedRoute` or `Location`, instantiation throws and the test fails.
+Then exercise every state-mutating method and assert no URL API is touched.
+
+**Acceptance Scenarios**:
+
+1. **Given** a test environment with no router configured, **When** the store is instantiated,
+ **Then** it constructs successfully.
+2. **Given** the store is running, **When** path, filters, sort, pagination or selection change,
+ **Then** no browser URL or history API is invoked.
+3. **Given** an asset is double-clicked or selected, **When** the store handles it, **Then** no
+ navigation to the content or page editor occurs.
+
+---
+
+### User Story 4 - Navigating folders and paging results (Priority: P2)
+
+An editor expands folders in the tree, loads more folders when a level is truncated, selects a
+folder to scope the list, and pages through results — all using the same folder and search APIs
+Content Drive uses, so the two views can never disagree about what exists.
+
+**Why this priority**: P2 because Stories 1–3 define whether the picker is *correct*; this defines
+whether it is *usable at scale*. A picker that works only on small sites still demonstrably works.
+
+**Independent Test**: Drive the store's folder-tree methods against mocked folder APIs and assert the
+resulting tree shape (including load-more sentinels); drive pagination and assert the cursor advances
+in the outgoing request.
+
+**Acceptance Scenarios**:
+
+1. **Given** a starting path several levels deep, **When** the tree loads, **Then** it is expanded
+ down to that path with the target folder selected.
+2. **Given** a folder level with more children than one page, **When** it loads, **Then** a load-more
+ affordance is present carrying the next page cursor.
+3. **Given** the user selects a folder, **When** the search request is rebuilt, **Then** it addresses
+ that folder and pagination has reset to the first page.
+4. **Given** the user advances a page, **When** the request is rebuilt, **Then** it carries the next
+ content cursor.
+
+---
+
+### User Story 5 - A failed search leaves the picker usable and says so (Priority: P3)
+
+If the search or folder API fails, the editor sees the failure reported through the platform's normal
+error surface and the picker stays open and interactive — they can adjust a filter and retry rather
+than staring at a spinner or an empty list that looks like "no results".
+
+**Why this priority**: P3 — it does not block the happy path, but silently swallowing an error is
+actively misleading here: an empty list is indistinguishable from a failed request, so the editor
+concludes the asset does not exist and goes off to re-upload it.
+
+**Independent Test**: Make the mocked search API error and assert the store reports through
+`DotHttpErrorManagerService`, leaves status in a terminal (non-loading) state, and still accepts
+subsequent filter changes and retries.
+
+**Acceptance Scenarios**:
+
+1. **Given** the search API fails, **When** the store handles the response, **Then** the error is
+ reported through the shared HTTP error manager, not swallowed or `console.error`-ed.
+2. **Given** a failed search, **When** the state is read, **Then** status is not left as "loading".
+3. **Given** a failed search, **When** the user changes a filter, **Then** a new search is issued.
+
+---
+
+### Edge Cases
+
+- **The store is created before it is configured.** A dialog host constructs the store, then calls
+ init with the field's configuration. Between those two moments the store must not issue a search
+ against an undefined site. (Content Drive gets away with an equivalent gap because its URL-driven
+ init effect runs at construction; the picker has no such trigger.)
+- **No starting path.** When the host supplies no folder (first ever use, before 6/7's remembered
+ path exists), the store browses the site root.
+- **A folder path that no longer exists** — e.g. a remembered path from 6/7 pointing at a since-deleted
+ folder. The picker must fall back to a browsable location rather than dead-ending.
+- **An empty result set** is a legitimate outcome and must be distinguishable from a failure
+ (User Story 5).
+- **Locale with no assets**: the editor's contentlet language may simply have no assets. Empty list,
+ not an error.
+- **Rapid filter changes** (typing in search while a request is in flight) must not let a stale
+ response overwrite a newer one.
+- **Reopening the picker** in the same session must start from the configured state, not from
+ whatever the previous session left behind.
+- **`SYSTEM_HOST`** is not a browsable site; a configuration naming it must not fire a search.
+
+---
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: The store MUST build a drive search request from its own state, addressing a site and
+ folder path supplied by its host.
+- **FR-002**: The store MUST set `showFolders: false` on every browse request it issues, regardless
+ of filter state. This is an invariant, not a default.
+- **FR-003**: The store MUST support these user-facing filters: free-text search, content types, base
+ types, and language.
+- **FR-004**: The store MUST support a mimetype restriction supplied by the host that is applied to
+ every request and is NOT exposed as user-facing filter state (so no UI can render it as a chip or
+ offer to clear it).
+- **FR-005**: The store MUST accept its full configuration — site, starting path, pre-selected locale,
+ allowed base types, mimetypes — explicitly from its host, and MUST NOT read any of it from the URL
+ or from global navigation state.
+- **FR-006**: The store MUST NOT depend on `ActivatedRoute` or `Location`, and MUST NOT write to the
+ browser URL or history.
+- **FR-007**: The store MUST NOT navigate to the content or page editor.
+- **FR-008**: The store MUST maintain sidebar folder-tree state — the tree, the selected node, per-level
+ expansion and load-more paging — using the shared folder helpers, not a private implementation.
+- **FR-009**: The store MUST maintain list state: items, pagination, sort, and a status distinguishing
+ loading, loaded, and error.
+- **FR-010**: The store MUST maintain a **single** asset selection.
+- **FR-011**: The store MUST use the shared drive search and folder APIs. It MUST NOT call
+ `/api/v1/browser`.
+- **FR-012**: The store MUST report API failures through the platform's shared HTTP error handling and
+ MUST leave itself in a state that accepts a retry.
+- **FR-013**: The store MUST NOT issue a search before it has been configured with a browsable site.
+- **FR-014**: The store MUST NOT include context-menu, drag-and-drop, workflow-action, or content-creation
+ state.
+- **FR-015**: The store MUST live in a shared location reachable by the AssetPicker shell, and MUST NOT
+ import from any portlet library.
+
+### Key Entities
+
+- **Picker configuration**: What the host hands the store when the picker opens — the site to browse,
+ an optional starting folder, an optional pre-selected locale, optional allowed base types, and
+ optional mimetypes. Two shapes matter: the File-field shape (locale only) and the Image-field shape
+ (locale + base types + mimetypes).
+- **Browse request**: The search payload sent for each browse. Derived entirely from configuration +
+ filter/pagination/sort state; never assembled by callers.
+- **Filter state**: The subset of the request the editor can see and change. Deliberately excludes the
+ mimetype restriction (FR-004).
+- **Folder tree node**: A node in the sidebar, either a real folder or a synthetic "load more" sentinel
+ carrying a paging cursor.
+- **Selected asset**: The single asset the editor has chosen; what the picker ultimately returns.
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 100% of browse requests carry `showFolders: false`, verified across every filter
+ permutation exercised in tests.
+- **SC-002**: A File-field configuration produces a request with the locale set and no content-type,
+ base-type, or mimetype restriction — asserted exactly, field by field.
+- **SC-003**: An Image-field configuration produces a request with the locale, base types, and
+ `mimeTypes: ['image/*']` — asserted exactly, field by field.
+- **SC-004**: The store instantiates and operates in a test with zero router providers configured.
+- **SC-005**: Zero references to `ActivatedRoute`, `Location`, `Router`, or `/api/v1/browser` in the
+ store's source, verified by inspection of the shipped files.
+- **SC-006**: Zero imports from any portlet library in the new code.
+- **SC-007**: Existing Content Drive behavior is unchanged: the portlet's full test suite passes at its
+ current count with no test modified to accommodate this work, except where a symbol was relocated.
+
+## Legacy Considerations *(dotCMS-specific — mandatory)*
+
+- **Existing behavior touched**: Content Drive's own store is **not modified**. The one shared-code
+ change is relocating the folder-tree constants (`ALL_FOLDER`, the load-more label key, and the
+ duplicated `SYSTEM_HOST_ID`) out of the Content Drive UI library into the shared UI library, because
+ the shared library cannot depend on a portlet. Content Drive keeps consuming the same values from
+ their new home. This follows the relocation pattern already used three times in this epic
+ (`DotFolderListView` in 1/7, the folder-tree helpers in 2/7, the chip-filter kit in 3/7).
+- **Backward-compatibility expectations**: Content Drive's URL behavior, deep links, and browser
+ Back/Forward restore must all keep working exactly as today — none of that code is touched. The
+ drive search endpoint contract is unchanged: `mimeTypes` and `showFolders` already exist on it and
+ are simply being used by a second caller.
+- **Deliberate divergence to record**: this store duplicates the *shape* of Content Drive's request
+ builder and sidebar logic rather than sharing an implementation. Unifying them would mean refactoring
+ a 507-line store whose initialization is welded to the router, mid-epic. The duplication is accepted
+ as known debt, revisitable once the AssetPicker series is complete and both call sites are stable.
+- **Known related decisions**: the numeric base-type encoding (`1`=CONTENT … `9`=DOTASSET) exists only
+ to make Content Drive's filters URL-safe. The picker has no URL, so it uses base-type **names**
+ throughout. This is a divergence on purpose, not an oversight.
+
+## Assumptions
+
+1. The picker **has** a folder-tree sidebar. The epic's "the right section lists no folders" refers to
+ the result list, which is exactly `showFolders: false`; this issue's own description asks for
+ "sidebar folder tree state (expand / load-more via shared helpers)".
+2. **Upload is out of scope here.** The epic requires upload parity with Content Drive, but this issue
+ lists no upload responsibility — the shell wires the upload trigger later in the series.
+3. **User Searchable and workflow filters are out of scope.** The epic marks User Searchable explicitly
+ out of scope; workflow filtering is not listed among this store's filters.
+4. The site to browse arrives through configuration rather than the global site switcher, so a host can
+ pin the picker to a specific site.
+5. Because `showFolders` is always false, folder paging never advances — only the content cursor does.
+ The store is not obliged to reproduce Content Drive's dual-cursor page reconciliation.
+6. The store is provided per picker instance (component-scoped), not application-wide, so each opening
+ starts clean.
+7. The relocated `ALL_FOLDER` label keeps its existing `content-drive.*` i18n key. Renaming keys in
+ `Language.properties` is separate work; the same trade-off was accepted in 3/7 for the upload labels.
+
+## Open Questions
+
+- None blocking. Assumptions 1–7 were reviewed with the developer on 2026-08-07 and accepted; any of
+ them turning out false changes scope and should reopen this section.
|