Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
32a0512
refactor(content-drive): migrate DotFolderListView to @dotcms/ui
nicobytes Aug 5, 2026
4302e08
feat(data-access): enhance folder tree utilities and add tests
nicobytes Aug 7, 2026
5e49ebf
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 7, 2026
c95a372
Refactor content-drive chip filters into reusable @dotcms/ui components
nicobytes Aug 7, 2026
7a59dab
Fix Nx build ordering so dotcms-ui waits on dotcms-webcomponents
nicobytes Aug 7, 2026
f3d23b0
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 7, 2026
5e08a7c
Add headless AssetPicker browse store with browse/folder-tree/selection
nicobytes Aug 7, 2026
d4ec739
Reformat import statement to fit project line-length style
nicobytes Aug 7, 2026
b4de567
Merge branch 'issue-36702-asset-picker' of github.com:dotCMS/core int…
nicobytes Aug 7, 2026
4f0d069
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 7, 2026
40e3d77
Extract shared upload UI into a reusable AssetPicker kit in @dotcms/ui
nicobytes Aug 7, 2026
3c179c7
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 7, 2026
94f7fa5
Extract shared upload UI into a reusable AssetPicker kit in @dotcms/ui
nicobytes Aug 7, 2026
fe7ce7e
Replace legacy browser selector with AssetPicker in file field
nicobytes Aug 7, 2026
7e7546d
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 7, 2026
32c9934
Restrict asset-picker content-type selector to asset base types, fix fol
nicobytes Aug 7, 2026
bf31fea
Fix import ordering and add missing state type constraint to withAssetSe
nicobytes Aug 7, 2026
507de73
Merge branch 'issue-36702-asset-picker' of github.com:dotCMS/core int…
nicobytes Aug 8, 2026
3986990
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 8, 2026
011f727
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 10, 2026
cf81ada
Refactor AssetPicker into a multi-site browser with full-screen support
nicobytes Aug 10, 2026
9a63b03
Add asset-picker header, sidebar, and full-screen dialog building blocks
nicobytes Aug 10, 2026
1bde8cb
Adjust asset picker dialog dimensions
nicobytes Aug 10, 2026
c10a584
Rework asset-picker folder tree to update by node key, not reference
nicobytes Aug 10, 2026
691eeb9
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 10, 2026
6c68e2c
Increase asset picker dialog max dimensions for large monitors
nicobytes Aug 10, 2026
548e8be
Merge branch 'issue-36702-asset-picker' of github.com:dotCMS/core int…
nicobytes Aug 10, 2026
7487de5
Fix asset picker base-type filtering to enforce allowedBaseTypes as a bo
nicobytes Aug 10, 2026
5ccfc27
Add E2E coverage and test hooks for AssetPicker selection flow
nicobytes Aug 11, 2026
adc25e7
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 11, 2026
1e39da9
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 11, 2026
7badce4
Fix asset picker regressions: dual search boxes, tree highlight, confirm
nicobytes Aug 11, 2026
d27c15b
Merge branch 'issue-36702-asset-picker' of github.com:dotCMS/core int…
nicobytes Aug 11, 2026
f54976c
Replace GlobalStore site lookup with DotSiteService in file field compon
nicobytes Aug 11, 2026
144a30e
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 11, 2026
5fb6e75
Merge branch 'main' into issue-36702-asset-picker
nicobytes Aug 11, 2026
ef7f3f4
Fix Playwright E2E CI flakiness from shared-memory and heap exhaustion
nicobytes Aug 11, 2026
3853c25
Update playwright config comment to explain why worker count shouldn't b
nicobytes Aug 11, 2026
c49471b
Merge branch 'issue-36702-asset-picker' of github.com:dotCMS/core int…
nicobytes Aug 11, 2026
26ea4a2
Fix picker asset preview test to use a PNG instead of text file
nicobytes Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions core-web/apps/dotcms-ui-e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Parallelize CI (2 workers); local keeps Playwright default. */
/*
* Parallelize CI (2 workers); local keeps Playwright default.
*
* Do NOT lower this to work around a crashing shard. The 1 -> 2 bump is a measured improvement
* from #36567 / PR #36647: the Playwright phase went from ~49m to a <30m target, so going back
* roughly doubles E2E time for every PR in the repo. If concurrency ever is proven to be the
* cause, that belongs in its own change against #36567, not smuggled into a feature PR.
*/
workers: process.env.CI ? 2 : undefined,
timeout: 60000,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
Expand All @@ -66,7 +73,16 @@ export default defineConfig({
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
headless: headless
headless: headless,
launchOptions: {
/*
* Chromium puts its shared-memory allocations in /dev/shm, which a container gives 64MB
* of by default. Exhausting it crashes the browser process outright — a SIGSEGV with no
* Playwright output and no JUnit report, which is exactly how the CI shard died. This
* flag moves those allocations to regular temp files instead.
*/
args: ['--disable-dev-shm-usage']
}
},
/* Run your local dev server before starting the tests */
webServer:
Expand Down
13 changes: 13 additions & 0 deletions core-web/apps/dotcms-ui-e2e/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@
<e2e.test.env>local</e2e.test.env>
<e2e.frontend.dir>../../</e2e.frontend.dir>
<e2e.playwright.args></e2e.playwright.args>
<!--
NODE_OPTIONS for the process that runs Playwright, raised from the default because the CI
shard was dying mid-run. Override per invocation while tuning, with
-De2e.node.options=... (note: XML comments cannot contain a double hyphen, so the flag
name is spelled without its leading dashes below).

This lifts V8's old-space ceiling and nothing else. It does NOT address a SIGSEGV or an
OOM-killed container: a JS heap exhaustion aborts with "Reached heap limit" and exit 134,
so for any other crash signature look at the memory the container has, not at this value.
max-old-space-size is the flag being set.
-->
<e2e.node.options>--max-old-space-size=4096</e2e.node.options>
<e2e.test.cmd>nx run dotcms-ui-e2e:e2e --configuration=${e2e.test.env} -- ${e2e.playwright.args}</e2e.test.cmd>
<e2e.post.test.cmd>exec sh -c "mkdir -p apps/dotcms-ui-e2e/target/playwright-reports &amp;&amp; cp apps/dotcms-ui-e2e/test-results/junit.xml apps/dotcms-ui-e2e/target/playwright-reports/junit.xml"</e2e.post.test.cmd>
<tomcat.port>8080</tomcat.port>
Expand Down Expand Up @@ -90,6 +102,7 @@
<environmentVariables>
<CI>true</CI>
<CURRENT_ENV>${e2e.test.env}</CURRENT_ENV>
<NODE_OPTIONS>${e2e.node.options}</NODE_OPTIONS>
<PATH>${node.install.dir}:${env.PATH}</PATH>
</environmentVariables>
</configuration>
Expand Down
42 changes: 42 additions & 0 deletions core-web/apps/dotcms-ui-e2e/src/requests/contentlets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,48 @@ export async function createContentlet(
return entity as Contentlet;
}

/**
* Creates a dotAsset contentlet from an in-memory file, in one multipart call.
*
* Mirrors what the product itself does (`DotUploadFileService.uploadDotAsset` →
* `DotWorkflowActionsFireService.newContentlet`): a `PUT .../fire/NEW` whose body carries the binary
* as the `file` part and the contentlet as a `json` part. Going through `/api/v1/temp` first would
* work too, but that endpoint fingerprints the caller (session + origin), so a single call is one
* less thing to get wrong from a test runner.
*
* `indexPolicy=WAIT_FOR` is what makes this usable for seeding — the asset is searchable by the time
* the request returns, so a test can open a picker and expect to find it.
*
* @param request - Playwright APIRequestContext
* @param file - The file to store, as `{ name, mimeType, buffer }`
* @param hostFolder - Site identifier or folder id the asset is created under
* @returns The created contentlet
*/
export async function createDotAsset(
request: APIRequestContext,
file: { name: string; mimeType: string; buffer: Buffer },
hostFolder: string
): Promise<Contentlet> {
const endpoint = `/api/v1/workflow/actions/default/fire/NEW?indexPolicy=WAIT_FOR`;
const response = await request.put(endpoint, {
multipart: {
file: { name: file.name, mimeType: file.mimeType, buffer: file.buffer },
json: JSON.stringify({
contentlet: { contentType: 'dotAsset', file: file.name, hostFolder }
})
},
headers: {
Authorization: generateBase64Credentials(admin1.username, admin1.password)
}
});

expect(response.status()).toBe(200);

const responseData = await response.json();

return responseData.entity as Contentlet;
}

/**
* Relates content via the relationship API.
* Uses the PUBLISH workflow action to save content with relationship data.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { faker } from '@faker-js/faker';
import { NewEditContentFormPage } from '@pages';
import { expect, test } from '@playwright/test';
import { Contentlet, createDotAsset, deleteContentlets } from '@requests/contentlets';
import { ContentType, createFakeContentType, deleteContentType } from '@requests/contentType';
import { getDefaultSite } from '@requests/sites';
import {
createFakePayloadFileField,
createFakePayloadTextField
Expand All @@ -10,7 +12,8 @@ import { uniqueSuffix } from '@utils/utils';

import { FileField } from './helpers/file-field';

import { E2E_IMPORT_URL, createTestTextFile } from '../helpers/file-test-data';
import { AssetPickerDialog } from '../helpers/asset-picker-dialog';
import { E2E_IMPORT_URL, createTestPngFile, createTestTextFile } from '../helpers/file-test-data';

const FILE_FIELD_VARIABLE = 'fileField';
const TEST_FILE = createTestTextFile();
Expand Down Expand Up @@ -118,6 +121,85 @@ test('import image URL shows Edit image button', async ({ page }) => {
await field.expectEditButtonVisible();
});

test.describe('select an existing file through the AssetPicker', () => {
let seededAsset: Contentlet | null = null;
let assetName: string;

// Seeded per test through the REST API: the picker only reads it, but a unique file name per
// test is what lets the search find exactly this asset regardless of what else the environment
// happens to contain.
//
// An image on purpose: the preview renders text assets as an editable code block
// (`code-preview`) and everything else as thumbnail + metadata, so a .txt here would never
// produce the file name this test asserts on.
test.beforeEach(async ({ request }) => {
const site = await getDefaultSite(request);
seededAsset = await createDotAsset(
request,
createTestPngFile(`e2e-picker-${uniqueSuffix()}.png`),
site.identifier
);
assetName = seededAsset.title;
});

test.afterEach(async ({ request }) => {
if (seededAsset) {
await deleteContentlets(request, [seededAsset.identifier]);
seededAsset = null;
}
});

test('open the picker, select a file, and populate the field @critical', async ({ page }) => {
const formPage = new NewEditContentFormPage(page);
await formPage.goToNew(contentTypeVariable);

const field = new FileField(page, FILE_FIELD_VARIABLE);
await field.expectVisible();

const picker = new AssetPickerDialog(page);
await field.openSelectExistingDialog();
await picker.waitForVisible();

// Nothing picked yet, so there is nothing to confirm.
await picker.expectConfirmDisabled();

await picker.searchFor(assetName);
await picker.expectRowVisible(assetName);

// Clicking the title, not the row padding: the whole row is the selection target here.
await picker.selectRowByTitle(assetName);
await picker.expectRowSelected(assetName);
await picker.expectConfirmEnabled();

await picker.confirm();

await picker.expectClosed();
await field.expectPreviewVisible();
await field.expectThumbnailVisible();
await field.expectPreviewShowsFileName(assetName);
});

test('cancel the picker and leave the field untouched', async ({ page }) => {
const formPage = new NewEditContentFormPage(page);
await formPage.goToNew(contentTypeVariable);

const field = new FileField(page, FILE_FIELD_VARIABLE);
await field.expectVisible();

const picker = new AssetPickerDialog(page);
await field.openSelectExistingDialog();
await picker.waitForVisible();

await picker.searchFor(assetName);
await picker.selectRowByTitle(assetName);
await picker.cancel();

await picker.expectClosed();
// Highlighting a row and backing out must not populate the field.
await field.expectPreviewHidden();
});
});

test.describe('required file field', () => {
let requiredContentType: ContentType;
let requiredContentTypeVariable: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,23 @@ export class FileField {
await this.expectPreviewVisible();
}

/**
* Opens the AssetPicker ("Select Existing File/Image") and waits for its first result page.
*
* The picker searches as soon as it is configured, so waiting on that request is what tells us
* the list is ready to be asserted on rather than still empty.
*/
async openSelectExistingDialog() {
const searchResponse = this.page.waitForResponse(
(response) =>
response.url().includes('/api/v1/drive/search') && response.status() === 200,
{ timeout: 30000 }
);

await this.selectExistingFileBtn.getByRole('button').click();
await searchResponse;
}

async expectPreviewVisible() {
await expect(this.preview).toBeVisible({ timeout: 15000 });
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { type Locator, type Page, expect } from '@playwright/test';

/**
* Locator helper for the AssetPicker dialog — the "Select Existing File/Image" modal opened from a
* File or Image field.
*
* The picker renders its own header (the dialog is opened with `showHeader: false`), so everything
* here is scoped to the picker root rather than to PrimeNG's chrome.
*/
export class AssetPickerDialog {
readonly root: Locator;
readonly title: Locator;
readonly closeButton: Locator;
readonly fullscreenButton: Locator;
readonly search: Locator;
readonly sidebar: Locator;
readonly treeSearch: Locator;
readonly list: Locator;
readonly rows: Locator;
readonly cancelButton: Locator;
readonly confirmButton: Locator;

constructor(private page: Page) {
this.root = page.getByTestId('asset-picker');
this.title = this.root.getByTestId('asset-picker-title');
this.closeButton = this.root.getByTestId('asset-picker-close-btn');
this.fullscreenButton = this.root.getByTestId('asset-picker-fullscreen-btn');
// Two search boxes are on screen at once, so each carries its own id — a shared one made
// every selector here ambiguous and was what broke this suite in CI.
this.search = this.root.getByTestId('asset-picker-search-input');
this.sidebar = this.root.getByTestId('asset-picker-sidebar');
this.treeSearch = this.root.getByTestId('asset-picker-tree-search-input');
this.list = this.root.getByTestId('asset-picker-list');
this.rows = this.list.getByTestId('item-row');
this.cancelButton = this.root.getByTestId('asset-picker-cancel');
this.confirmButton = this.root.getByTestId('asset-picker-confirm');
}

async waitForVisible(): Promise<void> {
await expect(this.root).toBeVisible({ timeout: 15000 });
}

async expectClosed(): Promise<void> {
await expect(this.root).toBeHidden({ timeout: 10000 });
}

async expectTitle(text: string): Promise<void> {
await expect(this.title).toHaveText(text);
}

/**
* Types a term into the asset search and waits for the results it produces.
*
* The search is debounced and widens the scope to the whole site, which is what makes it a
* reliable way to reach a seeded asset without depending on which folder the picker opened on.
*/
async searchFor(term: string): Promise<void> {
const response = this.page.waitForResponse(
(res) => res.url().includes('/api/v1/drive/search') && res.status() === 200,
{ timeout: 30000 }
);
await this.search.fill(term);
await response;
}

/** The row whose title cell contains `name`. */
row(name: string): Locator {
return this.rows.filter({ hasText: name });
}

async expectRowVisible(name: string): Promise<void> {
await expect(this.row(name)).toBeVisible({ timeout: 15000 });
}

/**
* Selects a row by clicking its title — the content, not the cell padding.
*
* Clicking the title specifically is the point: in the picker the whole row selects, whereas in
* Content Drive the title opens the item instead.
*/
async selectRowByTitle(name: string): Promise<void> {
await this.row(name).getByTestId('item-title-text').click();
}

async expectRowSelected(name: string): Promise<void> {
await expect(this.row(name).getByRole('radio')).toBeChecked();
}

async expectConfirmEnabled(): Promise<void> {
await expect(this.confirmButton.getByRole('button')).toBeEnabled();
}

async expectConfirmDisabled(): Promise<void> {
await expect(this.confirmButton.getByRole('button')).toBeDisabled();
}

async confirm(): Promise<void> {
await this.confirmButton.getByRole('button').click();
}

async cancel(): Promise<void> {
await this.cancelButton.getByRole('button').click();
}

async close(): Promise<void> {
await this.closeButton.getByRole('button').click();
}

/** Rows offer no per-row actions here — a row exists to be picked, not managed. */
async expectNoRowActions(): Promise<void> {
await expect(this.list.getByTestId('kebab-menu-button')).toHaveCount(0);
}
}
Loading
Loading